@axzydev/axzy_ui_system 1.2.16 → 1.3.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.
Files changed (65) hide show
  1. package/dist/index.cjs +3349 -1457
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.cts +719 -54
  5. package/dist/index.d.ts +719 -54
  6. package/dist/index.js +3283 -1387
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/App.tsx +32 -0
  10. package/src/components/atoms/badget/badget.tsx +2 -4
  11. package/src/components/atoms/chip/chip.doc.mdx +47 -0
  12. package/src/components/atoms/chip/chip.props.ts +32 -0
  13. package/src/components/atoms/chip/chip.stories.tsx +64 -0
  14. package/src/components/atoms/chip/chip.tsx +112 -0
  15. package/src/components/atoms/grid/grid.tsx +1 -2
  16. package/src/components/molecules/accordion/accordion.doc.mdx +47 -0
  17. package/src/components/molecules/accordion/accordion.props.ts +35 -0
  18. package/src/components/molecules/accordion/accordion.stories.tsx +68 -0
  19. package/src/components/molecules/accordion/accordion.tsx +123 -0
  20. package/src/components/molecules/calendar/calendar.doc.mdx +2 -1
  21. package/src/components/molecules/calendar/calendar.tsx +102 -24
  22. package/src/components/molecules/chip-input/chip-input.doc.mdx +44 -0
  23. package/src/components/molecules/chip-input/chip-input.props.ts +42 -0
  24. package/src/components/molecules/chip-input/chip-input.stories.tsx +52 -0
  25. package/src/components/molecules/chip-input/chip-input.tsx +205 -0
  26. package/src/components/molecules/date-picker/datePicker.tsx +45 -56
  27. package/src/components/molecules/dropdown-menu/dropdown-menu.doc.mdx +46 -0
  28. package/src/components/molecules/dropdown-menu/dropdown-menu.props.ts +41 -0
  29. package/src/components/molecules/dropdown-menu/dropdown-menu.stories.tsx +41 -0
  30. package/src/components/molecules/dropdown-menu/dropdown-menu.tsx +219 -0
  31. package/src/components/molecules/field/field.doc.mdx +43 -0
  32. package/src/components/molecules/field/field.props.ts +23 -0
  33. package/src/components/molecules/field/field.stories.tsx +42 -0
  34. package/src/components/molecules/field/field.tsx +54 -0
  35. package/src/components/molecules/masked-input/masked-input.doc.mdx +78 -0
  36. package/src/components/molecules/masked-input/masked-input.props.ts +58 -0
  37. package/src/components/molecules/masked-input/masked-input.stories.tsx +126 -0
  38. package/src/components/molecules/masked-input/masked-input.tsx +244 -0
  39. package/src/components/molecules/multi-select/multi-select.doc.mdx +55 -0
  40. package/src/components/molecules/multi-select/multi-select.props.ts +65 -0
  41. package/src/components/molecules/multi-select/multi-select.stories.tsx +59 -0
  42. package/src/components/molecules/multi-select/multi-select.tsx +386 -0
  43. package/src/components/molecules/search-select/search-select.doc.mdx +5 -0
  44. package/src/components/molecules/search-select/search-select.props.ts +10 -1
  45. package/src/components/molecules/search-select/search-select.stories.tsx +18 -0
  46. package/src/components/molecules/search-select/search-select.tsx +102 -28
  47. package/src/components/molecules/time-picker/timePicker.tsx +14 -26
  48. package/src/components/molecules/wysiwyg/wysiwyg.props.ts +51 -0
  49. package/src/components/molecules/wysiwyg/wysiwyg.stories.tsx +95 -0
  50. package/src/components/molecules/wysiwyg/wysiwyg.tsx +609 -0
  51. package/src/components/organisms/form-builder/fieldRenderer.tsx +144 -27
  52. package/src/components/organisms/form-builder/formBuilder.context.tsx +77 -15
  53. package/src/components/organisms/form-builder/formBuilder.doc.mdx +213 -86
  54. package/src/components/organisms/form-builder/formBuilder.props.ts +118 -36
  55. package/src/components/organisms/form-builder/formBuilder.stories.tsx +694 -96
  56. package/src/components/organisms/form-builder/formBuilder.tsx +152 -69
  57. package/src/components/organisms/form-builder/useFormBuilder.ts +59 -21
  58. package/src/hooks/useFloatingPanel.ts +100 -0
  59. package/src/index.ts +26 -4
  60. package/src/showcases/DataShowcases.tsx +76 -0
  61. package/src/showcases/FormShowcases.tsx +574 -10
  62. package/src/showcases/HomeShowcase.tsx +6 -6
  63. package/src/showcases/NavigationShowcases.tsx +72 -2
  64. package/src/showcases/StructureShowcases.tsx +64 -0
  65. 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
- if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
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
- if (!searchTerm || !isFocused) return options;
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 pointer-events-none">
215
- {isLoading && <div className="animate-spin h-4 w-4 border-2 border-primary-500 border-t-transparent rounded-full" />}
216
- {!isLoading && <FaSearch size={14} className={clsx({ "text-primary-500": isFocused })} />}
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
- <div className="absolute z-[70] w-full mt-1 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">
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
- <ITText
227
- as="div"
228
- key={option[valueField]}
229
- onClick={() => handleSelect(option)}
230
- className={clsx(
231
- "px-4 py-2 text-sm cursor-pointer transition-colors",
232
- value === option[valueField]
233
- ? "bg-primary-50 dark:bg-primary-950/40 text-primary-700 dark:text-primary-300 font-medium"
234
- : "hover:bg-secondary-50 dark:hover:bg-slate-800 text-secondary-700 dark:text-slate-300"
235
- )}
236
- >
237
- <ITText as="span">{option[labelField]}</ITText>
238
- </ITText>
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
- </div>
247
- )}
319
+ </div>,
320
+ document.body
321
+ )}
248
322
  </div>
249
323
 
250
324
  {/* Error Message */}