@axzydev/axzy_ui_system 1.2.16 → 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.
Files changed (59) hide show
  1. package/dist/index.cjs +3058 -1407
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.cts +620 -54
  5. package/dist/index.d.ts +620 -54
  6. package/dist/index.js +2974 -1318
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/App.tsx +28 -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/chip-input/chip-input.doc.mdx +44 -0
  21. package/src/components/molecules/chip-input/chip-input.props.ts +42 -0
  22. package/src/components/molecules/chip-input/chip-input.stories.tsx +52 -0
  23. package/src/components/molecules/chip-input/chip-input.tsx +205 -0
  24. package/src/components/molecules/date-picker/datePicker.tsx +45 -56
  25. package/src/components/molecules/dropdown-menu/dropdown-menu.doc.mdx +46 -0
  26. package/src/components/molecules/dropdown-menu/dropdown-menu.props.ts +41 -0
  27. package/src/components/molecules/dropdown-menu/dropdown-menu.stories.tsx +41 -0
  28. package/src/components/molecules/dropdown-menu/dropdown-menu.tsx +219 -0
  29. package/src/components/molecules/field/field.doc.mdx +43 -0
  30. package/src/components/molecules/field/field.props.ts +23 -0
  31. package/src/components/molecules/field/field.stories.tsx +42 -0
  32. package/src/components/molecules/field/field.tsx +54 -0
  33. package/src/components/molecules/multi-select/multi-select.doc.mdx +55 -0
  34. package/src/components/molecules/multi-select/multi-select.props.ts +65 -0
  35. package/src/components/molecules/multi-select/multi-select.stories.tsx +59 -0
  36. package/src/components/molecules/multi-select/multi-select.tsx +386 -0
  37. package/src/components/molecules/search-select/search-select.doc.mdx +5 -0
  38. package/src/components/molecules/search-select/search-select.props.ts +10 -1
  39. package/src/components/molecules/search-select/search-select.stories.tsx +18 -0
  40. package/src/components/molecules/search-select/search-select.tsx +102 -28
  41. package/src/components/molecules/time-picker/timePicker.tsx +14 -26
  42. package/src/components/molecules/wysiwyg/wysiwyg.props.ts +51 -0
  43. package/src/components/molecules/wysiwyg/wysiwyg.stories.tsx +95 -0
  44. package/src/components/molecules/wysiwyg/wysiwyg.tsx +609 -0
  45. package/src/components/organisms/form-builder/fieldRenderer.tsx +144 -27
  46. package/src/components/organisms/form-builder/formBuilder.context.tsx +77 -15
  47. package/src/components/organisms/form-builder/formBuilder.doc.mdx +213 -86
  48. package/src/components/organisms/form-builder/formBuilder.props.ts +118 -36
  49. package/src/components/organisms/form-builder/formBuilder.stories.tsx +694 -96
  50. package/src/components/organisms/form-builder/formBuilder.tsx +152 -69
  51. package/src/components/organisms/form-builder/useFormBuilder.ts +59 -21
  52. package/src/hooks/useFloatingPanel.ts +100 -0
  53. package/src/index.ts +24 -4
  54. package/src/showcases/DataShowcases.tsx +76 -0
  55. package/src/showcases/FormShowcases.tsx +396 -10
  56. package/src/showcases/HomeShowcase.tsx +6 -6
  57. package/src/showcases/NavigationShowcases.tsx +72 -2
  58. package/src/showcases/StructureShowcases.tsx +64 -0
  59. package/src/types/chip.types.ts +13 -0
@@ -0,0 +1,41 @@
1
+ import { ReactNode } from "react";
2
+
3
+ /** A single action inside {@link ITDropdownMenuProps}. */
4
+ export interface ITDropdownMenuItem {
5
+ /** Unique identifier for the item. */
6
+ id: string;
7
+ /** Item content (usually text). */
8
+ label: ReactNode;
9
+ /** Optional leading icon. */
10
+ icon?: ReactNode;
11
+ /** Optional shortcut hint rendered on the right (e.g. `"⌘K"`). */
12
+ shortcut?: string;
13
+ /** Click handler for the item. */
14
+ onClick?: () => void;
15
+ /** Disables the item. @default false */
16
+ disabled?: boolean;
17
+ /** Renders the item in the danger color (destructive actions). @default false */
18
+ danger?: boolean;
19
+ /** Draws a separator above this item. @default false */
20
+ divider?: boolean;
21
+ }
22
+
23
+ /** Props for the ITDropdownMenu component. */
24
+ export interface ITDropdownMenuProps {
25
+ /** Menu actions. */
26
+ items: ITDropdownMenuItem[];
27
+ /** Custom trigger content. Defaults to a vertical ellipsis icon. */
28
+ trigger?: ReactNode;
29
+ /** Accessible label for the trigger button. @default "Abrir menú" */
30
+ triggerLabel?: string;
31
+ /** Placement of the menu relative to the trigger. Valid values: `"bottom-start"`, `"bottom-end"`, `"top-start"`, `"top-end"`. @default "bottom-end" */
32
+ placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
33
+ /** Fired after an item is activated, with its id and definition. */
34
+ onSelect?: (id: string, item: ITDropdownMenuItem) => void;
35
+ /** Disables the trigger. @default false */
36
+ disabled?: boolean;
37
+ /** Additional CSS classes for the trigger button. */
38
+ className?: string;
39
+ /** Additional CSS classes for the menu panel. */
40
+ menuClassName?: string;
41
+ }
@@ -0,0 +1,41 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { FaEdit, FaCopy, FaTrash, FaDownload } from "react-icons/fa";
3
+ import ITDropdownMenu from "@/components/molecules/dropdown-menu/dropdown-menu";
4
+ import ITButton from "@/components/atoms/button/button";
5
+
6
+ const meta: Meta<typeof ITDropdownMenu> = {
7
+ title: "Components/Navigation/ITDropdownMenu",
8
+ component: ITDropdownMenu,
9
+ parameters: { layout: "centered" },
10
+ };
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof ITDropdownMenu>;
14
+
15
+ const items = [
16
+ { id: "edit", label: "Editar", icon: <FaEdit />, onClick: () => alert("Editar") },
17
+ { id: "copy", label: "Duplicar", icon: <FaCopy />, shortcut: "⌘D", onClick: () => alert("Duplicar") },
18
+ { id: "download", label: "Descargar", icon: <FaDownload />, disabled: true },
19
+ { id: "delete", label: "Eliminar", icon: <FaTrash />, danger: true, divider: true, onClick: () => alert("Eliminar") },
20
+ ];
21
+
22
+ export const Default: Story = {
23
+ args: { items },
24
+ };
25
+
26
+ export const CustomTrigger: Story = {
27
+ args: {
28
+ items,
29
+ trigger: <span className="text-sm font-semibold px-1">Acciones</span>,
30
+ triggerLabel: "Acciones",
31
+ },
32
+ };
33
+
34
+ export const WithLabelButton: Story = {
35
+ render: () => (
36
+ <div className="flex items-center gap-4">
37
+ <ITButton label="Guardar" variant="filled" color="primary" size="sm" />
38
+ <ITDropdownMenu items={items} />
39
+ </div>
40
+ ),
41
+ };
@@ -0,0 +1,219 @@
1
+ import clsx from "clsx";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { FaEllipsisV } from "react-icons/fa";
5
+ import { ITDropdownMenuProps } from "./dropdown-menu.props";
6
+ import { useFloatingPanel } from "@/hooks/useFloatingPanel";
7
+ import ITText from "@/components/atoms/text/text";
8
+
9
+ /**
10
+ * Action menu with keyboard navigation and ARIA menu semantics.
11
+ *
12
+ * Renders the menu through a portal so it escapes `overflow: hidden` and
13
+ * transformed ancestors, and flips above the trigger when there is no room
14
+ * below.
15
+ *
16
+ * @example
17
+ * <ITDropdownMenu
18
+ * items={[
19
+ * { id: "edit", label: "Editar", icon: <FaEdit />, onClick: edit },
20
+ * { id: "delete", label: "Eliminar", icon: <FaTrash />, danger: true, divider: true, onClick: remove },
21
+ * ]}
22
+ * />
23
+ */
24
+ export default function ITDropdownMenu({
25
+ items,
26
+ trigger,
27
+ triggerLabel = "Abrir menú",
28
+ placement = "bottom-end",
29
+ onSelect,
30
+ disabled = false,
31
+ className,
32
+ menuClassName,
33
+ }: ITDropdownMenuProps) {
34
+ const [isOpen, setIsOpen] = useState(false);
35
+ const [activeIndex, setActiveIndex] = useState(-1);
36
+
37
+ const triggerRef = useRef<HTMLButtonElement>(null);
38
+ const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
39
+
40
+ const { panelRef, style: panelStyle } = useFloatingPanel(triggerRef, isOpen, {
41
+ estimatedHeight: Math.max(items.length * 40, 120),
42
+ matchWidth: false,
43
+ align: placement.endsWith("end") ? "end" : "start",
44
+ });
45
+
46
+ const enabledIndexes = useMemo(
47
+ () => items.map((item, i) => (item.disabled ? -1 : i)).filter((i) => i >= 0),
48
+ [items]
49
+ );
50
+
51
+ useEffect(() => {
52
+ if (!isOpen) return;
53
+ function handleClickOutside(event: MouseEvent) {
54
+ const target = event.target as Node;
55
+ if (!triggerRef.current?.contains(target) && !panelRef.current?.contains(target)) {
56
+ setIsOpen(false);
57
+ }
58
+ }
59
+ document.addEventListener("mousedown", handleClickOutside);
60
+ return () => document.removeEventListener("mousedown", handleClickOutside);
61
+ }, [isOpen, panelRef]);
62
+
63
+ useEffect(() => {
64
+ if (isOpen) {
65
+ setActiveIndex(enabledIndexes[0] ?? -1);
66
+ requestAnimationFrame(() => {
67
+ if (enabledIndexes[0] !== undefined) itemRefs.current[enabledIndexes[0]]?.focus();
68
+ });
69
+ }
70
+ }, [isOpen, enabledIndexes]);
71
+
72
+ const open = () => {
73
+ if (disabled) return;
74
+ setIsOpen(true);
75
+ };
76
+
77
+ const close = (restoreFocus = true) => {
78
+ setIsOpen(false);
79
+ setActiveIndex(-1);
80
+ if (restoreFocus) triggerRef.current?.focus();
81
+ };
82
+
83
+ const activate = (index: number) => {
84
+ const item = items[index];
85
+ if (!item || item.disabled) return;
86
+ item.onClick?.();
87
+ onSelect?.(item.id, item);
88
+ close();
89
+ };
90
+
91
+ const moveActive = (dir: 1 | -1) => {
92
+ if (!enabledIndexes.length) return;
93
+ const currentPos = enabledIndexes.indexOf(activeIndex);
94
+ const nextPos =
95
+ currentPos === -1
96
+ ? dir === 1
97
+ ? 0
98
+ : enabledIndexes.length - 1
99
+ : (currentPos + dir + enabledIndexes.length) % enabledIndexes.length;
100
+ const next = enabledIndexes[nextPos];
101
+ setActiveIndex(next);
102
+ itemRefs.current[next]?.focus();
103
+ };
104
+
105
+ const handleTriggerKeyDown = (e: React.KeyboardEvent) => {
106
+ if (disabled) return;
107
+ if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
108
+ e.preventDefault();
109
+ if (!isOpen) open();
110
+ else moveActive(1);
111
+ } else if (e.key === "ArrowUp") {
112
+ e.preventDefault();
113
+ if (!isOpen) open();
114
+ else moveActive(-1);
115
+ } else if (e.key === "Escape") {
116
+ close();
117
+ }
118
+ };
119
+
120
+ const handleMenuKeyDown = (e: React.KeyboardEvent) => {
121
+ if (e.key === "ArrowDown") {
122
+ e.preventDefault();
123
+ moveActive(1);
124
+ } else if (e.key === "ArrowUp") {
125
+ e.preventDefault();
126
+ moveActive(-1);
127
+ } else if (e.key === "Home") {
128
+ e.preventDefault();
129
+ if (enabledIndexes.length) {
130
+ const first = enabledIndexes[0];
131
+ setActiveIndex(first);
132
+ itemRefs.current[first]?.focus();
133
+ }
134
+ } else if (e.key === "End") {
135
+ e.preventDefault();
136
+ const last = enabledIndexes[enabledIndexes.length - 1];
137
+ if (last !== undefined) {
138
+ setActiveIndex(last);
139
+ itemRefs.current[last]?.focus();
140
+ }
141
+ } else if (e.key === "Escape") {
142
+ e.preventDefault();
143
+ close();
144
+ } else if (e.key === "Tab") {
145
+ close(false);
146
+ }
147
+ };
148
+
149
+ return (
150
+ <>
151
+ <button
152
+ ref={triggerRef}
153
+ type="button"
154
+ aria-haspopup="menu"
155
+ aria-expanded={isOpen}
156
+ aria-label={triggerLabel}
157
+ disabled={disabled}
158
+ onClick={() => (isOpen ? close() : open())}
159
+ onKeyDown={handleTriggerKeyDown}
160
+ className={clsx(
161
+ "inline-flex items-center justify-center rounded-lg p-2 text-secondary-500 transition-colors",
162
+ "hover:bg-secondary-100 hover:text-secondary-700 dark:hover:bg-slate-800 dark:hover:text-slate-200",
163
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400",
164
+ disabled && "opacity-50 cursor-not-allowed",
165
+ className
166
+ )}
167
+ >
168
+ {trigger ?? <FaEllipsisV size={14} />}
169
+ </button>
170
+
171
+ {isOpen &&
172
+ createPortal(
173
+ <div
174
+ ref={panelRef}
175
+ role="menu"
176
+ aria-label={triggerLabel}
177
+ style={panelStyle}
178
+ onKeyDown={handleMenuKeyDown}
179
+ className={clsx(
180
+ "min-w-[180px] rounded-xl border border-secondary-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-xl py-1 overflow-hidden",
181
+ menuClassName
182
+ )}
183
+ >
184
+ {items.map((item, index) => (
185
+ <div key={item.id}>
186
+ {item.divider && <div className="my-1 h-px bg-secondary-100 dark:bg-slate-800" />}
187
+ <button
188
+ ref={(el) => { itemRefs.current[index] = el; }}
189
+ type="button"
190
+ role="menuitem"
191
+ tabIndex={-1}
192
+ disabled={item.disabled}
193
+ onClick={() => activate(index)}
194
+ onMouseEnter={() => !item.disabled && setActiveIndex(index)}
195
+ className={clsx(
196
+ "w-full flex items-center gap-3 px-3 py-2 text-left text-sm transition-colors",
197
+ "focus:outline-none",
198
+ item.disabled
199
+ ? "opacity-50 cursor-not-allowed text-secondary-400"
200
+ : item.danger
201
+ ? "text-danger-600 hover:bg-danger-50 focus:bg-danger-50 dark:hover:bg-danger-950/30"
202
+ : "text-secondary-700 dark:text-slate-200 hover:bg-secondary-50 focus:bg-secondary-50 dark:hover:bg-slate-800 dark:focus:bg-slate-800",
203
+ activeIndex === index && !item.disabled && !item.danger && "bg-secondary-50 dark:bg-slate-800"
204
+ )}
205
+ >
206
+ {item.icon && <span className="shrink-0">{item.icon}</span>}
207
+ <ITText as="span" className="flex-1 truncate">{item.label}</ITText>
208
+ {item.shortcut && (
209
+ <ITText as="span" className="text-xs text-secondary-400 font-mono">{item.shortcut}</ITText>
210
+ )}
211
+ </button>
212
+ </div>
213
+ ))}
214
+ </div>,
215
+ document.body
216
+ )}
217
+ </>
218
+ );
219
+ }
@@ -0,0 +1,43 @@
1
+ import { Meta, Title, Description, Primary, Controls, Stories } from '@storybook/blocks';
2
+ import * as StoriesData from './field.stories';
3
+
4
+ <Meta of={StoriesData} />
5
+
6
+ # ITField
7
+
8
+ Form-field wrapper that standardizes label, required marker, helper text, and error message around any control.
9
+
10
+ ## Props
11
+
12
+ | Prop | Type | Default | Description |
13
+ |------|------|---------|-------------|
14
+ | `label` | `string` | — | Label above the control. |
15
+ | `htmlFor` | `string` | — | `id` the label points to. |
16
+ | `required` | `boolean` | `false` | Shows a red asterisk. |
17
+ | `error` | `string \| boolean` | — | Error message, or `true` for the generic message. |
18
+ | `helpText` | `string` | — | Helper text shown when there is no error. |
19
+ | `children` | `ReactNode` | — | The form control. |
20
+ | `className` | `string` | — | Extra CSS classes for the wrapper. |
21
+ | `labelClassName` | `string` | — | Extra CSS classes for the label. |
22
+ | `contentClassName` | `string` | — | Extra CSS classes for the control container. |
23
+
24
+ ## Usage
25
+
26
+ ```tsx
27
+ import { ITField, ITInput } from '@axzydev/axzy_ui_system';
28
+
29
+ <ITField label="Email" htmlFor="email" required error={errors.email} helpText="We never share it.">
30
+ <ITInput name="email" value={email} onChange={onChange} />
31
+ </ITField>
32
+ ```
33
+
34
+ ## Notes
35
+
36
+ - Error text takes precedence over `helpText`; only one is shown at a time.
37
+ - `error={true}` renders the generic "Este campo es requerido" message.
38
+
39
+ ## Stories
40
+
41
+ <Primary />
42
+ <Controls />
43
+ <Stories />
@@ -0,0 +1,23 @@
1
+ import { ReactNode } from "react";
2
+
3
+ /** Props for the ITField form-field wrapper. */
4
+ export interface ITFieldProps {
5
+ /** Label rendered above the control. */
6
+ label?: string;
7
+ /** `id` of the control the label points to (`htmlFor`). */
8
+ htmlFor?: string;
9
+ /** Shows a red asterisk next to the label. @default false */
10
+ required?: boolean;
11
+ /** Validation error. Pass `true` for the generic message or a string for a custom one. */
12
+ error?: string | boolean;
13
+ /** Helper text shown below the control when there is no error. */
14
+ helpText?: string;
15
+ /** The form control (ITInput, ITSelect, ITTextarea, …). */
16
+ children: ReactNode;
17
+ /** Additional CSS classes for the wrapper. */
18
+ className?: string;
19
+ /** Additional CSS classes for the label. */
20
+ labelClassName?: string;
21
+ /** Additional CSS classes for the control container. */
22
+ contentClassName?: string;
23
+ }
@@ -0,0 +1,42 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import ITField from "@/components/molecules/field/field";
3
+ import ITInput from "@/components/atoms/input/input";
4
+
5
+ const meta: Meta<typeof ITField> = {
6
+ title: "Components/Form Elements/ITField",
7
+ component: ITField,
8
+ parameters: { layout: "centered" },
9
+ };
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof ITField>;
13
+
14
+ export const Default: Story = {
15
+ render: () => (
16
+ <div className="w-[360px]">
17
+ <ITField label="Nombre completo" htmlFor="name" required helpText="Como aparece en tu identificación.">
18
+ <ITInput name="name" value="" onChange={() => {}} onBlur={() => {}} />
19
+ </ITField>
20
+ </div>
21
+ ),
22
+ };
23
+
24
+ export const WithError: Story = {
25
+ render: () => (
26
+ <div className="w-[360px]">
27
+ <ITField label="Correo" htmlFor="email" required error="Ingresa un correo válido.">
28
+ <ITInput name="email" value="correo-malo" onChange={() => {}} onBlur={() => {}} />
29
+ </ITField>
30
+ </div>
31
+ ),
32
+ };
33
+
34
+ export const GenericError: Story = {
35
+ render: () => (
36
+ <div className="w-[360px]">
37
+ <ITField label="RFC" htmlFor="rfc" required error>
38
+ <ITInput name="rfc" value="" onChange={() => {}} onBlur={() => {}} />
39
+ </ITField>
40
+ </div>
41
+ ),
42
+ };
@@ -0,0 +1,54 @@
1
+ import clsx from "clsx";
2
+ import { ITFieldProps } from "./field.props";
3
+ import { inputError, inputLabel } from "@/utils/styles";
4
+ import ITText from "@/components/atoms/text/text";
5
+
6
+ /**
7
+ * Form-field wrapper that standardizes label, required marker, helper text,
8
+ * and error message around any control.
9
+ *
10
+ * Keeps spacing and error styling consistent across forms so each control
11
+ * does not re-implement label/error logic.
12
+ *
13
+ * @example
14
+ * <ITField label="Email" htmlFor="email" required error={errors.email} helpText="We never share it.">
15
+ * <ITInput name="email" value={email} onChange={onChange} />
16
+ * </ITField>
17
+ */
18
+ export default function ITField({
19
+ label,
20
+ htmlFor,
21
+ required = false,
22
+ error,
23
+ helpText,
24
+ children,
25
+ className,
26
+ labelClassName,
27
+ contentClassName,
28
+ }: ITFieldProps) {
29
+ const hasError = Boolean(error);
30
+ const message = typeof error === "string" ? error : hasError ? "Este campo es requerido" : undefined;
31
+
32
+ return (
33
+ <div className={clsx("flex flex-col gap-1.5", className)}>
34
+ {label && (
35
+ <label htmlFor={htmlFor} className={clsx(inputLabel(hasError), labelClassName)}>
36
+ <ITText as="span">{label}</ITText>
37
+ {required && (
38
+ <ITText as="span" className="text-danger-500 ml-1">
39
+ *
40
+ </ITText>
41
+ )}
42
+ </label>
43
+ )}
44
+
45
+ <div className={clsx(contentClassName)}>{children}</div>
46
+
47
+ {hasError ? (
48
+ <ITText as="p" className={inputError}>{message}</ITText>
49
+ ) : helpText ? (
50
+ <ITText as="p" className="text-xs text-secondary-500 dark:text-secondary-400">{helpText}</ITText>
51
+ ) : null}
52
+ </div>
53
+ );
54
+ }
@@ -0,0 +1,55 @@
1
+ import { Meta, Title, Description, Primary, Controls, Stories } from '@storybook/blocks';
2
+ import * as StoriesData from './multi-select.stories';
3
+
4
+ <Meta of={StoriesData} />
5
+
6
+ # ITMultiSelect
7
+
8
+ Multi-value select with searchable dropdown, removable chips, and keyboard navigation.
9
+
10
+ ## Props
11
+
12
+ | Prop | Type | Default | Description |
13
+ |------|------|---------|-------------|
14
+ | `options` | `ITMultiSelectOption[]` | — | Available options `{ label, value, ...custom }`. |
15
+ | `value` | `(string \| number)[]` | — | Selected values (controlled). |
16
+ | `onChange` | `(values, options) => void` | — | Fired with the next selected values. |
17
+ | `label` | `string` | — | Label above the control. |
18
+ | `placeholder` | `string` | `"Selecciona opciones"` | Placeholder when empty. |
19
+ | `valueField` | `string` | `"value"` | Field used as the option value. |
20
+ | `labelField` | `string` | `"label"` | Field used as the option label. |
21
+ | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Control size. |
22
+ | `maxVisibleChips` | `number` | `3` | Chips shown before collapsing into `+N`. |
23
+ | `clearable` | `boolean` | `true` | Shows a clear-all (X) button. |
24
+ | `searchable` | `boolean` | `true` | Enables the search input. |
25
+ | `onSearch` | `(query) => void` | — | Server-side search callback. |
26
+ | `isLoading` | `boolean` | `false` | Loading state for remote options. |
27
+ | `noResultsMessage` | `string` | `"No se encontraron resultados"` | Empty-results message. |
28
+ | `renderOption` | `(option, { isSelected, searchTerm }) => ReactNode` | — | Custom option template. |
29
+ | `disabled` / `readOnly` / `required` / `touched` / `error` | — | — | Standard form states. |
30
+
31
+ ## Usage
32
+
33
+ ```tsx
34
+ import { ITMultiSelect } from '@axzydev/axzy_ui_system';
35
+
36
+ <ITMultiSelect
37
+ label="Skills"
38
+ options={skills}
39
+ value={selected}
40
+ onChange={(values) => setSelected(values)}
41
+ maxVisibleChips={3}
42
+ />
43
+ ```
44
+
45
+ ## Notes
46
+
47
+ - Keyboard: `ArrowUp`/`ArrowDown` navigate, `Enter` toggles, `Backspace` removes the last chip, `Esc` closes.
48
+ - `role="combobox"` + `role="listbox"` (`aria-multiselectable`) for accessibility.
49
+ - The dropdown is portaled, so it is not clipped inside cards or `overflow: hidden` ancestors.
50
+
51
+ ## Stories
52
+
53
+ <Primary />
54
+ <Controls />
55
+ <Stories />
@@ -0,0 +1,65 @@
1
+ import { ReactNode } from "react";
2
+ import { SizesTypes } from "@/types/sizes.types";
3
+
4
+ /** Represents an option in the multi-select dropdown. */
5
+ export interface ITMultiSelectOption {
6
+ /** Display label for the option. */
7
+ label: string;
8
+ /** Value associated with the option. */
9
+ value: string | number;
10
+ /** Additional custom fields can be attached. */
11
+ [key: string]: any;
12
+ }
13
+
14
+ /** Props for the ITMultiSelect component. */
15
+ export interface ITMultiSelectProps {
16
+ /** Name attribute for form integrations. */
17
+ name?: string;
18
+ /** Label displayed above the control. */
19
+ label?: string;
20
+ /** Placeholder shown when no value is selected. @default "Selecciona opciones" */
21
+ placeholder?: string;
22
+ /** Available options. */
23
+ options: ITMultiSelectOption[];
24
+ /** Selected values (controlled). */
25
+ value: (string | number)[];
26
+ /** Fired with the next selected values and their option objects. */
27
+ onChange?: (values: (string | number)[], options: ITMultiSelectOption[]) => void;
28
+ /** Fired when the control loses focus. */
29
+ onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
30
+ /** Field used as the option value. @default "value" */
31
+ valueField?: string;
32
+ /** Field used as the option display label. @default "label" */
33
+ labelField?: string;
34
+ /** Disables the control. @default false */
35
+ disabled?: boolean;
36
+ /** Read-only mode: chips shown, no editing. @default false */
37
+ readOnly?: boolean;
38
+ /** Marks the field as required (red asterisk). @default false */
39
+ required?: boolean;
40
+ /** Whether the field has been touched (form validation). */
41
+ touched?: boolean;
42
+ /** Error message or boolean indicating an error state. */
43
+ error?: string | boolean;
44
+ /** Control size. Valid values: `"sm"`, `"md"`, `"lg"`. @default "md" */
45
+ size?: SizesTypes;
46
+ /** Maximum number of chips shown before collapsing into "+N". @default 3 */
47
+ maxVisibleChips?: number;
48
+ /** Shows a clear-all (X) button. @default true */
49
+ clearable?: boolean;
50
+ /** Enables the search input inside the control. @default true */
51
+ searchable?: boolean;
52
+ /** Callback for server-side search. Receives the query string. */
53
+ onSearch?: (query: string) => void;
54
+ /** Whether options are loading from an external source. @default false */
55
+ isLoading?: boolean;
56
+ /** Message shown when no options match. @default "No se encontraron resultados" */
57
+ noResultsMessage?: string;
58
+ /** Custom template for each dropdown option. */
59
+ renderOption?: (
60
+ option: ITMultiSelectOption,
61
+ state: { isSelected: boolean; searchTerm: string }
62
+ ) => ReactNode;
63
+ /** Additional CSS classes for the container. */
64
+ className?: string;
65
+ }
@@ -0,0 +1,59 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import ITMultiSelect from "@/components/molecules/multi-select/multi-select";
4
+ import { ITMultiSelectOption } from "./multi-select.props";
5
+
6
+ const meta: Meta<typeof ITMultiSelect> = {
7
+ title: "Components/Form Elements/ITMultiSelect",
8
+ component: ITMultiSelect,
9
+ parameters: { layout: "centered" },
10
+ };
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof ITMultiSelect>;
14
+
15
+ const options: ITMultiSelectOption[] = [
16
+ { value: "mx", label: "México" },
17
+ { value: "es", label: "España" },
18
+ { value: "co", label: "Colombia" },
19
+ { value: "ar", label: "Argentina" },
20
+ { value: "pe", label: "Perú" },
21
+ { value: "cl", label: "Chile" },
22
+ ];
23
+
24
+ const Wrapper = (args: any) => {
25
+ const [value, setValue] = useState<(string | number)[]>(args.value ?? []);
26
+ return (
27
+ <div className="w-[400px]">
28
+ <ITMultiSelect {...args} value={value} onChange={(v) => setValue(v)} />
29
+ </div>
30
+ );
31
+ };
32
+
33
+ export const Default: Story = {
34
+ render: (args) => <Wrapper {...args} />,
35
+ args: { name: "countries", label: "Países", options, value: [] },
36
+ };
37
+
38
+ export const WithPreselected: Story = {
39
+ render: (args) => <Wrapper {...args} />,
40
+ args: { name: "countries", label: "Países", options, value: ["mx", "es", "co", "ar"] },
41
+ };
42
+
43
+ export const WithError: Story = {
44
+ render: (args) => <Wrapper {...args} />,
45
+ args: {
46
+ name: "countries",
47
+ label: "Países",
48
+ options,
49
+ value: [],
50
+ required: true,
51
+ touched: true,
52
+ error: true,
53
+ },
54
+ };
55
+
56
+ export const Disabled: Story = {
57
+ render: (args) => <Wrapper {...args} />,
58
+ args: { name: "countries", label: "Países", options, value: ["mx"], disabled: true },
59
+ };