@axzydev/axzy_ui_system 1.2.15 → 1.3.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/dist/index.cjs +3067 -1409
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.cts +620 -54
- package/dist/index.d.ts +620 -54
- package/dist/index.js +2983 -1320
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/App.tsx +28 -0
- package/src/components/atoms/badget/badget.tsx +2 -4
- package/src/components/atoms/chip/chip.doc.mdx +47 -0
- package/src/components/atoms/chip/chip.props.ts +32 -0
- package/src/components/atoms/chip/chip.stories.tsx +64 -0
- package/src/components/atoms/chip/chip.tsx +112 -0
- package/src/components/atoms/grid/grid.tsx +1 -2
- package/src/components/molecules/accordion/accordion.doc.mdx +47 -0
- package/src/components/molecules/accordion/accordion.props.ts +35 -0
- package/src/components/molecules/accordion/accordion.stories.tsx +68 -0
- package/src/components/molecules/accordion/accordion.tsx +123 -0
- package/src/components/molecules/chip-input/chip-input.doc.mdx +44 -0
- package/src/components/molecules/chip-input/chip-input.props.ts +42 -0
- package/src/components/molecules/chip-input/chip-input.stories.tsx +52 -0
- package/src/components/molecules/chip-input/chip-input.tsx +205 -0
- package/src/components/molecules/date-picker/datePicker.tsx +45 -56
- package/src/components/molecules/dropdown-menu/dropdown-menu.doc.mdx +46 -0
- package/src/components/molecules/dropdown-menu/dropdown-menu.props.ts +41 -0
- package/src/components/molecules/dropdown-menu/dropdown-menu.stories.tsx +41 -0
- package/src/components/molecules/dropdown-menu/dropdown-menu.tsx +219 -0
- package/src/components/molecules/field/field.doc.mdx +43 -0
- package/src/components/molecules/field/field.props.ts +23 -0
- package/src/components/molecules/field/field.stories.tsx +42 -0
- package/src/components/molecules/field/field.tsx +54 -0
- package/src/components/molecules/multi-select/multi-select.doc.mdx +55 -0
- package/src/components/molecules/multi-select/multi-select.props.ts +65 -0
- package/src/components/molecules/multi-select/multi-select.stories.tsx +59 -0
- package/src/components/molecules/multi-select/multi-select.tsx +386 -0
- package/src/components/molecules/search-select/search-select.doc.mdx +5 -0
- package/src/components/molecules/search-select/search-select.props.ts +10 -1
- package/src/components/molecules/search-select/search-select.stories.tsx +18 -0
- package/src/components/molecules/search-select/search-select.tsx +102 -28
- package/src/components/molecules/time-picker/timePicker.tsx +14 -26
- package/src/components/molecules/wysiwyg/wysiwyg.props.ts +51 -0
- package/src/components/molecules/wysiwyg/wysiwyg.stories.tsx +95 -0
- package/src/components/molecules/wysiwyg/wysiwyg.tsx +609 -0
- package/src/components/organisms/data-table/dataTable.tsx +16 -5
- package/src/components/organisms/form-builder/fieldRenderer.tsx +144 -27
- package/src/components/organisms/form-builder/formBuilder.context.tsx +77 -15
- package/src/components/organisms/form-builder/formBuilder.doc.mdx +213 -86
- package/src/components/organisms/form-builder/formBuilder.props.ts +118 -36
- package/src/components/organisms/form-builder/formBuilder.stories.tsx +694 -96
- package/src/components/organisms/form-builder/formBuilder.tsx +152 -69
- package/src/components/organisms/form-builder/useFormBuilder.ts +59 -21
- package/src/hooks/useFloatingPanel.ts +100 -0
- package/src/index.ts +24 -4
- package/src/showcases/DataShowcases.tsx +76 -0
- package/src/showcases/FormShowcases.tsx +396 -10
- package/src/showcases/HomeShowcase.tsx +6 -6
- package/src/showcases/NavigationShowcases.tsx +72 -2
- package/src/showcases/StructureShowcases.tsx +64 -0
- package/src/types/chip.types.ts +13 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import clsx from "clsx";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
import { FaCheck, FaChevronDown, FaSearch, FaTimes } from "react-icons/fa";
|
|
5
|
+
import { ITMultiSelectOption, ITMultiSelectProps } from "./multi-select.props";
|
|
6
|
+
import { theme } from "@/theme/theme";
|
|
7
|
+
import { useFloatingPanel } from "@/hooks/useFloatingPanel";
|
|
8
|
+
import { inputFieldStyle } from "@/utils/styles";
|
|
9
|
+
import ITChip from "@/components/atoms/chip/chip";
|
|
10
|
+
import ITText from "@/components/atoms/text/text";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Multi-value select with searchable dropdown, removable chips, and keyboard navigation.
|
|
14
|
+
*
|
|
15
|
+
* Supports local filtering and remote search (`onSearch`), a collapsed chip
|
|
16
|
+
* summary (`maxVisibleChips`), and custom option templates (`renderOption`).
|
|
17
|
+
* The dropdown is portaled so it is never clipped by cards or overflow ancestors.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* <ITMultiSelect
|
|
21
|
+
* label="Skills"
|
|
22
|
+
* options={skills}
|
|
23
|
+
* value={selected}
|
|
24
|
+
* onChange={(values) => setSelected(values)}
|
|
25
|
+
* />
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* <ITMultiSelect
|
|
29
|
+
* label="Etiquetas"
|
|
30
|
+
* options={tags}
|
|
31
|
+
* value={selected}
|
|
32
|
+
* onChange={(values) => setSelected(values)}
|
|
33
|
+
* maxVisibleChips={2}
|
|
34
|
+
* renderOption={(option, { isSelected }) => (
|
|
35
|
+
* <span className={isSelected ? "font-bold" : ""}>{option.label}</span>
|
|
36
|
+
* )}
|
|
37
|
+
* />
|
|
38
|
+
*/
|
|
39
|
+
export default function ITMultiSelect({
|
|
40
|
+
name,
|
|
41
|
+
label,
|
|
42
|
+
placeholder = "Selecciona opciones",
|
|
43
|
+
options,
|
|
44
|
+
value,
|
|
45
|
+
onChange,
|
|
46
|
+
onBlur,
|
|
47
|
+
valueField = "value",
|
|
48
|
+
labelField = "label",
|
|
49
|
+
disabled = false,
|
|
50
|
+
readOnly = false,
|
|
51
|
+
required = false,
|
|
52
|
+
touched,
|
|
53
|
+
error,
|
|
54
|
+
size = "md",
|
|
55
|
+
maxVisibleChips = 3,
|
|
56
|
+
clearable = true,
|
|
57
|
+
searchable = true,
|
|
58
|
+
onSearch,
|
|
59
|
+
isLoading = false,
|
|
60
|
+
noResultsMessage = "No se encontraron resultados",
|
|
61
|
+
renderOption,
|
|
62
|
+
className,
|
|
63
|
+
}: ITMultiSelectProps) {
|
|
64
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
65
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
66
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
67
|
+
const [localTouched, setLocalTouched] = useState(false);
|
|
68
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
69
|
+
|
|
70
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
71
|
+
const controlRef = useRef<HTMLDivElement>(null);
|
|
72
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
73
|
+
const optionRefs = useRef<Array<HTMLDivElement | null>>([]);
|
|
74
|
+
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
75
|
+
|
|
76
|
+
const { panelRef, style: panelStyle } = useFloatingPanel(controlRef, isOpen, {
|
|
77
|
+
estimatedHeight: 260,
|
|
78
|
+
matchWidth: true,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const selectedOptions = useMemo(
|
|
82
|
+
() => options.filter((opt) => value.includes(opt[valueField])),
|
|
83
|
+
[options, value, valueField]
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const filteredOptions = useMemo(() => {
|
|
87
|
+
if (onSearch) return options;
|
|
88
|
+
if (!searchTerm) return options;
|
|
89
|
+
return options.filter((opt) =>
|
|
90
|
+
String(opt[labelField]).toLowerCase().includes(searchTerm.toLowerCase())
|
|
91
|
+
);
|
|
92
|
+
}, [options, searchTerm, onSearch, labelField]);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
function handleClickOutside(event: MouseEvent) {
|
|
96
|
+
const target = event.target as Node;
|
|
97
|
+
if (!containerRef.current?.contains(target) && !panelRef.current?.contains(target)) {
|
|
98
|
+
setIsOpen(false);
|
|
99
|
+
setIsFocused(false);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
103
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
104
|
+
}, [panelRef]);
|
|
105
|
+
|
|
106
|
+
const inputTheme = (theme as any).input || {};
|
|
107
|
+
|
|
108
|
+
const isTouched = touched !== undefined ? touched : localTouched;
|
|
109
|
+
const isEmpty = value.length === 0;
|
|
110
|
+
const effectiveError =
|
|
111
|
+
error !== undefined && error !== false
|
|
112
|
+
? error === true
|
|
113
|
+
? "Este campo es requerido"
|
|
114
|
+
: error
|
|
115
|
+
: required && isEmpty
|
|
116
|
+
? "Este campo es requerido"
|
|
117
|
+
: undefined;
|
|
118
|
+
const hasError = isTouched && !!effectiveError;
|
|
119
|
+
const errorMessage = typeof effectiveError === "string" ? effectiveError : "Este campo es requerido";
|
|
120
|
+
|
|
121
|
+
const getControlStyle = (): React.CSSProperties => {
|
|
122
|
+
const style = inputFieldStyle(inputTheme, size, { width: "100%", cursor: "text" });
|
|
123
|
+
style.display = "flex";
|
|
124
|
+
style.flexWrap = "wrap";
|
|
125
|
+
style.alignItems = "center";
|
|
126
|
+
style.gap = "0.375rem";
|
|
127
|
+
style.minHeight = "2.5rem";
|
|
128
|
+
|
|
129
|
+
if (disabled) {
|
|
130
|
+
style.backgroundColor = inputTheme.disabled?.backgroundColor || style.backgroundColor;
|
|
131
|
+
style.borderColor = inputTheme.disabled?.borderColor || style.borderColor;
|
|
132
|
+
style.opacity = 0.7;
|
|
133
|
+
style.cursor = "not-allowed";
|
|
134
|
+
}
|
|
135
|
+
if (hasError) {
|
|
136
|
+
style.borderColor = inputTheme.error?.borderColor || "red";
|
|
137
|
+
} else if (isFocused && !readOnly) {
|
|
138
|
+
style.boxShadow = inputTheme.focus?.ring;
|
|
139
|
+
style.borderColor = inputTheme.focus?.borderColor;
|
|
140
|
+
}
|
|
141
|
+
return style;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const open = () => {
|
|
145
|
+
if (disabled || readOnly) return;
|
|
146
|
+
setIsOpen(true);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const handleControlClick = () => {
|
|
150
|
+
if (disabled || readOnly) return;
|
|
151
|
+
inputRef.current?.focus();
|
|
152
|
+
open();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
156
|
+
const query = e.target.value;
|
|
157
|
+
setSearchTerm(query);
|
|
158
|
+
open();
|
|
159
|
+
if (onSearch) {
|
|
160
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
161
|
+
timeoutRef.current = setTimeout(() => onSearch(query), 500);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const toggleOption = (option: ITMultiSelectOption) => {
|
|
166
|
+
if (disabled || readOnly) return;
|
|
167
|
+
const optValue = option[valueField];
|
|
168
|
+
const isSelected = value.includes(optValue);
|
|
169
|
+
const nextValues = isSelected
|
|
170
|
+
? value.filter((v) => v !== optValue)
|
|
171
|
+
: [...value, optValue];
|
|
172
|
+
const nextOptions = options.filter((opt) => nextValues.includes(opt[valueField]));
|
|
173
|
+
onChange?.(nextValues, nextOptions);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const handleClear = () => {
|
|
177
|
+
if (disabled || readOnly) return;
|
|
178
|
+
onChange?.([], []);
|
|
179
|
+
setSearchTerm("");
|
|
180
|
+
if (onSearch) onSearch("");
|
|
181
|
+
inputRef.current?.focus();
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const moveActive = (dir: 1 | -1) => {
|
|
185
|
+
if (!filteredOptions.length) return;
|
|
186
|
+
const next =
|
|
187
|
+
activeIndex === -1
|
|
188
|
+
? dir === 1
|
|
189
|
+
? 0
|
|
190
|
+
: filteredOptions.length - 1
|
|
191
|
+
: (activeIndex + dir + filteredOptions.length) % filteredOptions.length;
|
|
192
|
+
setActiveIndex(next);
|
|
193
|
+
optionRefs.current[next]?.scrollIntoView({ block: "nearest" });
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
197
|
+
if (disabled || readOnly) return;
|
|
198
|
+
if (e.key === "ArrowDown") {
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
if (!isOpen) open();
|
|
201
|
+
else moveActive(1);
|
|
202
|
+
} else if (e.key === "ArrowUp") {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
if (!isOpen) open();
|
|
205
|
+
else moveActive(-1);
|
|
206
|
+
} else if (e.key === "Enter") {
|
|
207
|
+
if (isOpen && activeIndex >= 0 && filteredOptions[activeIndex]) {
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
toggleOption(filteredOptions[activeIndex]);
|
|
210
|
+
}
|
|
211
|
+
} else if (e.key === "Escape") {
|
|
212
|
+
setIsOpen(false);
|
|
213
|
+
} else if (e.key === "Backspace" && searchTerm === "" && value.length > 0) {
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
const nextValues = value.slice(0, -1);
|
|
216
|
+
const nextOptions = options.filter((opt) => nextValues.includes(opt[valueField]));
|
|
217
|
+
onChange?.(nextValues, nextOptions);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const handleFocus = () => {
|
|
222
|
+
if (disabled || readOnly) return;
|
|
223
|
+
setIsFocused(true);
|
|
224
|
+
open();
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
228
|
+
setLocalTouched(true);
|
|
229
|
+
setIsFocused(false);
|
|
230
|
+
onBlur?.(e);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const visibleChips = selectedOptions.slice(0, maxVisibleChips);
|
|
234
|
+
const hiddenCount = selectedOptions.length - visibleChips.length;
|
|
235
|
+
const showClear = clearable && !disabled && !readOnly && value.length > 0;
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<div className={clsx("w-full flex flex-col gap-1.5", className)} ref={containerRef}>
|
|
239
|
+
{label && (
|
|
240
|
+
<ITText
|
|
241
|
+
as="label"
|
|
242
|
+
className={clsx("text-sm font-medium text-secondary-700 dark:text-slate-300", {
|
|
243
|
+
"text-danger-500": hasError,
|
|
244
|
+
})}
|
|
245
|
+
>
|
|
246
|
+
<ITText as="span">{label}</ITText>
|
|
247
|
+
{required && <ITText as="span" className="text-danger-500 ml-1">*</ITText>}
|
|
248
|
+
</ITText>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
<div className="relative">
|
|
252
|
+
<div
|
|
253
|
+
ref={controlRef}
|
|
254
|
+
role="combobox"
|
|
255
|
+
aria-expanded={isOpen}
|
|
256
|
+
aria-haspopup="listbox"
|
|
257
|
+
aria-disabled={disabled}
|
|
258
|
+
onClick={handleControlClick}
|
|
259
|
+
className="flex items-center"
|
|
260
|
+
style={getControlStyle()}
|
|
261
|
+
>
|
|
262
|
+
{visibleChips.map((opt) => (
|
|
263
|
+
<ITChip
|
|
264
|
+
key={String(opt[valueField])}
|
|
265
|
+
label={String(opt[labelField])}
|
|
266
|
+
color="primary"
|
|
267
|
+
size="sm"
|
|
268
|
+
removable={!disabled && !readOnly}
|
|
269
|
+
onRemove={() => toggleOption(opt)}
|
|
270
|
+
/>
|
|
271
|
+
))}
|
|
272
|
+
{hiddenCount > 0 && (
|
|
273
|
+
<ITChip label={`+${hiddenCount}`} color="secondary" size="sm" />
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
<input
|
|
277
|
+
ref={inputRef}
|
|
278
|
+
name={name}
|
|
279
|
+
type="text"
|
|
280
|
+
value={searchTerm}
|
|
281
|
+
onChange={handleSearchChange}
|
|
282
|
+
onKeyDown={handleKeyDown}
|
|
283
|
+
onFocus={handleFocus}
|
|
284
|
+
onBlur={handleBlur}
|
|
285
|
+
disabled={disabled}
|
|
286
|
+
readOnly={readOnly || !searchable}
|
|
287
|
+
placeholder={selectedOptions.length === 0 ? placeholder : ""}
|
|
288
|
+
autoComplete="off"
|
|
289
|
+
aria-autocomplete="list"
|
|
290
|
+
aria-activedescendant={activeIndex >= 0 ? `it-multiselect-opt-${activeIndex}` : undefined}
|
|
291
|
+
className="flex-1 min-w-[60px] bg-transparent outline-none border-none text-sm text-secondary-900 dark:text-slate-100"
|
|
292
|
+
style={{ padding: 0, fontSize: inputFieldStyle(inputTheme, size).fontSize }}
|
|
293
|
+
/>
|
|
294
|
+
|
|
295
|
+
<div className="ml-auto flex items-center gap-2 pl-1 text-secondary-400 shrink-0">
|
|
296
|
+
{isLoading ? (
|
|
297
|
+
<div className="animate-spin h-4 w-4 border-2 border-primary-500 border-t-transparent rounded-full" />
|
|
298
|
+
) : showClear ? (
|
|
299
|
+
<button
|
|
300
|
+
type="button"
|
|
301
|
+
aria-label="Limpiar selección"
|
|
302
|
+
title="Limpiar selección"
|
|
303
|
+
onMouseDown={(e) => {
|
|
304
|
+
e.preventDefault();
|
|
305
|
+
e.stopPropagation();
|
|
306
|
+
handleClear();
|
|
307
|
+
}}
|
|
308
|
+
className="flex items-center justify-center hover:text-secondary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 rounded-full"
|
|
309
|
+
>
|
|
310
|
+
<FaTimes size={12} />
|
|
311
|
+
</button>
|
|
312
|
+
) : (
|
|
313
|
+
<FaSearch size={13} className="pointer-events-none" />
|
|
314
|
+
)}
|
|
315
|
+
<FaChevronDown
|
|
316
|
+
size={12}
|
|
317
|
+
className={clsx("pointer-events-none transition-transform", isOpen && "rotate-180")}
|
|
318
|
+
/>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
{isOpen &&
|
|
323
|
+
createPortal(
|
|
324
|
+
<div
|
|
325
|
+
ref={panelRef}
|
|
326
|
+
role="listbox"
|
|
327
|
+
aria-multiselectable="true"
|
|
328
|
+
style={panelStyle}
|
|
329
|
+
className="bg-white dark:bg-slate-900 border border-secondary-200 dark:border-slate-800 rounded-lg shadow-xl overflow-hidden"
|
|
330
|
+
>
|
|
331
|
+
<div className="max-h-60 overflow-y-auto">
|
|
332
|
+
{filteredOptions.length > 0 ? (
|
|
333
|
+
filteredOptions.map((option, index) => {
|
|
334
|
+
const isSelected = value.includes(option[valueField]);
|
|
335
|
+
return (
|
|
336
|
+
<div
|
|
337
|
+
id={`it-multiselect-opt-${index}`}
|
|
338
|
+
key={String(option[valueField])}
|
|
339
|
+
ref={(el) => { optionRefs.current[index] = el; }}
|
|
340
|
+
role="option"
|
|
341
|
+
aria-selected={isSelected}
|
|
342
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
343
|
+
onClick={() => toggleOption(option)}
|
|
344
|
+
className={clsx(
|
|
345
|
+
"flex items-center gap-2 px-4 py-2 text-sm cursor-pointer transition-colors",
|
|
346
|
+
activeIndex === index && "bg-secondary-50 dark:bg-slate-800",
|
|
347
|
+
isSelected
|
|
348
|
+
? "text-primary-700 dark:text-primary-300 font-medium"
|
|
349
|
+
: "text-secondary-700 dark:text-slate-300"
|
|
350
|
+
)}
|
|
351
|
+
>
|
|
352
|
+
<span
|
|
353
|
+
className={clsx(
|
|
354
|
+
"flex h-4 w-4 shrink-0 items-center justify-center rounded border",
|
|
355
|
+
isSelected
|
|
356
|
+
? "bg-primary-500 border-primary-500 text-white"
|
|
357
|
+
: "border-secondary-300 dark:border-slate-600"
|
|
358
|
+
)}
|
|
359
|
+
>
|
|
360
|
+
{isSelected && <FaCheck size={9} />}
|
|
361
|
+
</span>
|
|
362
|
+
<span className="flex-1 truncate">
|
|
363
|
+
{renderOption
|
|
364
|
+
? renderOption(option, { isSelected, searchTerm })
|
|
365
|
+
: option[labelField]}
|
|
366
|
+
</span>
|
|
367
|
+
</div>
|
|
368
|
+
);
|
|
369
|
+
})
|
|
370
|
+
) : (
|
|
371
|
+
<ITText as="div" className="px-4 py-6 text-sm text-center text-secondary-500 italic">
|
|
372
|
+
{isLoading ? "Cargando..." : noResultsMessage}
|
|
373
|
+
</ITText>
|
|
374
|
+
)}
|
|
375
|
+
</div>
|
|
376
|
+
</div>,
|
|
377
|
+
document.body
|
|
378
|
+
)}
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
{hasError && (
|
|
382
|
+
<ITText as="p" className="text-danger-500 text-xs mt-1">{errorMessage}</ITText>
|
|
383
|
+
)}
|
|
384
|
+
</div>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
@@ -29,6 +29,9 @@ Searchable dropdown select with support for local client-side filtering and remo
|
|
|
29
29
|
| `onSearch` | `(query: string) => void` | — | Callback for server-side search. Receives the search query string. |
|
|
30
30
|
| `isLoading` | `boolean` | `false` | Whether options are being loaded from an external API. |
|
|
31
31
|
| `noResultsMessage` | `string` | `"No se encontraron resultados"` | Message displayed when no results match the search. |
|
|
32
|
+
| `renderOption` | `(option, state: { isSelected, searchTerm }) => ReactNode` | — | Custom template for each dropdown option. |
|
|
33
|
+
| `clearable` | `boolean` | `true` | Shows a clear (X) button inside the input. |
|
|
34
|
+
| `onClear` | `() => void` | — | Callback fired when the clear button is pressed. |
|
|
32
35
|
|
|
33
36
|
## Usage
|
|
34
37
|
|
|
@@ -64,6 +67,8 @@ import { ITSearchSelect } from '@axzydev/axzy_ui_system';
|
|
|
64
67
|
- Validation states: `error` string displays inline, `touched` gates error visibility, `required` adds a red asterisk.
|
|
65
68
|
- `readOnly` mode renders the selected value as non-interactive text.
|
|
66
69
|
- Supports `className` for style overrides and width control.
|
|
70
|
+
- **Custom options**: `renderOption` replaces the default option rendering and receives `{ isSelected, searchTerm }`.
|
|
71
|
+
- **Clear button**: an (X) button appears inside the input when there is a search term or a selected value; `clearable={false}` disables it. Clearing resets the search and selection, and calls `onSearch("")` / `onClear()`.
|
|
67
72
|
|
|
68
73
|
## Stories
|
|
69
74
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FocusEvent } from "react";
|
|
1
|
+
import { FocusEvent, ReactNode } from "react";
|
|
2
2
|
import { SizesTypes } from "@/types/sizes.types";
|
|
3
3
|
|
|
4
4
|
/** Represents an option in the search-select dropdown. */
|
|
@@ -51,4 +51,13 @@ export interface ITSearchSelectProps {
|
|
|
51
51
|
isLoading?: boolean;
|
|
52
52
|
/** Message displayed when no results are found. Default: "No se encontraron resultados". */
|
|
53
53
|
noResultsMessage?: string;
|
|
54
|
+
/** Custom template for each option in the dropdown list. Receives the option and its state (`isSelected`, `searchTerm`). */
|
|
55
|
+
renderOption?: (
|
|
56
|
+
option: ITSearchSelectOption,
|
|
57
|
+
state: { isSelected: boolean; searchTerm: string }
|
|
58
|
+
) => ReactNode;
|
|
59
|
+
/** Shows a clear (X) button inside the input. Default: true. */
|
|
60
|
+
clearable?: boolean;
|
|
61
|
+
/** Callback fired when the clear button is pressed. */
|
|
62
|
+
onClear?: () => void;
|
|
54
63
|
}
|
|
@@ -106,6 +106,24 @@ export const RemoteAPI: Story = {
|
|
|
106
106
|
},
|
|
107
107
|
};
|
|
108
108
|
|
|
109
|
+
export const CustomOptionTemplate: Story = {
|
|
110
|
+
render: (args) => <SearchSelectWrapper {...args} />,
|
|
111
|
+
args: {
|
|
112
|
+
name: "search-select",
|
|
113
|
+
label: "Usuario (template personalizado)",
|
|
114
|
+
options: options,
|
|
115
|
+
placeholder: "Busca un usuario...",
|
|
116
|
+
renderOption: (option, { isSelected, searchTerm }) => (
|
|
117
|
+
<div className="flex items-center justify-between gap-2">
|
|
118
|
+
<span className={isSelected ? "font-semibold" : ""}>{option.label}</span>
|
|
119
|
+
{searchTerm && (
|
|
120
|
+
<span className="text-xs text-secondary-400">{option.label}</span>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
),
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
109
127
|
export const WithError: Story = {
|
|
110
128
|
render: (args) => <SearchSelectWrapper {...args} />,
|
|
111
129
|
args: {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
2
3
|
import clsx from "clsx";
|
|
3
|
-
import { FaSearch } from "react-icons/fa";
|
|
4
|
+
import { FaSearch, FaTimes } from "react-icons/fa";
|
|
4
5
|
import { ITSearchSelectProps, ITSearchSelectOption } from "./search-select.props";
|
|
5
6
|
import { theme, inputSizeTokens } from "@/theme/theme";
|
|
7
|
+
import { useFloatingPanel } from "@/hooks/useFloatingPanel";
|
|
6
8
|
import ITText from "@/components/atoms/text/text";
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -32,6 +34,18 @@ import ITText from "@/components/atoms/text/text";
|
|
|
32
34
|
* value={selectedUser}
|
|
33
35
|
* onChange={(value) => setSelectedUser(value)}
|
|
34
36
|
* />
|
|
37
|
+
*
|
|
38
|
+
* // Custom option template + clear button
|
|
39
|
+
* <ITSearchSelect
|
|
40
|
+
* label="User"
|
|
41
|
+
* options={users}
|
|
42
|
+
* value={selectedUser}
|
|
43
|
+
* onChange={(value) => setSelectedUser(value)}
|
|
44
|
+
* onClear={() => console.log("cleared")}
|
|
45
|
+
* renderOption={(option, { isSelected }) => (
|
|
46
|
+
* <span className={isSelected ? "font-bold" : ""}>{option.label}</span>
|
|
47
|
+
* )}
|
|
48
|
+
* />
|
|
35
49
|
* ```
|
|
36
50
|
*/
|
|
37
51
|
export default function ITSearchSelect({
|
|
@@ -54,14 +68,24 @@ export default function ITSearchSelect({
|
|
|
54
68
|
isLoading = false,
|
|
55
69
|
noResultsMessage = "No se encontraron resultados",
|
|
56
70
|
size = "md",
|
|
71
|
+
renderOption,
|
|
72
|
+
clearable = true,
|
|
73
|
+
onClear,
|
|
57
74
|
}: ITSearchSelectProps) {
|
|
58
75
|
const [isOpen, setIsOpen] = useState(false);
|
|
59
76
|
const [searchTerm, setSearchTerm] = useState("");
|
|
60
77
|
const [isFocused, setIsFocused] = useState(false);
|
|
61
78
|
const [localTouched, setLocalTouched] = useState(false);
|
|
62
79
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
80
|
+
const inputWrapperRef = useRef<HTMLDivElement>(null);
|
|
63
81
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
64
82
|
|
|
83
|
+
// Panel position (rendered in a portal so it escapes overflow/transform ancestors).
|
|
84
|
+
const { panelRef, style: panelStyle } = useFloatingPanel(inputWrapperRef, isOpen, {
|
|
85
|
+
estimatedHeight: 260,
|
|
86
|
+
matchWidth: true,
|
|
87
|
+
});
|
|
88
|
+
|
|
65
89
|
// Encontrar la opción seleccionada inicialmente
|
|
66
90
|
const selectedOption = useMemo(() => {
|
|
67
91
|
return options.find((opt) => opt[valueField] === value);
|
|
@@ -77,7 +101,10 @@ export default function ITSearchSelect({
|
|
|
77
101
|
// Cerrar el dropdown al hacer click afuera
|
|
78
102
|
useEffect(() => {
|
|
79
103
|
function handleClickOutside(event: MouseEvent) {
|
|
80
|
-
|
|
104
|
+
const target = event.target as Node;
|
|
105
|
+
const inContainer = containerRef.current?.contains(target);
|
|
106
|
+
const inPanel = panelRef.current?.contains(target);
|
|
107
|
+
if (!inContainer && !inPanel) {
|
|
81
108
|
setIsOpen(false);
|
|
82
109
|
}
|
|
83
110
|
}
|
|
@@ -86,13 +113,16 @@ export default function ITSearchSelect({
|
|
|
86
113
|
}, []);
|
|
87
114
|
|
|
88
115
|
// Filtrado local de opciones (Modo 1)
|
|
116
|
+
const selectedLabel = selectedOption ? String(selectedOption[labelField]) : "";
|
|
89
117
|
const filteredOptions = useMemo(() => {
|
|
90
118
|
if (onSearch) return options; // Modo API
|
|
91
|
-
|
|
119
|
+
// Show every option while the input still displays the selected label;
|
|
120
|
+
// filter only once the user actually types a different query.
|
|
121
|
+
if (!searchTerm || !isFocused || searchTerm === selectedLabel) return options;
|
|
92
122
|
return options.filter((opt) =>
|
|
93
123
|
String(opt[labelField]).toLowerCase().includes(searchTerm.toLowerCase())
|
|
94
124
|
);
|
|
95
|
-
}, [options, searchTerm, onSearch, labelField, isFocused]);
|
|
125
|
+
}, [options, searchTerm, selectedLabel, onSearch, labelField, isFocused]);
|
|
96
126
|
|
|
97
127
|
// Manejar cambio en el input
|
|
98
128
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
@@ -116,6 +146,16 @@ export default function ITSearchSelect({
|
|
|
116
146
|
setIsOpen(false);
|
|
117
147
|
};
|
|
118
148
|
|
|
149
|
+
const handleClear = () => {
|
|
150
|
+
setSearchTerm("");
|
|
151
|
+
setIsOpen(true);
|
|
152
|
+
if (onSearch) onSearch("");
|
|
153
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
154
|
+
onChange?.("", undefined);
|
|
155
|
+
}
|
|
156
|
+
onClear?.();
|
|
157
|
+
};
|
|
158
|
+
|
|
119
159
|
const handleFocus = () => {
|
|
120
160
|
if (disabled || readOnly) return;
|
|
121
161
|
setIsFocused(true);
|
|
@@ -145,6 +185,9 @@ export default function ITSearchSelect({
|
|
|
145
185
|
|
|
146
186
|
const hasError = isTouched && !!effectiveError;
|
|
147
187
|
const errorMessage = typeof effectiveError === "string" ? effectiveError : "Este campo es requerido";
|
|
188
|
+
|
|
189
|
+
const showClearButton =
|
|
190
|
+
clearable && !disabled && !readOnly && (searchTerm.length > 0 || !isEmpty);
|
|
148
191
|
|
|
149
192
|
const getInputStyle = () => {
|
|
150
193
|
const sizeConfig = inputSizeTokens[size] ?? inputSizeTokens.md;
|
|
@@ -196,7 +239,7 @@ export default function ITSearchSelect({
|
|
|
196
239
|
)}
|
|
197
240
|
|
|
198
241
|
<div className="relative">
|
|
199
|
-
<div className="relative flex items-center">
|
|
242
|
+
<div ref={inputWrapperRef} className="relative flex items-center">
|
|
200
243
|
<input
|
|
201
244
|
type="text"
|
|
202
245
|
name={name}
|
|
@@ -211,40 +254,71 @@ export default function ITSearchSelect({
|
|
|
211
254
|
style={getInputStyle()}
|
|
212
255
|
autoComplete="off"
|
|
213
256
|
/>
|
|
214
|
-
<div className="absolute right-3 flex items-center gap-2 text-secondary-400
|
|
215
|
-
{isLoading &&
|
|
216
|
-
|
|
257
|
+
<div className="absolute right-3 flex items-center gap-2 text-secondary-400">
|
|
258
|
+
{isLoading && (
|
|
259
|
+
<div className="pointer-events-none animate-spin h-4 w-4 border-2 border-primary-500 border-t-transparent rounded-full" />
|
|
260
|
+
)}
|
|
261
|
+
{!isLoading && showClearButton && (
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
aria-label="Limpiar"
|
|
265
|
+
title="Limpiar"
|
|
266
|
+
onMouseDown={(e) => {
|
|
267
|
+
e.preventDefault();
|
|
268
|
+
handleClear();
|
|
269
|
+
}}
|
|
270
|
+
className="flex items-center justify-center text-secondary-400 hover:text-secondary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 rounded-full"
|
|
271
|
+
>
|
|
272
|
+
<FaTimes size={14} />
|
|
273
|
+
</button>
|
|
274
|
+
)}
|
|
275
|
+
{!isLoading && !showClearButton && (
|
|
276
|
+
<FaSearch size={14} className={clsx("pointer-events-none", { "text-primary-500": isFocused })} />
|
|
277
|
+
)}
|
|
217
278
|
</div>
|
|
218
279
|
</div>
|
|
219
280
|
|
|
220
|
-
{/* Dropdown Panel */}
|
|
221
|
-
{isOpen &&
|
|
222
|
-
|
|
281
|
+
{/* Dropdown Panel (portal: escapes overflow-hidden / transform ancestors) */}
|
|
282
|
+
{isOpen &&
|
|
283
|
+
createPortal(
|
|
284
|
+
<div
|
|
285
|
+
ref={panelRef}
|
|
286
|
+
style={panelStyle}
|
|
287
|
+
className="bg-white dark:bg-slate-900 border border-secondary-200 dark:border-slate-800 rounded-lg shadow-xl overflow-hidden animate-in fade-in zoom-in duration-200 origin-top"
|
|
288
|
+
>
|
|
223
289
|
<div className="max-h-60 overflow-y-auto">
|
|
224
290
|
{filteredOptions.length > 0 ? (
|
|
225
|
-
filteredOptions.map((option) =>
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
291
|
+
filteredOptions.map((option) => {
|
|
292
|
+
const isSelected = value === option[valueField];
|
|
293
|
+
return (
|
|
294
|
+
<ITText
|
|
295
|
+
as="div"
|
|
296
|
+
key={option[valueField]}
|
|
297
|
+
onClick={() => handleSelect(option)}
|
|
298
|
+
className={clsx(
|
|
299
|
+
"px-4 py-2 text-sm cursor-pointer transition-colors",
|
|
300
|
+
isSelected
|
|
301
|
+
? "bg-primary-50 dark:bg-primary-950/40 text-primary-700 dark:text-primary-300 font-medium"
|
|
302
|
+
: "hover:bg-secondary-50 dark:hover:bg-slate-800 text-secondary-700 dark:text-slate-300"
|
|
303
|
+
)}
|
|
304
|
+
>
|
|
305
|
+
{renderOption ? (
|
|
306
|
+
renderOption(option, { isSelected, searchTerm })
|
|
307
|
+
) : (
|
|
308
|
+
<ITText as="span">{option[labelField]}</ITText>
|
|
309
|
+
)}
|
|
310
|
+
</ITText>
|
|
311
|
+
);
|
|
312
|
+
})
|
|
240
313
|
) : (
|
|
241
314
|
<ITText as="div" className="px-4 py-6 text-sm text-center text-secondary-500 italic">
|
|
242
315
|
{isLoading ? "Cargando..." : noResultsMessage}
|
|
243
316
|
</ITText>
|
|
244
317
|
)}
|
|
245
318
|
</div>
|
|
246
|
-
|
|
247
|
-
|
|
319
|
+
</div>,
|
|
320
|
+
document.body
|
|
321
|
+
)}
|
|
248
322
|
</div>
|
|
249
323
|
|
|
250
324
|
{/* Error Message */}
|