@object-ui/fields 3.1.2 → 3.1.4

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 (91) hide show
  1. package/dist/AddressField-B6fPb2Fs.js +3 -0
  2. package/dist/AddressField-Ctsxkj35.cjs +1 -0
  3. package/dist/AutoNumberField-DXIYJfiB.js +3 -0
  4. package/dist/AutoNumberField-f76XU1bR.cjs +1 -0
  5. package/dist/AvatarField-BHpex3Wa.js +3 -0
  6. package/dist/AvatarField-CXOdYavl.cjs +1 -0
  7. package/dist/BooleanField-Bs0urhsx.js +3 -0
  8. package/dist/BooleanField-DeexI_O2.cjs +1 -0
  9. package/dist/CodeField-DDOMH8E8.cjs +1 -0
  10. package/dist/CodeField-tRACqngO.js +3 -0
  11. package/dist/ColorField-Bar6bOJw.js +3 -0
  12. package/dist/ColorField-CM9CxZ9h.cjs +1 -0
  13. package/dist/CurrencyField-CezTX_rA.cjs +1 -0
  14. package/dist/CurrencyField-D4VwXI9k.js +3 -0
  15. package/dist/DateField-DDUllj7w.cjs +1 -0
  16. package/dist/DateField-DjGiK6BH.js +3 -0
  17. package/dist/DateTimeField-DBh-dYdR.js +3 -0
  18. package/dist/DateTimeField-DDqgVN3b.cjs +1 -0
  19. package/dist/EmailField-B-3nY8Pr.cjs +1 -0
  20. package/dist/EmailField-Cp6yJlpH.js +3 -0
  21. package/dist/FileField-C1bcL7tu.cjs +1 -0
  22. package/dist/FileField-DUgt6ai3.js +3 -0
  23. package/dist/FormulaField-BLV1KgkQ.js +3 -0
  24. package/dist/FormulaField-CsbQIZ7S.cjs +1 -0
  25. package/dist/GeolocationField-BbIRH51u.js +3 -0
  26. package/dist/GeolocationField-UFu9VR-x.cjs +1 -0
  27. package/dist/GridField-2m74haE0.cjs +1 -0
  28. package/dist/GridField-O3Jd1NuJ.js +3 -0
  29. package/dist/ImageField-Cna7g6KL.cjs +1 -0
  30. package/dist/ImageField-rc1Nzcvb.js +3 -0
  31. package/dist/LocationField-DpRe9QlH.cjs +1 -0
  32. package/dist/LocationField-QZPPYo0J.js +3 -0
  33. package/dist/LookupField-BX4sK8NG.js +904 -0
  34. package/dist/LookupField-C9rPrHi_.cjs +1 -0
  35. package/dist/MasterDetailField-BXncIT1s.js +3 -0
  36. package/dist/MasterDetailField-TNP2825Z.cjs +1 -0
  37. package/dist/NumberField-DCHTzmDG.js +3 -0
  38. package/dist/NumberField-SqRwbcY8.cjs +1 -0
  39. package/dist/ObjectField-BEUHpn_X.js +3 -0
  40. package/dist/ObjectField-BWZwaFIG.cjs +1 -0
  41. package/dist/PasswordField-BZ_OeQyw.js +3 -0
  42. package/dist/PasswordField-kLp-ZyPm.cjs +1 -0
  43. package/dist/PercentField-B2t2knlc.js +3 -0
  44. package/dist/PercentField-BzhD6tQm.cjs +1 -0
  45. package/dist/PhoneField-CirvuysP.cjs +1 -0
  46. package/dist/PhoneField-CkrCFmcO.js +3 -0
  47. package/dist/QRCodeField-C4OOLCbE.cjs +1 -0
  48. package/dist/QRCodeField-CQoGow8y.js +3 -0
  49. package/dist/RatingField-CZG8SgQ8.cjs +1 -0
  50. package/dist/RatingField-DecDJkou.js +3 -0
  51. package/dist/RichTextField-Bog2i4NN.cjs +1 -0
  52. package/dist/RichTextField-b-8QbNCJ.js +3 -0
  53. package/dist/SelectField-CAa6c0cB.cjs +1 -0
  54. package/dist/SelectField-DMy5IxRc.js +3 -0
  55. package/dist/SignatureField-BvTPE7Dn.js +3 -0
  56. package/dist/SignatureField-jsb-0AYp.cjs +1 -0
  57. package/dist/SliderField-B7WvKFv7.js +3 -0
  58. package/dist/SliderField-DRjEJSxK.cjs +1 -0
  59. package/dist/SummaryField-DA16Edq3.cjs +1 -0
  60. package/dist/SummaryField-DVSVfKmY.js +3 -0
  61. package/dist/TextAreaField--ut87UiB.js +3 -0
  62. package/dist/TextAreaField-Cxp3DFC6.cjs +1 -0
  63. package/dist/TextField-CkEDGbkQ.js +3 -0
  64. package/dist/TextField-aLAzBaLZ.cjs +1 -0
  65. package/dist/TimeField-D0lF0y_I.cjs +1 -0
  66. package/dist/TimeField-DA_S6lQV.js +3 -0
  67. package/dist/UrlField-D0ugmD3f.cjs +1 -0
  68. package/dist/UrlField-DCnXHR1A.js +3 -0
  69. package/dist/UserField-DGlorzyG.cjs +1 -0
  70. package/dist/UserField-V0GmvyGV.js +3 -0
  71. package/dist/VectorField-BSD85ntU.cjs +1 -0
  72. package/dist/VectorField-CVi5yrrc.js +3 -0
  73. package/dist/fields/src/index.d.ts +8 -0
  74. package/dist/fields/src/record-picker-dialog.stories.d.ts +25 -0
  75. package/dist/fields/src/record-picker.test.d.ts +1 -0
  76. package/dist/fields/src/widgets/LookupField.d.ts +13 -2
  77. package/dist/fields/src/widgets/RecordPickerDialog.d.ts +166 -0
  78. package/dist/fields/src/widgets/_cell-renderer-bridge.d.ts +3 -0
  79. package/dist/index.cjs +4 -9
  80. package/dist/index.js +2229 -2682
  81. package/dist/jsx-runtime-DHCF5ANT.cjs +6 -0
  82. package/dist/jsx-runtime-MxADXuRH.js +189 -0
  83. package/package.json +8 -7
  84. package/src/__tests__/cell-renderers.test.tsx +133 -4
  85. package/src/complex-widgets.test.tsx +426 -2
  86. package/src/index.tsx +76 -22
  87. package/src/record-picker-dialog.stories.tsx +160 -0
  88. package/src/record-picker.test.tsx +1696 -0
  89. package/src/widgets/LookupField.tsx +485 -80
  90. package/src/widgets/RecordPickerDialog.tsx +1182 -0
  91. package/src/widgets/_cell-renderer-bridge.ts +19 -0
@@ -1,73 +1,338 @@
1
- import React, { useState } from 'react';
1
+ import React, { useState, useEffect, useCallback, useRef, useContext, useMemo } from 'react';
2
2
  import {
3
3
  Button,
4
- Dialog,
5
- DialogContent,
6
- DialogHeader,
7
- DialogTitle,
8
- DialogTrigger,
9
4
  Input,
10
- Badge
5
+ Badge,
6
+ Popover,
7
+ PopoverTrigger,
8
+ PopoverContent,
11
9
  } from '@object-ui/components';
12
- import { Search, X } from 'lucide-react';
10
+ import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
13
11
  import { FieldWidgetProps } from './types';
12
+ import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
13
+ import { RecordPickerDialog } from './RecordPickerDialog';
14
+ import type { RecordPickerFilterColumn } from './RecordPickerDialog';
15
+ import { getCellRendererResolver } from './_cell-renderer-bridge';
16
+ import { SchemaRendererContext as ImportedSchemaRendererContext } from '@object-ui/react';
14
17
 
15
- interface LookupOption {
18
+ export interface LookupOption {
16
19
  value: string | number;
17
20
  label: string;
21
+ description?: string;
18
22
  [key: string]: any;
19
23
  }
20
24
 
25
+ /** Page size for the quick-select popover typeahead */
26
+ const LOOKUP_PAGE_SIZE = 50;
27
+
28
+ /**
29
+ * SchemaRendererContext is created by @object-ui/react.
30
+ * Using a static import to be compatible with Next.js Turbopack SSR.
31
+ */
32
+ const SchemaRendererContext: React.Context<any> = ImportedSchemaRendererContext;
33
+
21
34
  /**
22
- * Lookup field for selecting related records
23
- * Supports single and multi-select with search
35
+ * Map a raw record to a LookupOption using a display field and an id field.
36
+ */
37
+ function recordToOption(
38
+ record: any,
39
+ displayField: string,
40
+ idField: string,
41
+ descriptionField?: string,
42
+ ): LookupOption {
43
+ const val = record[idField] ?? record.id ?? record._id;
44
+ const label = record[displayField] ?? record.label ?? record.name ?? String(val);
45
+ const description = descriptionField ? record[descriptionField] : undefined;
46
+ return { value: val, label: String(label), description, ...record };
47
+ }
48
+
49
+ /**
50
+ * Map a LookupColumnDef.type to a filter input type for the filter bar.
51
+ * Returns undefined if the field type is not filterable.
52
+ */
53
+ function mapFieldTypeToFilterType(
54
+ fieldType: string,
55
+ ): RecordPickerFilterColumn['type'] | undefined {
56
+ const mapping: Record<string, RecordPickerFilterColumn['type']> = {
57
+ text: 'text',
58
+ number: 'number',
59
+ currency: 'number',
60
+ percent: 'number',
61
+ select: 'select',
62
+ status: 'select',
63
+ date: 'date',
64
+ datetime: 'date',
65
+ boolean: 'boolean',
66
+ };
67
+ return mapping[fieldType];
68
+ }
69
+
70
+ /**
71
+ * Lookup field for selecting related records.
72
+ * Supports single and multi-select with search.
73
+ *
74
+ * When a `dataSource` is provided (either via props, via `field.dataSource`,
75
+ * or via SchemaRendererContext), the dialog will dynamically load records
76
+ * from the referenced object using `DataSource.find()`.
77
+ * Falls back to static `options` when no DataSource is available.
24
78
  */
25
79
  export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
26
80
  const [isOpen, setIsOpen] = useState(false);
27
81
  const [searchQuery, setSearchQuery] = useState('');
28
82
 
83
+ // Dynamic data loading state
84
+ const [fetchedOptions, setFetchedOptions] = useState<LookupOption[]>([]);
85
+ const [loading, setLoading] = useState(false);
86
+ const [error, setError] = useState<string | null>(null);
87
+ const [totalCount, setTotalCount] = useState(0);
88
+ const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
89
+
90
+ // Records selected via RecordPickerDialog (Level 2).
91
+ // Stored as LookupOption so that findOption can resolve display labels
92
+ // even when the record wasn't part of the Level 1 popover fetch.
93
+ const [pickerResolvedRecords, setPickerResolvedRecords] = useState<LookupOption[]>([]);
94
+
95
+ // Arrow-key active index (-1 = none)
96
+ const [activeIndex, setActiveIndex] = useState(-1);
97
+ const listRef = useRef<HTMLDivElement>(null);
98
+
29
99
  const lookupField = (field || (props as any).schema) as any;
30
- const options: LookupOption[] = lookupField?.options || [];
31
- const multiple = lookupField.multiple || false;
32
- const displayField = lookupField.display_field || 'label';
33
100
 
34
- // Filter options based on search
35
- const filteredOptions = options.filter(opt =>
36
- opt.label.toLowerCase().includes(searchQuery.toLowerCase())
101
+ // When rendered via createFieldRenderer wrapper the actual objectSchema field
102
+ // metadata (reference_to, display_field, etc.) lives at lookupField.field.
103
+ // Unwrap it so lookup-specific properties resolve correctly.
104
+ // ObjectStack convention uses `reference` while the types use `reference_to`,
105
+ // so we check for both property names.
106
+ const innerField = lookupField?.field;
107
+ const fieldMeta = (innerField && typeof innerField === 'object' && ('reference_to' in innerField || 'reference' in innerField || 'type' in innerField))
108
+ ? innerField
109
+ : lookupField;
110
+
111
+ const staticOptions: LookupOption[] = fieldMeta?.options || [];
112
+ const multiple = fieldMeta?.multiple || false;
113
+ const displayField = fieldMeta?.display_field || fieldMeta?.reference_field || 'name';
114
+ const descriptionField: string | undefined = fieldMeta?.description_field;
115
+ const idField = fieldMeta?.id_field || 'id';
116
+ // ObjectStack convention uses `reference`; types define `reference_to` — support both
117
+ const referenceTo: string | undefined = fieldMeta?.reference_to || fieldMeta?.reference;
118
+
119
+ // Enterprise Record Picker configuration
120
+ const lookupColumns: Array<string | LookupColumnDef> | undefined = fieldMeta?.lookup_columns;
121
+ const lookupPageSize: number | undefined = fieldMeta?.lookup_page_size;
122
+ const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookup_filters;
123
+
124
+ // Derive filter columns from lookup_columns that have type info
125
+ const filterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
126
+ if (!lookupColumns) return undefined;
127
+ const cols: RecordPickerFilterColumn[] = [];
128
+ for (const c of lookupColumns) {
129
+ if (typeof c === 'object' && c.type) {
130
+ const filterType = mapFieldTypeToFilterType(c.type);
131
+ if (filterType) {
132
+ cols.push({
133
+ field: c.field,
134
+ label: c.label,
135
+ type: filterType,
136
+ });
137
+ }
138
+ }
139
+ }
140
+ return cols.length > 0 ? cols : undefined;
141
+ }, [lookupColumns]);
142
+
143
+ // Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none
144
+ const ctx = useContext(SchemaRendererContext);
145
+ const contextDataSource = ctx?.dataSource ?? null;
146
+ const dataSource: DataSource | null =
147
+ (props as any).dataSource ?? lookupField?.dataSource ?? fieldMeta?.dataSource ?? contextDataSource;
148
+
149
+ const hasDataSource = dataSource != null && typeof dataSource.find === 'function' && !!referenceTo;
150
+
151
+ // Optional create-new callback
152
+ const onCreateNew: ((searchQuery: string) => void) | undefined =
153
+ (props as any).onCreateNew ?? lookupField?.onCreateNew;
154
+
155
+ // State for the full Record Picker dialog (Level 2)
156
+ const [isPickerOpen, setIsPickerOpen] = useState(false);
157
+
158
+ // Determine which options to display
159
+ const allOptions = hasDataSource ? fetchedOptions : staticOptions;
160
+
161
+ // For static options, filter locally based on search
162
+ const filteredOptions = useMemo(() => {
163
+ if (hasDataSource) return allOptions;
164
+ if (!searchQuery) return allOptions;
165
+ const q = searchQuery.toLowerCase();
166
+ return allOptions.filter(opt =>
167
+ opt.label.toLowerCase().includes(q) ||
168
+ (opt.description && opt.description.toLowerCase().includes(q))
169
+ );
170
+ }, [hasDataSource, allOptions, searchQuery]);
171
+
172
+ // Reset active index when options change
173
+ useEffect(() => {
174
+ setActiveIndex(-1);
175
+ }, [filteredOptions.length]);
176
+
177
+ // Fetch data from DataSource
178
+ const fetchLookupData = useCallback(
179
+ async (search?: string) => {
180
+ if (!dataSource || !referenceTo) return;
181
+
182
+ setLoading(true);
183
+ setError(null);
184
+
185
+ try {
186
+ const params: QueryParams = {
187
+ $top: LOOKUP_PAGE_SIZE,
188
+ };
189
+ if (search && search.trim()) {
190
+ params.$search = search.trim();
191
+ }
192
+
193
+ const result = await dataSource.find(referenceTo, params);
194
+ const records: any[] = result?.data ?? result ?? [];
195
+ const mapped = records.map(r => recordToOption(r, displayField, idField, descriptionField));
196
+
197
+ setFetchedOptions(mapped);
198
+ setTotalCount(result?.total ?? records.length);
199
+ } catch (err) {
200
+ const msg = err instanceof Error ? err.message : String(err);
201
+ setError(msg);
202
+ setFetchedOptions([]);
203
+ } finally {
204
+ setLoading(false);
205
+ }
206
+ },
207
+ [dataSource, referenceTo, displayField, idField, descriptionField],
208
+ );
209
+
210
+ // Fetch data when dialog opens.
211
+ // We intentionally depend only on `isOpen` so the effect fires once per
212
+ // open/close transition. `fetchLookupData` is stable-enough via its own
213
+ // useCallback deps; including it here would cause spurious re-fetches.
214
+ useEffect(() => {
215
+ if (isOpen && hasDataSource) {
216
+ fetchLookupData(searchQuery || undefined);
217
+ }
218
+ // Clean up fetched data when dialog closes
219
+ if (!isOpen) {
220
+ setSearchQuery('');
221
+ setError(null);
222
+ setActiveIndex(-1);
223
+ }
224
+ // eslint-disable-next-line react-hooks/exhaustive-deps
225
+ }, [isOpen]);
226
+
227
+ // Debounced search
228
+ const handleSearchChange = useCallback(
229
+ (query: string) => {
230
+ setSearchQuery(query);
231
+
232
+ if (!hasDataSource) return;
233
+
234
+ if (debounceTimer.current) {
235
+ clearTimeout(debounceTimer.current);
236
+ }
237
+ debounceTimer.current = setTimeout(() => {
238
+ fetchLookupData(query || undefined);
239
+ }, 300);
240
+ },
241
+ [hasDataSource, fetchLookupData],
242
+ );
243
+
244
+ // Clean up debounce timer
245
+ useEffect(() => {
246
+ return () => {
247
+ if (debounceTimer.current) {
248
+ clearTimeout(debounceTimer.current);
249
+ }
250
+ };
251
+ }, []);
252
+
253
+ // Get selected option(s) — check static, fetched, and picker-resolved options
254
+ const findOption = useCallback(
255
+ (v: any): LookupOption | undefined => {
256
+ return (
257
+ staticOptions.find(opt => opt.value === v) ??
258
+ fetchedOptions.find(opt => opt.value === v) ??
259
+ pickerResolvedRecords.find(opt => opt.value === v)
260
+ );
261
+ },
262
+ [staticOptions, fetchedOptions, pickerResolvedRecords],
37
263
  );
38
264
 
39
- // Get selected option(s)
40
265
  const selectedOptions = multiple
41
- ? (Array.isArray(value) ? value : []).map(v =>
42
- options.find(opt => opt.value === v)
43
- ).filter(Boolean)
44
- : value ? [options.find(opt => opt.value === value)].filter(Boolean) : [];
266
+ ? (Array.isArray(value) ? value : []).map(findOption).filter(Boolean)
267
+ : value ? [findOption(value)].filter(Boolean) : [];
45
268
 
46
- const handleSelect = (option: LookupOption) => {
47
- if (multiple) {
48
- const currentValues = Array.isArray(value) ? value : [];
49
- const isSelected = currentValues.includes(option.value);
50
-
51
- if (isSelected) {
52
- onChange(currentValues.filter(v => v !== option.value));
269
+ const handleSelect = useCallback(
270
+ (option: LookupOption) => {
271
+ if (multiple) {
272
+ const currentValues = Array.isArray(value) ? value : [];
273
+ const isSelected = currentValues.includes(option.value);
274
+
275
+ if (isSelected) {
276
+ onChange(currentValues.filter((v: any) => v !== option.value));
277
+ } else {
278
+ onChange([...currentValues, option.value]);
279
+ }
53
280
  } else {
54
- onChange([...currentValues, option.value]);
281
+ onChange(option.value);
282
+ setIsOpen(false);
55
283
  }
56
- } else {
57
- onChange(option.value);
58
- setIsOpen(false);
59
- }
60
- };
284
+ },
285
+ [multiple, value, onChange],
286
+ );
61
287
 
62
288
  const handleRemove = (optionValue: any) => {
63
289
  if (multiple) {
64
290
  const currentValues = Array.isArray(value) ? value : [];
65
- onChange(currentValues.filter(v => v !== optionValue));
291
+ onChange(currentValues.filter((v: any) => v !== optionValue));
66
292
  } else {
67
293
  onChange(null);
68
294
  }
69
295
  };
70
296
 
297
+ // Callback from RecordPickerDialog — caches selected records so that
298
+ // findOption can resolve display labels after the dialog closes.
299
+ const handlePickerSelectRecords = useCallback(
300
+ (records: any[]) => {
301
+ const mapped = records.map(r => recordToOption(r, displayField, idField, descriptionField));
302
+ setPickerResolvedRecords(mapped);
303
+ },
304
+ [displayField, idField, descriptionField],
305
+ );
306
+
307
+ // Keyboard handler for the search input — arrow keys + Enter
308
+ const handleSearchKeyDown = useCallback(
309
+ (e: React.KeyboardEvent) => {
310
+ if (e.key === 'ArrowDown') {
311
+ e.preventDefault();
312
+ setActiveIndex(prev =>
313
+ prev < filteredOptions.length - 1 ? prev + 1 : prev,
314
+ );
315
+ } else if (e.key === 'ArrowUp') {
316
+ e.preventDefault();
317
+ setActiveIndex(prev => (prev > 0 ? prev - 1 : 0));
318
+ } else if (e.key === 'Enter') {
319
+ e.preventDefault();
320
+ if (activeIndex >= 0 && activeIndex < filteredOptions.length) {
321
+ handleSelect(filteredOptions[activeIndex]);
322
+ }
323
+ }
324
+ },
325
+ [filteredOptions, activeIndex, handleSelect],
326
+ );
327
+
328
+ // Scroll active item into view
329
+ useEffect(() => {
330
+ if (activeIndex >= 0 && listRef.current) {
331
+ const el = listRef.current.querySelector(`[data-lookup-index="${activeIndex}"]`);
332
+ el?.scrollIntoView({ block: 'nearest' });
333
+ }
334
+ }, [activeIndex]);
335
+
71
336
  if (readonly) {
72
337
  if (!selectedOptions.length) {
73
338
  return <span className="text-sm">-</span>;
@@ -117,12 +382,13 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
117
382
  </div>
118
383
  )}
119
384
 
120
- {/* Lookup dialog trigger */}
121
- <Dialog open={isOpen} onOpenChange={setIsOpen}>
122
- <DialogTrigger asChild>
385
+ {/* Level 1: Quick-select Popover (inline typeahead) */}
386
+ <div className="flex items-center gap-1.5">
387
+ <Popover open={isOpen} onOpenChange={setIsOpen}>
388
+ <PopoverTrigger asChild>
123
389
  <Button
124
390
  variant="outline"
125
- className="w-full justify-start text-left font-normal"
391
+ className="min-w-0 flex-1 justify-start text-left font-normal"
126
392
  type="button"
127
393
  >
128
394
  <Search className="mr-2 size-4" />
@@ -131,56 +397,195 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
131
397
  : multiple ? `${selectedOptions.length} selected` : 'Change selection'
132
398
  }
133
399
  </Button>
134
- </DialogTrigger>
135
- <DialogContent className="max-w-md">
136
- <DialogHeader>
137
- <DialogTitle>
138
- {lookupField?.label || 'Select'} {multiple && '(multiple)'}
139
- </DialogTitle>
140
- </DialogHeader>
141
-
400
+ </PopoverTrigger>
401
+ <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
142
402
  {/* Search input */}
143
- <div className="space-y-4">
144
- <Input
145
- placeholder="Search..."
146
- value={searchQuery}
147
- onChange={(e) => setSearchQuery(e.target.value)}
148
- className="w-full"
149
- />
150
-
151
- {/* Options list */}
152
- <div className="max-h-64 overflow-y-auto space-y-1">
403
+ <div className="p-2">
404
+ <div className="relative">
405
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
406
+ <Input
407
+ placeholder="Search..."
408
+ value={searchQuery}
409
+ onChange={(e) => handleSearchChange(e.target.value)}
410
+ onKeyDown={handleSearchKeyDown}
411
+ className="w-full pl-9 h-8 text-sm"
412
+ />
413
+ {loading && (
414
+ <Loader2
415
+ className="absolute right-3 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground"
416
+ data-testid="lookup-loading"
417
+ />
418
+ )}
419
+ </div>
420
+ </div>
421
+
422
+ {/* Error state */}
423
+ {error && (
424
+ <div className="flex flex-col items-center gap-2 py-4 px-2" role="alert">
425
+ <AlertCircle className="size-5 text-destructive" />
426
+ <p className="text-sm text-destructive">{error}</p>
427
+ <Button
428
+ variant="outline"
429
+ size="sm"
430
+ onClick={() => fetchLookupData(searchQuery || undefined)}
431
+ type="button"
432
+ >
433
+ Retry
434
+ </Button>
435
+ </div>
436
+ )}
437
+
438
+ {/* Loading state (initial load only, not search refinement) */}
439
+ {loading && filteredOptions.length === 0 && !error && (
440
+ <div className="flex flex-col items-center gap-2 py-6" role="status" aria-live="polite">
441
+ <Loader2 className="size-6 animate-spin text-muted-foreground" />
442
+ <p className="text-sm text-muted-foreground">Loading…</p>
443
+ </div>
444
+ )}
445
+
446
+ {/* Options list */}
447
+ {!error && !(loading && filteredOptions.length === 0) && (
448
+ <div ref={listRef} className="max-h-64 overflow-y-auto px-1 pb-1" role="listbox">
153
449
  {filteredOptions.length === 0 ? (
154
- <p className="text-sm text-gray-500 text-center py-4">
155
- No options found
156
- </p>
450
+ <div className="py-4 text-center">
451
+ <p className="text-sm text-muted-foreground">
452
+ No options found
453
+ </p>
454
+ {/* Quick-create entry */}
455
+ {onCreateNew && (
456
+ <Button
457
+ variant="ghost"
458
+ size="sm"
459
+ className="mt-2 gap-1"
460
+ type="button"
461
+ onClick={() => {
462
+ onCreateNew(searchQuery);
463
+ setIsOpen(false);
464
+ }}
465
+ >
466
+ <Plus className="size-4" />
467
+ Create new
468
+ </Button>
469
+ )}
470
+ </div>
157
471
  ) : (
158
- filteredOptions.map((option) => {
159
- const isSelected = multiple
160
- ? (Array.isArray(value) ? value : []).includes(option.value)
161
- : value === option.value;
472
+ <>
473
+ {filteredOptions.map((option, idx) => {
474
+ const isSelected = multiple
475
+ ? (Array.isArray(value) ? value : []).includes(option.value)
476
+ : value === option.value;
477
+ const isActive = idx === activeIndex;
162
478
 
163
- return (
479
+ return (
480
+ <button
481
+ key={option.value}
482
+ data-lookup-index={idx}
483
+ role="option"
484
+ aria-selected={isSelected}
485
+ onClick={() => handleSelect(option)}
486
+ className={`w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center justify-between ${
487
+ isActive
488
+ ? 'bg-accent text-accent-foreground'
489
+ : isSelected
490
+ ? 'bg-accent/50 text-accent-foreground'
491
+ : ''
492
+ }`}
493
+ type="button"
494
+ >
495
+ <div className="min-w-0 flex-1">
496
+ <span className="block truncate">{option.label}</span>
497
+ {option.description && (
498
+ <span className="block truncate text-xs text-muted-foreground">
499
+ {option.description}
500
+ </span>
501
+ )}
502
+ </div>
503
+ {isSelected && (
504
+ <Badge variant="default" className="ml-2 shrink-0">Selected</Badge>
505
+ )}
506
+ </button>
507
+ );
508
+ })}
509
+ {/* Show total count when fetched from DataSource */}
510
+ {hasDataSource && totalCount > filteredOptions.length && (
511
+ <p className="text-xs text-muted-foreground text-center py-2">
512
+ Showing {filteredOptions.length} of {totalCount} results.
513
+ </p>
514
+ )}
515
+ {/* "Show All Results" button — opens the full Record Picker (Level 2) */}
516
+ {hasDataSource && totalCount > filteredOptions.length && (
517
+ <button
518
+ type="button"
519
+ className="w-full text-center px-3 py-2 rounded-md text-sm font-medium text-primary hover:bg-accent flex items-center justify-center gap-1.5"
520
+ onClick={() => {
521
+ setIsOpen(false);
522
+ setIsPickerOpen(true);
523
+ }}
524
+ data-testid="show-all-results"
525
+ >
526
+ <TableProperties className="size-3.5" />
527
+ Show All Results ({totalCount})
528
+ </button>
529
+ )}
530
+ {/* Quick-create entry (below results) */}
531
+ {onCreateNew && (
164
532
  <button
165
- key={option.value}
166
- onClick={() => handleSelect(option)}
167
- className={`w-full text-left px-3 py-2 rounded-md text-sm hover:bg-gray-100 flex items-center justify-between ${
168
- isSelected ? 'bg-blue-50 text-blue-700' : ''
169
- }`}
170
533
  type="button"
534
+ className="w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center gap-1.5 text-muted-foreground"
535
+ onClick={() => {
536
+ onCreateNew(searchQuery);
537
+ setIsOpen(false);
538
+ }}
171
539
  >
172
- <span>{option.label}</span>
173
- {isSelected && (
174
- <Badge variant="default" className="ml-2">Selected</Badge>
175
- )}
540
+ <Plus className="size-3.5" />
541
+ Create new{searchQuery ? ` "${searchQuery}"` : ''}
176
542
  </button>
177
- );
178
- })
543
+ )}
544
+ </>
179
545
  )}
180
546
  </div>
181
- </div>
182
- </DialogContent>
183
- </Dialog>
547
+ )}
548
+ </PopoverContent>
549
+ </Popover>
550
+
551
+ {/* "Browse All" button — always visible when DataSource is available */}
552
+ {hasDataSource && (
553
+ <Button
554
+ variant="outline"
555
+ size="icon"
556
+ className="shrink-0"
557
+ type="button"
558
+ onClick={() => setIsPickerOpen(true)}
559
+ aria-label="Browse all records"
560
+ title="Browse all records"
561
+ data-testid="browse-all-records"
562
+ >
563
+ <TableProperties className="size-4" />
564
+ </Button>
565
+ )}
566
+ </div>
567
+
568
+ {/* Level 2: Full Record Picker Dialog */}
569
+ {hasDataSource && dataSource && referenceTo && (
570
+ <RecordPickerDialog
571
+ open={isPickerOpen}
572
+ onOpenChange={setIsPickerOpen}
573
+ title={lookupField?.label || 'Select'}
574
+ multiple={multiple}
575
+ dataSource={dataSource}
576
+ objectName={referenceTo}
577
+ columns={lookupColumns}
578
+ displayField={displayField}
579
+ idField={idField}
580
+ pageSize={lookupPageSize}
581
+ value={value}
582
+ onSelect={onChange}
583
+ onSelectRecords={handlePickerSelectRecords}
584
+ lookupFilters={lookupFilters}
585
+ cellRenderer={getCellRendererResolver()}
586
+ filterColumns={filterColumns}
587
+ />
588
+ )}
184
589
  </div>
185
590
  );
186
591
  }