@asteby/metacore-runtime-react 49.5.0 → 49.6.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.
- package/CHANGELOG.md +6 -0
- package/dist/business/customer-picker.d.ts +8 -2
- package/dist/business/customer-picker.d.ts.map +1 -1
- package/dist/business/customer-picker.js +30 -27
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +1 -0
- package/dist/business/line-product-cell.d.ts +8 -10
- package/dist/business/line-product-cell.d.ts.map +1 -1
- package/dist/business/line-product-cell.js +13 -90
- package/dist/business/product-options.d.ts +41 -0
- package/dist/business/product-options.d.ts.map +1 -0
- package/dist/business/product-options.js +58 -0
- package/dist/business/product-picker.d.ts +5 -1
- package/dist/business/product-picker.d.ts.map +1 -1
- package/dist/business/product-picker.js +26 -35
- package/dist/business/relate-documents.d.ts +1 -1
- package/dist/business/relate-documents.d.ts.map +1 -1
- package/dist/business/relate-documents.js +6 -8
- package/dist/business/vehicle-picker.d.ts +8 -2
- package/dist/business/vehicle-picker.d.ts.map +1 -1
- package/dist/business/vehicle-picker.js +28 -28
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +28 -43
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +17 -5
- package/dist/dynamic-select-field.d.ts +7 -23
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +67 -161
- package/dist/entity-select.d.ts +5 -2
- package/dist/entity-select.d.ts.map +1 -1
- package/dist/entity-select.js +23 -116
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/record-picker-actions.d.ts +40 -2
- package/dist/record-picker-actions.d.ts.map +1 -1
- package/dist/record-picker-actions.js +44 -4
- package/dist/record-picker-dialog.d.ts +23 -0
- package/dist/record-picker-dialog.d.ts.map +1 -0
- package/dist/record-picker-dialog.js +37 -0
- package/dist/record-picker-option.d.ts +25 -0
- package/dist/record-picker-option.d.ts.map +1 -0
- package/dist/record-picker-option.js +55 -0
- package/dist/record-picker.d.ts +149 -0
- package/dist/record-picker.d.ts.map +1 -0
- package/dist/record-picker.js +372 -0
- package/package.json +1 -1
- package/src/__tests__/business-pickers.test.tsx +6 -3
- package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
- package/src/__tests__/document-form-dialog.test.tsx +4 -3
- package/src/__tests__/document-lines-grid.test.tsx +2 -1
- package/src/__tests__/record-picker.test.tsx +469 -0
- package/src/business/customer-picker.tsx +87 -98
- package/src/business/index.ts +1 -0
- package/src/business/line-product-cell.tsx +55 -180
- package/src/business/product-options.tsx +119 -0
- package/src/business/product-picker.tsx +63 -126
- package/src/business/relate-documents.tsx +36 -45
- package/src/business/vehicle-picker.tsx +76 -99
- package/src/dialogs/dynamic-record.tsx +44 -97
- package/src/dynamic-multi-select-field.tsx +39 -13
- package/src/dynamic-select-field.tsx +143 -368
- package/src/entity-select.tsx +53 -202
- package/src/index.ts +27 -2
- package/src/record-picker-actions.tsx +101 -8
- package/src/record-picker-dialog.tsx +80 -0
- package/src/record-picker-option.tsx +96 -0
- package/src/record-picker.tsx +971 -0
- package/dist/business/use-async-search.d.ts +0 -10
- package/dist/business/use-async-search.d.ts.map +0 -1
- package/dist/business/use-async-search.js +0 -38
- 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
|
+
}
|