@asteby/metacore-runtime-react 49.5.0 → 49.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/business/customer-picker.d.ts +8 -2
- package/dist/business/customer-picker.d.ts.map +1 -1
- package/dist/business/customer-picker.js +30 -27
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +1 -0
- package/dist/business/line-product-cell.d.ts +8 -10
- package/dist/business/line-product-cell.d.ts.map +1 -1
- package/dist/business/line-product-cell.js +13 -90
- package/dist/business/product-options.d.ts +41 -0
- package/dist/business/product-options.d.ts.map +1 -0
- package/dist/business/product-options.js +58 -0
- package/dist/business/product-picker.d.ts +5 -1
- package/dist/business/product-picker.d.ts.map +1 -1
- package/dist/business/product-picker.js +26 -35
- package/dist/business/relate-documents.d.ts +1 -1
- package/dist/business/relate-documents.d.ts.map +1 -1
- package/dist/business/relate-documents.js +6 -8
- package/dist/business/vehicle-picker.d.ts +8 -2
- package/dist/business/vehicle-picker.d.ts.map +1 -1
- package/dist/business/vehicle-picker.js +28 -28
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +28 -43
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +17 -5
- package/dist/dynamic-select-field.d.ts +7 -23
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +67 -161
- package/dist/entity-select.d.ts +5 -2
- package/dist/entity-select.d.ts.map +1 -1
- package/dist/entity-select.js +23 -116
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/record-picker-actions.d.ts +40 -2
- package/dist/record-picker-actions.d.ts.map +1 -1
- package/dist/record-picker-actions.js +44 -4
- package/dist/record-picker-dialog.d.ts +23 -0
- package/dist/record-picker-dialog.d.ts.map +1 -0
- package/dist/record-picker-dialog.js +37 -0
- package/dist/record-picker-option.d.ts +25 -0
- package/dist/record-picker-option.d.ts.map +1 -0
- package/dist/record-picker-option.js +55 -0
- package/dist/record-picker.d.ts +149 -0
- package/dist/record-picker.d.ts.map +1 -0
- package/dist/record-picker.js +372 -0
- package/package.json +1 -1
- package/src/__tests__/business-pickers.test.tsx +6 -3
- package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
- package/src/__tests__/document-form-dialog.test.tsx +4 -3
- package/src/__tests__/document-lines-grid.test.tsx +2 -1
- package/src/__tests__/record-picker.test.tsx +469 -0
- package/src/business/customer-picker.tsx +87 -98
- package/src/business/index.ts +1 -0
- package/src/business/line-product-cell.tsx +55 -180
- package/src/business/product-options.tsx +119 -0
- package/src/business/product-picker.tsx +63 -126
- package/src/business/relate-documents.tsx +36 -45
- package/src/business/vehicle-picker.tsx +76 -99
- package/src/dialogs/dynamic-record.tsx +44 -97
- package/src/dynamic-multi-select-field.tsx +39 -13
- package/src/dynamic-select-field.tsx +143 -368
- package/src/entity-select.tsx +53 -202
- package/src/index.ts +27 -2
- package/src/record-picker-actions.tsx +101 -8
- package/src/record-picker-dialog.tsx +80 -0
- package/src/record-picker-option.tsx +96 -0
- package/src/record-picker.tsx +971 -0
- package/dist/business/use-async-search.d.ts +0 -10
- package/dist/business/use-async-search.d.ts.map +0 -1
- package/dist/business/use-async-search.js +0 -38
- package/src/business/use-async-search.ts +0 -43
|
@@ -0,0 +1,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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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()
|