react-core-ts 2.0.24 → 2.1.1

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.
@@ -0,0 +1,559 @@
1
+ // NOTE: This file was renamed from ReactAutoCompleteNameCount.tsx
2
+ // The implementation is unchanged; only the filename and exported component name differ.
3
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { FieldErrors } from 'react-hook-form';
5
+ import Spinner from './components/loader/Spinner';
6
+ import Portal from './components/portal';
7
+ import CustomIcons from './components/customIcons';
8
+
9
+ type SuggestionItem = {
10
+ name: string;
11
+ [key: string]: any;
12
+ };
13
+
14
+ type ModernAutoCompleteSuggectionsProps = {
15
+ name: string;
16
+ label?: string;
17
+ id?: string;
18
+ placeholder?: string;
19
+ /** Controlled value for the input (optional). */
20
+ value?: string;
21
+ /** Called whenever the user types (only the raw text, no selection). */
22
+ onChange?: (value: string) => void;
23
+ /**
24
+ * Optional static list of items. Each item should have at least `name`.
25
+ */
26
+ data?: SuggestionItem[];
27
+ /**
28
+ * Async/source function that returns items for a search text.
29
+ */
30
+ getData?: (key?: string) => Promise<SuggestionItem[]> | SuggestionItem[];
31
+ /**
32
+ * Field that holds an explicit count per item (fallback: duplicates count).
33
+ * Similar to desc, this allows customizing which field contains the count.
34
+ * e.g. "count", "total", "quantity".
35
+ */
36
+ countField?: string;
37
+ /**
38
+ * Whether the API provides count in the data. If true, uses countField value directly.
39
+ * If false, groups items and counts duplicates.
40
+ */
41
+ countProvided?: boolean;
42
+ /** Use modern label styling (floating label). */
43
+ isModern?: boolean;
44
+ /** Mark field as required. */
45
+ required?: boolean;
46
+ /** Input size. */
47
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xxs';
48
+ /** Disable the input. */
49
+ disabled?: boolean;
50
+ /** Custom className. */
51
+ className?: string;
52
+ /** Render dropdown inside component container (false = use Portal). */
53
+ insideOpen?: boolean;
54
+ /** Form validation errors. */
55
+ errors?: FieldErrors;
56
+ /** Label title for error messages. */
57
+ labelTitle?: string;
58
+ /** Field name that contains the display text (default: 'name'). */
59
+ desc?: string;
60
+ /** Field name that contains the unique identifier (default: 'id'). */
61
+ descId?: string;
62
+ };
63
+
64
+ /**
65
+ * Simple suggestion item:
66
+ * - No tooltip
67
+ * - No truncation
68
+ * - Text wraps to next line when long
69
+ */
70
+ const SuggestionItem: React.FC<{ name: string; count: number; showCount: boolean }> = ({
71
+ name,
72
+ count,
73
+ showCount,
74
+ }) => {
75
+ return (
76
+ <div className="py-0.5 flex items-center justify-between gap-2 group relative">
77
+ <span className="flex-1 whitespace-normal break-words">
78
+ {name}
79
+ </span>
80
+ {showCount && (
81
+ <span className="ml-2 suggection-count flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-orange-500 text-white text-[10px] font-medium flex-shrink-0">
82
+ {count}
83
+ </span>
84
+ )}
85
+ </div>
86
+ );
87
+ };
88
+
89
+ /**
90
+ * Read‑only suggestion helper:
91
+ * - User types freely in the input (no item click / selection)
92
+ * - Below the field we show unique names
93
+ * - If a name is duplicated, show: "Name (count)"
94
+ */
95
+ const ModernAutoCompleteSuggections: React.FC<
96
+ ModernAutoCompleteSuggectionsProps
97
+ > = ({
98
+ name,
99
+ label,
100
+ id,
101
+ placeholder,
102
+ data,
103
+ getData,
104
+ value,
105
+ onChange,
106
+ countField = 'count',
107
+ countProvided = false,
108
+ isModern = true,
109
+ required = false,
110
+ size = 'md',
111
+ disabled = false,
112
+ className: propsClassName,
113
+ insideOpen = false,
114
+ errors,
115
+ labelTitle,
116
+ desc = 'name',
117
+ descId = 'id',
118
+ }) => {
119
+ const [inputValue, setInputValue] = useState(value ?? '');
120
+ const [items, setItems] = useState<SuggestionItem[]>(data ?? []);
121
+ const [isLoading, setIsLoading] = useState(false);
122
+ const [showDropdown, setShowDropdown] = useState(false);
123
+ const [isHovered, setIsHovered] = useState<boolean>(false);
124
+ const [dropPosition, setDropPosition] = useState<any>({
125
+ top: 0,
126
+ left: 0,
127
+ bottom: 0,
128
+ right: 0,
129
+ });
130
+ const getDataRef = useRef(getData);
131
+ const [hasTyped, setHasTyped] = useState(false);
132
+ const inputRef = useRef<HTMLInputElement>(null);
133
+ const adorementRef = useRef<HTMLDivElement>(null);
134
+ const containerRef = useRef<HTMLDivElement>(null);
135
+
136
+ // keep latest getData in a ref so we don't re-trigger effects on every render
137
+ useEffect(() => {
138
+ getDataRef.current = getData;
139
+ }, [getData]);
140
+
141
+ // Keep internal state in sync when used as a controlled component
142
+ useEffect(() => {
143
+ if (value !== undefined) {
144
+ setInputValue(value);
145
+ }
146
+ }, [value]);
147
+
148
+ // Sync external data prop
149
+ useEffect(() => {
150
+ if (data) {
151
+ setItems(data);
152
+ }
153
+ }, [data]);
154
+
155
+ // Load from getData only when user types some text (no initial preload)
156
+ useEffect(() => {
157
+ // Do not call API until the user has actually typed at least once
158
+ if (!hasTyped) return;
159
+
160
+ let cancelled = false;
161
+ const query = inputValue.trim();
162
+ if (!getDataRef.current || query === '') {
163
+ // Clear items when input is cleared so dropdown also clears
164
+ if (!cancelled) {
165
+ setItems(data ?? []);
166
+ setIsLoading(false);
167
+ }
168
+ return;
169
+ }
170
+
171
+ const run = async () => {
172
+ setIsLoading(true);
173
+ try {
174
+ const res = await getDataRef.current?.(query);
175
+ if (!cancelled && Array.isArray(res)) {
176
+ setItems(res);
177
+ }
178
+ } catch (error) {
179
+ // Optionally log error
180
+ } finally {
181
+ if (!cancelled) {
182
+ setIsLoading(false);
183
+ }
184
+ }
185
+ };
186
+
187
+ run();
188
+
189
+ return () => {
190
+ cancelled = true;
191
+ };
192
+ }, [inputValue, hasTyped, data]);
193
+
194
+ // Group by desc field and compute counts
195
+ const grouped = useMemo(() => {
196
+ const map = new Map<string, { name: string; count: number }>();
197
+
198
+ items.forEach((item) => {
199
+ const n = item?.[desc];
200
+ if (!n) return;
201
+
202
+ if (countProvided) {
203
+ // API provides count - use it directly
204
+ const apiCount = item?.[countField];
205
+ const countValue = typeof apiCount === 'number' ? apiCount : typeof apiCount === 'string' ? Number(apiCount) : 0;
206
+
207
+ const existing = map.get(n);
208
+ if (existing) {
209
+ // Sum counts for items with same name
210
+ existing.count += countValue;
211
+ } else {
212
+ map.set(n, { name: n, count: countValue });
213
+ }
214
+ } else {
215
+ // API doesn't provide count - group and count duplicates
216
+ const existing = map.get(n);
217
+ if (existing) {
218
+ existing.count += 1;
219
+ } else {
220
+ map.set(n, { name: n, count: 0 });
221
+ }
222
+ }
223
+ });
224
+
225
+ return Array.from(map.values());
226
+ }, [items, countField, desc, countProvided]);
227
+
228
+ // Filter based on what user typed
229
+ const visible = useMemo(() => {
230
+ const term = inputValue.trim().toLowerCase();
231
+ if (!term || !hasTyped) return [];
232
+ return grouped.filter((g) => g.name.toLowerCase().includes(term));
233
+ }, [grouped, inputValue, hasTyped]);
234
+
235
+ // Update dropdown visibility based on visible items
236
+ useEffect(() => {
237
+ setShowDropdown(visible.length > 0);
238
+ }, [visible.length]);
239
+
240
+ // Handle click outside to close dropdown
241
+ const handleClickOutside = (event: MouseEvent) => {
242
+ if (
243
+ containerRef.current &&
244
+ !containerRef.current.contains(event.target as Node)
245
+ ) {
246
+ setShowDropdown(false);
247
+ }
248
+ };
249
+
250
+ // Handle scroll to close dropdown
251
+ const handleScroll = () => {
252
+ setShowDropdown(false);
253
+ };
254
+
255
+ // Attach event listeners for click outside and scroll
256
+ useEffect(() => {
257
+ if (showDropdown) {
258
+ document.addEventListener('click', handleClickOutside);
259
+ window.addEventListener('scroll', handleScroll);
260
+
261
+ const mainElement = document.querySelector('main');
262
+ mainElement?.addEventListener('scroll', handleScroll);
263
+
264
+ const gridElements = document.querySelectorAll(
265
+ '.k-grid-content, .overflow-auto, .overflow-y-auto, .overflow-x-auto'
266
+ );
267
+ gridElements.forEach((gridElement: any) => {
268
+ gridElement.addEventListener('scroll', handleScroll);
269
+ });
270
+
271
+ return () => {
272
+ document.removeEventListener('click', handleClickOutside);
273
+ window.removeEventListener('scroll', handleScroll);
274
+ mainElement?.removeEventListener('scroll', handleScroll);
275
+ gridElements.forEach((gridElement: any) => {
276
+ gridElement.removeEventListener('scroll', handleScroll);
277
+ });
278
+ };
279
+ }
280
+ }, [showDropdown]);
281
+
282
+ const checkIsEmptyField = (): boolean => {
283
+ if (inputValue === undefined) return true;
284
+ else return (inputValue?.toString().length ?? 0) <= 0 ? true : false;
285
+ };
286
+
287
+ const getHeight = () => {
288
+ switch (size) {
289
+ case 'sm':
290
+ return ' h-[34px] ';
291
+ default:
292
+ return ' ';
293
+ }
294
+ };
295
+
296
+ const getErrors = (err: any) => {
297
+ let errMsg = '';
298
+ if (err.message) {
299
+ errMsg = err?.message;
300
+ } else if (err?.id?.message) {
301
+ errMsg = err?.id?.message;
302
+ } else if (err?.name?.message) {
303
+ errMsg = err?.name?.message;
304
+ }
305
+ return errMsg;
306
+ };
307
+
308
+ const handleError = (data: any) => {
309
+ if (
310
+ getErrors(data[name]) === 'required' ||
311
+ getErrors(data[name]) === 'Required'
312
+ ) {
313
+ return `${label ?? labelTitle} is ${getErrors(data[name])}`;
314
+ } else {
315
+ return getErrors(data[name]) ?? '';
316
+ }
317
+ };
318
+
319
+ const generateClassName = (
320
+ type: 'input' | 'label' | 'message' | 'adorement'
321
+ ): string => {
322
+ let className = propsClassName || '';
323
+ switch (type) {
324
+ case 'input':
325
+ className += ` block text-common text-input-text font-normal px-3.5 w-full text-sm text-gray-900 bg-transparent border appearance-none peer h-10 rounded-[4px] disabled:text-input-disabled bg-white disabled:bg-disabled ${
326
+ label && isModern
327
+ ? 'placeholder-transparent'
328
+ : 'focus:placeholder-grey-secondary placeholder-input-label'
329
+ } focus:placeholder-grey-secondary`;
330
+
331
+ if (errors && errors[name]) {
332
+ className +=
333
+ ' border-[#FDA29B] focus:border-error-[#FDA29B] focus:ring-[#FDA29B] focus:ring-3 focus:outline-[#FDA29B] input-outline';
334
+ } else {
335
+ className +=
336
+ ' text-grey-dark border-input-light focus:border-blue-navy focus:outline-none focus:ring-0';
337
+ }
338
+
339
+ className += getHeight();
340
+
341
+ break;
342
+ case 'label':
343
+ className += ` flex modern-input-label-truncate peer-focus:modern-input-peer-focus-label-size
344
+ ${
345
+ disabled
346
+ ? 'cursor-pointer'
347
+ : 'cursor-text peer-focus:cursor-pointer'
348
+ } ${
349
+ disabled && !checkIsEmptyField()
350
+ ? 'disabled-input-label-bg'
351
+ : !disabled || !checkIsEmptyField()
352
+ ? 'active-input-label-bg'
353
+ : ''
354
+ } absolute duration-300 transform -translate-y-4 top-2 z-1 origin-[0] px-0 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:top-1/2 peer-focus:top-2 peer-focus:-translate-y-4 start-[14px] rtl:peer-focus:translate-x-1/4 rtl:peer-focus:left-auto ${
355
+ disabled
356
+ ? 'cursor-pointer'
357
+ : 'cursor-text peer-focus:cursor-pointer'
358
+ }
359
+ ${
360
+ checkIsEmptyField()
361
+ ? 'modern-input-label-size'
362
+ : 'modern-input-peer-focus-label-size'
363
+ }`;
364
+ if (errors && errors[name]) {
365
+ className += ' text-error-light ';
366
+ } else {
367
+ className += ' text-grey-dark peer-focus:text-blue-navy';
368
+ }
369
+ break;
370
+ case 'message':
371
+ className = ' text-error-icon ';
372
+ break;
373
+ case 'adorement':
374
+ className += ' absolute right-0 adorement gap-1 flex items-center ';
375
+ break;
376
+ default:
377
+ break;
378
+ }
379
+ return className;
380
+ };
381
+
382
+ const getInnerWidth = () => {
383
+ const innerwidth = adorementRef.current
384
+ ? adorementRef.current.offsetWidth
385
+ : 0;
386
+ return innerwidth;
387
+ };
388
+
389
+ const onLabelClick = () => {
390
+ if (!disabled) {
391
+ inputRef?.current?.focus();
392
+ }
393
+ };
394
+
395
+ const getDropPosition = (): any => {
396
+ if (!insideOpen && inputRef?.current) {
397
+ const dropdownHeight = 200; // Assume the height of the dropdown is 200px, adjust as needed
398
+
399
+ const inputRect = inputRef.current.getBoundingClientRect();
400
+ const spaceBelow = window.innerHeight - inputRect.bottom;
401
+ const spaceAbove = inputRect.top;
402
+
403
+ let top: number | undefined;
404
+ let bottom: number | undefined;
405
+ if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
406
+ // Show above if not enough space below
407
+ bottom = window.innerHeight - inputRect.top + 2;
408
+ } else {
409
+ // Show below (default)
410
+ top = inputRect.bottom + 2;
411
+ }
412
+ setDropPosition({
413
+ position: 'fixed',
414
+ left: inputRect.left,
415
+ top,
416
+ bottom,
417
+ width: inputRect.width,
418
+ });
419
+ }
420
+
421
+ return undefined;
422
+ };
423
+
424
+ // Calculate dropdown position when it opens
425
+ useEffect(() => {
426
+ if (showDropdown && !insideOpen) {
427
+ window.addEventListener('resize', getDropPosition);
428
+ getDropPosition();
429
+ return () => {
430
+ window.removeEventListener('resize', getDropPosition);
431
+ };
432
+ }
433
+ }, [showDropdown, insideOpen]);
434
+
435
+ return (
436
+ <div
437
+ ref={containerRef}
438
+ className={`flex flex-col gap-1 autocomplete-with-suggection ${propsClassName || ''}`}
439
+ >
440
+ {label && !isModern && (
441
+ <div className="mb-3">
442
+ <label className={`text-xs font-medium`}>
443
+ {label}
444
+ {required ? <span className="text-error"> *</span> : <></>}
445
+ </label>
446
+ </div>
447
+ )}
448
+ <div className="tooltip-container">
449
+ {isHovered && errors && errors[name] && (
450
+ <span className="tooltip">{handleError(errors)} </span>
451
+ )}
452
+ <div className="flex relative w-full">
453
+ <div
454
+ className="relative w-full"
455
+ onMouseEnter={() => {}}
456
+ onMouseLeave={() => {}}
457
+ >
458
+ <input
459
+ type="text"
460
+ id={
461
+ id ? `custom-autocomplete-${id}` : `custom-autocomplete-${name}`
462
+ }
463
+ name={name}
464
+ value={inputValue ? inputValue : ''}
465
+ ref={inputRef}
466
+ disabled={disabled}
467
+ onChange={(e) => {
468
+ const val = e.target.value;
469
+ setInputValue(val);
470
+ setHasTyped(true);
471
+ onChange?.(val);
472
+ }}
473
+ onBlur={() => {}}
474
+ placeholder={placeholder}
475
+ autoComplete="off"
476
+ data-testid="custom-autocomplete-suggestions"
477
+ aria-describedby={id}
478
+ style={{
479
+ paddingRight: getInnerWidth(),
480
+ }}
481
+ className={` ${generateClassName('input')}`}
482
+ />
483
+ {isModern && (
484
+ <label
485
+ htmlFor={id ?? name}
486
+ onClick={() => onLabelClick()}
487
+ className={generateClassName('label')}
488
+ >
489
+ {label ? <span className="truncate">{label}</span> : ''}
490
+ {required ? <span className="text-error"> *</span> : <></>}
491
+ </label>
492
+ )}
493
+ </div>
494
+ <div className="flex items-center justify-center">
495
+ <div
496
+ ref={adorementRef}
497
+ className={`${generateClassName('adorement')} qbs-autocomplete-adorement custom-dorpdown-adorement mr-[1px] ${
498
+ isLoading ? 'bg-white' : ''
499
+ }`}
500
+ >
501
+ {isLoading && <Spinner />}
502
+ {errors && errors[name] && (
503
+ <div
504
+ className={` text-error-label relative cursor-pointer ${generateClassName(
505
+ 'message'
506
+ )}`}
507
+ onMouseEnter={() => setIsHovered(true)}
508
+ onMouseLeave={() => setIsHovered(false)}
509
+ >
510
+ <CustomIcons name="alert" type="medium" />
511
+ </div>
512
+ )}
513
+ </div>
514
+ </div>
515
+ </div>
516
+ </div>
517
+ {showDropdown && visible.length > 0 && (
518
+ <>
519
+ {insideOpen ? (
520
+ <div className="suggection-list">
521
+ <div className="bg-white border border-grey-light shadow-gray-300 shadow-md rounded-sm max-h-40 overflow-auto text-[11px] text-gray-700 px-3.5 py-1.5">
522
+ {visible.map((g) => (
523
+ <SuggestionItem
524
+ key={g.name}
525
+ name={g.name}
526
+ count={g.count}
527
+ showCount={g.count > 0}
528
+ />
529
+ ))}
530
+ </div>
531
+ </div>
532
+ ) : (
533
+ <Portal>
534
+ <div
535
+ className="autocomplete-suggections suggection-list bg-white shadow-gray-300 shadow-md border border-grey-light z-50"
536
+ style={dropPosition}
537
+ >
538
+ <div className="h-auto max-h-40 overflow-auto w-full py-1.5">
539
+ {visible.map((g) => (
540
+ <div key={g.name} className="px-3.5 text-[11px] text-gray-700">
541
+ <SuggestionItem
542
+ name={g.name}
543
+ count={g.count}
544
+ showCount={g.count > -1}
545
+ />
546
+ </div>
547
+ ))}
548
+ </div>
549
+ </div>
550
+ </Portal>
551
+ )}
552
+ </>
553
+ )}
554
+ </div>
555
+ );
556
+ };
557
+
558
+ export default ModernAutoCompleteSuggections;
559
+