@asteby/metacore-runtime-react 49.5.0 → 49.7.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/business/customer-picker.d.ts +8 -2
  3. package/dist/business/customer-picker.d.ts.map +1 -1
  4. package/dist/business/customer-picker.js +30 -27
  5. package/dist/business/index.d.ts +1 -0
  6. package/dist/business/index.d.ts.map +1 -1
  7. package/dist/business/index.js +1 -0
  8. package/dist/business/line-product-cell.d.ts +8 -10
  9. package/dist/business/line-product-cell.d.ts.map +1 -1
  10. package/dist/business/line-product-cell.js +13 -90
  11. package/dist/business/product-options.d.ts +41 -0
  12. package/dist/business/product-options.d.ts.map +1 -0
  13. package/dist/business/product-options.js +58 -0
  14. package/dist/business/product-picker.d.ts +5 -1
  15. package/dist/business/product-picker.d.ts.map +1 -1
  16. package/dist/business/product-picker.js +26 -35
  17. package/dist/business/relate-documents.d.ts +1 -1
  18. package/dist/business/relate-documents.d.ts.map +1 -1
  19. package/dist/business/relate-documents.js +6 -8
  20. package/dist/business/vehicle-picker.d.ts +8 -2
  21. package/dist/business/vehicle-picker.d.ts.map +1 -1
  22. package/dist/business/vehicle-picker.js +28 -28
  23. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  24. package/dist/dialogs/dynamic-record.js +28 -43
  25. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  26. package/dist/dynamic-multi-select-field.js +36 -6
  27. package/dist/dynamic-select-field.d.ts +9 -23
  28. package/dist/dynamic-select-field.d.ts.map +1 -1
  29. package/dist/dynamic-select-field.js +105 -172
  30. package/dist/entity-select.d.ts +5 -2
  31. package/dist/entity-select.d.ts.map +1 -1
  32. package/dist/entity-select.js +23 -116
  33. package/dist/index.d.ts +9 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +10 -1
  36. package/dist/query-batch.d.ts.map +1 -1
  37. package/dist/query-batch.js +4 -0
  38. package/dist/record-picker-actions.d.ts +40 -2
  39. package/dist/record-picker-actions.d.ts.map +1 -1
  40. package/dist/record-picker-actions.js +44 -4
  41. package/dist/record-picker-dialog.d.ts +23 -0
  42. package/dist/record-picker-dialog.d.ts.map +1 -0
  43. package/dist/record-picker-dialog.js +37 -0
  44. package/dist/record-picker-option.d.ts +25 -0
  45. package/dist/record-picker-option.d.ts.map +1 -0
  46. package/dist/record-picker-option.js +55 -0
  47. package/dist/record-picker.d.ts +149 -0
  48. package/dist/record-picker.d.ts.map +1 -0
  49. package/dist/record-picker.js +372 -0
  50. package/dist/use-option-ids.d.ts +34 -0
  51. package/dist/use-option-ids.d.ts.map +1 -0
  52. package/dist/use-option-ids.js +138 -0
  53. package/dist/use-options-resolver.d.ts +4 -0
  54. package/dist/use-options-resolver.d.ts.map +1 -1
  55. package/dist/use-options-resolver.js +10 -1
  56. package/package.json +1 -1
  57. package/src/__tests__/business-pickers.test.tsx +6 -3
  58. package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
  59. package/src/__tests__/document-form-dialog.test.tsx +4 -3
  60. package/src/__tests__/document-lines-grid.test.tsx +2 -1
  61. package/src/__tests__/edit-field-polish.test.tsx +6 -3
  62. package/src/__tests__/option-ids.test.tsx +181 -0
  63. package/src/__tests__/record-picker.test.tsx +469 -0
  64. package/src/business/customer-picker.tsx +87 -98
  65. package/src/business/index.ts +1 -0
  66. package/src/business/line-product-cell.tsx +55 -180
  67. package/src/business/product-options.tsx +119 -0
  68. package/src/business/product-picker.tsx +63 -126
  69. package/src/business/relate-documents.tsx +36 -45
  70. package/src/business/vehicle-picker.tsx +76 -99
  71. package/src/dialogs/dynamic-record.tsx +44 -97
  72. package/src/dynamic-multi-select-field.tsx +53 -15
  73. package/src/dynamic-select-field.tsx +188 -379
  74. package/src/entity-select.tsx +53 -202
  75. package/src/index.ts +35 -2
  76. package/src/query-batch.ts +3 -0
  77. package/src/record-picker-actions.tsx +101 -8
  78. package/src/record-picker-dialog.tsx +80 -0
  79. package/src/record-picker-option.tsx +96 -0
  80. package/src/record-picker.tsx +971 -0
  81. package/src/use-option-ids.ts +168 -0
  82. package/src/use-options-resolver.ts +11 -1
  83. package/dist/business/use-async-search.d.ts +0 -10
  84. package/dist/business/use-async-search.d.ts.map +0 -1
  85. package/dist/business/use-async-search.js +0 -38
  86. package/src/business/use-async-search.ts +0 -43
@@ -0,0 +1,971 @@
1
+ // RecordPicker — THE "pick a related record" control of the runtime.
2
+ //
3
+ // Before it, every surface carried its own picker: the declarative
4
+ // DynamicSelectField, the hand-written EntitySelect, the business
5
+ // Customer/Product/Vehicle pickers, the DocumentEditor's product cell, the
6
+ // legacy `search` field of the generic modal and the multi-select of `ref`
7
+ // arrays — each with its own search loop, list markup, keyboard handling and
8
+ // create button. They are now thin configurations of this one primitive:
9
+ //
10
+ // - data: caller-owned `items` (options resolver, static list, a search the
11
+ // caller drives) or a `search(q, signal)` fetcher run here with debounce,
12
+ // cancellation and a min-chars gate; loading / empty / error states;
13
+ // - selection: single or `multiple` (chips with remove);
14
+ // - create / edit: "+" joined to the trigger when empty, pencil when a value
15
+ // is selected, "Crear …" at the foot of the list prefilled with the search
16
+ // (record-picker-actions.tsx);
17
+ // - rich options by props/slots: lead (avatar / initials / thumbnail),
18
+ // subtitle (SKU, RFC…), trailing (price / stock), or a full `renderItem`;
19
+ // - presentation: `trigger="button"` (select-like, search inside the list)
20
+ // or `trigger="input"` (type in the field itself; `freeText` keeps the text
21
+ // as the value, e.g. a free invoice line), `variant="field" | "cell"`;
22
+ // - the list is ALWAYS portaled (never clipped by a table / modal overflow),
23
+ // collision-aware (flips when there is no room) and stacked above dialogs;
24
+ // - keyboard: ↑/↓/Home/End move, Enter picks, Esc closes (without closing the
25
+ // dialog around it), Tab closes and moves on; ARIA combobox + listbox with
26
+ // aria-activedescendant (focus never leaves the text box).
27
+ //
28
+ // Built only on @asteby/metacore-ui primitives (Popover, Button, Input, Badge),
29
+ // so it themes (light / dark) with the host.
30
+ import {
31
+ useCallback,
32
+ useEffect,
33
+ useId,
34
+ useRef,
35
+ useState,
36
+ type KeyboardEvent,
37
+ type ReactNode,
38
+ type Ref,
39
+ } from 'react'
40
+ import { Check, ChevronsUpDown, Loader2, Plus, Search, X, type LucideIcon } from 'lucide-react'
41
+ import {
42
+ Badge,
43
+ Button,
44
+ Input,
45
+ Popover,
46
+ PopoverAnchor,
47
+ PopoverContent,
48
+ PopoverTrigger,
49
+ } from '@asteby/metacore-ui/primitives'
50
+ import { JOINED_TRIGGER_CLASS, RecordPickerAction, hasRecordPickerAction } from './record-picker-actions'
51
+ import { useDebouncedValue } from './use-debounced-value'
52
+
53
+ /**
54
+ * z-index of the portaled list. Dialogs/sheets sit at 50; the list must stay
55
+ * above the modal it was opened from regardless of portal order.
56
+ */
57
+ export const RECORD_PICKER_Z_INDEX = 60
58
+
59
+ const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ')
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // useRecordSearch — debounced, cancellable async search with a min-chars gate.
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export interface UseRecordSearchOptions {
66
+ /** Characters required before searching (default 2). 0 = search on empty too. */
67
+ minChars?: number
68
+ /** Debounce in ms (default 250). */
69
+ delay?: number
70
+ /** When false nothing is fetched and results reset (e.g. list closed). Default true. */
71
+ enabled?: boolean
72
+ }
73
+
74
+ export interface UseRecordSearchResult<T> {
75
+ results: T[]
76
+ /** A request is in flight. */
77
+ loading: boolean
78
+ /** The last request failed. */
79
+ error: boolean
80
+ /** The debounce has not caught up with the typed text yet. */
81
+ pending: boolean
82
+ }
83
+
84
+ /** Debounced async search with cancellation. `search` must be stable (useCallback). */
85
+ export function useRecordSearch<T>(
86
+ query: string,
87
+ search: (q: string, signal: AbortSignal) => Promise<T[]>,
88
+ opts: UseRecordSearchOptions = {},
89
+ ): UseRecordSearchResult<T> {
90
+ const { minChars = 2, delay = 250, enabled = true } = opts
91
+ const trimmed = query.trim()
92
+ const q = useDebouncedValue(trimmed, delay)
93
+ const [results, setResults] = useState<T[]>([])
94
+ const [loading, setLoading] = useState(false)
95
+ const [error, setError] = useState(false)
96
+ // The query the current results answer. Until it matches what was typed
97
+ // (debounce running, or the request not answered yet) the search is
98
+ // `pending` — so a list never flashes "no results" before searching.
99
+ const [doneFor, setDoneFor] = useState<string | null>(null)
100
+
101
+ useEffect(() => {
102
+ if (!enabled || q.length < minChars) {
103
+ setResults((r) => (r.length ? [] : r))
104
+ setLoading(false)
105
+ setError(false)
106
+ setDoneFor(null)
107
+ return
108
+ }
109
+ const ctrl = new AbortController()
110
+ setLoading(true)
111
+ setError(false)
112
+ search(q, ctrl.signal)
113
+ .then((rows) => {
114
+ if (!ctrl.signal.aborted) setResults(rows)
115
+ })
116
+ .catch(() => {
117
+ if (!ctrl.signal.aborted) {
118
+ setResults([])
119
+ setError(true)
120
+ }
121
+ })
122
+ .finally(() => {
123
+ if (!ctrl.signal.aborted) {
124
+ setLoading(false)
125
+ setDoneFor(q)
126
+ }
127
+ })
128
+ return () => ctrl.abort()
129
+ }, [q, search, minChars, enabled])
130
+
131
+ return { results, loading, error, pending: enabled && trimmed.length >= minChars && doneFor !== trimmed }
132
+ }
133
+
134
+ const NO_SEARCH = () => Promise.resolve([] as never[])
135
+
136
+ /**
137
+ * Stable identity for a caller's search function: always calls the latest one,
138
+ * so an inline (non-memoized) fetcher doesn't re-trigger the search on every
139
+ * render.
140
+ */
141
+ export function useLatestSearch<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R {
142
+ const ref = useRef(fn)
143
+ ref.current = fn
144
+ return useCallback((...args: A) => ref.current(...args), [])
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // RecordPicker
149
+ // ---------------------------------------------------------------------------
150
+
151
+ export interface RecordPickerItemState {
152
+ /** Keyboard / pointer highlight. */
153
+ active: boolean
154
+ /** Part of the current value. */
155
+ selected: boolean
156
+ disabled: boolean
157
+ }
158
+
159
+ export type RecordPickerVariant = 'field' | 'cell'
160
+ export type RecordPickerTrigger = 'button' | 'input'
161
+
162
+ export interface RecordPickerProps<T> {
163
+ /** Stable id of an item (the stored value). */
164
+ getKey: (item: T) => string
165
+ /** Display text of an item. */
166
+ getLabel: (item: T) => string
167
+
168
+ // --- data -------------------------------------------------------------
169
+ /** Caller-owned list (resolver, static options, a search the caller drives). Wins over `search`. */
170
+ items?: readonly T[]
171
+ /** Async source run here (debounced, cancelled, gated by `minChars`). Must be stable. */
172
+ search?: (q: string, signal: AbortSignal) => Promise<T[]>
173
+ /** Characters required before searching / opening the input list. Default 0 (button) / 2 (input). */
174
+ minChars?: number
175
+ /** Debounce for `search` (ms). Default 250. */
176
+ debounceMs?: number
177
+ /** Loading flag for caller-owned `items`. */
178
+ loading?: boolean
179
+ /** Error flag for caller-owned `items`. */
180
+ error?: boolean
181
+
182
+ // --- query / open (controlled or not) ----------------------------------
183
+ query?: string
184
+ onQueryChange?: (query: string) => void
185
+ /** Button: popover open. Input: the user is actively searching. */
186
+ open?: boolean
187
+ onOpenChange?: (open: boolean) => void
188
+
189
+ // --- selection --------------------------------------------------------
190
+ multiple?: boolean
191
+ /** Current value: an id (single) or ids (multiple). */
192
+ value?: string | number | null | readonly (string | number)[]
193
+ /** Resolved item(s) of the current value, for the trigger. */
194
+ selected?: T | readonly T[] | null
195
+ /** An item was picked (in `multiple`, toggle it). */
196
+ onSelect: (item: T) => void
197
+ /** Shows a clear (×) when there is a value. */
198
+ onClear?: () => void
199
+ /** Chip remove in `multiple`. */
200
+ onRemove?: (item: T) => void
201
+ /** Close after a pick. Default: true for single, false for multiple. */
202
+ closeOnSelect?: boolean
203
+
204
+ // --- rendering --------------------------------------------------------
205
+ /** Full option body override. */
206
+ renderItem?: (item: T, state: RecordPickerItemState) => ReactNode
207
+ /** Leading visual (avatar / initials / thumbnail / icon). */
208
+ renderLead?: (item: T, where: 'option' | 'value') => ReactNode
209
+ /** Subtitle under the label (SKU, RFC, phone…). */
210
+ getDescription?: (item: T) => ReactNode
211
+ /** Right-aligned content (price, stock badge…). */
212
+ renderTrailing?: (item: T, where: 'option' | 'value') => ReactNode
213
+ /** Trigger content override (button) / selected display (input). */
214
+ renderValue?: (selected: T | null) => ReactNode
215
+ isItemDisabled?: (item: T) => boolean
216
+ /** Check mark column. Default: true in button mode. */
217
+ showCheck?: boolean
218
+
219
+ // --- copy -------------------------------------------------------------
220
+ placeholder?: string
221
+ searchPlaceholder?: string
222
+ emptyText?: ReactNode
223
+ loadingText?: ReactNode
224
+ errorText?: ReactNode
225
+ /** Shown below `minChars` in button mode. */
226
+ minCharsText?: ReactNode
227
+ clearLabel?: string
228
+ /** Accessible name of the listbox. */
229
+ listLabel?: string
230
+
231
+ // --- create / edit ----------------------------------------------------
232
+ /** Human name of the referenced model, for "Crear …" / "Editar …". */
233
+ entityLabel?: string
234
+ /** "+" (empty) and list footer. Receives the searched text to prefill. */
235
+ onCreate?: (query: string) => void
236
+ /** Pencil (with a value). */
237
+ onEdit?: () => void
238
+ createLabel?: string
239
+ editLabel?: string
240
+ /** When the "Crear …" footer shows: always (default), only with no hits, or never. */
241
+ createFooter?: 'always' | 'empty' | 'never'
242
+ createFooterLabel?: (query: string) => ReactNode
243
+ /** Hide the joined +/pencil segment (keeps the footer). */
244
+ hideJoinedAction?: boolean
245
+
246
+ // --- presentation -----------------------------------------------------
247
+ trigger?: RecordPickerTrigger
248
+ variant?: RecordPickerVariant
249
+ /** Input mode: the typed text IS the value (never shows a selected display). */
250
+ freeText?: boolean
251
+ icon?: LucideIcon
252
+ id?: string
253
+ ariaLabel?: string
254
+ disabled?: boolean
255
+ /** Locked display of the current value (no list, no actions). */
256
+ readOnly?: boolean
257
+ invalid?: boolean
258
+ /** data-slot of the root (keeps legacy hooks like `dynamic-select`). */
259
+ slot?: string
260
+ /** data-slot of the input-mode anchor. */
261
+ anchorSlot?: string
262
+ /** data-slot of the portaled list container. */
263
+ contentSlot?: string
264
+ className?: string
265
+ triggerClassName?: string
266
+ /** Extra attributes for the trigger / text box (data-*, …). */
267
+ triggerProps?: Record<string, unknown>
268
+ inputRef?: Ref<HTMLInputElement>
269
+ /** Floor for the list width (CSS length). Default 14rem. */
270
+ minListWidth?: string
271
+ /** Rendered after the joined group (e.g. a scan button). */
272
+ after?: ReactNode
273
+ /** Rendered under the control (badges, hints). */
274
+ below?: ReactNode
275
+ /** Rendered at the top of the list. */
276
+ beforeList?: ReactNode
277
+ /** Runs first on every key in the text box; `preventDefault()` to take over. */
278
+ onInputKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void
279
+ /** Keys the picker did not consume (closed list, Enter with no hits…). */
280
+ onUnhandledKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void
281
+ }
282
+
283
+ const TABBABLE =
284
+ 'a[href],button:not([disabled]),input:not([disabled]):not([type="hidden"]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
285
+
286
+ /** Focus the tabbable after `from` (skipping portaled popper content). */
287
+ function focusNextAfter(from: HTMLElement | null) {
288
+ if (!from || typeof document === 'undefined') return
289
+ const all = Array.from(document.querySelectorAll<HTMLElement>(TABBABLE)).filter(
290
+ (el) => el.tabIndex >= 0 && !el.closest('[data-radix-popper-content-wrapper]'),
291
+ )
292
+ const i = all.indexOf(from)
293
+ ;(all[i + 1] ?? from).focus()
294
+ }
295
+
296
+ function asKeys(value: RecordPickerProps<unknown>['value'], multiple: boolean): string[] {
297
+ if (multiple) return Array.isArray(value) ? value.map(String) : []
298
+ if (value == null || value === '' || Array.isArray(value)) return []
299
+ return [String(value)]
300
+ }
301
+
302
+ export function RecordPicker<T>(props: RecordPickerProps<T>) {
303
+ const {
304
+ getKey,
305
+ getLabel,
306
+ items,
307
+ search,
308
+ debounceMs = 250,
309
+ multiple = false,
310
+ value,
311
+ selected,
312
+ onSelect,
313
+ onClear,
314
+ onRemove,
315
+ renderItem,
316
+ renderLead,
317
+ getDescription,
318
+ renderTrailing,
319
+ renderValue,
320
+ isItemDisabled,
321
+ placeholder = 'Seleccionar…',
322
+ searchPlaceholder = 'Buscar…',
323
+ emptyText = 'Sin resultados',
324
+ loadingText = 'Buscando…',
325
+ errorText = 'No se pudo buscar. Inténtalo de nuevo.',
326
+ minCharsText,
327
+ clearLabel = 'Quitar selección',
328
+ listLabel,
329
+ entityLabel = '',
330
+ onCreate,
331
+ onEdit,
332
+ createLabel,
333
+ editLabel,
334
+ createFooter = 'always',
335
+ createFooterLabel,
336
+ hideJoinedAction = false,
337
+ trigger = 'button',
338
+ variant = 'field',
339
+ freeText = false,
340
+ icon: Icon,
341
+ id,
342
+ ariaLabel,
343
+ disabled = false,
344
+ readOnly = false,
345
+ invalid = false,
346
+ slot,
347
+ anchorSlot,
348
+ contentSlot = 'record-picker-content',
349
+ className,
350
+ triggerClassName,
351
+ triggerProps,
352
+ inputRef,
353
+ minListWidth = '14rem',
354
+ after,
355
+ below,
356
+ beforeList,
357
+ onInputKeyDown,
358
+ onUnhandledKeyDown,
359
+ } = props
360
+ const isInput = trigger === 'input'
361
+ const minChars = props.minChars ?? (isInput ? 2 : 0)
362
+ const showCheck = props.showCheck ?? !isInput
363
+ const closeOnSelect = props.closeOnSelect ?? !multiple
364
+ const listId = useId()
365
+ const triggerRef = useRef<HTMLElement | null>(null)
366
+ const anchorRef = useRef<HTMLDivElement>(null)
367
+ const tabbingRef = useRef(false)
368
+
369
+ // open / query: controlled when the caller passes them.
370
+ const [openState, setOpenState] = useState(false)
371
+ const open = props.open ?? openState
372
+ const { onOpenChange, onQueryChange } = props
373
+ const controlledOpen = props.open !== undefined
374
+ const setOpen = useCallback(
375
+ (o: boolean) => {
376
+ if (!controlledOpen) setOpenState(o)
377
+ onOpenChange?.(o)
378
+ },
379
+ [controlledOpen, onOpenChange],
380
+ )
381
+ const [queryState, setQueryState] = useState('')
382
+ const query = props.query ?? queryState
383
+ const controlledQuery = props.query !== undefined
384
+ const setQuery = useCallback(
385
+ (q: string) => {
386
+ if (!controlledQuery) setQueryState(q)
387
+ onQueryChange?.(q)
388
+ },
389
+ [controlledQuery, onQueryChange],
390
+ )
391
+
392
+ const trimmed = query.trim()
393
+ const belowMin = trimmed.length < minChars
394
+ const listOpen = !disabled && !readOnly && open && (!isInput || !belowMin)
395
+
396
+ const internal = useRecordSearch<T>(query, (search ?? NO_SEARCH) as (q: string, s: AbortSignal) => Promise<T[]>, {
397
+ minChars,
398
+ delay: debounceMs,
399
+ enabled: !!search && !items && open && !disabled && !readOnly,
400
+ })
401
+ const rows: readonly T[] = items ?? internal.results
402
+ const loading = props.loading ?? (internal.loading || internal.pending)
403
+ const error = props.error ?? internal.error
404
+
405
+ const keys = asKeys(value as RecordPickerProps<unknown>['value'], multiple)
406
+ const hasValue = keys.length > 0
407
+ const isSelected = (item: T) => keys.includes(getKey(item))
408
+ const single = !multiple ? ((selected as T | null | undefined) ?? null) : null
409
+ const selectedList: readonly T[] = multiple && Array.isArray(selected) ? (selected as readonly T[]) : []
410
+
411
+ const showFooter =
412
+ !!onCreate &&
413
+ !loading &&
414
+ createFooter !== 'never' &&
415
+ (createFooter === 'always' || (rows.length === 0 && !belowMin && !error))
416
+ const count = rows.length + (showFooter ? 1 : 0)
417
+
418
+ // Highlight: the selected row when (re)opening, else the first. Keyed on the
419
+ // row ids (not the array identity) so a caller recomputing `items` on every
420
+ // render doesn't reset the keyboard position.
421
+ const [active, setActive] = useState(0)
422
+ const rowSig = rows.map(getKey).join('\u0001')
423
+ useEffect(() => {
424
+ const i = rows.findIndex((r) => keys.includes(getKey(r)))
425
+ setActive(i >= 0 ? i : 0)
426
+ // eslint-disable-next-line react-hooks/exhaustive-deps
427
+ }, [rowSig, trimmed, listOpen])
428
+
429
+ useEffect(() => {
430
+ if (!listOpen || typeof document === 'undefined') return
431
+ const el = document.getElementById(`${listId}-o${active}`)
432
+ el?.scrollIntoView?.({ block: 'nearest' })
433
+ }, [active, listOpen, listId])
434
+
435
+ const enabledAt = (i: number) => i === rows.length ? showFooter : !!rows[i] && !isItemDisabled?.(rows[i]!)
436
+ const step = (from: number, dir: 1 | -1) => {
437
+ for (let i = from + dir; i >= 0 && i < count; i += dir) if (enabledAt(i)) return i
438
+ return from
439
+ }
440
+
441
+ const close = () => setOpen(false)
442
+
443
+ const runCreate = () => {
444
+ const q = trimmed
445
+ close()
446
+ if (!freeText) setQuery('')
447
+ onCreate?.(q)
448
+ }
449
+
450
+ const choose = (i: number) => {
451
+ if (i === rows.length && showFooter) return runCreate()
452
+ const item = rows[i]
453
+ if (!item || isItemDisabled?.(item)) return
454
+ onSelect(item)
455
+ if (closeOnSelect) close()
456
+ if (!freeText && !multiple) setQuery('')
457
+ }
458
+
459
+ const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
460
+ if (onInputKeyDown) {
461
+ // Only a preventDefault() from the caller takes over (Radix may
462
+ // already have prevented Esc at the document level).
463
+ const before = e.defaultPrevented
464
+ onInputKeyDown(e)
465
+ if (!before && e.defaultPrevented) return
466
+ }
467
+ if (listOpen && count > 0) {
468
+ if (e.key === 'ArrowDown') {
469
+ e.preventDefault()
470
+ setActive((a) => step(Math.min(a, count - 1), 1))
471
+ return
472
+ }
473
+ if (e.key === 'ArrowUp') {
474
+ e.preventDefault()
475
+ setActive((a) => step(Math.min(a, count), -1))
476
+ return
477
+ }
478
+ if (!isInput && (e.key === 'Home' || e.key === 'End')) {
479
+ e.preventDefault()
480
+ setActive(e.key === 'Home' ? step(-1, 1) : step(count, -1))
481
+ return
482
+ }
483
+ if (e.key === 'Enter' && enabledAt(active)) {
484
+ e.preventDefault()
485
+ choose(active)
486
+ return
487
+ }
488
+ }
489
+ if (isInput) {
490
+ if (listOpen && e.key === 'Escape') {
491
+ // Close the list, not the dialog around it.
492
+ e.preventDefault()
493
+ e.stopPropagation()
494
+ close()
495
+ return
496
+ }
497
+ if (!listOpen && !freeText && e.key === 'ArrowDown' && !belowMin) {
498
+ e.preventDefault()
499
+ setOpen(true)
500
+ return
501
+ }
502
+ if (e.key === 'Tab') close()
503
+ } else if (e.key === 'Tab' && !e.shiftKey) {
504
+ // Close and move on to the next field (not to the end of <body>,
505
+ // where the portaled list lives).
506
+ e.preventDefault()
507
+ tabbingRef.current = true
508
+ close()
509
+ return
510
+ }
511
+ onUnhandledKeyDown?.(e)
512
+ }
513
+
514
+ const activeId = listOpen && count > 0 && enabledAt(active) ? `${listId}-o${active}` : undefined
515
+
516
+ // ---- the list (shared by both trigger modes) --------------------------
517
+ const status = (() => {
518
+ if (loading && rows.length === 0) {
519
+ return (
520
+ <div role="status" className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground" data-slot="picker-loading">
521
+ <Loader2 className="size-4 animate-spin" aria-hidden />
522
+ {loadingText}
523
+ </div>
524
+ )
525
+ }
526
+ if (loading) return null
527
+ if (error) {
528
+ return (
529
+ <p role="alert" className="px-2 py-4 text-center text-sm text-destructive" data-slot="picker-error">
530
+ {errorText}
531
+ </p>
532
+ )
533
+ }
534
+ if (rows.length > 0) return null
535
+ if (belowMin && minChars > 0) {
536
+ return (
537
+ <div className="flex flex-col items-center gap-1 py-6 text-muted-foreground" data-slot="picker-min-chars">
538
+ <Search className="size-5" aria-hidden />
539
+ <span className="text-xs">{minCharsText ?? `Escribe al menos ${minChars} caracteres`}</span>
540
+ </div>
541
+ )
542
+ }
543
+ return (
544
+ <div className="px-2 py-6 text-center text-sm text-muted-foreground" data-slot="picker-empty">
545
+ {emptyText}
546
+ </div>
547
+ )
548
+ })()
549
+
550
+ const optionClass = (on: boolean, dis: boolean) =>
551
+ cx(
552
+ 'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden',
553
+ on && 'bg-accent text-accent-foreground',
554
+ dis && 'pointer-events-none opacity-50',
555
+ )
556
+
557
+ const list = (
558
+ <div className="max-h-72 overflow-y-auto overflow-x-hidden overscroll-contain" data-slot="record-picker-list">
559
+ {beforeList}
560
+ {status}
561
+ <ul
562
+ id={listId}
563
+ role="listbox"
564
+ aria-label={listLabel ?? ariaLabel ?? entityLabel ?? undefined}
565
+ aria-multiselectable={multiple || undefined}
566
+ className={cx('p-1', count === 0 && 'hidden')}
567
+ >
568
+ {rows.map((item, i) => {
569
+ const sel = isSelected(item)
570
+ const dis = !!isItemDisabled?.(item)
571
+ const on = i === active
572
+ const desc = getDescription?.(item)
573
+ return (
574
+ <li
575
+ key={getKey(item)}
576
+ id={`${listId}-o${i}`}
577
+ role="option"
578
+ aria-selected={multiple ? sel : on}
579
+ aria-disabled={dis || undefined}
580
+ data-active={on || undefined}
581
+ data-selected={sel || undefined}
582
+ data-slot="record-picker-option"
583
+ className={optionClass(on, dis)}
584
+ onMouseDown={(e) => e.preventDefault()}
585
+ onMouseMove={() => {
586
+ if (!on && !dis) setActive(i)
587
+ }}
588
+ onClick={() => choose(i)}
589
+ >
590
+ {renderItem ? (
591
+ renderItem(item, { active: on, selected: sel, disabled: dis })
592
+ ) : (
593
+ <>
594
+ {showCheck ? (
595
+ <Check className={cx('size-4 shrink-0', sel ? 'opacity-100' : 'opacity-0')} aria-hidden />
596
+ ) : null}
597
+ {renderLead?.(item, 'option')}
598
+ <span className="flex min-w-0 flex-1 flex-col">
599
+ <span className="truncate">{getLabel(item)}</span>
600
+ {desc != null && desc !== '' ? (
601
+ <span className="truncate text-xs text-muted-foreground">{desc}</span>
602
+ ) : null}
603
+ </span>
604
+ {renderTrailing ? (
605
+ <span className="ml-auto flex shrink-0 items-center gap-2">{renderTrailing(item, 'option')}</span>
606
+ ) : null}
607
+ </>
608
+ )}
609
+ </li>
610
+ )
611
+ })}
612
+ {showFooter ? (
613
+ <>
614
+ {rows.length > 0 ? <li role="presentation" className="-mx-1 my-1 h-px bg-border" /> : null}
615
+ <li
616
+ id={`${listId}-o${rows.length}`}
617
+ role="option"
618
+ aria-selected={active === rows.length}
619
+ data-active={active === rows.length || undefined}
620
+ data-slot="picker-create"
621
+ className={cx(optionClass(active === rows.length, false), 'text-primary')}
622
+ onMouseDown={(e) => e.preventDefault()}
623
+ onMouseMove={() => setActive(rows.length)}
624
+ onClick={runCreate}
625
+ >
626
+ <Plus className="size-4 shrink-0" aria-hidden />
627
+ <span className="truncate">
628
+ {createFooterLabel ? createFooterLabel(trimmed) : `Crear ${entityLabel}`.trim()}
629
+ {!createFooterLabel && trimmed ? <span className="text-muted-foreground"> «{trimmed}»</span> : null}
630
+ </span>
631
+ </li>
632
+ </>
633
+ ) : null}
634
+ </ul>
635
+ </div>
636
+ )
637
+
638
+ const contentStyle = {
639
+ width: `max(var(--radix-popover-trigger-width), ${minListWidth})`,
640
+ maxWidth: 'calc(100vw - 1rem)',
641
+ zIndex: RECORD_PICKER_Z_INDEX,
642
+ }
643
+
644
+ const joined = !hideJoinedAction && !readOnly && hasRecordPickerAction(hasValue, onCreate, onEdit)
645
+ const action = joined ? (
646
+ <RecordPickerAction
647
+ hasValue={hasValue}
648
+ label={entityLabel}
649
+ onCreate={onCreate ? () => runCreate() : undefined}
650
+ onEdit={onEdit}
651
+ createLabel={createLabel}
652
+ editLabel={editLabel}
653
+ disabled={disabled}
654
+ variant={variant}
655
+ />
656
+ ) : null
657
+
658
+ const defaultValueNode = (item: T | null) =>
659
+ item ? (
660
+ <>
661
+ {renderLead?.(item, 'value')}
662
+ <span className="min-w-0 flex-1 truncate">{getLabel(item)}</span>
663
+ {renderTrailing?.(item, 'value')}
664
+ </>
665
+ ) : (
666
+ <span className="min-w-0 flex-1 truncate text-muted-foreground">{placeholder}</span>
667
+ )
668
+ const valueNode = renderValue ? renderValue(single) : defaultValueNode(single)
669
+
670
+ const wrap = (row: ReactNode) =>
671
+ below ? (
672
+ <div className={cx('w-full min-w-0 space-y-1.5', className)} data-slot={slot}>
673
+ {row}
674
+ {below}
675
+ </div>
676
+ ) : (
677
+ row
678
+ )
679
+ const rowClass = cx('flex w-full min-w-0 items-center gap-1.5', !below && className)
680
+ const rowSlot = below ? undefined : slot
681
+
682
+ // ---- read-only --------------------------------------------------------
683
+ if (readOnly) {
684
+ return (
685
+ <Button
686
+ type="button"
687
+ variant="outline"
688
+ role="combobox"
689
+ id={id}
690
+ disabled
691
+ aria-readonly="true"
692
+ aria-label={ariaLabel}
693
+ className={cx('w-full min-w-0 cursor-default justify-start font-normal opacity-100', triggerClassName)}
694
+ {...triggerProps}
695
+ >
696
+ <span className="flex min-w-0 flex-1 items-center gap-2 text-left">{valueNode}</span>
697
+ </Button>
698
+ )
699
+ }
700
+
701
+ // ---- input trigger ----------------------------------------------------
702
+ if (isInput) {
703
+ const showValue = !freeText && !multiple && hasValue && !!(single || renderValue)
704
+ const inputCls = cx(
705
+ variant === 'cell' ? 'h-8 pl-8' : 'pl-8',
706
+ joined && JOINED_TRIGGER_CLASS,
707
+ triggerClassName,
708
+ )
709
+ return wrap(
710
+ <div className={rowClass} data-slot={rowSlot}>
711
+ <div className="flex min-w-0 flex-1 items-stretch">
712
+ <Popover open={listOpen} onOpenChange={(o) => !o && close()}>
713
+ <PopoverAnchor asChild>
714
+ <div ref={anchorRef} className="relative w-full min-w-0" data-slot={anchorSlot}>
715
+ {showValue ? (
716
+ <div
717
+ className={cx(
718
+ 'flex min-h-9 w-full min-w-0 items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm dark:bg-input/30',
719
+ joined && 'rounded-r-none',
720
+ invalid && 'border-destructive',
721
+ )}
722
+ data-slot="record-picker-value"
723
+ >
724
+ {Icon ? <Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden /> : null}
725
+ <div className="min-w-0 flex-1">{valueNode}</div>
726
+ {onClear && !disabled ? (
727
+ <Button
728
+ type="button"
729
+ size="icon"
730
+ variant="ghost"
731
+ className="size-7 shrink-0"
732
+ onClick={onClear}
733
+ aria-label={clearLabel}
734
+ >
735
+ <X className="size-4" />
736
+ </Button>
737
+ ) : null}
738
+ </div>
739
+ ) : (
740
+ <>
741
+ <Search
742
+ className={cx(
743
+ 'pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground',
744
+ variant === 'cell' ? 'size-3.5' : 'size-4',
745
+ )}
746
+ aria-hidden
747
+ />
748
+ <Input
749
+ ref={inputRef}
750
+ id={id}
751
+ value={query}
752
+ disabled={disabled}
753
+ role="combobox"
754
+ aria-autocomplete="list"
755
+ aria-expanded={listOpen}
756
+ aria-controls={listOpen ? listId : undefined}
757
+ aria-activedescendant={activeId}
758
+ aria-label={ariaLabel}
759
+ aria-invalid={invalid || undefined}
760
+ autoComplete="off"
761
+ spellCheck={false}
762
+ placeholder={placeholder}
763
+ className={inputCls}
764
+ onChange={(e) => {
765
+ setQuery(e.target.value)
766
+ setOpen(true)
767
+ }}
768
+ onBlur={() => close()}
769
+ onKeyDown={onKeyDown}
770
+ {...triggerProps}
771
+ />
772
+ </>
773
+ )}
774
+ </div>
775
+ </PopoverAnchor>
776
+ <PopoverContent
777
+ align="start"
778
+ sideOffset={4}
779
+ collisionPadding={8}
780
+ className="p-0"
781
+ data-slot={contentSlot}
782
+ // Focus stays in the text box: keep typing while the list shows.
783
+ onOpenAutoFocus={(e) => e.preventDefault()}
784
+ // Esc is handled by the text box (closes the list and
785
+ // stops there, so the dialog around it stays open).
786
+ onEscapeKeyDown={(e) => e.preventDefault()}
787
+ onCloseAutoFocus={(e) => e.preventDefault()}
788
+ // Clicking the list (scrollbar included) must not blur the box.
789
+ onMouseDown={(e) => e.preventDefault()}
790
+ onInteractOutside={(e) => {
791
+ if (anchorRef.current?.contains(e.target as Node)) e.preventDefault()
792
+ }}
793
+ style={contentStyle}
794
+ >
795
+ {list}
796
+ </PopoverContent>
797
+ </Popover>
798
+ {action}
799
+ </div>
800
+ {after}
801
+ </div>,
802
+ )
803
+ }
804
+
805
+ // ---- button trigger ---------------------------------------------------
806
+ const onTriggerKeyDown = (e: KeyboardEvent<HTMLElement>) => {
807
+ if (disabled) return
808
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || (multiple && (e.key === 'Enter' || e.key === ' '))) {
809
+ e.preventDefault()
810
+ setOpen(true)
811
+ return
812
+ }
813
+ // Type-to-search: a printable key on the closed trigger opens the list
814
+ // with that character already in the search box.
815
+ if (!open && e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) {
816
+ e.preventDefault()
817
+ setQuery(e.key)
818
+ setOpen(true)
819
+ }
820
+ }
821
+ const clearable = !!onClear && hasValue && !disabled && !multiple
822
+ const cellCls =
823
+ 'h-8 border-transparent bg-transparent px-2 shadow-none hover:border-input hover:bg-transparent dark:bg-transparent dark:hover:bg-transparent'
824
+ const comboboxAria = {
825
+ role: 'combobox' as const,
826
+ 'aria-expanded': listOpen,
827
+ 'aria-haspopup': 'listbox' as const,
828
+ 'aria-controls': listOpen ? listId : undefined,
829
+ 'aria-invalid': invalid || undefined,
830
+ 'aria-label': ariaLabel,
831
+ id,
832
+ }
833
+
834
+ const triggerEl = multiple ? (
835
+ <div
836
+ {...comboboxAria}
837
+ ref={(el) => {
838
+ triggerRef.current = el
839
+ }}
840
+ tabIndex={disabled ? -1 : 0}
841
+ aria-disabled={disabled || undefined}
842
+ onKeyDown={onTriggerKeyDown}
843
+ className={cx(
844
+ 'flex min-h-9 w-full min-w-0 cursor-pointer items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30',
845
+ variant === 'cell' && 'min-h-8 border-transparent shadow-none',
846
+ invalid && 'border-destructive ring-1 ring-destructive/30',
847
+ disabled && 'pointer-events-none opacity-50',
848
+ joined && JOINED_TRIGGER_CLASS,
849
+ triggerClassName,
850
+ )}
851
+ data-empty={!hasValue}
852
+ {...triggerProps}
853
+ >
854
+ <span className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
855
+ {selectedList.length > 0 ? (
856
+ selectedList.map((item) => (
857
+ <Badge key={getKey(item)} variant="secondary" className="max-w-full gap-1 pr-1 font-normal" data-slot="record-picker-chip">
858
+ <span className="truncate">{getLabel(item)}</span>
859
+ {onRemove && !disabled ? (
860
+ <button
861
+ type="button"
862
+ aria-label={`Quitar ${getLabel(item)}`}
863
+ className="rounded-full p-0.5 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
864
+ onPointerDown={(e) => e.stopPropagation()}
865
+ onClick={(e) => {
866
+ e.stopPropagation()
867
+ onRemove(item)
868
+ }}
869
+ onKeyDown={(e) => e.stopPropagation()}
870
+ >
871
+ <X className="size-3" />
872
+ </button>
873
+ ) : null}
874
+ </Badge>
875
+ ))
876
+ ) : (
877
+ <span className="truncate text-muted-foreground">{placeholder}</span>
878
+ )}
879
+ </span>
880
+ <ChevronsUpDown className="size-4 shrink-0 opacity-50" aria-hidden />
881
+ </div>
882
+ ) : (
883
+ <Button
884
+ {...comboboxAria}
885
+ ref={(el: HTMLButtonElement | null) => {
886
+ triggerRef.current = el
887
+ }}
888
+ type="button"
889
+ variant="outline"
890
+ disabled={disabled}
891
+ onKeyDown={onTriggerKeyDown}
892
+ className={cx(
893
+ 'w-full min-w-0 flex-1 justify-between gap-2 font-normal',
894
+ variant === 'cell' && cellCls,
895
+ clearable && 'pr-14',
896
+ joined && JOINED_TRIGGER_CLASS,
897
+ invalid && 'border-destructive ring-1 ring-destructive/30',
898
+ triggerClassName,
899
+ )}
900
+ data-empty={!hasValue}
901
+ {...triggerProps}
902
+ >
903
+ {Icon ? <Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden /> : null}
904
+ <span className="flex min-w-0 flex-1 items-center gap-2 text-left">{valueNode}</span>
905
+ <ChevronsUpDown className="size-4 shrink-0 opacity-50" aria-hidden />
906
+ </Button>
907
+ )
908
+
909
+ return wrap(
910
+ <div className={rowClass} data-slot={rowSlot}>
911
+ <div className="flex min-w-0 flex-1 items-center">
912
+ <div className="relative flex min-w-0 flex-1 items-center">
913
+ <Popover open={listOpen} onOpenChange={(o) => (disabled ? undefined : setOpen(o))}>
914
+ <PopoverTrigger asChild>{triggerEl}</PopoverTrigger>
915
+ <PopoverContent
916
+ align="start"
917
+ sideOffset={4}
918
+ collisionPadding={8}
919
+ className="p-0"
920
+ data-slot={contentSlot}
921
+ style={contentStyle}
922
+ onCloseAutoFocus={(e) => {
923
+ if (!tabbingRef.current) return
924
+ tabbingRef.current = false
925
+ e.preventDefault()
926
+ focusNextAfter(triggerRef.current)
927
+ }}
928
+ >
929
+ <div className="flex h-10 items-center gap-2 border-b px-3" data-slot="record-picker-search">
930
+ <Search className="size-4 shrink-0 opacity-50" aria-hidden />
931
+ <input
932
+ role="combobox"
933
+ aria-expanded={listOpen}
934
+ aria-controls={listId}
935
+ aria-autocomplete="list"
936
+ aria-activedescendant={activeId}
937
+ aria-label={searchPlaceholder}
938
+ autoComplete="off"
939
+ spellCheck={false}
940
+ placeholder={searchPlaceholder}
941
+ value={query}
942
+ onChange={(e) => setQuery(e.target.value)}
943
+ onKeyDown={onKeyDown}
944
+ className="flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground"
945
+ />
946
+ </div>
947
+ {list}
948
+ </PopoverContent>
949
+ </Popover>
950
+ {clearable ? (
951
+ <button
952
+ type="button"
953
+ aria-label={clearLabel}
954
+ title={clearLabel}
955
+ onClick={(e) => {
956
+ e.stopPropagation()
957
+ onClear?.()
958
+ }}
959
+ className="absolute right-8 top-1/2 z-10 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
960
+ data-slot="record-picker-clear"
961
+ >
962
+ <X className="size-3.5" />
963
+ </button>
964
+ ) : null}
965
+ </div>
966
+ {action}
967
+ </div>
968
+ {after}
969
+ </div>,
970
+ )
971
+ }