@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +6 -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 +17 -5
  27. package/dist/dynamic-select-field.d.ts +7 -23
  28. package/dist/dynamic-select-field.d.ts.map +1 -1
  29. package/dist/dynamic-select-field.js +67 -161
  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 +7 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +8 -1
  36. package/dist/record-picker-actions.d.ts +40 -2
  37. package/dist/record-picker-actions.d.ts.map +1 -1
  38. package/dist/record-picker-actions.js +44 -4
  39. package/dist/record-picker-dialog.d.ts +23 -0
  40. package/dist/record-picker-dialog.d.ts.map +1 -0
  41. package/dist/record-picker-dialog.js +37 -0
  42. package/dist/record-picker-option.d.ts +25 -0
  43. package/dist/record-picker-option.d.ts.map +1 -0
  44. package/dist/record-picker-option.js +55 -0
  45. package/dist/record-picker.d.ts +149 -0
  46. package/dist/record-picker.d.ts.map +1 -0
  47. package/dist/record-picker.js +372 -0
  48. package/package.json +1 -1
  49. package/src/__tests__/business-pickers.test.tsx +6 -3
  50. package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
  51. package/src/__tests__/document-form-dialog.test.tsx +4 -3
  52. package/src/__tests__/document-lines-grid.test.tsx +2 -1
  53. package/src/__tests__/record-picker.test.tsx +469 -0
  54. package/src/business/customer-picker.tsx +87 -98
  55. package/src/business/index.ts +1 -0
  56. package/src/business/line-product-cell.tsx +55 -180
  57. package/src/business/product-options.tsx +119 -0
  58. package/src/business/product-picker.tsx +63 -126
  59. package/src/business/relate-documents.tsx +36 -45
  60. package/src/business/vehicle-picker.tsx +76 -99
  61. package/src/dialogs/dynamic-record.tsx +44 -97
  62. package/src/dynamic-multi-select-field.tsx +39 -13
  63. package/src/dynamic-select-field.tsx +143 -368
  64. package/src/entity-select.tsx +53 -202
  65. package/src/index.ts +27 -2
  66. package/src/record-picker-actions.tsx +101 -8
  67. package/src/record-picker-dialog.tsx +80 -0
  68. package/src/record-picker-option.tsx +96 -0
  69. package/src/record-picker.tsx +971 -0
  70. package/dist/business/use-async-search.d.ts +0 -10
  71. package/dist/business/use-async-search.d.ts.map +0 -1
  72. package/dist/business/use-async-search.js +0 -38
  73. package/src/business/use-async-search.ts +0 -43
@@ -1,4 +1,5 @@
1
1
  // DynamicSelectField — async, searchable single-select for declarative forms.
2
+ // A configuration of the shared <RecordPicker> (record-picker.tsx).
2
3
  //
3
4
  // This is the declarative answer to "I don't want to type a raw FK UUID".
4
5
  // Instead of a plain <select> that dumps every option (RefSelect) or a free
@@ -24,29 +25,19 @@
24
25
  import { useEffect, useRef, useState } from 'react'
25
26
  import { getOptionFilter } from './option-filter'
26
27
  import { useTranslation } from 'react-i18next'
27
- import {
28
- Badge,
29
- Button,
30
- Command,
31
- CommandGroup,
32
- CommandInput,
33
- CommandItem,
34
- CommandList,
35
- Popover,
36
- PopoverContent,
37
- PopoverTrigger,
38
- InitialsAvatar,
39
- } from '@asteby/metacore-ui/primitives'
40
- import { Check, ChevronsUpDown, Loader2, ScanLine } from 'lucide-react'
41
- import { resolveColorCss } from '@asteby/metacore-ui/lib'
28
+ import { Badge, Button } from '@asteby/metacore-ui/primitives'
29
+ import { ScanLine } from 'lucide-react'
42
30
  import { BarcodeScanner } from './barcode-scanner'
43
- import { JOINED_TRIGGER_CLASS, PickerCreateItem, RecordPickerAction, hasRecordPickerAction } from './record-picker-actions'
44
- import { DynamicIcon, isLucideIconName } from './dynamic-icon'
45
- import { useDebouncedValue } from './use-debounced-value'
31
+ import { RecordPicker } from './record-picker'
32
+ import { OptionLead } from './record-picker-option'
33
+ import { recordLabel, requestRecordCreate, requestRecordEdit, withSearchPrefill } from './record-picker-actions'
46
34
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
35
+ import { useDebouncedValue } from './use-debounced-value'
47
36
  import { getDependsOn, getFieldRef, resolveOptionsSource } from './dynamic-form-schema'
48
37
  import type { ActionFieldDef } from './types'
49
38
 
39
+ export { OptionLead, OptionThumb } from './record-picker-option'
40
+
50
41
  /**
51
42
  * Default hint shown when a cascading picker's depended-on field is still
52
43
  * empty. Domain-neutral on purpose; a caller may override it per field via
@@ -54,98 +45,6 @@ import type { ActionFieldDef } from './types'
54
45
  */
55
46
  export const DEFAULT_DEPENDS_HINT = 'Selecciona primero el campo del que depende'
56
47
 
57
- /**
58
- * Small square thumbnail for an option's `image`. When the option has no image
59
- * it falls back to a deterministic initials avatar (shared `InitialsAvatar`)
60
- * derived from `name`, so an imageless reference reads as a colored badge rather
61
- * than an empty placeholder — and rows/triggers stay aligned. `size` is in
62
- * pixels (kept small — 20–24px — so the picker reads as a list, not a gallery).
63
- * Inline style for the box dimensions: arbitrary Tailwind classes from a
64
- * federated addon don't always survive the host's class scan.
65
- */
66
- export function OptionThumb({
67
- image,
68
- name,
69
- size = 20,
70
- }: {
71
- image?: string | null
72
- name?: string | null
73
- size?: number
74
- }) {
75
- const [broken, setBroken] = useState(false)
76
- const box = { width: size, height: size }
77
- // Missing or 404 image (bare avatar filenames often 404) → initials, never
78
- // an invisible box that looks like "no avatar".
79
- if (!image || broken) {
80
- return <InitialsAvatar name={name} size={size} rounded="sm" tone="neutral" />
81
- }
82
- // A lucide icon name stored where an image url/path is expected (the `icon`
83
- // form widget's icon mode) → render the glyph instead of a broken <img>.
84
- if (isLucideIconName(image)) {
85
- return (
86
- <span className="flex shrink-0 items-center justify-center rounded-sm bg-muted" style={box} aria-hidden>
87
- <DynamicIcon name={image} className="size-4" />
88
- </span>
89
- )
90
- }
91
- return (
92
- <img
93
- src={image}
94
- alt=""
95
- aria-hidden
96
- loading="lazy"
97
- className="shrink-0 rounded-sm object-cover"
98
- style={box}
99
- onError={() => setBroken(true)}
100
- />
101
- )
102
- }
103
-
104
- /**
105
- * Leading visual for an option: a photo thumbnail (FK relations with an image),
106
- * else a declared icon, else a color dot (enum/status options with a color).
107
- * Returns null when the option carries none, so plain text options stay plain.
108
- */
109
- export function OptionLead({
110
- option,
111
- size = 20,
112
- }: {
113
- option?: Pick<ResolvedOption, 'image' | 'color' | 'icon' | 'label'> | null
114
- size?: number
115
- }) {
116
- if (!option) return null
117
- if (option.image) return <OptionThumb image={option.image} name={option.label} size={size} />
118
- if (option.icon) {
119
- return (
120
- <span
121
- className="flex shrink-0 items-center justify-center"
122
- style={{ width: size, height: size, color: option.color ? resolveColorCss(option.color) : undefined }}
123
- aria-hidden
124
- >
125
- <DynamicIcon name={option.icon} className="size-4" />
126
- </span>
127
- )
128
- }
129
- if (option.color) {
130
- return (
131
- <span
132
- className="shrink-0 rounded-full"
133
- style={{ width: Math.round(size * 0.5), height: Math.round(size * 0.5), background: resolveColorCss(option.color) }}
134
- aria-hidden
135
- />
136
- )
137
- }
138
- // No image/icon/color: an imageless reference option. Show its initials
139
- // (shared InitialsAvatar) so it stays visually aligned with the sibling
140
- // options that DO carry a thumbnail, rather than a blank gap.
141
- if (option.label) return <InitialsAvatar name={option.label} size={size} rounded="sm" tone="neutral" />
142
- return null
143
- }
144
-
145
- function useDebounced<T>(value: T, ms: number): T {
146
- return useDebouncedValue(value, ms)
147
- }
148
-
149
48
  export interface DynamicSelectFieldProps {
150
49
  field: ActionFieldDef
151
50
  value: any
@@ -212,6 +111,12 @@ export interface DynamicSelectFieldProps {
212
111
  invalid?: boolean
213
112
  }
214
113
 
114
+ /**
115
+ * Declarative `dynamic_select` (manifest `ref` / `optionsConfig.source`) on top
116
+ * of the shared {@link RecordPicker}: options from the canonical options
117
+ * endpoint (`useOptionsResolver`: option_filter, options[].when keep-value,
118
+ * cascade `filter_value`), inline create/edit through the host modal events.
119
+ */
215
120
  export function DynamicSelectField({
216
121
  field,
217
122
  value,
@@ -234,35 +139,28 @@ export function DynamicSelectField({
234
139
  const [open, setOpen] = useState(false)
235
140
  const [search, setSearch] = useState('')
236
141
  const [scanOpen, setScanOpen] = useState(false)
237
- const debounced = useDebounced(search, 250)
142
+ const debounced = useDebouncedValue(search, 250)
238
143
 
239
144
  // Escaneo por cámara para "llenar rápido": opt-in por campo (`scan`). El
240
- // botón aparece SIEMPRE que el campo lo declara (igual que el POS), sin
241
- // esconderse por `isCameraScanSupported()` — en escritorio el botón no salía
242
- // aunque el campo lo pidiera. El BarcodeScanner ya degrada con un mensaje
243
- // cuando no hay cámara. Un código escaneado alimenta la búsqueda y abre el
244
- // picker para elegir la referencia sin tipear el UUID.
145
+ // botón aparece SIEMPRE que el campo lo declara (igual que el POS). El
146
+ // BarcodeScanner ya degrada con un mensaje cuando no hay cámara. Un código
147
+ // escaneado alimenta la búsqueda y abre el picker.
245
148
  const scanEnabled = !!(field.scan ?? field.scannable)
246
149
  const handleScanDetected = (code: string) => {
247
150
  setSearch(code)
248
151
  setOpen(true)
249
152
  }
250
- // Remember the label of the option the user actually picked so the trigger
251
- // shows a name (not a UUID) without a round-trip.
153
+ // Remember the option the user actually picked so the trigger shows a name
154
+ // (not a UUID) without a round-trip.
252
155
  const [picked, setPicked] = useState<ResolvedOption | null>(null)
253
156
 
254
- // Tolerate the snake_case `source`/`relation` aliases the kernel may serve
255
- // for the FK target, not just camelCase `ref`.
157
+ // Tolerate the snake_case `source`/`relation` aliases for the FK target.
256
158
  const fieldRef = getFieldRef(field)
257
-
258
- // Options routing: an `optionsConfig.source` (dependent/scoped picker) wins
259
- // over the field's `ref` — query the SOURCE model with `field=<value>`;
260
- // otherwise keep the canonical `ref`-based resolution.
159
+ // An `optionsConfig.source` (dependent/scoped picker) wins over `ref`.
261
160
  const source = resolveOptionsSource(field)
262
161
 
263
162
  // Cascade: a `dependsOn` field whose value is still empty leaves this
264
- // picker disabled until the parent is set. `dependsValue` is the resolved
265
- // value the caller threaded from the form context.
163
+ // picker disabled until the parent is set.
266
164
  const dependsOn = getDependsOn(field)
267
165
  const scope = dependsValue ? String(dependsValue) : ''
268
166
  const blockedByDependency = !!dependsOn && scope === ''
@@ -275,21 +173,15 @@ export function DynamicSelectField({
275
173
  fieldKey: source.fieldKey,
276
174
  ref: source.ref,
277
175
  // optionsConfig.source → `/options/<source>`. Else searchEndpoint only
278
- // drives the URL when there's no ref — ref is the canonical,
279
- // kernel-derived path and wins.
176
+ // drives the URL when there's no ref (ref is canonical and wins).
280
177
  endpoint: source.endpoint ?? (source.ref ? undefined : field.searchEndpoint),
281
178
  query: debounced,
282
179
  limit: 20,
283
- // Cascade scope forwarded as filter_value (only when this field
284
- // declares a dependency). Re-fetches when the parent value changes.
285
180
  filterValue: dependsOn ? scope : undefined,
286
181
  optionFilter,
287
182
  keepValue: value,
288
- // Don't fetch until the popover opens (and keep fetching as the query
289
- // changes while open). A picker blocked by an unset dependency never
290
- // fetches. A readonly cell fetches eagerly so its value's label resolves
291
- // to the name without the user ever opening it.
292
- // Static lists never hit the network.
183
+ // Fetch only while open; a readonly cell fetches eagerly so its label
184
+ // resolves to the name. Blocked cascades and static lists never fetch.
293
185
  enabled: !useStatic && (open || readonly) && !blockedByDependency,
294
186
  })
295
187
 
@@ -305,8 +197,7 @@ export function DynamicSelectField({
305
197
  : fetchedOptions
306
198
  const loading = useStatic ? false : fetchLoading
307
199
 
308
- // When the depended-on value changes, the previously-picked option no longer
309
- // belongs to the new scope, so clear the selection (skip the initial mount).
200
+ // A new cascade scope invalidates the selection (skip the initial mount).
310
201
  const prevScopeRef = useRef<string>(scope)
311
202
  useEffect(() => {
312
203
  if (!dependsOn) return
@@ -317,21 +208,13 @@ export function DynamicSelectField({
317
208
  }
318
209
  }, [dependsOn, scope, value, onChange])
319
210
 
320
- // The currently-selected option, resolved either from what the user picked
321
- // (cached in `picked`) or from the loaded page. Drives both the trigger
322
- // label and its thumbnail.
323
211
  const selectedOption =
324
212
  (picked && String(picked.id) === String(value) ? picked : null) ??
325
213
  options.find((o) => String(o.id) === String(value)) ??
326
214
  (seedOption && String(seedOption.id) === String(value) ? seedOption : null) ??
327
215
  null
328
-
329
216
  const selectedLabel = selectedOption?.label ?? (value ? String(value) : '')
330
217
 
331
- // Only switch the picker into "with thumbnails" mode when the data actually
332
- // carries images — a relation whose options have no `image` keeps the plain
333
- // text list it had before (no empty placeholder column).
334
-
335
218
  const handlePick = (opt: ResolvedOption) => {
336
219
  setPicked(opt)
337
220
  onChange(String(opt.id))
@@ -340,238 +223,130 @@ export function DynamicSelectField({
340
223
  setSearch('')
341
224
  }
342
225
 
343
- // Inline-create: the "+" opens the REFERENCED model's own create modal (the
344
- // real one the host renders for that model — full fields, not a duplicate),
345
- // via a decoupled window event the host listens for. On success the host
346
- // hands back the new record and we select it immediately. No host import →
347
- // no circular dependency; works for ANY dynamic_select with a `ref`.
348
- // Inline-edit: with a value selected the joined pencil opens THAT record's
349
- // edit modal through the sibling `metacore:edit-record` event (the host's
350
- // RecordCreateBridge already listens for it). On save the trigger label
351
- // refreshes from the returned record.
352
- const openEdit = () => {
353
- if (!fieldRef || !value || typeof window === 'undefined') return
354
- setOpen(false)
355
- window.dispatchEvent(
356
- new CustomEvent('metacore:edit-record', {
357
- detail: {
358
- model: fieldRef,
359
- recordId: String(value),
360
- onSaved: (rec: any) => {
361
- const id = String(value)
362
- const label = rec ? rec.name ?? rec.label ?? rec.title : undefined
363
- if (label != null) setPicked({ ...(selectedOption ?? { id, value: id }), id, value: id, label: String(label), name: String(label) })
364
- },
365
- },
366
- }),
367
- )
368
- }
226
+ const toOption = (id: string, label: string): ResolvedOption => ({ id, value: id, label, name: label })
369
227
 
370
- const openCreate = () => {
371
- if (!fieldRef || typeof window === 'undefined') return
228
+ // Inline create / edit: the REFERENCED model's real modal, rendered by the
229
+ // host (RecordCreateBridge) via decoupled window events — no host import.
230
+ const openCreate = (query: string) => {
231
+ if (!fieldRef) return
372
232
  setOpen(false)
373
- window.dispatchEvent(
374
- new CustomEvent('metacore:create-record', {
375
- detail: {
376
- model: fieldRef,
377
- // Generic passthrough: the host's create modal seeds these
378
- // values and locks these fields (DynamicRecordDialog's own
379
- // defaults/lockedFields props). Undefined when unused.
380
- defaults: createDefaults,
381
- lockedFields: createLockedFields,
382
- onCreated: (rec: any) => {
383
- if (rec && rec.id != null) {
384
- const id = String(rec.id)
385
- const label = String(rec.name ?? rec.label ?? rec.title ?? rec.id)
386
- handlePick({ id, value: id, label, name: label })
387
- }
388
- },
389
- },
390
- }),
391
- )
233
+ requestRecordCreate({
234
+ model: fieldRef,
235
+ // The searched text seeds the new record's name; explicit
236
+ // createDefaults win. Keys that aren't fields are ignored.
237
+ defaults: withSearchPrefill(query, 'name', createDefaults),
238
+ lockedFields: createLockedFields,
239
+ onCreated: (rec: any) => {
240
+ if (rec && rec.id != null) {
241
+ const id = String(rec.id)
242
+ handlePick(toOption(id, recordLabel(rec) ?? id))
243
+ }
244
+ },
245
+ })
392
246
  }
393
-
394
- // Locked display: the value is fixed by context, so render the resolved
395
- // label (name) as a disabled control — no popover, no inline-create. While
396
- // the eager fetch is in flight the label falls back to the seed/raw value,
397
- // then snaps to the name once options arrive.
398
- if (readonly) {
399
- return (
400
- <Button
401
- type="button"
402
- variant="outline"
403
- role="combobox"
404
- id={field.key}
405
- disabled
406
- aria-readonly="true"
407
- className="w-full min-w-0 cursor-default justify-start font-normal opacity-100"
408
- >
409
- <span className="flex min-w-0 flex-1 items-center gap-2 text-left">
410
- {selectedOption ? <OptionLead option={selectedOption} size={20} /> : null}
411
- <span className={'min-w-0 flex-1 truncate ' + (selectedOption ? '' : 'text-muted-foreground')}>
412
- {/* Never flash the raw id: until the eager fetch resolves the
413
- option, show a loading hint instead of String(value). */}
414
- {selectedOption?.label ?? (loading ? 'Cargando…' : ph('—'))}
415
- </span>
416
- </span>
417
- </Button>
418
- )
247
+ const openEdit = () => {
248
+ if (!fieldRef || !value) return
249
+ setOpen(false)
250
+ const id = String(value)
251
+ requestRecordEdit({
252
+ model: fieldRef,
253
+ recordId: id,
254
+ onSaved: (rec: any) => {
255
+ const label = recordLabel(rec)
256
+ if (label != null) setPicked({ ...(selectedOption ?? toOption(id, label)), id, value: id, label, name: label })
257
+ },
258
+ })
419
259
  }
420
260
 
421
- // w-full + min-w-0: as a grid cell child, the row must be allowed to shrink
422
- // to the cell. Without min-w-0 the combobox+button row sizes to its content
423
- // (the long empty-state placeholder) and overflows the column, pushing the
424
- // "+" off-screen — it only "fit" once a short value was selected.
425
- const canMutate = !!fieldRef && !hideCreate && !useStatic
426
- const joined = canMutate && !blockedByDependency && hasRecordPickerAction(!!value, openCreate, openEdit)
261
+ const canMutate = !!fieldRef && !hideCreate && !useStatic && !blockedByDependency
427
262
  const fieldName = field.label ? t(field.label, { defaultValue: field.label }) : fieldRef ?? ''
263
+ const badge = (opt: ResolvedOption) =>
264
+ opt.description ? (
265
+ <Badge variant="secondary" className="shrink-0 font-normal tabular-nums">
266
+ {opt.description}
267
+ </Badge>
268
+ ) : null
269
+
428
270
  return (
429
- <div className="flex w-full min-w-0 items-center gap-1.5" data-slot="dynamic-select">
430
- <div className="flex min-w-0 flex-1 items-center">
431
- <Popover open={open && !blockedByDependency} onOpenChange={(o: boolean) => { if (!blockedByDependency) setOpen(o) }}>
432
- <PopoverTrigger asChild>
433
- <Button
434
- type="button"
435
- variant="outline"
436
- role="combobox"
437
- aria-expanded={open}
438
- id={field.key}
439
- disabled={blockedByDependency}
440
- aria-invalid={invalid || undefined}
441
- className={
442
- 'min-w-0 flex-1 justify-between font-normal' +
443
- (joined ? ` ${JOINED_TRIGGER_CLASS}` : '') +
444
- (invalid ? ' border-destructive ring-1 ring-destructive/30' : '')
445
- }
446
- data-empty={!value}
447
- data-depends-blocked={blockedByDependency ? '' : undefined}
448
- >
449
- <span className="flex min-w-0 flex-1 items-center gap-2 text-left">
450
- {value && selectedOption ? (
451
- <OptionLead option={selectedOption} size={20} />
452
- ) : null}
271
+ <RecordPicker<ResolvedOption>
272
+ slot="dynamic-select"
273
+ id={field.key}
274
+ items={options}
275
+ loading={loading}
276
+ getKey={(o) => String(o.id)}
277
+ getLabel={(o) => o.label}
278
+ value={value}
279
+ selected={selectedOption}
280
+ onSelect={handlePick}
281
+ query={search}
282
+ onQueryChange={setSearch}
283
+ open={open && !blockedByDependency}
284
+ onOpenChange={(o) => {
285
+ if (!blockedByDependency) setOpen(o)
286
+ }}
287
+ disabled={blockedByDependency}
288
+ readOnly={readonly}
289
+ invalid={invalid}
290
+ renderLead={(o, where) => <OptionLead option={o} size={where === 'option' ? 24 : 20} />}
291
+ getDescription={descriptionAsBadge ? undefined : (o) => o.description}
292
+ renderTrailing={descriptionAsBadge ? (o) => badge(o) : undefined}
293
+ renderValue={() => {
294
+ if (readonly) {
295
+ return (
296
+ <>
297
+ {selectedOption ? <OptionLead option={selectedOption} size={20} /> : null}
298
+ <span className={'min-w-0 flex-1 truncate ' + (selectedOption ? '' : 'text-muted-foreground')}>
299
+ {/* Never flash the raw id while the eager fetch resolves. */}
300
+ {selectedOption?.label ?? (loading ? 'Cargando…' : ph('—'))}
301
+ </span>
302
+ </>
303
+ )
304
+ }
305
+ return (
306
+ <>
307
+ {value && selectedOption ? <OptionLead option={selectedOption} size={20} /> : null}
453
308
  <span className={'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground')}>
454
- {blockedByDependency
455
- ? (dependsHint || DEFAULT_DEPENDS_HINT)
456
- : selectedLabel || ph('Buscar…')}
309
+ {blockedByDependency ? dependsHint || DEFAULT_DEPENDS_HINT : selectedLabel || ph('Buscar…')}
457
310
  </span>
458
- {descriptionAsBadge && selectedOption?.description ? (
459
- <Badge variant="secondary" className="shrink-0 font-normal tabular-nums">
460
- {selectedOption.description}
461
- </Badge>
462
- ) : null}
463
- </span>
464
- <ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
465
- </Button>
466
- </PopoverTrigger>
467
- <PopoverContent
468
- className="p-0"
469
- align="start"
470
- // Portaled (never clipped by a table/modal overflow) and
471
- // collision-aware: flips above the trigger when there is no
472
- // room below. Match the trigger width without an arbitrary
473
- // Tailwind class (those don't always survive a consuming app's
474
- // Tailwind scan), with a floor so short cells stay readable.
475
- collisionPadding={8}
476
- style={{ width: 'max(var(--radix-popover-trigger-width), 14rem)', maxWidth: 'calc(100vw - 1rem)' }}
477
- >
478
- <Command shouldFilter={false}>
479
- <CommandInput
480
- placeholder={ph('Buscar…')}
481
- value={search}
482
- onValueChange={setSearch}
483
- />
484
- <CommandList>
485
- {loading && (
486
- <div className="text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm">
487
- <Loader2 className="size-4 animate-spin" />
488
- Buscando…
489
- </div>
490
- )}
491
- {!loading && options.length === 0 && (
492
- // Plain block, not <CommandEmpty>: cmdk hides Empty while
493
- // any item is mounted, and the "Crear …" footer is one.
494
- <div className="text-muted-foreground py-6 text-center text-sm" data-slot="picker-empty">
495
- {useStatic
496
- ? debounced
497
- ? 'Sin resultados'
498
- : 'Sin opciones'
499
- : debounced
500
- ? 'Sin resultados'
501
- : 'Sin resultados — escribe para filtrar'}
502
- </div>
503
- )}
504
- {!loading && options.length > 0 && (
505
- <CommandGroup className="max-h-64 overflow-auto">
506
- {options.map((opt) => {
507
- const isSel = String(opt.id) === String(value)
508
- return (
509
- <CommandItem
510
- key={String(opt.id)}
511
- value={String(opt.id)}
512
- onSelect={() => handlePick(opt)}
513
- >
514
- <Check className={'mr-2 size-4 shrink-0 ' + (isSel ? 'opacity-100' : 'opacity-0')} />
515
- <OptionLead option={opt} size={24} />
516
- {descriptionAsBadge ? (
517
- <div className="ml-2 flex min-w-0 flex-1 items-center gap-2">
518
- <span className="min-w-0 flex-1 truncate">{opt.label}</span>
519
- {opt.description ? (
520
- <Badge variant="secondary" className="shrink-0 font-normal tabular-nums">
521
- {opt.description}
522
- </Badge>
523
- ) : null}
524
- </div>
525
- ) : (
526
- <div className="ml-2 flex min-w-0 flex-col">
527
- <span className="truncate">{opt.label}</span>
528
- {opt.description && (
529
- <span className="text-muted-foreground truncate text-xs">
530
- {opt.description}
531
- </span>
532
- )}
533
- </div>
534
- )}
535
- </CommandItem>
536
- )
537
- })}
538
- </CommandGroup>
539
- )}
540
- {canMutate && !loading && (
541
- <PickerCreateItem label={fieldName} onSelect={openCreate} />
542
- )}
543
- </CommandList>
544
- </Command>
545
- </PopoverContent>
546
- </Popover>
547
- {canMutate && !blockedByDependency && (
548
- <RecordPickerAction hasValue={!!value} label={fieldName} onCreate={openCreate} onEdit={openEdit} />
549
- )}
550
- </div>
551
- {scanEnabled && (
552
- <Button
553
- type="button"
554
- variant="outline"
555
- size="icon"
556
- className="size-9 shrink-0"
557
- onClick={() => setScanOpen(true)}
558
- title="Escanear con la cámara"
559
- aria-label="Escanear código de barras con la cámara"
560
- >
561
- <ScanLine className="size-4" />
562
- </Button>
563
- )}
564
- {scanEnabled && (
565
- <BarcodeScanner
566
- open={scanOpen}
567
- onClose={() => setScanOpen(false)}
568
- onDetected={handleScanDetected}
569
- continuous={false}
570
- position="fixed"
571
- title={`Escanear ${field.label ?? ''}`.trim()}
572
- />
573
- )}
574
- </div>
311
+ {descriptionAsBadge && selectedOption ? badge(selectedOption) : null}
312
+ </>
313
+ )
314
+ }}
315
+ placeholder={ph('Buscar…')}
316
+ searchPlaceholder={ph('Buscar…')}
317
+ emptyText={debounced ? 'Sin resultados' : useStatic ? 'Sin opciones' : 'Sin resultados — escribe para filtrar'}
318
+ entityLabel={fieldName}
319
+ onCreate={canMutate ? openCreate : undefined}
320
+ onEdit={canMutate ? openEdit : undefined}
321
+ triggerProps={{
322
+ 'data-depends-blocked': blockedByDependency ? '' : undefined,
323
+ }}
324
+ after={
325
+ scanEnabled ? (
326
+ <>
327
+ <Button
328
+ type="button"
329
+ variant="outline"
330
+ size="icon"
331
+ className="size-9 shrink-0"
332
+ onClick={() => setScanOpen(true)}
333
+ title="Escanear con la cámara"
334
+ aria-label="Escanear código de barras con la cámara"
335
+ >
336
+ <ScanLine className="size-4" />
337
+ </Button>
338
+ <BarcodeScanner
339
+ open={scanOpen}
340
+ onClose={() => setScanOpen(false)}
341
+ onDetected={handleScanDetected}
342
+ continuous={false}
343
+ position="fixed"
344
+ title={`Escanear ${field.label ?? ''}`.trim()}
345
+ />
346
+ </>
347
+ ) : null
348
+ }
349
+ />
575
350
  )
576
351
  }
577
352