@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
@@ -0,0 +1,372 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // RecordPicker — THE "pick a related record" control of the runtime.
3
+ //
4
+ // Before it, every surface carried its own picker: the declarative
5
+ // DynamicSelectField, the hand-written EntitySelect, the business
6
+ // Customer/Product/Vehicle pickers, the DocumentEditor's product cell, the
7
+ // legacy `search` field of the generic modal and the multi-select of `ref`
8
+ // arrays — each with its own search loop, list markup, keyboard handling and
9
+ // create button. They are now thin configurations of this one primitive:
10
+ //
11
+ // - data: caller-owned `items` (options resolver, static list, a search the
12
+ // caller drives) or a `search(q, signal)` fetcher run here with debounce,
13
+ // cancellation and a min-chars gate; loading / empty / error states;
14
+ // - selection: single or `multiple` (chips with remove);
15
+ // - create / edit: "+" joined to the trigger when empty, pencil when a value
16
+ // is selected, "Crear …" at the foot of the list prefilled with the search
17
+ // (record-picker-actions.tsx);
18
+ // - rich options by props/slots: lead (avatar / initials / thumbnail),
19
+ // subtitle (SKU, RFC…), trailing (price / stock), or a full `renderItem`;
20
+ // - presentation: `trigger="button"` (select-like, search inside the list)
21
+ // or `trigger="input"` (type in the field itself; `freeText` keeps the text
22
+ // as the value, e.g. a free invoice line), `variant="field" | "cell"`;
23
+ // - the list is ALWAYS portaled (never clipped by a table / modal overflow),
24
+ // collision-aware (flips when there is no room) and stacked above dialogs;
25
+ // - keyboard: ↑/↓/Home/End move, Enter picks, Esc closes (without closing the
26
+ // dialog around it), Tab closes and moves on; ARIA combobox + listbox with
27
+ // aria-activedescendant (focus never leaves the text box).
28
+ //
29
+ // Built only on @asteby/metacore-ui primitives (Popover, Button, Input, Badge),
30
+ // so it themes (light / dark) with the host.
31
+ import { useCallback, useEffect, useId, useRef, useState, } from 'react';
32
+ import { Check, ChevronsUpDown, Loader2, Plus, Search, X } from 'lucide-react';
33
+ import { Badge, Button, Input, Popover, PopoverAnchor, PopoverContent, PopoverTrigger, } from '@asteby/metacore-ui/primitives';
34
+ import { JOINED_TRIGGER_CLASS, RecordPickerAction, hasRecordPickerAction } from './record-picker-actions';
35
+ import { useDebouncedValue } from './use-debounced-value';
36
+ /**
37
+ * z-index of the portaled list. Dialogs/sheets sit at 50; the list must stay
38
+ * above the modal it was opened from regardless of portal order.
39
+ */
40
+ export const RECORD_PICKER_Z_INDEX = 60;
41
+ const cx = (...parts) => parts.filter(Boolean).join(' ');
42
+ /** Debounced async search with cancellation. `search` must be stable (useCallback). */
43
+ export function useRecordSearch(query, search, opts = {}) {
44
+ const { minChars = 2, delay = 250, enabled = true } = opts;
45
+ const trimmed = query.trim();
46
+ const q = useDebouncedValue(trimmed, delay);
47
+ const [results, setResults] = useState([]);
48
+ const [loading, setLoading] = useState(false);
49
+ const [error, setError] = useState(false);
50
+ // The query the current results answer. Until it matches what was typed
51
+ // (debounce running, or the request not answered yet) the search is
52
+ // `pending` — so a list never flashes "no results" before searching.
53
+ const [doneFor, setDoneFor] = useState(null);
54
+ useEffect(() => {
55
+ if (!enabled || q.length < minChars) {
56
+ setResults((r) => (r.length ? [] : r));
57
+ setLoading(false);
58
+ setError(false);
59
+ setDoneFor(null);
60
+ return;
61
+ }
62
+ const ctrl = new AbortController();
63
+ setLoading(true);
64
+ setError(false);
65
+ search(q, ctrl.signal)
66
+ .then((rows) => {
67
+ if (!ctrl.signal.aborted)
68
+ setResults(rows);
69
+ })
70
+ .catch(() => {
71
+ if (!ctrl.signal.aborted) {
72
+ setResults([]);
73
+ setError(true);
74
+ }
75
+ })
76
+ .finally(() => {
77
+ if (!ctrl.signal.aborted) {
78
+ setLoading(false);
79
+ setDoneFor(q);
80
+ }
81
+ });
82
+ return () => ctrl.abort();
83
+ }, [q, search, minChars, enabled]);
84
+ return { results, loading, error, pending: enabled && trimmed.length >= minChars && doneFor !== trimmed };
85
+ }
86
+ const NO_SEARCH = () => Promise.resolve([]);
87
+ /**
88
+ * Stable identity for a caller's search function: always calls the latest one,
89
+ * so an inline (non-memoized) fetcher doesn't re-trigger the search on every
90
+ * render.
91
+ */
92
+ export function useLatestSearch(fn) {
93
+ const ref = useRef(fn);
94
+ ref.current = fn;
95
+ return useCallback((...args) => ref.current(...args), []);
96
+ }
97
+ const TABBABLE = 'a[href],button:not([disabled]),input:not([disabled]):not([type="hidden"]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
98
+ /** Focus the tabbable after `from` (skipping portaled popper content). */
99
+ function focusNextAfter(from) {
100
+ if (!from || typeof document === 'undefined')
101
+ return;
102
+ const all = Array.from(document.querySelectorAll(TABBABLE)).filter((el) => el.tabIndex >= 0 && !el.closest('[data-radix-popper-content-wrapper]'));
103
+ const i = all.indexOf(from);
104
+ (all[i + 1] ?? from).focus();
105
+ }
106
+ function asKeys(value, multiple) {
107
+ if (multiple)
108
+ return Array.isArray(value) ? value.map(String) : [];
109
+ if (value == null || value === '' || Array.isArray(value))
110
+ return [];
111
+ return [String(value)];
112
+ }
113
+ export function RecordPicker(props) {
114
+ const { getKey, getLabel, items, search, debounceMs = 250, multiple = false, value, selected, onSelect, onClear, onRemove, renderItem, renderLead, getDescription, renderTrailing, renderValue, isItemDisabled, placeholder = 'Seleccionar…', searchPlaceholder = 'Buscar…', emptyText = 'Sin resultados', loadingText = 'Buscando…', errorText = 'No se pudo buscar. Inténtalo de nuevo.', minCharsText, clearLabel = 'Quitar selección', listLabel, entityLabel = '', onCreate, onEdit, createLabel, editLabel, createFooter = 'always', createFooterLabel, hideJoinedAction = false, trigger = 'button', variant = 'field', freeText = false, icon: Icon, id, ariaLabel, disabled = false, readOnly = false, invalid = false, slot, anchorSlot, contentSlot = 'record-picker-content', className, triggerClassName, triggerProps, inputRef, minListWidth = '14rem', after, below, beforeList, onInputKeyDown, onUnhandledKeyDown, } = props;
115
+ const isInput = trigger === 'input';
116
+ const minChars = props.minChars ?? (isInput ? 2 : 0);
117
+ const showCheck = props.showCheck ?? !isInput;
118
+ const closeOnSelect = props.closeOnSelect ?? !multiple;
119
+ const listId = useId();
120
+ const triggerRef = useRef(null);
121
+ const anchorRef = useRef(null);
122
+ const tabbingRef = useRef(false);
123
+ // open / query: controlled when the caller passes them.
124
+ const [openState, setOpenState] = useState(false);
125
+ const open = props.open ?? openState;
126
+ const { onOpenChange, onQueryChange } = props;
127
+ const controlledOpen = props.open !== undefined;
128
+ const setOpen = useCallback((o) => {
129
+ if (!controlledOpen)
130
+ setOpenState(o);
131
+ onOpenChange?.(o);
132
+ }, [controlledOpen, onOpenChange]);
133
+ const [queryState, setQueryState] = useState('');
134
+ const query = props.query ?? queryState;
135
+ const controlledQuery = props.query !== undefined;
136
+ const setQuery = useCallback((q) => {
137
+ if (!controlledQuery)
138
+ setQueryState(q);
139
+ onQueryChange?.(q);
140
+ }, [controlledQuery, onQueryChange]);
141
+ const trimmed = query.trim();
142
+ const belowMin = trimmed.length < minChars;
143
+ const listOpen = !disabled && !readOnly && open && (!isInput || !belowMin);
144
+ const internal = useRecordSearch(query, (search ?? NO_SEARCH), {
145
+ minChars,
146
+ delay: debounceMs,
147
+ enabled: !!search && !items && open && !disabled && !readOnly,
148
+ });
149
+ const rows = items ?? internal.results;
150
+ const loading = props.loading ?? (internal.loading || internal.pending);
151
+ const error = props.error ?? internal.error;
152
+ const keys = asKeys(value, multiple);
153
+ const hasValue = keys.length > 0;
154
+ const isSelected = (item) => keys.includes(getKey(item));
155
+ const single = !multiple ? (selected ?? null) : null;
156
+ const selectedList = multiple && Array.isArray(selected) ? selected : [];
157
+ const showFooter = !!onCreate &&
158
+ !loading &&
159
+ createFooter !== 'never' &&
160
+ (createFooter === 'always' || (rows.length === 0 && !belowMin && !error));
161
+ const count = rows.length + (showFooter ? 1 : 0);
162
+ // Highlight: the selected row when (re)opening, else the first. Keyed on the
163
+ // row ids (not the array identity) so a caller recomputing `items` on every
164
+ // render doesn't reset the keyboard position.
165
+ const [active, setActive] = useState(0);
166
+ const rowSig = rows.map(getKey).join('\u0001');
167
+ useEffect(() => {
168
+ const i = rows.findIndex((r) => keys.includes(getKey(r)));
169
+ setActive(i >= 0 ? i : 0);
170
+ // eslint-disable-next-line react-hooks/exhaustive-deps
171
+ }, [rowSig, trimmed, listOpen]);
172
+ useEffect(() => {
173
+ if (!listOpen || typeof document === 'undefined')
174
+ return;
175
+ const el = document.getElementById(`${listId}-o${active}`);
176
+ el?.scrollIntoView?.({ block: 'nearest' });
177
+ }, [active, listOpen, listId]);
178
+ const enabledAt = (i) => i === rows.length ? showFooter : !!rows[i] && !isItemDisabled?.(rows[i]);
179
+ const step = (from, dir) => {
180
+ for (let i = from + dir; i >= 0 && i < count; i += dir)
181
+ if (enabledAt(i))
182
+ return i;
183
+ return from;
184
+ };
185
+ const close = () => setOpen(false);
186
+ const runCreate = () => {
187
+ const q = trimmed;
188
+ close();
189
+ if (!freeText)
190
+ setQuery('');
191
+ onCreate?.(q);
192
+ };
193
+ const choose = (i) => {
194
+ if (i === rows.length && showFooter)
195
+ return runCreate();
196
+ const item = rows[i];
197
+ if (!item || isItemDisabled?.(item))
198
+ return;
199
+ onSelect(item);
200
+ if (closeOnSelect)
201
+ close();
202
+ if (!freeText && !multiple)
203
+ setQuery('');
204
+ };
205
+ const onKeyDown = (e) => {
206
+ if (onInputKeyDown) {
207
+ // Only a preventDefault() from the caller takes over (Radix may
208
+ // already have prevented Esc at the document level).
209
+ const before = e.defaultPrevented;
210
+ onInputKeyDown(e);
211
+ if (!before && e.defaultPrevented)
212
+ return;
213
+ }
214
+ if (listOpen && count > 0) {
215
+ if (e.key === 'ArrowDown') {
216
+ e.preventDefault();
217
+ setActive((a) => step(Math.min(a, count - 1), 1));
218
+ return;
219
+ }
220
+ if (e.key === 'ArrowUp') {
221
+ e.preventDefault();
222
+ setActive((a) => step(Math.min(a, count), -1));
223
+ return;
224
+ }
225
+ if (!isInput && (e.key === 'Home' || e.key === 'End')) {
226
+ e.preventDefault();
227
+ setActive(e.key === 'Home' ? step(-1, 1) : step(count, -1));
228
+ return;
229
+ }
230
+ if (e.key === 'Enter' && enabledAt(active)) {
231
+ e.preventDefault();
232
+ choose(active);
233
+ return;
234
+ }
235
+ }
236
+ if (isInput) {
237
+ if (listOpen && e.key === 'Escape') {
238
+ // Close the list, not the dialog around it.
239
+ e.preventDefault();
240
+ e.stopPropagation();
241
+ close();
242
+ return;
243
+ }
244
+ if (!listOpen && !freeText && e.key === 'ArrowDown' && !belowMin) {
245
+ e.preventDefault();
246
+ setOpen(true);
247
+ return;
248
+ }
249
+ if (e.key === 'Tab')
250
+ close();
251
+ }
252
+ else if (e.key === 'Tab' && !e.shiftKey) {
253
+ // Close and move on to the next field (not to the end of <body>,
254
+ // where the portaled list lives).
255
+ e.preventDefault();
256
+ tabbingRef.current = true;
257
+ close();
258
+ return;
259
+ }
260
+ onUnhandledKeyDown?.(e);
261
+ };
262
+ const activeId = listOpen && count > 0 && enabledAt(active) ? `${listId}-o${active}` : undefined;
263
+ // ---- the list (shared by both trigger modes) --------------------------
264
+ const status = (() => {
265
+ if (loading && rows.length === 0) {
266
+ return (_jsxs("div", { role: "status", className: "flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground", "data-slot": "picker-loading", children: [_jsx(Loader2, { className: "size-4 animate-spin", "aria-hidden": true }), loadingText] }));
267
+ }
268
+ if (loading)
269
+ return null;
270
+ if (error) {
271
+ return (_jsx("p", { role: "alert", className: "px-2 py-4 text-center text-sm text-destructive", "data-slot": "picker-error", children: errorText }));
272
+ }
273
+ if (rows.length > 0)
274
+ return null;
275
+ if (belowMin && minChars > 0) {
276
+ return (_jsxs("div", { className: "flex flex-col items-center gap-1 py-6 text-muted-foreground", "data-slot": "picker-min-chars", children: [_jsx(Search, { className: "size-5", "aria-hidden": true }), _jsx("span", { className: "text-xs", children: minCharsText ?? `Escribe al menos ${minChars} caracteres` })] }));
277
+ }
278
+ return (_jsx("div", { className: "px-2 py-6 text-center text-sm text-muted-foreground", "data-slot": "picker-empty", children: emptyText }));
279
+ })();
280
+ const optionClass = (on, dis) => cx('relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden', on && 'bg-accent text-accent-foreground', dis && 'pointer-events-none opacity-50');
281
+ const list = (_jsxs("div", { className: "max-h-72 overflow-y-auto overflow-x-hidden overscroll-contain", "data-slot": "record-picker-list", children: [beforeList, status, _jsxs("ul", { id: listId, role: "listbox", "aria-label": listLabel ?? ariaLabel ?? entityLabel ?? undefined, "aria-multiselectable": multiple || undefined, className: cx('p-1', count === 0 && 'hidden'), children: [rows.map((item, i) => {
282
+ const sel = isSelected(item);
283
+ const dis = !!isItemDisabled?.(item);
284
+ const on = i === active;
285
+ const desc = getDescription?.(item);
286
+ return (_jsx("li", { id: `${listId}-o${i}`, role: "option", "aria-selected": multiple ? sel : on, "aria-disabled": dis || undefined, "data-active": on || undefined, "data-selected": sel || undefined, "data-slot": "record-picker-option", className: optionClass(on, dis), onMouseDown: (e) => e.preventDefault(), onMouseMove: () => {
287
+ if (!on && !dis)
288
+ setActive(i);
289
+ }, onClick: () => choose(i), children: renderItem ? (renderItem(item, { active: on, selected: sel, disabled: dis })) : (_jsxs(_Fragment, { children: [showCheck ? (_jsx(Check, { className: cx('size-4 shrink-0', sel ? 'opacity-100' : 'opacity-0'), "aria-hidden": true })) : null, renderLead?.(item, 'option'), _jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "truncate", children: getLabel(item) }), desc != null && desc !== '' ? (_jsx("span", { className: "truncate text-xs text-muted-foreground", children: desc })) : null] }), renderTrailing ? (_jsx("span", { className: "ml-auto flex shrink-0 items-center gap-2", children: renderTrailing(item, 'option') })) : null] })) }, getKey(item)));
290
+ }), showFooter ? (_jsxs(_Fragment, { children: [rows.length > 0 ? _jsx("li", { role: "presentation", className: "-mx-1 my-1 h-px bg-border" }) : null, _jsxs("li", { id: `${listId}-o${rows.length}`, role: "option", "aria-selected": active === rows.length, "data-active": active === rows.length || undefined, "data-slot": "picker-create", className: cx(optionClass(active === rows.length, false), 'text-primary'), onMouseDown: (e) => e.preventDefault(), onMouseMove: () => setActive(rows.length), onClick: runCreate, children: [_jsx(Plus, { className: "size-4 shrink-0", "aria-hidden": true }), _jsxs("span", { className: "truncate", children: [createFooterLabel ? createFooterLabel(trimmed) : `Crear ${entityLabel}`.trim(), !createFooterLabel && trimmed ? _jsxs("span", { className: "text-muted-foreground", children: [" \u00AB", trimmed, "\u00BB"] }) : null] })] })] })) : null] })] }));
291
+ const contentStyle = {
292
+ width: `max(var(--radix-popover-trigger-width), ${minListWidth})`,
293
+ maxWidth: 'calc(100vw - 1rem)',
294
+ zIndex: RECORD_PICKER_Z_INDEX,
295
+ };
296
+ const joined = !hideJoinedAction && !readOnly && hasRecordPickerAction(hasValue, onCreate, onEdit);
297
+ const action = joined ? (_jsx(RecordPickerAction, { hasValue: hasValue, label: entityLabel, onCreate: onCreate ? () => runCreate() : undefined, onEdit: onEdit, createLabel: createLabel, editLabel: editLabel, disabled: disabled, variant: variant })) : null;
298
+ const defaultValueNode = (item) => item ? (_jsxs(_Fragment, { children: [renderLead?.(item, 'value'), _jsx("span", { className: "min-w-0 flex-1 truncate", children: getLabel(item) }), renderTrailing?.(item, 'value')] })) : (_jsx("span", { className: "min-w-0 flex-1 truncate text-muted-foreground", children: placeholder }));
299
+ const valueNode = renderValue ? renderValue(single) : defaultValueNode(single);
300
+ const wrap = (row) => below ? (_jsxs("div", { className: cx('w-full min-w-0 space-y-1.5', className), "data-slot": slot, children: [row, below] })) : (row);
301
+ const rowClass = cx('flex w-full min-w-0 items-center gap-1.5', !below && className);
302
+ const rowSlot = below ? undefined : slot;
303
+ // ---- read-only --------------------------------------------------------
304
+ if (readOnly) {
305
+ return (_jsx(Button, { type: "button", variant: "outline", role: "combobox", id: id, disabled: true, "aria-readonly": "true", "aria-label": ariaLabel, className: cx('w-full min-w-0 cursor-default justify-start font-normal opacity-100', triggerClassName), ...triggerProps, children: _jsx("span", { className: "flex min-w-0 flex-1 items-center gap-2 text-left", children: valueNode }) }));
306
+ }
307
+ // ---- input trigger ----------------------------------------------------
308
+ if (isInput) {
309
+ const showValue = !freeText && !multiple && hasValue && !!(single || renderValue);
310
+ const inputCls = cx(variant === 'cell' ? 'h-8 pl-8' : 'pl-8', joined && JOINED_TRIGGER_CLASS, triggerClassName);
311
+ return wrap(_jsxs("div", { className: rowClass, "data-slot": rowSlot, children: [_jsxs("div", { className: "flex min-w-0 flex-1 items-stretch", children: [_jsxs(Popover, { open: listOpen, onOpenChange: (o) => !o && close(), children: [_jsx(PopoverAnchor, { asChild: true, children: _jsx("div", { ref: anchorRef, className: "relative w-full min-w-0", "data-slot": anchorSlot, children: showValue ? (_jsxs("div", { className: cx('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', joined && 'rounded-r-none', invalid && 'border-destructive'), "data-slot": "record-picker-value", children: [Icon ? _jsx(Icon, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true }) : null, _jsx("div", { className: "min-w-0 flex-1", children: valueNode }), onClear && !disabled ? (_jsx(Button, { type: "button", size: "icon", variant: "ghost", className: "size-7 shrink-0", onClick: onClear, "aria-label": clearLabel, children: _jsx(X, { className: "size-4" }) })) : null] })) : (_jsxs(_Fragment, { children: [_jsx(Search, { className: cx('pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground', variant === 'cell' ? 'size-3.5' : 'size-4'), "aria-hidden": true }), _jsx(Input, { ref: inputRef, id: id, value: query, disabled: disabled, role: "combobox", "aria-autocomplete": "list", "aria-expanded": listOpen, "aria-controls": listOpen ? listId : undefined, "aria-activedescendant": activeId, "aria-label": ariaLabel, "aria-invalid": invalid || undefined, autoComplete: "off", spellCheck: false, placeholder: placeholder, className: inputCls, onChange: (e) => {
312
+ setQuery(e.target.value);
313
+ setOpen(true);
314
+ }, onBlur: () => close(), onKeyDown: onKeyDown, ...triggerProps })] })) }) }), _jsx(PopoverContent, { align: "start", sideOffset: 4, collisionPadding: 8, className: "p-0", "data-slot": contentSlot,
315
+ // Focus stays in the text box: keep typing while the list shows.
316
+ onOpenAutoFocus: (e) => e.preventDefault(),
317
+ // Esc is handled by the text box (closes the list and
318
+ // stops there, so the dialog around it stays open).
319
+ onEscapeKeyDown: (e) => e.preventDefault(), onCloseAutoFocus: (e) => e.preventDefault(),
320
+ // Clicking the list (scrollbar included) must not blur the box.
321
+ onMouseDown: (e) => e.preventDefault(), onInteractOutside: (e) => {
322
+ if (anchorRef.current?.contains(e.target))
323
+ e.preventDefault();
324
+ }, style: contentStyle, children: list })] }), action] }), after] }));
325
+ }
326
+ // ---- button trigger ---------------------------------------------------
327
+ const onTriggerKeyDown = (e) => {
328
+ if (disabled)
329
+ return;
330
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || (multiple && (e.key === 'Enter' || e.key === ' '))) {
331
+ e.preventDefault();
332
+ setOpen(true);
333
+ return;
334
+ }
335
+ // Type-to-search: a printable key on the closed trigger opens the list
336
+ // with that character already in the search box.
337
+ if (!open && e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) {
338
+ e.preventDefault();
339
+ setQuery(e.key);
340
+ setOpen(true);
341
+ }
342
+ };
343
+ const clearable = !!onClear && hasValue && !disabled && !multiple;
344
+ const cellCls = 'h-8 border-transparent bg-transparent px-2 shadow-none hover:border-input hover:bg-transparent dark:bg-transparent dark:hover:bg-transparent';
345
+ const comboboxAria = {
346
+ role: 'combobox',
347
+ 'aria-expanded': listOpen,
348
+ 'aria-haspopup': 'listbox',
349
+ 'aria-controls': listOpen ? listId : undefined,
350
+ 'aria-invalid': invalid || undefined,
351
+ 'aria-label': ariaLabel,
352
+ id,
353
+ };
354
+ const triggerEl = multiple ? (_jsxs("div", { ...comboboxAria, ref: (el) => {
355
+ triggerRef.current = el;
356
+ }, tabIndex: disabled ? -1 : 0, "aria-disabled": disabled || undefined, onKeyDown: onTriggerKeyDown, className: cx('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', variant === 'cell' && 'min-h-8 border-transparent shadow-none', invalid && 'border-destructive ring-1 ring-destructive/30', disabled && 'pointer-events-none opacity-50', joined && JOINED_TRIGGER_CLASS, triggerClassName), "data-empty": !hasValue, ...triggerProps, children: [_jsx("span", { className: "flex min-w-0 flex-1 flex-wrap items-center gap-1", children: selectedList.length > 0 ? (selectedList.map((item) => (_jsxs(Badge, { variant: "secondary", className: "max-w-full gap-1 pr-1 font-normal", "data-slot": "record-picker-chip", children: [_jsx("span", { className: "truncate", children: getLabel(item) }), onRemove && !disabled ? (_jsx("button", { type: "button", "aria-label": `Quitar ${getLabel(item)}`, className: "rounded-full p-0.5 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring", onPointerDown: (e) => e.stopPropagation(), onClick: (e) => {
357
+ e.stopPropagation();
358
+ onRemove(item);
359
+ }, onKeyDown: (e) => e.stopPropagation(), children: _jsx(X, { className: "size-3" }) })) : null] }, getKey(item))))) : (_jsx("span", { className: "truncate text-muted-foreground", children: placeholder })) }), _jsx(ChevronsUpDown, { className: "size-4 shrink-0 opacity-50", "aria-hidden": true })] })) : (_jsxs(Button, { ...comboboxAria, ref: (el) => {
360
+ triggerRef.current = el;
361
+ }, type: "button", variant: "outline", disabled: disabled, onKeyDown: onTriggerKeyDown, className: cx('w-full min-w-0 flex-1 justify-between gap-2 font-normal', variant === 'cell' && cellCls, clearable && 'pr-14', joined && JOINED_TRIGGER_CLASS, invalid && 'border-destructive ring-1 ring-destructive/30', triggerClassName), "data-empty": !hasValue, ...triggerProps, children: [Icon ? _jsx(Icon, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true }) : null, _jsx("span", { className: "flex min-w-0 flex-1 items-center gap-2 text-left", children: valueNode }), _jsx(ChevronsUpDown, { className: "size-4 shrink-0 opacity-50", "aria-hidden": true })] }));
362
+ return wrap(_jsxs("div", { className: rowClass, "data-slot": rowSlot, children: [_jsxs("div", { className: "flex min-w-0 flex-1 items-center", children: [_jsxs("div", { className: "relative flex min-w-0 flex-1 items-center", children: [_jsxs(Popover, { open: listOpen, onOpenChange: (o) => (disabled ? undefined : setOpen(o)), children: [_jsx(PopoverTrigger, { asChild: true, children: triggerEl }), _jsxs(PopoverContent, { align: "start", sideOffset: 4, collisionPadding: 8, className: "p-0", "data-slot": contentSlot, style: contentStyle, onCloseAutoFocus: (e) => {
363
+ if (!tabbingRef.current)
364
+ return;
365
+ tabbingRef.current = false;
366
+ e.preventDefault();
367
+ focusNextAfter(triggerRef.current);
368
+ }, children: [_jsxs("div", { className: "flex h-10 items-center gap-2 border-b px-3", "data-slot": "record-picker-search", children: [_jsx(Search, { className: "size-4 shrink-0 opacity-50", "aria-hidden": true }), _jsx("input", { role: "combobox", "aria-expanded": listOpen, "aria-controls": listId, "aria-autocomplete": "list", "aria-activedescendant": activeId, "aria-label": searchPlaceholder, autoComplete: "off", spellCheck: false, placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), onKeyDown: onKeyDown, className: "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground" })] }), list] })] }), clearable ? (_jsx("button", { type: "button", "aria-label": clearLabel, title: clearLabel, onClick: (e) => {
369
+ e.stopPropagation();
370
+ onClear?.();
371
+ }, 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", "data-slot": "record-picker-clear", children: _jsx(X, { className: "size-3.5" }) })) : null] }), action] }), after] }));
372
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "49.5.0",
3
+ "version": "49.6.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
  // #1025 VehiclePicker, #1026 RelateDocuments, #1027 PrintSendDialog.
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest'
4
- import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
5
5
 
6
6
  vi.mock('react-i18next', () => ({
7
7
  useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k, i18n: { language: 'es' } }),
@@ -49,7 +49,9 @@ describe('VehiclePicker', () => {
49
49
  fireEvent.change(screen.getByPlaceholderText('Placa o VIN'), { target: { value: 'ABC' } })
50
50
  const opt = await screen.findByRole('option')
51
51
  expect(search.mock.calls[0][0]).toBe('ABC')
52
- fireEvent.click(opt.querySelector('button')!)
52
+ // La opción misma es el elemento interactivo (RecordPicker: role=option
53
+ // sin <button> anidado, como pide ARIA para listbox).
54
+ fireEvent.click(opt)
53
55
  expect(onChange).toHaveBeenCalledWith(veh)
54
56
  })
55
57
 
@@ -102,7 +104,8 @@ describe('RelateDocuments', () => {
102
104
  const { rerender } = render(<RelateDocuments value={value} onChange={onChange} relationTypes={types} search={search} />)
103
105
  fireEvent.change(screen.getByPlaceholderText('Buscar por folio o UUID'), { target: { value: 'A-' } })
104
106
  const list = await screen.findByRole('listbox')
105
- fireEvent.click(list.querySelectorAll('button')[0])
107
+ // RecordPicker: se elige la opción (role=option), no un <button> interno.
108
+ fireEvent.click((await within(list).findAllByRole('option'))[0]!)
106
109
  expect(onChange).toHaveBeenLastCalledWith([
107
110
  expect.objectContaining({ related_document_id: 'd1', relation_type: '01', uuid: 'UUID-1', folio: 'A-1' }),
108
111
  ])
@@ -95,7 +95,8 @@ describe('DocumentLinesGrid — select de producto en la celda', () => {
95
95
  expect(cell.closest('tr')!.getAttribute('data-line-key')).toBe(free.key)
96
96
  fireEvent.change(cell, { target: { value: '205' } })
97
97
  const option = await waitFor(() => screen.getByRole('option', { name: /Llanta 205/ }), { timeout: 2000 })
98
- fireEvent.click(within(option).getByRole('button'))
98
+ // La opción (role=option) es el elemento interactivo del RecordPicker.
99
+ fireEvent.click(option)
99
100
  expect(state()).toHaveLength(1)
100
101
  expect(state()[0]).toMatchObject({ key: free.key, product_id: 'p1', unit_price: 1500, sku: 'LL-205' })
101
102
  })
@@ -169,7 +169,8 @@ describe('DocumentFormDialog: buscador de catálogo por defecto', () => {
169
169
  fireEvent.change(document.querySelectorAll('input')[0], { target: { value: 'ACME' } })
170
170
  fireEvent.click(screen.getByRole('button', { name: 'Siguiente' }))
171
171
  fireEvent.change(await screen.findByLabelText('Buscar producto'), { target: { value: query } })
172
- fireEvent.click(await waitFor(() => screen.getByRole('button', { name }), { timeout: 2000 }))
172
+ // RecordPicker: la opción del producto es role="option" clicable (sin <button> anidado).
173
+ fireEvent.click(await waitFor(() => screen.getByRole('option', { name }), { timeout: 2000 }))
173
174
  }
174
175
 
175
176
  it('sin searchProducts del host busca en el catálogo y el renglón entra con precio, cantidad 1, unidad, SKU e IVA de la org', async () => {
@@ -234,9 +235,9 @@ describe('DocumentFormDialog: IVA de la org con el buscador del host', () => {
234
235
  fireEvent.click(screen.getByRole('button', { name: 'Siguiente' }))
235
236
  const search = await screen.findByLabelText('Buscar producto')
236
237
  fireEvent.change(search, { target: { value: 'host' } })
237
- fireEvent.click(await waitFor(() => screen.getByRole('button', { name: /Llanta host/ }), { timeout: 2000 }))
238
+ fireEvent.click(await waitFor(() => screen.getByRole('option', { name: /Llanta host/ }), { timeout: 2000 }))
238
239
  fireEvent.change(await screen.findByLabelText('Buscar producto'), { target: { value: 'fron' } })
239
- fireEvent.click(await waitFor(() => screen.getByRole('button', { name: /Frontera host/ }), { timeout: 2000 }))
240
+ fireEvent.click(await waitFor(() => screen.getByRole('option', { name: /Frontera host/ }), { timeout: 2000 }))
240
241
  fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
241
242
  await waitFor(() => expect(post).toHaveBeenCalled())
242
243
  const body = post.mock.calls[0][1] as any
@@ -49,7 +49,8 @@ describe('DocumentLinesGrid', () => {
49
49
  const search = vi.fn(async () => [tire])
50
50
  render(<Harness search={search} priceSource="cost" />)
51
51
  fireEvent.change(screen.getByLabelText('Buscar producto'), { target: { value: 'llan' } })
52
- const option = await waitFor(() => screen.getByRole('button', { name: /Llanta X/ }), { timeout: 2000 })
52
+ // RecordPicker: la opción del producto es role="option" clicable (sin <button> anidado).
53
+ const option = await waitFor(() => screen.getByRole('option', { name: /Llanta X/ }), { timeout: 2000 })
53
54
  fireEvent.click(option)
54
55
  expect(state()[0]).toMatchObject({ description: 'Llanta X', sku: 'LX', quantity: 1, unit_price: 800, catalog_price: 800 })
55
56
  expect(screen.getByText(/Catálogo/)).toBeTruthy()