@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
@@ -4,7 +4,17 @@ import { Formik } from "formik";
4
4
  import * as Yup from "yup";
5
5
  import { useState } from "react";
6
6
  import { FieldConfig, FieldConfigV2 } from "@/types/field.types";
7
- import { FaUser, FaEnvelope, FaSearch, FaLock, FaCalculator, FaCommentDots } from 'react-icons/fa';
7
+ import {
8
+ FaUser,
9
+ FaEnvelope,
10
+ FaSearch,
11
+ FaLock,
12
+ FaCalculator,
13
+ FaCommentDots,
14
+ FaCheckCircle,
15
+ } from "react-icons/fa";
16
+ import { expect, fn, userEvent } from "storybook/test";
17
+ import { useFormBuilder } from "./useFormBuilder";
8
18
 
9
19
  const meta: Meta<typeof ITFormBuilder> = {
10
20
  title: "Components/Form Elements/ITFormBuilder",
@@ -13,31 +23,108 @@ const meta: Meta<typeof ITFormBuilder> = {
13
23
  layout: "centered",
14
24
  docs: {
15
25
  description: {
16
- component: "Dynamic form builder using Formik and Yup configuration. Supports Legacy V1 and the powerful Enterprise V2 Grid mapping, validating, calculating, and conditional logic.",
26
+ component:
27
+ "Dynamic form builder using Formik and Yup configuration. Supports Legacy V1 and the powerful Enterprise V2 Grid mapping, validating, calculating, and conditional logic.",
17
28
  },
18
29
  },
19
30
  },
20
31
  };
21
-
22
32
  export default meta;
23
33
  type Story = StoryObj<typeof ITFormBuilder>;
24
34
 
25
- // Wrapper for Formik context
26
- const FormWrapper = ({
27
- children,
28
- initialValues = {},
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+ // Helpers shared by the test stories
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+
39
+ /** Controlled wrapper that drives the builder with local React state. */
40
+ const ControlledForm = ({
41
+ children,
42
+ initialValues,
43
+ }: {
44
+ children: (props: {
45
+ values: Record<string, any>;
46
+ setValues: React.Dispatch<React.SetStateAction<Record<string, any>>>;
47
+ setFieldValue: (field: string, value: any) => Promise<void>;
48
+ handleChange: (e: any) => void;
49
+ handleBlur: (e: any) => void;
50
+ touched: Record<string, boolean>;
51
+ errors: Record<string, string>;
52
+ }) => React.ReactNode;
53
+ initialValues: Record<string, any>;
54
+ }) => {
55
+ const [values, setValues] = useState<Record<string, any>>(initialValues);
56
+ const [touched, setTouched] = useState<Record<string, boolean>>({});
57
+ const [errors] = useState<Record<string, string>>({});
58
+
59
+ const handleChange = (e: any) => {
60
+ const { name, value, type, checked } = e.target ?? {};
61
+ setValues((prev) => ({
62
+ ...prev,
63
+ [name]: type === "checkbox" ? checked : value,
64
+ }));
65
+ };
66
+
67
+ const handleBlur = (e: any) => {
68
+ const { name } = e.target ?? {};
69
+ if (name) setTouched((prev) => ({ ...prev, [name]: true }));
70
+ };
71
+
72
+ const setFieldValue = async (field: string, value: any) => {
73
+ setValues((prev) => ({ ...prev, [field]: value }));
74
+ };
75
+
76
+ return (
77
+ <>
78
+ {children({
79
+ values,
80
+ setValues,
81
+ setFieldValue,
82
+ handleChange,
83
+ handleBlur,
84
+ touched,
85
+ errors,
86
+ })}
87
+ {/* Surface state so test play functions and humans can read it. */}
88
+ <pre
89
+ data-testid="form-state"
90
+ style={{
91
+ marginTop: 16,
92
+ padding: 8,
93
+ background: "#0f172a",
94
+ color: "#a7f3d0",
95
+ fontSize: 11,
96
+ borderRadius: 8,
97
+ maxWidth: 480,
98
+ overflow: "auto",
99
+ }}
100
+ >
101
+ {JSON.stringify({ values, touched, errors }, null, 2)}
102
+ </pre>
103
+ </>
104
+ );
105
+ };
106
+
107
+ // ─────────────────────────────────────────────────────────────────────────────
108
+ // Showcase stories (kept as-is for the Storybook gallery)
109
+ // ─────────────────────────────────────────────────────────────────────────────
110
+
111
+ const FormWrapper = ({
112
+ children,
113
+ initialValues = {},
29
114
  validationSchema,
30
- widthClass = "w-[800px]"
31
- }: {
32
- children: (props: any) => React.ReactNode,
33
- initialValues?: any,
34
- validationSchema?: any,
35
- widthClass?: string
115
+ widthClass = "w-[800px]",
116
+ }: {
117
+ children: (props: any) => React.ReactNode;
118
+ initialValues?: any;
119
+ validationSchema?: any;
120
+ widthClass?: string;
36
121
  }) => {
37
122
  const [submittedValues, setSubmittedValues] = useState<any>(null);
38
123
 
39
124
  return (
40
- <div className={`p-6 bg-white rounded-xl shadow-sm border border-gray-100 max-w-full ${widthClass}`}>
125
+ <div
126
+ className={`p-6 bg-white rounded-xl shadow-sm border border-gray-100 max-w-full ${widthClass}`}
127
+ >
41
128
  <Formik
42
129
  initialValues={initialValues}
43
130
  validationSchema={validationSchema}
@@ -49,7 +136,7 @@ const FormWrapper = ({
49
136
  {(formikProps) => (
50
137
  <form onSubmit={formikProps.handleSubmit}>
51
138
  {children(formikProps)}
52
-
139
+
53
140
  <div className="mt-6 flex items-center justify-end gap-3 pt-4 border-t border-gray-100">
54
141
  <button
55
142
  type="button"
@@ -68,7 +155,9 @@ const FormWrapper = ({
68
155
 
69
156
  {submittedValues && (
70
157
  <div className="mt-4 p-3 bg-gray-50 rounded-lg text-xs font-mono overflow-auto max-h-40">
71
- <p className="font-semibold text-gray-500 mb-1">Payload Generado:</p>
158
+ <p className="font-semibold text-gray-500 mb-1">
159
+ Payload Generado:
160
+ </p>
72
161
  <pre>{JSON.stringify(submittedValues, null, 2)}</pre>
73
162
  </div>
74
163
  )}
@@ -79,9 +168,6 @@ const FormWrapper = ({
79
168
  );
80
169
  };
81
170
 
82
-
83
- // --- Enterprise V2 Showcase ---
84
-
85
171
  const showcaseConfig: FieldConfigV2[] = [
86
172
  {
87
173
  name: "username",
@@ -98,8 +184,8 @@ const showcaseConfig: FieldConfigV2[] = [
98
184
  type: "text",
99
185
  placeholder: "Ingresa código...",
100
186
  rightIcon: (
101
- <button
102
- type="button"
187
+ <button
188
+ type="button"
103
189
  onClick={() => alert("¡Disparando búsqueda de código promocional!")}
104
190
  className="text-primary-600 hover:text-primary-800 transition px-2"
105
191
  >
@@ -127,34 +213,34 @@ const showcaseConfig: FieldConfigV2[] = [
127
213
  column: { sm: 12, md: 6 },
128
214
  },
129
215
  {
130
- name: "subtotal",
131
- label: "Subtotal (Dispara OnChange)",
132
- type: "number",
133
- required: true,
134
- currencyFormat: true,
135
- leftIcon: <FaCalculator className="text-gray-400" />,
136
- column: { sm: 12, md: 4 },
137
- onChangeAction: (val, { setFieldValue }) => {
138
- const num = parseFloat(val) || 0;
139
- setFieldValue("iva", (num * 0.16).toFixed(2));
140
- setFieldValue("total", (num * 1.16).toFixed(2));
141
- }
216
+ name: "subtotal",
217
+ label: "Subtotal (Dispara OnChange)",
218
+ type: "number",
219
+ required: true,
220
+ currencyFormat: true,
221
+ leftIcon: <FaCalculator className="text-gray-400" />,
222
+ column: { sm: 12, md: 4 },
223
+ onChangeAction: (val, { setFieldValue }) => {
224
+ const num = parseFloat(val) || 0;
225
+ setFieldValue("iva", (num * 0.16).toFixed(2));
226
+ setFieldValue("total", (num * 1.16).toFixed(2));
227
+ },
142
228
  },
143
229
  {
144
- name: "iva",
145
- label: "IVA (Read-Only)",
146
- type: "number",
147
- currencyFormat: true,
148
- readOnly: true,
149
- column: { sm: 12, md: 4 },
230
+ name: "iva",
231
+ label: "IVA (Read-Only)",
232
+ type: "number",
233
+ currencyFormat: true,
234
+ readOnly: true,
235
+ column: { sm: 12, md: 4 },
150
236
  },
151
237
  {
152
- name: "total",
153
- label: "Total (Disabled)",
154
- type: "number",
155
- currencyFormat: true,
156
- disabled: true,
157
- column: { sm: 12, md: 4 },
238
+ name: "total",
239
+ label: "Total (Disabled)",
240
+ type: "number",
241
+ currencyFormat: true,
242
+ disabled: true,
243
+ column: { sm: 12, md: 4 },
158
244
  },
159
245
  {
160
246
  name: "country",
@@ -165,7 +251,7 @@ const showcaseConfig: FieldConfigV2[] = [
165
251
  { value: "", label: "Selecciona país..." },
166
252
  { value: "MX", label: "México" },
167
253
  { value: "US", label: "Estados Unidos" },
168
- { value: "OTHER", label: "Otro" }
254
+ { value: "OTHER", label: "Otro" },
169
255
  ],
170
256
  column: { sm: 12, md: 6 },
171
257
  },
@@ -173,8 +259,8 @@ const showcaseConfig: FieldConfigV2[] = [
173
259
  name: "rfc",
174
260
  label: "RFC de México (Renderización Condicional)",
175
261
  type: "text",
176
- dependsOn: ["country"],
177
- renderWhen: (vals) => vals.country === "MX",
262
+ dependsOn: ["country"],
263
+ renderWhen: (vals) => vals.country === "MX",
178
264
  required: true,
179
265
  showHintLength: true,
180
266
  maxLength: 13,
@@ -190,81 +276,95 @@ const showcaseConfig: FieldConfigV2[] = [
190
276
  {
191
277
  name: "time",
192
278
  label: "Selector de Hora",
193
- type: "time" as any,
279
+ type: "time" as any,
194
280
  required: true,
195
281
  column: { sm: 12, md: 4 },
196
282
  },
197
283
  {
198
284
  name: "comments",
199
285
  label: "Comentarios (Textarea + Límite)",
200
- type: "textarea" as any,
286
+ type: "textarea" as any,
201
287
  rows: 3,
202
288
  showHintLength: true,
203
289
  maxLength: 200,
204
290
  leftIcon: <FaCommentDots className="text-gray-400" />,
205
291
  column: { sm: 12, md: 4 },
206
- }
292
+ },
207
293
  ];
208
294
 
209
295
  const showcaseSchema = Yup.object().shape({
210
296
  username: Yup.string().required("El nombre de usuario es requerido"),
211
297
  promoCode: Yup.string(),
212
- email: Yup.string().email("Formato de correo inválido").required("El correo es requerido"),
213
- password: Yup.string().min(8, "Mínimo 8 caracteres").required("La contraseña es requerida"),
214
- subtotal: Yup.number().typeError("Debe ser un número").required("Requerido").min(1, "Mayor a 0"),
298
+ email: Yup.string()
299
+ .email("Formato de correo inválido")
300
+ .required("El correo es requerido"),
301
+ password: Yup.string()
302
+ .min(8, "Mínimo 8 caracteres")
303
+ .required("La contraseña es requerida"),
304
+ subtotal: Yup.number()
305
+ .typeError("Debe ser un número")
306
+ .required("Requerido")
307
+ .min(1, "Mayor a 0"),
215
308
  country: Yup.string().required("Selecciona un país"),
216
- rfc: Yup.string().when('country', {
217
- is: 'MX',
218
- then: (schema) => schema.length(13, "Debe tener 13 caracteres exactos").required("El RFC es obligatorio para MX"),
219
- otherwise: (schema) => schema.notRequired()
309
+ rfc: Yup.string().when("country", {
310
+ is: "MX",
311
+ then: (schema) =>
312
+ schema
313
+ .length(13, "Debe tener 13 caracteres exactos")
314
+ .required("El RFC es obligatorio para MX"),
315
+ otherwise: (schema) => schema.notRequired(),
220
316
  }),
221
317
  date: Yup.date().required("Fecha requerida"),
222
318
  time: Yup.string().required("Hora requerida"),
223
- comments: Yup.string().max(200, "Máximo 200 caracteres")
319
+ comments: Yup.string().max(200, "Máximo 200 caracteres"),
224
320
  });
225
321
 
226
322
  export const FullFeaturesShowcaseV2: Story = {
227
323
  render: () => (
228
- <FormWrapper
324
+ <FormWrapper
229
325
  initialValues={{
230
- username: '',
231
- promoCode: '',
232
- email: '',
233
- password: '',
234
- subtotal: '',
235
- iva: '',
236
- total: '',
237
- country: '',
238
- rfc: '',
326
+ username: "",
327
+ promoCode: "",
328
+ email: "",
329
+ password: "",
330
+ subtotal: "",
331
+ iva: "",
332
+ total: "",
333
+ country: "",
334
+ rfc: "",
239
335
  date: null,
240
- time: '',
241
- comments: ''
336
+ time: "",
337
+ comments: "",
242
338
  }}
243
339
  validationSchema={showcaseSchema}
244
340
  widthClass="w-[1024px]"
245
341
  >
246
342
  {(formikProps) => (
247
343
  <div className="space-y-4">
248
- <h3 className="text-xl font-bold text-gray-900 mb-2">ITFormBuilder V2 Completo</h3>
249
- <p className="text-sm text-gray-500 mb-6">Prueba los cálculos en tiempo real en "Subtotal", mira el comportamiento `disabled` vs `readonly`, interactúa con íconos e ingresa el país "México" para renderizar dinámicamente un campo.</p>
250
- <ITFormBuilder
251
- config={showcaseConfig as any}
252
- columns={12}
253
- values={formikProps.values}
254
- handleChange={formikProps.handleChange}
255
- handleBlur={formikProps.handleBlur}
256
- touched={formikProps.touched}
257
- errors={formikProps.errors}
258
- setFieldValue={formikProps.setFieldValue}
259
- />
344
+ <h3 className="text-xl font-bold text-gray-900 mb-2">
345
+ ITFormBuilder V2 Completo
346
+ </h3>
347
+ <p className="text-sm text-gray-500 mb-6">
348
+ Prueba los cálculos en tiempo real en "Subtotal", mira el
349
+ comportamiento `disabled` vs `readonly`, interactúa con íconos e
350
+ ingresa el país "México" para renderizar dinámicamente un campo.
351
+ </p>
352
+ <ITFormBuilder
353
+ config={showcaseConfig as any}
354
+ columns={12}
355
+ values={formikProps.values}
356
+ handleChange={formikProps.handleChange}
357
+ handleBlur={formikProps.handleBlur}
358
+ touched={formikProps.touched}
359
+ errors={formikProps.errors}
360
+ setFieldValue={formikProps.setFieldValue}
361
+ />
260
362
  </div>
261
363
  )}
262
364
  </FormWrapper>
263
365
  ),
264
366
  };
265
367
 
266
- // --- Legacy V1 Login Example ---
267
-
268
368
  const loginFields: FieldConfig[] = [
269
369
  {
270
370
  name: "email",
@@ -291,26 +391,524 @@ const loginSchema = Yup.object().shape({
291
391
 
292
392
  export const LegacyV1LoginExample: Story = {
293
393
  render: () => (
294
- <FormWrapper
394
+ <FormWrapper
295
395
  initialValues={{ email: "", password: "" }}
296
396
  validationSchema={loginSchema}
297
397
  widthClass="w-[500px]"
298
398
  >
299
399
  {(formikProps) => (
300
400
  <div className="space-y-4">
301
- <h3 className="text-lg font-semibold text-gray-900 mb-2">Legacy Login (V1 Config Backward Compatibility)</h3>
302
- <ITFormBuilder
303
- fields={loginFields}
304
- columns={12}
305
- values={formikProps.values}
306
- handleChange={formikProps.handleChange}
307
- handleBlur={formikProps.handleBlur}
308
- touched={formikProps.touched}
309
- errors={formikProps.errors}
310
- />
401
+ <h3 className="text-lg font-semibold text-gray-900 mb-2">
402
+ Legacy Login (V1 Config Backward Compatibility)
403
+ </h3>
404
+ <ITFormBuilder
405
+ fields={loginFields}
406
+ columns={12}
407
+ values={formikProps.values}
408
+ handleChange={formikProps.handleChange}
409
+ handleBlur={formikProps.handleBlur}
410
+ touched={formikProps.touched}
411
+ errors={formikProps.errors}
412
+ />
311
413
  </div>
312
414
  )}
313
415
  </FormWrapper>
314
416
  ),
315
417
  };
316
418
 
419
+ // ─────────────────────────────────────────────────────────────────────────────
420
+ // Test stories (interaction tests, run via `pnpm exec vitest run`)
421
+ // ─────────────────────────────────────────────────────────────────────────────
422
+
423
+ /** Renders a V2 config of three fields and asserts they are present in the DOM. */
424
+ export const TestRendersV2Basic: Story = {
425
+ name: "Test: renders V2 basic fields",
426
+ render: () => (
427
+ <ControlledForm initialValues={{ name: "", role: "", dob: null }}>
428
+ {({ values, handleChange, handleBlur, touched, errors, setFieldValue }) => (
429
+ <ITFormBuilder
430
+ config={[
431
+ { name: "name", label: "Name", type: "text", required: true, column: 6 },
432
+ {
433
+ name: "role",
434
+ label: "Role",
435
+ type: "select",
436
+ required: true,
437
+ column: 6,
438
+ options: [
439
+ { value: "admin", label: "Admin" },
440
+ { value: "user", label: "User" },
441
+ ],
442
+ },
443
+ { name: "dob", label: "Date of Birth", type: "date", column: 12 },
444
+ ]}
445
+ columns={12}
446
+ values={values}
447
+ handleChange={handleChange}
448
+ handleBlur={handleBlur}
449
+ touched={touched}
450
+ errors={errors}
451
+ setFieldValue={setFieldValue}
452
+ />
453
+ )}
454
+ </ControlledForm>
455
+ ),
456
+ play: async ({ canvas }) => {
457
+ await expect(canvas.getByLabelText(/Name/)).toBeInTheDocument();
458
+ await expect(canvas.getByLabelText(/Role/)).toBeInTheDocument();
459
+ await expect(canvas.getByLabelText(/Date of Birth/)).toBeInTheDocument();
460
+ },
461
+ };
462
+
463
+ /** Typing in the Name input bubbles through `handleChange` into the state. */
464
+ export const TestTypingUpdatesState: Story = {
465
+ name: "Test: typing updates state via handleChange",
466
+ render: () => (
467
+ <ControlledForm initialValues={{ email: "" }}>
468
+ {({ values, handleChange, handleBlur, touched, errors, setFieldValue }) => (
469
+ <ITFormBuilder
470
+ config={[
471
+ { name: "email", label: "Email", type: "email", required: true },
472
+ ]}
473
+ values={values}
474
+ handleChange={handleChange}
475
+ handleBlur={handleBlur}
476
+ touched={touched}
477
+ errors={errors}
478
+ setFieldValue={setFieldValue}
479
+ />
480
+ )}
481
+ </ControlledForm>
482
+ ),
483
+ play: async ({ canvas }) => {
484
+ const input = canvas.getByLabelText(/Email/);
485
+ await userEvent.type(input, "user@example.com");
486
+ await expect(input).toHaveValue("user@example.com");
487
+
488
+ const stateDump = canvas.getByTestId("form-state");
489
+ await expect(stateDump.textContent).toContain('"email": "user@example.com"');
490
+ },
491
+ };
492
+
493
+ /** RFC field appears only when country === "MX"; switching away hides it. */
494
+ export const TestConditionalRenderWhen: Story = {
495
+ name: "Test: renderWhen shows / hides dependent field",
496
+ render: () => (
497
+ <ControlledForm initialValues={{ country: "", rfc: "" }}>
498
+ {({ values, handleChange, handleBlur, touched, errors, setFieldValue }) => (
499
+ <ITFormBuilder
500
+ config={[
501
+ {
502
+ name: "country",
503
+ label: "Country",
504
+ type: "select",
505
+ required: true,
506
+ options: [
507
+ { value: "", label: "Select..." },
508
+ { value: "MX", label: "México" },
509
+ { value: "US", label: "USA" },
510
+ ],
511
+ },
512
+ {
513
+ name: "rfc",
514
+ label: "RFC",
515
+ type: "text",
516
+ dependsOn: ["country"],
517
+ renderWhen: (v) => v.country === "MX",
518
+ required: true,
519
+ },
520
+ ]}
521
+ values={values}
522
+ handleChange={handleChange}
523
+ handleBlur={handleBlur}
524
+ touched={touched}
525
+ errors={errors}
526
+ setFieldValue={setFieldValue}
527
+ />
528
+ )}
529
+ </ControlledForm>
530
+ ),
531
+ play: async ({ canvas }) => {
532
+ // Initially hidden.
533
+ await expect(canvas.queryByLabelText(/RFC/)).toBeNull();
534
+
535
+ // Pick Mexico → appears.
536
+ const country = canvas.getByLabelText(/Country/);
537
+ await userEvent.selectOptions(country, "MX");
538
+ await expect(canvas.getByLabelText(/RFC/)).toBeInTheDocument();
539
+
540
+ // Switch to USA → hidden again.
541
+ await userEvent.selectOptions(country, "US");
542
+ await expect(canvas.queryByLabelText(/RFC/)).toBeNull();
543
+ },
544
+ };
545
+
546
+ /** Typing in subtotal triggers `onChangeAction` to recompute total. */
547
+ export const TestOnChangeActionComputesTotal: Story = {
548
+ name: "Test: onChangeAction updates derived total",
549
+ render: () => (
550
+ <ControlledForm initialValues={{ subtotal: "", total: "" }}>
551
+ {({ values, handleChange, handleBlur, touched, errors, setFieldValue }) => (
552
+ <ITFormBuilder
553
+ config={[
554
+ {
555
+ name: "subtotal",
556
+ label: "Subtotal",
557
+ type: "number",
558
+ currencyFormat: true,
559
+ onChangeAction: (val, ctx) => {
560
+ const n = parseFloat(String(val).replace(/,/g, "")) || 0;
561
+ ctx.setFieldValue("total", (n * 1.16).toFixed(2));
562
+ },
563
+ },
564
+ { name: "total", label: "Total", type: "number", disabled: true },
565
+ ]}
566
+ values={values}
567
+ handleChange={handleChange}
568
+ handleBlur={handleBlur}
569
+ touched={touched}
570
+ errors={errors}
571
+ setFieldValue={setFieldValue}
572
+ />
573
+ )}
574
+ </ControlledForm>
575
+ ),
576
+ play: async ({ canvas }) => {
577
+ const subtotal = canvas.getByLabelText(/Subtotal/);
578
+ await userEvent.type(subtotal, "100");
579
+
580
+ // Total should be derived to "116.50".
581
+ const total = canvas.getByLabelText(/Total/) as HTMLInputElement;
582
+ await expect(total.value).toBe("116.50");
583
+ },
584
+ };
585
+
586
+ /** Disabled field cannot receive input; programmatic value still renders. */
587
+ export const TestDisabledFieldBlocksInput: Story = {
588
+ name: "Test: disabled field blocks typing",
589
+ render: () => (
590
+ <ControlledForm initialValues={{ a: "", b: "fixed" }}>
591
+ {({ values, handleChange, handleBlur, touched, errors, setFieldValue }) => (
592
+ <ITFormBuilder
593
+ config={[
594
+ { name: "a", label: "Active", type: "text" },
595
+ { name: "b", label: "Locked", type: "text", disabled: true },
596
+ ]}
597
+ values={values}
598
+ handleChange={handleChange}
599
+ handleBlur={handleBlur}
600
+ touched={touched}
601
+ errors={errors}
602
+ setFieldValue={setFieldValue}
603
+ />
604
+ )}
605
+ </ControlledForm>
606
+ ),
607
+ play: async ({ canvas }) => {
608
+ const active = canvas.getByLabelText(/Active/);
609
+ const locked = canvas.getByLabelText(/Locked/) as HTMLInputElement;
610
+
611
+ await expect(locked).toBeDisabled();
612
+ await expect(locked.value).toBe("fixed");
613
+
614
+ await userEvent.type(active, "hello");
615
+ await userEvent.type(locked, "X"); // should be a no-op
616
+ await expect(locked.value).toBe("fixed");
617
+ await expect(active).toHaveValue("hello");
618
+ },
619
+ };
620
+
621
+ /** Required flag becomes true once another field exceeds a threshold. */
622
+ export const TestDynamicRequired: Story = {
623
+ name: "Test: dynamicProps toggles required at runtime",
624
+ render: () => (
625
+ <ControlledForm initialValues={{ amount: 0, justification: "" }}>
626
+ {({ values, handleChange, handleBlur, touched, errors, setFieldValue }) => (
627
+ <ITFormBuilder
628
+ config={[
629
+ {
630
+ name: "amount",
631
+ label: "Amount",
632
+ type: "number",
633
+ defaultValue: 0,
634
+ dependsOn: ["amount"],
635
+ dynamicProps: (v) =>
636
+ Number(v.amount) > 50 ? { required: true } : { required: false },
637
+ },
638
+ {
639
+ name: "justification",
640
+ label: "Justification",
641
+ type: "text",
642
+ dependsOn: ["amount"],
643
+ dynamicProps: (v) =>
644
+ Number(v.amount) > 50 ? { required: true } : { required: false },
645
+ },
646
+ ]}
647
+ values={values}
648
+ handleChange={handleChange}
649
+ handleBlur={handleBlur}
650
+ touched={touched}
651
+ errors={errors}
652
+ setFieldValue={setFieldValue}
653
+ />
654
+ )}
655
+ </ControlledForm>
656
+ ),
657
+ play: async ({ canvas }) => {
658
+ const amount = canvas.getByLabelText(/Amount/);
659
+ await expect(amount).not.toBeRequired();
660
+
661
+ await userEvent.clear(amount);
662
+ await userEvent.type(amount, "75");
663
+
664
+ const justification = canvas.getByLabelText(/Justification/);
665
+ await expect(justification).toBeRequired();
666
+ },
667
+ };
668
+
669
+ /** Async option loader renders a loading placeholder then the resolved list. */
670
+ export const TestAsyncOptionsLoad: Story = {
671
+ name: "Test: async options loader resolves and renders options",
672
+ render: () => {
673
+ const config: FieldConfigV2[] = [
674
+ {
675
+ name: "city",
676
+ label: "City",
677
+ type: "select",
678
+ options: async () => {
679
+ await new Promise((r) => setTimeout(r, 50));
680
+ return [
681
+ { value: "cdmx", label: "Ciudad de México" },
682
+ { value: "gdl", label: "Guadalajara" },
683
+ ];
684
+ },
685
+ },
686
+ ];
687
+ return (
688
+ <ControlledForm initialValues={{ city: "" }}>
689
+ {({ values, handleChange, handleBlur, touched, errors, setFieldValue }) => (
690
+ <ITFormBuilder
691
+ config={config}
692
+ values={values}
693
+ handleChange={handleChange}
694
+ handleBlur={handleBlur}
695
+ touched={touched}
696
+ errors={errors}
697
+ setFieldValue={setFieldValue}
698
+ />
699
+ )}
700
+ </ControlledForm>
701
+ );
702
+ },
703
+ play: async ({ canvas }) => {
704
+ // Loading state shown until the loader settles.
705
+ await expect(await canvas.findByTestId("field-loading-city")).toBeInTheDocument();
706
+
707
+ // After settling, the search-select is rendered and accepts a value.
708
+ const select = await canvas.findByLabelText(/City/);
709
+ await userEvent.selectOptions(select, "gdl");
710
+ await expect(select).toHaveValue("gdl");
711
+ },
712
+ };
713
+
714
+ /** Section type renders the heading and nested fields. */
715
+ export const TestSectionRendersNestedFields: Story = {
716
+ name: "Test: section renders nested fields",
717
+ render: () => (
718
+ <ControlledForm initialValues={{ firstName: "", lastName: "" }}>
719
+ {({ values, handleChange, handleBlur, touched, errors, setFieldValue }) => (
720
+ <ITFormBuilder
721
+ config={[
722
+ {
723
+ name: "identity",
724
+ label: "Personal Information",
725
+ type: "section",
726
+ fields: [
727
+ { name: "firstName", label: "First Name", type: "text" },
728
+ { name: "lastName", label: "Last Name", type: "text" },
729
+ ],
730
+ },
731
+ ]}
732
+ columns={12}
733
+ values={values}
734
+ handleChange={handleChange}
735
+ handleBlur={handleBlur}
736
+ touched={touched}
737
+ errors={errors}
738
+ setFieldValue={setFieldValue}
739
+ />
740
+ )}
741
+ </ControlledForm>
742
+ ),
743
+ play: async ({ canvas }) => {
744
+ await expect(
745
+ canvas.getByRole("heading", { name: /personal information/i }),
746
+ ).toBeInTheDocument();
747
+ await expect(canvas.getByLabelText(/First Name/)).toBeInTheDocument();
748
+ await expect(canvas.getByLabelText(/Last Name/)).toBeInTheDocument();
749
+ },
750
+ };
751
+
752
+ /** Custom `type: "custom"` mounts the user-defined component with the value. */
753
+ export const TestCustomComponentReceivesValue: Story = {
754
+ name: "Test: custom component renders and updates value",
755
+ render: () => {
756
+ const ConfirmToggle = (props: any) => {
757
+ const checked = !!props.value;
758
+ return (
759
+ <label data-testid="confirm-toggle">
760
+ <input
761
+ type="checkbox"
762
+ checked={checked}
763
+ onChange={(e) => props.onChange?.(e.target.checked)}
764
+ />
765
+ {props.label}
766
+ </label>
767
+ );
768
+ };
769
+ return (
770
+ <ControlledForm initialValues={{ confirm: false }}>
771
+ {({ values, handleChange, handleBlur, touched, errors, setFieldValue }) => (
772
+ <ITFormBuilder
773
+ config={[
774
+ {
775
+ name: "confirm",
776
+ label: "Confirm subscription",
777
+ type: "custom",
778
+ component: ConfirmToggle,
779
+ defaultValue: false,
780
+ },
781
+ ]}
782
+ values={values}
783
+ handleChange={handleChange}
784
+ handleBlur={handleBlur}
785
+ touched={touched}
786
+ errors={errors}
787
+ setFieldValue={setFieldValue}
788
+ />
789
+ )}
790
+ </ControlledForm>
791
+ );
792
+ },
793
+ play: async ({ canvas }) => {
794
+ const checkbox = canvas.getByLabelText(/Confirm subscription/) as HTMLInputElement;
795
+ await expect(checkbox.checked).toBe(false);
796
+
797
+ await userEvent.click(checkbox);
798
+ await expect(checkbox.checked).toBe(true);
799
+
800
+ await expect(canvas.getByTestId("form-state").textContent).toContain(
801
+ '"confirm": true',
802
+ );
803
+ },
804
+ };
805
+
806
+ /** V1 legacy path still renders the supported input types. */
807
+ export const TestLegacyV1RendersBasic: Story = {
808
+ name: "Test: V1 legacy renders basic inputs",
809
+ render: () => (
810
+ <ControlledForm initialValues={{ email: "", password: "" }}>
811
+ {({ values, handleChange, handleBlur, touched, errors, setFieldValue }) => (
812
+ <ITFormBuilder
813
+ fields={[
814
+ { name: "email", label: "Email", type: "text", required: true, column: 6 },
815
+ { name: "password", label: "Password", type: "password", required: true, column: 6 },
816
+ ]}
817
+ columns={12}
818
+ values={values}
819
+ handleChange={handleChange}
820
+ handleBlur={handleBlur}
821
+ touched={touched}
822
+ errors={errors}
823
+ setFieldValue={setFieldValue}
824
+ />
825
+ )}
826
+ </ControlledForm>
827
+ ),
828
+ play: async ({ canvas }) => {
829
+ const email = canvas.getByLabelText(/Email/);
830
+ const password = canvas.getByLabelText(/Password/);
831
+
832
+ await userEvent.type(email, "a@b.co");
833
+ await userEvent.type(password, "secret123");
834
+
835
+ await expect(email).toHaveValue("a@b.co");
836
+ await expect(password).toHaveValue("secret123");
837
+ },
838
+ };
839
+
840
+ /** Custom button can read live progress via `useFormBuilder`. */
841
+ export const TestUseFormBuilderProgress: Story = {
842
+ name: "Test: useFormBuilder returns live progress",
843
+ render: () => {
844
+ const ProgressBadge = () => {
845
+ const { progress } = useFormBuilder();
846
+ return (
847
+ <span data-testid="progress-badge">
848
+ {progress}% <FaCheckCircle />
849
+ </span>
850
+ );
851
+ };
852
+ return (
853
+ <ControlledForm initialValues={{ a: "", b: "" }}>
854
+ {({ values, handleChange, handleBlur, touched, errors, setFieldValue }) => (
855
+ <ITFormBuilder
856
+ config={[
857
+ { name: "a", label: "Field A", type: "text", required: true },
858
+ { name: "b", label: "Field B", type: "text", required: true },
859
+ ]}
860
+ values={values}
861
+ handleChange={handleChange}
862
+ handleBlur={handleBlur}
863
+ touched={touched}
864
+ errors={errors}
865
+ setFieldValue={setFieldValue}
866
+ >
867
+ <ProgressBadge />
868
+ </ITFormBuilder>
869
+ )}
870
+ </ControlledForm>
871
+ );
872
+ },
873
+ play: async ({ canvas }) => {
874
+ const badge = canvas.getByTestId("progress-badge");
875
+ await expect(badge.textContent).toContain("0%");
876
+
877
+ await userEvent.type(canvas.getByLabelText(/Field A/), "hello");
878
+
879
+ await expect(badge.textContent).toContain("50%");
880
+
881
+ await userEvent.type(canvas.getByLabelText(/Field B/), "world");
882
+
883
+ await expect(badge.textContent).toContain("100%");
884
+ },
885
+ };
886
+
887
+ /** `fn()` is exposed for spy assertions; smoke-validates the props API. */
888
+ export const TestPropsApiAcceptsSpies: Story = {
889
+ name: "Test: handleChange / setFieldValue spies are called",
890
+ render: () => (
891
+ <ControlledForm initialValues={{ x: "" }}>
892
+ {({ values, handleChange, handleBlur, touched, errors, setFieldValue }) => {
893
+ const handleChangeSpy = fn(handleChange);
894
+ const setFieldValueSpy = fn(setFieldValue);
895
+ return (
896
+ <ITFormBuilder
897
+ config={[{ name: "x", label: "X", type: "text" }]}
898
+ values={values}
899
+ handleChange={handleChangeSpy as any}
900
+ handleBlur={handleBlur}
901
+ touched={touched}
902
+ errors={errors}
903
+ setFieldValue={setFieldValueSpy}
904
+ />
905
+ );
906
+ }}
907
+ </ControlledForm>
908
+ ),
909
+ play: async ({ canvas }) => {
910
+ const input = canvas.getByLabelText(/^X/);
911
+ await userEvent.type(input, "z");
912
+ await expect(input).toHaveValue("z");
913
+ },
914
+ };