@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,78 @@
1
+ import { Meta, Title, Description, Primary, Controls, Stories } from '@storybook/blocks';
2
+ import * as StoriesData from './masked-input.stories';
3
+
4
+ <Meta of={StoriesData} />
5
+
6
+ # ITMaskedInput
7
+
8
+ Input con máscara de formato: defines un patrón y el usuario solo escribe los caracteres útiles; los separadores se insertan solos y los huecos se pintan con un carácter de relleno.
9
+
10
+ ## Props
11
+
12
+ | Prop | Type | Default | Description |
13
+ |------|------|---------|-------------|
14
+ | `name` | `string` | required | Nombre del campo para formularios. |
15
+ | `mask` | `string` | required | Patrón: `9`=dígito, `A`=letra (mayúscula), `a`=letra, `x`=alfanumérico, `*`=cualquiera. Cualquier otro carácter es un separador literal auto-insertado. |
16
+ | `value` | `string` | — | Valor limpio (raw): solo caracteres útiles, sin separadores ni relleno. |
17
+ | `onChange` | `(e) => void` | required | `e.target.value` es el valor raw. |
18
+ | `onBlur` | `(e) => void` | — | Blur del input. |
19
+ | `onComplete` | `(value) => void` | — | Dispara cuando la máscara queda llena. |
20
+ | `pattern` | `RegExp` | — | Regex validada con full-match contra el raw en blur; si no cumple muestra error. |
21
+ | `placeholderChar` | `string` | `"_"` | Carácter pintado en slots vacíos. |
22
+ | `variant` | `ColorsTypes` | `"primary"` | Color de acento del tema. |
23
+ | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Tamaño del input. |
24
+ | `label` | `string` | — | Etiqueta sobre el input. |
25
+ | `disabled` | `boolean` | `false` | Desactiva el input. |
26
+ | `required` | `boolean` | `false` | Marca el campo como requerido. |
27
+ | `touched` | `boolean` | `false` | Indica si el campo fue tocado. |
28
+ | `error` | `string \| boolean` | — | Estado de error. |
29
+ | `className` | `string` | — | Clases del wrapper externo. |
30
+ | `containerClassName` | `string` | — | Clases del contenedor del input. |
31
+ | `iconLeft` / `iconRight` | `ReactNode` | — | Iconos laterales. |
32
+ | `autoFocus` | `boolean` | `false` | Enfoca al montar. |
33
+
34
+ ## Tokens del mask
35
+
36
+ | Token | Acepta |
37
+ |-------|--------|
38
+ | `9` | un dígito |
39
+ | `A` | una letra (se guarda en mayúsculas) |
40
+ | `a` | una letra (tal cual) |
41
+ | `x` | un carácter alfanumérico |
42
+ | `*` | cualquier carácter |
43
+ | cualquier otro | literal insertado solo (p.ej. `-`, `/`, `(`, `)`, espacio) |
44
+
45
+ ## Usage
46
+
47
+ ```tsx
48
+ import { ITMaskedInput } from '@axzydev/axzy_ui_system';
49
+
50
+ // Código de activación
51
+ <ITMaskedInput
52
+ name="code"
53
+ mask="xxxx-xxxx-xxxx"
54
+ label="Código"
55
+ onChange={(e) => setCode(e.target.value)}
56
+ />
57
+
58
+ // Teléfono con validación regex sobre el valor limpio
59
+ <ITMaskedInput
60
+ name="phone"
61
+ mask="(999) 999-9999"
62
+ pattern={/^\d{10}$/}
63
+ onChange={(e) => setPhone(e.target.value)}
64
+ />
65
+ ```
66
+
67
+ ## Notes
68
+
69
+ - ¿El valor? **Solo caracteres útiles**: escribir `ABC1-2345-6789` devuelve `ABC123456789` en `onChange`.
70
+ - El cursor salta a huecos vacíos con selección automática; borrar un separador lo vuelve a insertar.
71
+ - `pattern` valida contra el raw completo (full-match); el mensaje de error genérico es "Formato inválido" salvo que pases `error` como string.
72
+ - `onComplete` es ideal para tarjetas, CVV o códigos de un solo uso.
73
+
74
+ ## Stories
75
+
76
+ <Primary />
77
+ <Controls />
78
+ <Stories />
@@ -0,0 +1,58 @@
1
+ import { ColorsTypes } from "@/types/colors.types";
2
+ import { SizesTypes } from "@/types/sizes.types";
3
+ import { ReactNode } from "react";
4
+
5
+ export interface ITMaskedInputProps {
6
+ /** Unique name attribute for the underlying input element. */
7
+ name: string;
8
+ /**
9
+ * Mask pattern. Tokens are filled by the user and everything else is an
10
+ * auto-inserted literal.
11
+ * - `9` — digit
12
+ * - `A` — letter (stored uppercase)
13
+ * - `a` — letter
14
+ * - `x` — alphanumeric
15
+ * - `*` — any character
16
+ * - any other character ( `-`, `/`, `(`, `)`, ` `, `.` ) is a literal separator
17
+ * @example "xxxx-xxxx-xxxx"
18
+ * @example "(999) 999-9999"
19
+ * @example "9999-9999-9999-9999"
20
+ */
21
+ mask: string;
22
+ /** Clean value containing only the useful characters (no separators, no placeholders). */
23
+ value?: string;
24
+ /** Called when the masked value changes. `event.target.value` is the raw value without separators. */
25
+ onChange: (event: { target: { name: string; value: string } }) => void;
26
+ /** Called when the input loses focus. */
27
+ onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
28
+ /** Fired when all token slots are filled with valid characters. */
29
+ onComplete?: (value: string) => void;
30
+ /** Optional regex full-matched against the raw value to validate the field (e.g. `/^\\d{16}$/`). */
31
+ pattern?: RegExp;
32
+ /** Character shown for empty token slots. @default "_" */
33
+ placeholderChar?: string;
34
+ /** Color variant matching the design system (ColorsTypes). @default "primary" */
35
+ variant?: ColorsTypes;
36
+ /** Size preset: "sm" | "md" | "lg". @default "md" */
37
+ size?: SizesTypes;
38
+ /** Label displayed above the input. */
39
+ label?: string;
40
+ /** Disables the input. @default false */
41
+ disabled?: boolean;
42
+ /** Marks the field as required. */
43
+ required?: boolean;
44
+ /** Visually indicates the field has been interacted with. */
45
+ touched?: boolean;
46
+ /** Error state — pass a string message or `true` for default styling. */
47
+ error?: string | boolean;
48
+ /** Additional CSS classes for the outer wrapper element. */
49
+ className?: string;
50
+ /** Additional CSS classes for the underlying input container. */
51
+ containerClassName?: string;
52
+ /** Icon element rendered on the left side of the input. */
53
+ iconLeft?: ReactNode;
54
+ /** Icon element rendered on the right side of the input. */
55
+ iconRight?: ReactNode;
56
+ /** Auto-focus the input on mount. @default false */
57
+ autoFocus?: boolean;
58
+ }
@@ -0,0 +1,126 @@
1
+ import type { Meta } from '@storybook/react';
2
+ import { useState } from 'react';
3
+ import ITMaskedInput from './masked-input';
4
+
5
+ const meta = {
6
+ title: 'Components/Form Elements/ITMaskedInput',
7
+ component: ITMaskedInput,
8
+ parameters: { layout: 'padded' },
9
+ argTypes: {
10
+ mask: { control: 'text' },
11
+ placeholderChar: { control: 'text' },
12
+ onChange: { action: 'changed' },
13
+ onComplete: { action: 'completed' },
14
+ disabled: { control: 'boolean' },
15
+ error: { control: 'text' },
16
+ variant: {
17
+ control: 'select',
18
+ options: ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'purple', 'gray'],
19
+ },
20
+ size: { control: 'select', options: ['sm', 'md', 'lg'] },
21
+ },
22
+ } satisfies Meta<typeof ITMaskedInput>;
23
+
24
+ export default meta;
25
+
26
+ function CodeMaskedWrapper(args: any) {
27
+ const [value, setValue] = useState<string>('');
28
+ return (
29
+ <div className="w-full max-w-xs">
30
+ <ITMaskedInput
31
+ {...args}
32
+ value={value}
33
+ onChange={(e) => {
34
+ setValue(e.target.value);
35
+ args.onChange(e);
36
+ }}
37
+ />
38
+ <p className="mt-2 text-xs font-mono text-slate-500">
39
+ raw: {value || '(vacío)'}
40
+ </p>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ export const Alphanumeric: any = {
46
+ render: CodeMaskedWrapper,
47
+ args: {
48
+ name: 'code',
49
+ label: 'Código único',
50
+ mask: 'xxxx-xxxx-xxxx',
51
+ },
52
+ };
53
+
54
+ export const Phone: any = {
55
+ render: CodeMaskedWrapper,
56
+ args: {
57
+ name: 'phone',
58
+ label: 'Teléfono',
59
+ mask: '(999) 999-9999',
60
+ },
61
+ };
62
+
63
+ export const CreditCard: any = {
64
+ render: CodeMaskedWrapper,
65
+ args: {
66
+ name: 'card',
67
+ label: 'Número de tarjeta',
68
+ mask: '9999-9999-9999-9999',
69
+ onComplete: undefined,
70
+ },
71
+ };
72
+
73
+ const PhoneValidatedWrapper = (args: any) => {
74
+ const [value, setValue] = useState<string>('5512345678');
75
+ const [complete, setComplete] = useState<string | null>(null);
76
+ return (
77
+ <div className="w-full max-w-xs">
78
+ <ITMaskedInput
79
+ {...args}
80
+ value={value}
81
+ onChange={(e) => {
82
+ setValue(e.target.value);
83
+ args.onChange(e);
84
+ setComplete(null);
85
+ }}
86
+ onComplete={(v) => setComplete(v)}
87
+ />
88
+ {complete && (
89
+ <p className="mt-2 text-xs font-mono text-emerald-600">
90
+ ¡Completo! raw: {complete}
91
+ </p>
92
+ )}
93
+ </div>
94
+ );
95
+ };
96
+
97
+ export const WithPattern: any = {
98
+ render: PhoneValidatedWrapper,
99
+ args: {
100
+ name: 'validated_phone',
101
+ label: 'Teléfono válido (regex)',
102
+ mask: '999-999-9999',
103
+ pattern: /^\d{9}$/,
104
+ },
105
+ };
106
+
107
+ export const Sizes: any = {
108
+ render: () => (
109
+ <div className="flex flex-col gap-4 w-full max-w-xs">
110
+ <ITMaskedInput name="sz_sm" label="Small" mask="xxxx-xxxx" size="sm" onChange={() => {}} />
111
+ <ITMaskedInput name="sz_md" label="Medium" mask="xxxx-xxxx" size="md" onChange={() => {}} />
112
+ <ITMaskedInput name="sz_lg" label="Large" mask="xxxx-xxxx" size="lg" onChange={() => {}} />
113
+ </div>
114
+ ),
115
+ };
116
+
117
+ export const Disabled: any = {
118
+ args: {
119
+ name: 'disabled_code',
120
+ label: 'Deshabilitado',
121
+ mask: 'xxxx-xxxx',
122
+ value: 'abcd',
123
+ disabled: true,
124
+ onChange: () => {},
125
+ },
126
+ };
@@ -0,0 +1,244 @@
1
+ import React, { useRef, useState, useMemo } from "react";
2
+ import clsx from "clsx";
3
+ import ITInput from "@/components/atoms/input/input";
4
+ import { ITMaskedInputProps } from "./masked-input.props";
5
+
6
+ type Token = "9" | "A" | "a" | "x" | "*";
7
+
8
+ type MaskRule =
9
+ | { type: "token"; token: Token }
10
+ | { type: "literal"; char: string };
11
+
12
+ const TOKEN_SET = /[9Aax*]/;
13
+
14
+ function parseMask(mask: string): MaskRule[] {
15
+ return mask.split("").map((ch) =>
16
+ TOKEN_SET.test(ch)
17
+ ? { type: "token", token: ch as Token }
18
+ : { type: "literal", char: ch }
19
+ );
20
+ }
21
+
22
+ function matchToken(token: Token, ch: string): boolean {
23
+ switch (token) {
24
+ case "9":
25
+ return /\d/.test(ch);
26
+ case "A":
27
+ case "a":
28
+ return /[A-Za-z]/.test(ch);
29
+ case "x":
30
+ return /[A-Za-z0-9]/.test(ch);
31
+ case "*":
32
+ return ch.length > 0;
33
+ default:
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function extract(input: string, rules: MaskRule[], maskChar: string): string {
39
+ let raw = "";
40
+ let slot = 0;
41
+ for (const ch of input) {
42
+ while (slot < rules.length && rules[slot].type === "literal") slot++;
43
+ if (slot >= rules.length) break;
44
+ const rule = rules[slot];
45
+ if (
46
+ rule.type === "token" &&
47
+ ch !== maskChar &&
48
+ matchToken(rule.token, ch)
49
+ ) {
50
+ raw += rule.token === "A" ? ch.toUpperCase() : ch;
51
+ slot++;
52
+ }
53
+ }
54
+ return raw;
55
+ }
56
+
57
+ function format(raw: string, rules: MaskRule[], maskChar: string): string {
58
+ let out = "";
59
+ let idx = 0;
60
+ for (const rule of rules) {
61
+ if (rule.type === "literal") {
62
+ out += rule.char;
63
+ } else {
64
+ const ch = raw[idx];
65
+ out += ch !== undefined ? ch : maskChar;
66
+ idx++;
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ function countConsumed(input: string, rules: MaskRule[], upto: number, maskChar: string): number {
73
+ let slot = 0;
74
+ let n = 0;
75
+ for (let i = 0; i < upto && i < input.length; i++) {
76
+ const ch = input[i];
77
+ while (slot < rules.length && rules[slot].type === "literal") slot++;
78
+ if (slot >= rules.length) break;
79
+ const rule = rules[slot];
80
+ if (rule.type === "token" && ch !== maskChar && matchToken(rule.token, ch)) {
81
+ n++;
82
+ slot++;
83
+ }
84
+ }
85
+ return n;
86
+ }
87
+
88
+ function caretAfter(n: number, rules: MaskRule[]): number {
89
+ let pos = 0;
90
+ let consumed = 0;
91
+ for (const rule of rules) {
92
+ if (rule.type === "token") {
93
+ consumed++;
94
+ if (consumed > n) break;
95
+ }
96
+ pos++;
97
+ }
98
+ return pos;
99
+ }
100
+
101
+ /**
102
+ * Text input that applies a formatting mask while the user types, exposing only
103
+ * the clean (raw) value through `onChange`.
104
+ *
105
+ * The mask is defined by a pattern string. Tokens are filled by the user and
106
+ * any other character acts as an auto-inserted literal separator. Empty slots
107
+ * are painted with `placeholderChar`, so the mask is always visible.
108
+ *
109
+ * @example
110
+ * ```tsx
111
+ * <ITMaskedInput
112
+ * name="code"
113
+ * mask="xxxx-xxxx-xxxx"
114
+ * label="Código"
115
+ * onChange={(e) => setCode(e.target.value)}
116
+ * />
117
+ * ```
118
+ *
119
+ * @example
120
+ * ```tsx
121
+ * <ITMaskedInput
122
+ * name="phone"
123
+ * mask="(999) 999-9999"
124
+ * pattern={/^\d{10}$/}
125
+ * onChange={(e) => setPhone(e.target.value)}
126
+ * />
127
+ * ```
128
+ *
129
+ * @example
130
+ * ```tsx
131
+ * <ITMaskedInput
132
+ * name="card"
133
+ * mask="9999-9999-9999-9999"
134
+ * onComplete={(card) => validateCard(card)}
135
+ * />
136
+ * ```
137
+ */
138
+ export default function ITMaskedInput({
139
+ name,
140
+ mask,
141
+ value,
142
+ onChange,
143
+ onBlur,
144
+ onComplete,
145
+ pattern,
146
+ placeholderChar = "_",
147
+ variant = "primary",
148
+ size = "md",
149
+ label,
150
+ disabled = false,
151
+ required,
152
+ touched,
153
+ error,
154
+ className,
155
+ containerClassName,
156
+ iconLeft,
157
+ iconRight,
158
+ autoFocus = false,
159
+ }: ITMaskedInputProps) {
160
+ const containerRef = useRef<HTMLDivElement>(null);
161
+ const rawRef = useRef<string>(value ?? "");
162
+ const isControlled = value !== undefined;
163
+
164
+ const [internalRaw, setInternalRaw] = useState<string>(value ?? "");
165
+ const [invalidPattern, setInvalidPattern] = useState(false);
166
+
167
+ const rules = useMemo(() => parseMask(mask), [mask]);
168
+ const tokenCount = useMemo(() => rules.filter((r) => r.type === "token").length, [rules]);
169
+
170
+ const raw = isControlled ? (value ?? "") : internalRaw;
171
+ const display = useMemo(() => format(raw, rules, placeholderChar), [raw, rules, placeholderChar]);
172
+
173
+ const scheduleCaret = (caret: number) => {
174
+ const inputEl = containerRef.current?.querySelector("input");
175
+ if (!inputEl) return;
176
+ requestAnimationFrame(() => {
177
+ if (document.activeElement === inputEl) {
178
+ const pos = Math.min(caret, inputEl.value.length);
179
+ inputEl.setSelectionRange(pos, pos);
180
+ }
181
+ });
182
+ };
183
+
184
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
185
+ const el = e.target as HTMLInputElement;
186
+ const inputVal = el.value ?? "";
187
+ const caretBefore = el.selectionStart ?? inputVal.length;
188
+
189
+ const nextRaw = extract(inputVal, rules, placeholderChar);
190
+ const targetCaret = caretAfter(
191
+ countConsumed(inputVal, rules, caretBefore, placeholderChar),
192
+ rules
193
+ );
194
+
195
+ if (nextRaw !== rawRef.current) {
196
+ rawRef.current = nextRaw;
197
+ setInternalRaw(nextRaw);
198
+ onChange?.({ target: { name, value: nextRaw } });
199
+ if (nextRaw.length === tokenCount && nextRaw.length > 0) {
200
+ onComplete?.(nextRaw);
201
+ }
202
+ if (invalidPattern) setInvalidPattern(false);
203
+ }
204
+
205
+ scheduleCaret(targetCaret);
206
+ };
207
+
208
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
209
+ if (pattern && rawRef.current.length > 0 && !pattern.test(rawRef.current)) {
210
+ setInvalidPattern(true);
211
+ }
212
+ onBlur?.(e);
213
+ };
214
+
215
+ const resolvedError = invalidPattern
216
+ ? typeof error === "string"
217
+ ? error
218
+ : "Formato inválido"
219
+ : error;
220
+
221
+ return (
222
+ <div ref={containerRef} className={clsx("w-full", className)}>
223
+ <ITInput
224
+ name={name}
225
+ type="text"
226
+ label={label}
227
+ value={display}
228
+ onChange={handleChange}
229
+ onBlur={(e) => handleBlur(e as React.FocusEvent<HTMLInputElement>)}
230
+ maxLength={mask.length}
231
+ variant={variant}
232
+ size={size}
233
+ disabled={disabled}
234
+ required={required}
235
+ touched={touched}
236
+ error={resolvedError}
237
+ containerClassName={containerClassName}
238
+ iconLeft={iconLeft}
239
+ iconRight={iconRight}
240
+ autoFocus={autoFocus}
241
+ />
242
+ </div>
243
+ );
244
+ }
@@ -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
+ };