@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,123 @@
1
+ import clsx from "clsx";
2
+ import { useState } from "react";
3
+ import { FaChevronDown } from "react-icons/fa";
4
+ import { ITAccordionProps } from "./accordion.props";
5
+ import ITText from "@/components/atoms/text/text";
6
+
7
+ /**
8
+ * Collapsible sections for FAQs, settings panels, and long content.
9
+ *
10
+ * Works uncontrolled (`defaultOpenIds`) or controlled (`openIds` + `onChange`).
11
+ * Supports single-open (default) or multiple-open mode, and animates the body
12
+ * with a CSS grid-rows transition (no JS height measurement).
13
+ *
14
+ * @example
15
+ * <ITAccordion
16
+ * items={[
17
+ * { id: "a", title: "¿Qué es AXZY?", content: "Un sistema de componentes." },
18
+ * { id: "b", title: "¿Cómo instalo?", content: "pnpm add @axzydev/axzy_ui_system" },
19
+ * ]}
20
+ * defaultOpenIds={["a"]}
21
+ * />
22
+ */
23
+ export default function ITAccordion({
24
+ items,
25
+ allowMultiple = false,
26
+ defaultOpenIds = [],
27
+ openIds,
28
+ onChange,
29
+ variant = "separated",
30
+ className,
31
+ itemClassName,
32
+ }: ITAccordionProps) {
33
+ const [internalOpen, setInternalOpen] = useState<string[]>(defaultOpenIds);
34
+ const isControlled = openIds !== undefined;
35
+ const currentOpen = isControlled ? openIds : internalOpen;
36
+
37
+ const toggle = (id: string) => {
38
+ const isOpen = currentOpen.includes(id);
39
+ let next: string[];
40
+ if (isOpen) {
41
+ next = currentOpen.filter((x) => x !== id);
42
+ } else {
43
+ next = allowMultiple ? [...currentOpen, id] : [id];
44
+ }
45
+ if (!isControlled) setInternalOpen(next);
46
+ onChange?.(next);
47
+ };
48
+
49
+ const wrapperClass =
50
+ variant === "separated"
51
+ ? clsx("flex flex-col gap-2", className)
52
+ : clsx(
53
+ "rounded-xl border border-secondary-200 dark:border-slate-700 overflow-hidden",
54
+ className
55
+ );
56
+
57
+ return (
58
+ <div className={wrapperClass}>
59
+ {items.map((item, index) => {
60
+ const isOpen = currentOpen.includes(item.id);
61
+ const panelId = `it-accordion-panel-${item.id}`;
62
+ const buttonId = `it-accordion-button-${item.id}`;
63
+ const itemClass = clsx(
64
+ variant === "separated" &&
65
+ "rounded-xl border border-secondary-200 dark:border-slate-700 overflow-hidden bg-white dark:bg-slate-900",
66
+ variant === "bordered" &&
67
+ index > 0 &&
68
+ "border-t border-secondary-200 dark:border-slate-700",
69
+ item.disabled && "opacity-50",
70
+ itemClassName
71
+ );
72
+
73
+ return (
74
+ <div key={item.id} className={itemClass}>
75
+ <h3 className="m-0">
76
+ <button
77
+ type="button"
78
+ id={buttonId}
79
+ aria-expanded={isOpen}
80
+ aria-controls={panelId}
81
+ disabled={item.disabled}
82
+ onClick={() => toggle(item.id)}
83
+ className={clsx(
84
+ "w-full flex items-center gap-3 px-4 py-3 text-left transition-colors",
85
+ "text-sm font-semibold text-secondary-800 dark:text-slate-100",
86
+ "hover:bg-secondary-50 dark:hover:bg-slate-800",
87
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-400",
88
+ item.disabled && "cursor-not-allowed hover:bg-transparent"
89
+ )}
90
+ >
91
+ {item.icon && <span className="shrink-0 text-secondary-500">{item.icon}</span>}
92
+ <ITText as="span" className="flex-1">{item.title}</ITText>
93
+ <FaChevronDown
94
+ size={12}
95
+ className={clsx(
96
+ "shrink-0 text-secondary-400 transition-transform duration-200",
97
+ isOpen && "rotate-180"
98
+ )}
99
+ />
100
+ </button>
101
+ </h3>
102
+
103
+ <div
104
+ id={panelId}
105
+ role="region"
106
+ aria-labelledby={buttonId}
107
+ className={clsx(
108
+ "grid transition-all duration-200 ease-in-out",
109
+ isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
110
+ )}
111
+ >
112
+ <div className="overflow-hidden">
113
+ <div className="px-4 pb-4 pt-1 text-sm text-secondary-600 dark:text-slate-300">
114
+ {item.content}
115
+ </div>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ );
120
+ })}
121
+ </div>
122
+ );
123
+ }
@@ -61,7 +61,8 @@ import { ITCalendar } from '@axzydev/axzy_ui_system';
61
61
  - **month** — Traditional month grid with date picker, single and range selection modes.
62
62
  - **week** — 7-column time grid from 6 AM to 10 PM with event overlays and drag-to-select range.
63
63
  - **day** — Single-column time grid for focused daily scheduling.
64
- - **years** — Click the month/year header to switch to a year selection view (12-year grid).
64
+ - **years** — Click the year in the header to switch to a year selection view (12-year grid).
65
+ - **months** — Click the month name in the header (or pick a year) to switch to a month selection view (12-month grid, same style as the year grid). Flow: header → year → month → days.
65
66
  - Uses `date-fns` with Spanish locale (`es`) for month and weekday names.
66
67
  - Month view supports `minDate`/`maxDate` boundaries — out-of-range dates are disabled and dimmed.
67
68
  - Events render as colored bars in week/day views with customizable `color` prop.
@@ -78,7 +78,7 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
78
78
  const mode = modeProp || (onChange ? 'month' : 'week');
79
79
 
80
80
  const [currentDate, setCurrentDate] = useState(value || new Date());
81
- const [view, setView] = useState<'calendar' | 'years'>('calendar');
81
+ const [view, setView] = useState<'calendar' | 'years' | 'months'>('calendar');
82
82
 
83
83
  // Selection/Accent colors based on variant
84
84
  const getVariantStyles = () => {
@@ -96,6 +96,25 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
96
96
  if (value) setCurrentDate(value);
97
97
  }, [value]);
98
98
 
99
+ // Shift the view year while keeping the day-of-month safe (clamped to target month).
100
+ const shiftYear = (yearDelta: number) => {
101
+ setCurrentDate((d) => {
102
+ const year = d.getFullYear() + yearDelta;
103
+ const daysInTarget = new Date(year, d.getMonth() + 1, 0).getDate();
104
+ return new Date(year, d.getMonth(), Math.min(d.getDate(), daysInTarget));
105
+ });
106
+ };
107
+
108
+ // Jump to a month of the currently viewed year (day-of-month clamped).
109
+ const pickMonth = (monthIndex: number) => {
110
+ setCurrentDate((d) => {
111
+ const year = d.getFullYear();
112
+ const daysInTarget = new Date(year, monthIndex + 1, 0).getDate();
113
+ return new Date(year, monthIndex, Math.min(d.getDate(), daysInTarget));
114
+ });
115
+ setView('calendar');
116
+ };
117
+
99
118
  // Navigation handlers
100
119
  const handleNext = () => {
101
120
  if (view === 'years') {
@@ -104,6 +123,8 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
104
123
  newDate.setFullYear(d.getFullYear() + 12);
105
124
  return newDate;
106
125
  });
126
+ } else if (view === 'months') {
127
+ shiftYear(1);
107
128
  } else if (mode === 'month') {
108
129
  setCurrentDate((d) => addMonths(d, 1));
109
130
  } else if (mode === 'day') {
@@ -120,7 +141,9 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
120
141
  newDate.setFullYear(d.getFullYear() - 12);
121
142
  return newDate;
122
143
  });
123
- } else if (mode === 'month') {
144
+ } else if (view === 'months') {
145
+ shiftYear(-1);
146
+ } else if (mode === 'month') {
124
147
  setCurrentDate((d) => addMonths(d, -1));
125
148
  } else if (mode === 'day') {
126
149
  setCurrentDate((d) => addDays(d, -1));
@@ -249,6 +272,7 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
249
272
  // ----------------------------------------------------------------------
250
273
  const startYear = currentDate.getFullYear() - 6;
251
274
  const years = Array.from({ length: 12 }, (_, i) => startYear + i);
275
+ const months = Array.from({ length: 12 }, (_, i) => i);
252
276
 
253
277
  // ----------------------------------------------------------------------
254
278
  // Render
@@ -277,25 +301,50 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
277
301
  borderBottomColor: 'var(--it-calendar-border, #e2e8f0)'
278
302
  }}
279
303
  >
280
- <ITText
281
- as="h2"
282
- className="text-sm font-bold capitalize cursor-pointer transition-colors select-none px-2 py-1 rounded"
283
- style={{
284
- color: 'var(--it-calendar-header-text, #1e293b)',
285
- }}
286
- onClick={() => setView(view === 'calendar' ? 'years' : 'calendar')}
287
- onMouseEnter={(e) => {
288
- e.currentTarget.style.backgroundColor = 'var(--it-calendar-header-hover, #f1f5f9)';
289
- }}
290
- onMouseLeave={(e) => {
291
- e.currentTarget.style.backgroundColor = 'transparent';
292
- }}
293
- >
294
- {view === 'years'
295
- ? `${years[0]} - ${years[years.length - 1]}`
296
- : format(currentDate, 'MMMM yyyy', { locale: es })
297
- }
298
- </ITText>
304
+ {view === 'years' ? (
305
+ <ITText
306
+ as="h2"
307
+ className="text-sm font-bold select-none px-2 py-1"
308
+ style={{
309
+ color: 'var(--it-calendar-header-text, #1e293b)',
310
+ }}
311
+ >
312
+ {`${years[0]} - ${years[years.length - 1]}`}
313
+ </ITText>
314
+ ) : (
315
+ <div className="flex items-center">
316
+ {view !== 'months' && (
317
+ <button
318
+ type="button"
319
+ onClick={() => setView('months')}
320
+ className="text-sm font-bold capitalize select-none px-2 py-1 rounded transition-colors"
321
+ style={{ color: 'var(--it-calendar-header-text, #1e293b)' }}
322
+ onMouseEnter={(e) => {
323
+ e.currentTarget.style.backgroundColor = 'var(--it-calendar-header-hover, #f1f5f9)';
324
+ }}
325
+ onMouseLeave={(e) => {
326
+ e.currentTarget.style.backgroundColor = 'transparent';
327
+ }}
328
+ >
329
+ {format(currentDate, 'MMMM', { locale: es })}
330
+ </button>
331
+ )}
332
+ <button
333
+ type="button"
334
+ onClick={() => setView('years')}
335
+ className="text-sm font-bold select-none px-2 py-1 rounded transition-colors"
336
+ style={{ color: 'var(--it-calendar-header-text, #1e293b)' }}
337
+ onMouseEnter={(e) => {
338
+ e.currentTarget.style.backgroundColor = 'var(--it-calendar-header-hover, #f1f5f9)';
339
+ }}
340
+ onMouseLeave={(e) => {
341
+ e.currentTarget.style.backgroundColor = 'transparent';
342
+ }}
343
+ >
344
+ {format(currentDate, 'yyyy')}
345
+ </button>
346
+ </div>
347
+ )}
299
348
  <div className="flex items-center gap-1">
300
349
  <button
301
350
  onClick={handlePrev}
@@ -359,13 +408,41 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
359
408
  newDate.setFullYear(year);
360
409
  return newDate;
361
410
  });
362
- setView('calendar');
411
+ setView('months');
363
412
  }}
364
413
  >
365
414
  <ITText as="span">{year}</ITText>
366
415
  </button>
367
416
  ))}
368
417
  </div>
418
+ ) : view === 'months' ? (
419
+ <div className="p-4 grid grid-cols-4 gap-2">
420
+ {months.map(monthIndex => {
421
+ const isActive = monthIndex === currentDate.getMonth();
422
+ return (
423
+ <button
424
+ key={monthIndex}
425
+ type="button"
426
+ className={cn(
427
+ "h-10 rounded-md text-sm font-medium transition-colors border border-transparent",
428
+ isActive
429
+ ? "bg-[var(--it-calendar-selected-bg)] text-[var(--it-calendar-selected-text)]"
430
+ : "hover:bg-[var(--it-calendar-today-bg)] hover:text-[var(--it-calendar-today-text)]"
431
+ )}
432
+ style={{
433
+ color: isActive
434
+ ? 'var(--it-calendar-selected-text, #ffffff)'
435
+ : 'var(--it-calendar-days-text, #334155)'
436
+ }}
437
+ onClick={() => pickMonth(monthIndex)}
438
+ >
439
+ <ITText as="span" className="capitalize">
440
+ {format(new Date(2000, monthIndex, 1), 'MMM', { locale: es })}
441
+ </ITText>
442
+ </button>
443
+ );
444
+ })}
445
+ </div>
369
446
  ) : mode === 'month' ? (
370
447
  <div className="p-4">
371
448
  {/* Weekday Headers */}
@@ -396,6 +473,7 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
396
473
  onClick={() => onChange && onChange(day)}
397
474
  className={cn(
398
475
  "h-10 w-full flex items-center justify-center rounded-md text-sm transition-colors relative",
476
+ "text-[var(--it-calendar-days-text)] hover:bg-[var(--it-calendar-today-bg)] hover:text-[var(--it-calendar-today-text)]",
399
477
  !isCurrentMonth && "opacity-40",
400
478
  isDisabled && "opacity-20 cursor-not-allowed",
401
479
  )}
@@ -406,12 +484,12 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
406
484
  ? 'var(--it-calendar-range-bg, #eff6ff)'
407
485
  : isToday(day)
408
486
  ? 'var(--it-calendar-today-bg, #eff6ff)'
409
- : 'transparent',
487
+ : undefined,
410
488
  color: isSelected || isRangeStart || isRangeEnd
411
489
  ? 'var(--it-calendar-selected-text, #ffffff)'
412
490
  : isToday(day)
413
491
  ? 'var(--it-calendar-today-text, #2563eb)'
414
- : 'var(--it-calendar-days-text, #334155)',
492
+ : undefined,
415
493
  fontWeight: isSelected || isRangeStart || isRangeEnd || isToday(day) ? '700' : '400',
416
494
  }}
417
495
  >
@@ -0,0 +1,44 @@
1
+ import { Meta, Title, Description, Primary, Controls, Stories } from '@storybook/blocks';
2
+ import * as StoriesData from './chip-input.stories';
3
+
4
+ <Meta of={StoriesData} />
5
+
6
+ # ITChipInput
7
+
8
+ Free-text input that turns entered values into removable tags (chips).
9
+
10
+ ## Props
11
+
12
+ | Prop | Type | Default | Description |
13
+ |------|------|---------|-------------|
14
+ | `value` | `string[]` | — | Current tags (controlled). |
15
+ | `onChange` | `(values: string[]) => void` | — | Fired with the next list of tags. |
16
+ | `label` | `string` | — | Label above the control. |
17
+ | `placeholder` | `string` | `"Escribe y presiona Enter"` | Input placeholder. |
18
+ | `delimiters` | `string[]` | `["Enter", ","]` | Keys that commit a tag. |
19
+ | `maxTags` | `number` | — | Maximum number of tags. |
20
+ | `allowDuplicates` | `boolean` | `false` | Allows repeated tags. |
21
+ | `validate` | `(value) => string \| undefined` | — | Rejects a tag with a message. |
22
+ | `color` | semantic color key | `"primary"` | Color of the tag chips. |
23
+ | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Control size. |
24
+ | `disabled` / `required` / `touched` / `error` / `helpText` | — | — | Standard form states. |
25
+
26
+ ## Usage
27
+
28
+ ```tsx
29
+ import { ITChipInput } from '@axzydev/axzy_ui_system';
30
+
31
+ <ITChipInput label="Etiquetas" value={tags} onChange={setTags} />
32
+ ```
33
+
34
+ ## Notes
35
+
36
+ - Commits tags on `Enter`, `,`, blur, or when pasting delimited text.
37
+ - `Backspace` on an empty input removes the last tag.
38
+ - Local errors (duplicate, max reached, `validate`) are shown inline.
39
+
40
+ ## Stories
41
+
42
+ <Primary />
43
+ <Controls />
44
+ <Stories />
@@ -0,0 +1,42 @@
1
+ import { ColorsTypes } from "@/types/colors.types";
2
+ import { SizesTypes } from "@/types/sizes.types";
3
+
4
+ /** Props for the ITChipInput component. */
5
+ export interface ITChipInputProps {
6
+ /** Name attribute for form integrations. */
7
+ name?: string;
8
+ /** Label displayed above the control. */
9
+ label?: string;
10
+ /** Placeholder for the text input. @default "Escribe y presiona Enter" */
11
+ placeholder?: string;
12
+ /** Current list of tags (controlled). */
13
+ value: string[];
14
+ /** Fired with the next list of tags. */
15
+ onChange?: (values: string[]) => void;
16
+ /** Fired when the control loses focus. */
17
+ onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
18
+ /** Keys that commit the current text as a tag. @default ["Enter", ","] */
19
+ delimiters?: string[];
20
+ /** Maximum number of tags allowed. */
21
+ maxTags?: number;
22
+ /** Allows duplicated tags. @default false */
23
+ allowDuplicates?: boolean;
24
+ /** Returns an error string to reject the tag, or undefined if valid. */
25
+ validate?: (value: string) => string | undefined;
26
+ /** Color applied to the tag chips. @default "primary" */
27
+ color?: ColorsTypes;
28
+ /** Control size. Valid values: `"sm"`, `"md"`, `"lg"`. @default "md" */
29
+ size?: SizesTypes;
30
+ /** Disables the control. @default false */
31
+ disabled?: boolean;
32
+ /** Marks the field as required (red asterisk). @default false */
33
+ required?: boolean;
34
+ /** Whether the field has been touched (form validation). */
35
+ touched?: boolean;
36
+ /** Error message or boolean indicating an error state. */
37
+ error?: string | boolean;
38
+ /** Helper text shown below the control when there is no error. */
39
+ helpText?: string;
40
+ /** Additional CSS classes for the container. */
41
+ className?: string;
42
+ }
@@ -0,0 +1,52 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import ITChipInput from "@/components/molecules/chip-input/chip-input";
4
+
5
+ const meta: Meta<typeof ITChipInput> = {
6
+ title: "Components/Form Elements/ITChipInput",
7
+ component: ITChipInput,
8
+ parameters: { layout: "centered" },
9
+ };
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof ITChipInput>;
13
+
14
+ const Wrapper = (args: any) => {
15
+ const [value, setValue] = useState<string[]>(args.value ?? []);
16
+ return (
17
+ <div className="w-[420px]">
18
+ <ITChipInput {...args} value={value} onChange={setValue} />
19
+ </div>
20
+ );
21
+ };
22
+
23
+ export const Default: Story = {
24
+ render: (args) => <Wrapper {...args} />,
25
+ args: { name: "tags", label: "Etiquetas", value: [] },
26
+ };
27
+
28
+ export const WithValues: Story = {
29
+ render: (args) => <Wrapper {...args} />,
30
+ args: { name: "tags", label: "Etiquetas", value: ["react", "typescript", "tailwind"] },
31
+ };
32
+
33
+ export const MaxTags: Story = {
34
+ render: (args) => <Wrapper {...args} />,
35
+ args: { name: "tags", label: "Máximo 3", value: ["uno", "dos"], maxTags: 3, helpText: "Máximo 3 etiquetas." },
36
+ };
37
+
38
+ export const WithValidation: Story = {
39
+ render: (args) => <Wrapper {...args} />,
40
+ args: {
41
+ name: "emails",
42
+ label: "Correos",
43
+ value: [],
44
+ placeholder: "correo@dominio.com",
45
+ validate: (v: string) => (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? undefined : "Correo inválido"),
46
+ },
47
+ };
48
+
49
+ export const WithError: Story = {
50
+ render: (args) => <Wrapper {...args} />,
51
+ args: { name: "tags", label: "Etiquetas", value: [], required: true, touched: true, error: true },
52
+ };
@@ -0,0 +1,205 @@
1
+ import clsx from "clsx";
2
+ import { useRef, useState } from "react";
3
+ import { ITChipInputProps } from "./chip-input.props";
4
+ import { theme } from "@/theme/theme";
5
+ import { inputFieldStyle } from "@/utils/styles";
6
+ import ITChip from "@/components/atoms/chip/chip";
7
+ import ITText from "@/components/atoms/text/text";
8
+
9
+ /**
10
+ * Free-text input that turns entered values into removable tags (chips).
11
+ *
12
+ * Commits a tag on `Enter`/`,` (configurable via `delimiters`), on blur, or on
13
+ * paste of delimited text. Supports `maxTags`, dedupe control, and per-tag
14
+ * validation.
15
+ *
16
+ * @example
17
+ * <ITChipInput
18
+ * label="Etiquetas"
19
+ * value={tags}
20
+ * onChange={setTags}
21
+ * placeholder="Agrega una etiqueta..."
22
+ * />
23
+ */
24
+ export default function ITChipInput({
25
+ name,
26
+ label,
27
+ placeholder = "Escribe y presiona Enter",
28
+ value,
29
+ onChange,
30
+ onBlur,
31
+ delimiters = ["Enter", ","],
32
+ maxTags,
33
+ allowDuplicates = false,
34
+ validate,
35
+ color = "primary",
36
+ size = "md",
37
+ disabled = false,
38
+ required = false,
39
+ touched,
40
+ error,
41
+ helpText,
42
+ className,
43
+ }: ITChipInputProps) {
44
+ const [inputValue, setInputValue] = useState("");
45
+ const [localTouched, setLocalTouched] = useState(false);
46
+ const [localError, setLocalError] = useState<string | undefined>(undefined);
47
+ const [isFocused, setIsFocused] = useState(false);
48
+ const inputRef = useRef<HTMLInputElement>(null);
49
+
50
+ const inputTheme = (theme as any).input || {};
51
+
52
+ const isTouched = touched !== undefined ? touched : localTouched;
53
+ const externalError =
54
+ error !== undefined && error !== false
55
+ ? error === true
56
+ ? "Este campo es requerido"
57
+ : error
58
+ : required && value.length === 0
59
+ ? "Este campo es requerido"
60
+ : undefined;
61
+ const effectiveError = localError || externalError;
62
+ const hasError = isTouched && !!effectiveError;
63
+
64
+ const getControlStyle = (): React.CSSProperties => {
65
+ const style = inputFieldStyle(inputTheme, size, { width: "100%", cursor: "text" });
66
+ style.display = "flex";
67
+ style.flexWrap = "wrap";
68
+ style.alignItems = "center";
69
+ style.gap = "0.375rem";
70
+ style.minHeight = "2.5rem";
71
+
72
+ if (disabled) {
73
+ style.backgroundColor = inputTheme.disabled?.backgroundColor || style.backgroundColor;
74
+ style.borderColor = inputTheme.disabled?.borderColor || style.borderColor;
75
+ style.opacity = 0.7;
76
+ style.cursor = "not-allowed";
77
+ }
78
+ if (hasError) {
79
+ style.borderColor = inputTheme.error?.borderColor || "red";
80
+ } else if (isFocused) {
81
+ style.boxShadow = inputTheme.focus?.ring;
82
+ style.borderColor = inputTheme.focus?.borderColor;
83
+ }
84
+ return style;
85
+ };
86
+
87
+ const commitTag = (raw: string) => {
88
+ const tag = raw.trim();
89
+ if (!tag) return;
90
+ if (maxTags !== undefined && value.length >= maxTags) {
91
+ setLocalError(`Máximo ${maxTags} etiquetas`);
92
+ return;
93
+ }
94
+ if (!allowDuplicates && value.includes(tag)) {
95
+ setLocalError("Etiqueta duplicada");
96
+ return;
97
+ }
98
+ const validationError = validate?.(tag);
99
+ if (validationError) {
100
+ setLocalError(validationError);
101
+ return;
102
+ }
103
+ setLocalError(undefined);
104
+ onChange?.([...value, tag]);
105
+ setInputValue("");
106
+ };
107
+
108
+ const removeTag = (tag: string) => {
109
+ if (disabled) return;
110
+ onChange?.(value.filter((t) => t !== tag));
111
+ };
112
+
113
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
114
+ if (disabled) return;
115
+ if (delimiters.includes(e.key)) {
116
+ if (e.key !== "Enter") e.preventDefault();
117
+ commitTag(inputValue);
118
+ } else if (e.key === "Backspace" && inputValue === "" && value.length > 0) {
119
+ removeTag(value[value.length - 1]);
120
+ } else if (localError) {
121
+ setLocalError(undefined);
122
+ }
123
+ };
124
+
125
+ const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
126
+ const text = e.clipboardData.getData("text");
127
+ const separators = delimiters.filter((d) => d.length === 1);
128
+ if (separators.length && separators.some((s) => text.includes(s))) {
129
+ e.preventDefault();
130
+ const parts = text
131
+ .split(new RegExp(`[${separators.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("")}]`))
132
+ .map((p) => p.trim())
133
+ .filter(Boolean);
134
+ parts.forEach((p) => commitTag(p));
135
+ }
136
+ };
137
+
138
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
139
+ if (inputValue.trim()) commitTag(inputValue);
140
+ setIsFocused(false);
141
+ setLocalTouched(true);
142
+ onBlur?.(e);
143
+ };
144
+
145
+ const handleContainerClick = () => {
146
+ if (disabled) return;
147
+ inputRef.current?.focus();
148
+ };
149
+
150
+ return (
151
+ <div className={clsx("w-full flex flex-col gap-1.5", className)}>
152
+ {label && (
153
+ <ITText
154
+ as="label"
155
+ className={clsx("text-sm font-medium text-secondary-700 dark:text-slate-300", {
156
+ "text-danger-500": hasError,
157
+ })}
158
+ >
159
+ <ITText as="span">{label}</ITText>
160
+ {required && <ITText as="span" className="text-danger-500 ml-1">*</ITText>}
161
+ </ITText>
162
+ )}
163
+
164
+ <div
165
+ onClick={handleContainerClick}
166
+ className={clsx(disabled && "cursor-not-allowed")}
167
+ style={getControlStyle()}
168
+ >
169
+ {value.map((tag) => (
170
+ <ITChip
171
+ key={tag}
172
+ label={tag}
173
+ color={color}
174
+ size="sm"
175
+ removable={!disabled}
176
+ onRemove={() => removeTag(tag)}
177
+ />
178
+ ))}
179
+
180
+ <input
181
+ ref={inputRef}
182
+ name={name}
183
+ type="text"
184
+ value={inputValue}
185
+ onChange={(e) => setInputValue(e.target.value)}
186
+ onKeyDown={handleKeyDown}
187
+ onPaste={handlePaste}
188
+ onFocus={() => setIsFocused(true)}
189
+ onBlur={handleBlur}
190
+ disabled={disabled}
191
+ placeholder={value.length === 0 ? placeholder : ""}
192
+ autoComplete="off"
193
+ className="flex-1 min-w-[100px] bg-transparent outline-none border-none text-sm text-secondary-900 dark:text-slate-100"
194
+ style={{ padding: 0, fontSize: inputFieldStyle(inputTheme, size).fontSize }}
195
+ />
196
+ </div>
197
+
198
+ {hasError ? (
199
+ <ITText as="p" className="text-danger-500 text-xs mt-1">{effectiveError}</ITText>
200
+ ) : helpText ? (
201
+ <ITText as="p" className="text-xs text-secondary-500 dark:text-secondary-400">{helpText}</ITText>
202
+ ) : null}
203
+ </div>
204
+ );
205
+ }