@k3-universe/react-kit 0.0.26 → 0.0.27

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 (29) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +15979 -15536
  4. package/dist/kit/builder/form/components/FormBuilder.d.ts.map +1 -1
  5. package/dist/kit/builder/form/components/FormBuilderField.d.ts.map +1 -1
  6. package/dist/kit/builder/form/hooks/useFormBuilder.d.ts +15 -0
  7. package/dist/kit/builder/form/hooks/useFormBuilder.d.ts.map +1 -0
  8. package/dist/kit/builder/form/index.d.ts +1 -0
  9. package/dist/kit/builder/form/index.d.ts.map +1 -1
  10. package/dist/kit/builder/form/types.d.ts +1 -1
  11. package/dist/kit/builder/form/types.d.ts.map +1 -1
  12. package/dist/kit/components/forminfo/FormInfoError.d.ts +12 -0
  13. package/dist/kit/components/forminfo/FormInfoError.d.ts.map +1 -0
  14. package/dist/kit/components/forminfo/index.d.ts +2 -0
  15. package/dist/kit/components/forminfo/index.d.ts.map +1 -0
  16. package/dist/kit/themes/base.css +1 -1
  17. package/dist/kit/themes/clean-slate.css +11 -5
  18. package/dist/kit/themes/default.css +11 -5
  19. package/dist/kit/themes/minimal-modern.css +11 -5
  20. package/dist/kit/themes/spotify.css +11 -5
  21. package/package.json +11 -8
  22. package/src/index.ts +1 -0
  23. package/src/kit/builder/form/components/FormBuilder.tsx +51 -332
  24. package/src/kit/builder/form/components/FormBuilderField.tsx +12 -4
  25. package/src/kit/builder/form/hooks/useFormBuilder.ts +399 -0
  26. package/src/kit/builder/form/index.ts +1 -0
  27. package/src/kit/builder/form/types.ts +8 -1
  28. package/src/kit/components/forminfo/FormInfoError.tsx +120 -0
  29. package/src/kit/components/forminfo/index.ts +1 -0
@@ -0,0 +1,399 @@
1
+ import { useMemo } from 'react';
2
+ import { useForm, type FieldValues, type UseFormReturn } from 'react-hook-form';
3
+ import { zodResolver } from '@hookform/resolvers/zod';
4
+ import { z } from 'zod';
5
+ import type { FormBuilderFieldConfig, FormBuilderSectionConfig } from '../types';
6
+
7
+ export interface UseFormBuilderOptions<TFieldValues extends FieldValues = FieldValues> {
8
+ sections: FormBuilderSectionConfig<TFieldValues>[];
9
+ defaultValues?: Partial<TFieldValues>;
10
+ schema?: z.ZodType<TFieldValues>;
11
+ }
12
+
13
+ export interface UseFormBuilderReturn<TFieldValues extends FieldValues = FieldValues> {
14
+ form: UseFormReturn<TFieldValues>;
15
+ sections: FormBuilderSectionConfig<TFieldValues>[];
16
+ schema: z.ZodType<unknown>;
17
+ }
18
+
19
+ export function useFormBuilder<TFieldValues extends FieldValues = FieldValues>(
20
+ options: UseFormBuilderOptions<TFieldValues>
21
+ ): UseFormBuilderReturn<TFieldValues> {
22
+ const { sections, defaultValues, schema: providedSchema } = options;
23
+
24
+ // Generate schema from field configs if not provided
25
+ const generatedSchema = useMemo(() => {
26
+ if (providedSchema) return providedSchema;
27
+
28
+ const generateFieldSchema = (
29
+ field: FormBuilderFieldConfig<TFieldValues, string>
30
+ ): z.ZodType<unknown> => {
31
+ if (field.validation && field.validation instanceof z.ZodType) {
32
+ return field.validation;
33
+ }
34
+
35
+ // Handle validation object format
36
+ if (
37
+ field.validation &&
38
+ typeof field.validation === 'object' &&
39
+ !(field.validation instanceof z.ZodType)
40
+ ) {
41
+ const validationObj = field.validation as Record<string, unknown>;
42
+ let baseSchema: z.ZodType<unknown>;
43
+
44
+ switch (field.type) {
45
+ case 'email':
46
+ baseSchema = z.string().email(
47
+ (validationObj.email as { message?: string })?.message || 'Invalid email address'
48
+ );
49
+ break;
50
+ case 'number':
51
+ baseSchema = z.number();
52
+ break;
53
+ case 'checkbox':
54
+ case 'switch':
55
+ baseSchema = z.boolean();
56
+ break;
57
+ case 'select':
58
+ case 'radio':
59
+ case 'autocomplete':
60
+ // Check if field supports multiple values
61
+ if ((field as { multiple?: boolean }).multiple) {
62
+ // Multiple: array of string/number/boolean
63
+ baseSchema = z.array(z.union([z.string(), z.number(), z.boolean()]));
64
+ } else {
65
+ // Single: string, number, boolean, or null (when empty)
66
+ baseSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
67
+ }
68
+ break;
69
+ case 'file':
70
+ baseSchema = z.array(z.unknown());
71
+ break;
72
+ case 'date_picker':
73
+ case 'date':
74
+ case 'time':
75
+ case 'date_time':
76
+ case 'month':
77
+ baseSchema = z.date();
78
+ break;
79
+ case 'date_range':
80
+ case 'time_range':
81
+ case 'date_time_range':
82
+ case 'month_range':
83
+ baseSchema = z.object({
84
+ from: z.date().optional().nullable(),
85
+ to: z.date().optional().nullable(),
86
+ }).nullable();
87
+ break;
88
+ default:
89
+ baseSchema = z.string();
90
+ }
91
+
92
+ if (validationObj.min && baseSchema instanceof z.ZodNumber) {
93
+ baseSchema = baseSchema.min(
94
+ (validationObj.min as { value: number }).value,
95
+ (validationObj.min as { message?: string }).message
96
+ );
97
+ }
98
+ if (validationObj.max && baseSchema instanceof z.ZodNumber) {
99
+ baseSchema = baseSchema.max(
100
+ (validationObj.max as { value: number }).value,
101
+ (validationObj.max as { message?: string }).message
102
+ );
103
+ }
104
+ if (validationObj.minLength && baseSchema instanceof z.ZodString) {
105
+ baseSchema = baseSchema.min(
106
+ (validationObj.minLength as { value: number }).value,
107
+ (validationObj.minLength as { message?: string }).message
108
+ );
109
+ }
110
+ if (validationObj.maxLength && baseSchema instanceof z.ZodString) {
111
+ baseSchema = baseSchema.max(
112
+ (validationObj.maxLength as { value: number }).value,
113
+ (validationObj.maxLength as { message?: string }).message
114
+ );
115
+ }
116
+ // Array item count constraints
117
+ if (baseSchema instanceof z.ZodArray) {
118
+ let arr = baseSchema as z.ZodArray<z.ZodTypeAny>;
119
+ if (validationObj.minItems) {
120
+ arr = arr.min(
121
+ (validationObj.minItems as { value: number }).value,
122
+ (validationObj.minItems as { message?: string }).message,
123
+ );
124
+ }
125
+ if (validationObj.maxItems) {
126
+ arr = arr.max(
127
+ (validationObj.maxItems as { value: number }).value,
128
+ (validationObj.maxItems as { message?: string }).message,
129
+ );
130
+ }
131
+ // If required and file field, enforce at least 1 item when no explicit minItems
132
+ if (field.type === 'file' && field.required && !validationObj.minItems) {
133
+ arr = arr.min(1, `${field.label} requires at least 1 file`);
134
+ }
135
+ baseSchema = arr;
136
+ }
137
+
138
+ // Make optional fields accept undefined/null/empty FIRST (before any refinements)
139
+ if (!field.required) {
140
+ return baseSchema.nullish(); // Accepts null, undefined, and the base type
141
+ }
142
+
143
+ // For required fields, add validation
144
+ // For string fields, enforce non-empty when required (only if minLength not already set)
145
+ if (baseSchema instanceof z.ZodString && !validationObj.minLength) {
146
+ baseSchema = baseSchema.min(1, `${field.label || field.name} is required`);
147
+ }
148
+
149
+ // For union fields (select/radio/autocomplete), add refinement for required validation
150
+ if (baseSchema instanceof z.ZodUnion) {
151
+ baseSchema = baseSchema.refine(
152
+ (val) => val !== null && val !== undefined,
153
+ { message: `${field.label || field.name} is required` }
154
+ );
155
+ }
156
+
157
+ return baseSchema;
158
+ }
159
+
160
+ let fieldSchema: z.ZodType<unknown>;
161
+
162
+ const fieldLabel = field.label || field.name;
163
+
164
+ switch (field.type) {
165
+ case 'email':
166
+ fieldSchema = z.string().email('Please enter a valid email address');
167
+ break;
168
+ case 'number':
169
+ fieldSchema = z.number({
170
+ message: `${fieldLabel} must be a number`,
171
+ });
172
+ break;
173
+ case 'checkbox':
174
+ case 'switch':
175
+ fieldSchema = z.boolean({
176
+ message: `${fieldLabel} must be true or false`,
177
+ });
178
+ break;
179
+ case 'select':
180
+ case 'radio':
181
+ case 'autocomplete':
182
+ // Check if field supports multiple values
183
+ if ((field as { multiple?: boolean }).multiple) {
184
+ // Multiple: array of string/number/boolean
185
+ fieldSchema = z.array(z.union([z.string(), z.number(), z.boolean()]));
186
+ } else {
187
+ // Single: string, number, boolean, or null (when empty)
188
+ fieldSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
189
+ }
190
+ break;
191
+ case 'file': {
192
+ let arr = z.array(z.unknown());
193
+ // If required, ensure at least 1 file
194
+ if (field.required) {
195
+ arr = arr.min(1, `Please select at least 1 file for ${fieldLabel}`);
196
+ }
197
+ fieldSchema = arr;
198
+ break;
199
+ }
200
+ case 'date_picker':
201
+ case 'date':
202
+ case 'time':
203
+ case 'date_time':
204
+ case 'month':
205
+ fieldSchema = z.date({
206
+ message: `${fieldLabel} must be a valid date`,
207
+ });
208
+ break;
209
+ case 'date_range':
210
+ case 'time_range':
211
+ case 'date_time_range':
212
+ case 'month_range':
213
+ fieldSchema = z.object({
214
+ from: z.date().optional().nullable(),
215
+ to: z.date().optional().nullable(),
216
+ }).nullable();
217
+ break;
218
+ case 'object':
219
+ if (field.fields) {
220
+ const nestedSchema: Record<string, z.ZodType<unknown>> = {};
221
+ for (const subField of field.fields) {
222
+ nestedSchema[subField.name] = generateFieldSchema(subField);
223
+ }
224
+ fieldSchema = z.object(nestedSchema);
225
+ } else {
226
+ fieldSchema = z.object({});
227
+ }
228
+ break;
229
+ case 'array':
230
+ if (field.fields && field.fields.length > 0) {
231
+ const itemSchema: Record<string, z.ZodType<unknown>> = {};
232
+ for (const subField of field.fields) {
233
+ itemSchema[subField.name] = generateFieldSchema(subField);
234
+ }
235
+ fieldSchema = z.array(z.object(itemSchema));
236
+ } else {
237
+ fieldSchema = z.array(z.unknown());
238
+ }
239
+ break;
240
+ case 'custom_field':
241
+ fieldSchema = z.any();
242
+ break;
243
+ default:
244
+ // Default to string for text, textarea, password, etc.
245
+ fieldSchema = z.string();
246
+ }
247
+
248
+ // Make optional fields accept undefined/null/empty FIRST (before any refinements)
249
+ if (!field.required) {
250
+ return fieldSchema.nullish(); // Accepts null, undefined, and the base type
251
+ }
252
+
253
+ // For required fields, add validation
254
+ // For string fields, enforce non-empty when required
255
+ if (fieldSchema instanceof z.ZodString) {
256
+ fieldSchema = fieldSchema.min(1, `${fieldLabel} is required`);
257
+ }
258
+
259
+ // For array fields (multiple select/autocomplete), enforce at least 1 item when required
260
+ if (fieldSchema instanceof z.ZodArray) {
261
+ fieldSchema = fieldSchema.min(1, `${fieldLabel} requires at least 1 selection`);
262
+ }
263
+
264
+ // For union fields (single select/radio/autocomplete), add refinement for required validation
265
+ if (fieldSchema instanceof z.ZodUnion) {
266
+ fieldSchema = fieldSchema.refine(
267
+ (val) => val !== null && val !== undefined,
268
+ { message: `${fieldLabel} is required` }
269
+ );
270
+ }
271
+
272
+ return fieldSchema;
273
+ };
274
+
275
+ const schemaObject: Record<string, z.ZodType<unknown>> = {};
276
+
277
+ const forEachField = (secs: FormBuilderSectionConfig<TFieldValues>[]) => {
278
+ for (const section of secs) {
279
+ // Traverse tabs if present
280
+ if (section.tabs && section.tabs.length > 0) {
281
+ for (const tab of section.tabs) {
282
+ forEachField(tab.sections);
283
+ }
284
+ }
285
+ // Process fields
286
+ for (const field of section.fields ?? []) {
287
+ schemaObject[field.name] = generateFieldSchema(field);
288
+ }
289
+ }
290
+ };
291
+
292
+ forEachField(sections);
293
+
294
+ return z.object(schemaObject) as unknown as z.ZodType<TFieldValues>;
295
+ }, [sections, providedSchema]);
296
+
297
+ // Generate default values from field configs
298
+ const generatedDefaultValues = useMemo(() => {
299
+ const values: Record<string, unknown> = { ...((defaultValues ?? {}) as Record<string, unknown>) };
300
+
301
+ const processFields = (fields: FormBuilderFieldConfig<TFieldValues, string>[]) => {
302
+ for (const field of fields) {
303
+ // Skip if value already exists
304
+ if (values[field.name] !== undefined) {
305
+ continue;
306
+ }
307
+
308
+ // Use explicit defaultValue if provided
309
+ if (field.defaultValue !== undefined) {
310
+ values[field.name] = field.defaultValue;
311
+ continue;
312
+ }
313
+
314
+ // Set sensible defaults based on field type to avoid undefined
315
+ switch (field.type) {
316
+ case 'text':
317
+ case 'email':
318
+ case 'textarea':
319
+ case 'password':
320
+ case 'select':
321
+ case 'radio':
322
+ values[field.name] = '';
323
+ break;
324
+ case 'number':
325
+ values[field.name] = null;
326
+ break;
327
+ case 'checkbox':
328
+ case 'switch':
329
+ values[field.name] = false;
330
+ break;
331
+ case 'file':
332
+ case 'array':
333
+ values[field.name] = [];
334
+ break;
335
+ case 'object':
336
+ if (field.fields) {
337
+ const nestedValues: Record<string, unknown> = {};
338
+ for (const subField of field.fields) {
339
+ if (subField.defaultValue !== undefined) {
340
+ nestedValues[subField.name] = subField.defaultValue;
341
+ }
342
+ }
343
+ values[field.name] = nestedValues;
344
+ } else {
345
+ values[field.name] = {};
346
+ }
347
+ break;
348
+ case 'date_picker':
349
+ case 'date':
350
+ case 'time':
351
+ case 'date_time':
352
+ case 'month':
353
+ values[field.name] = null;
354
+ break;
355
+ case 'date_range':
356
+ case 'time_range':
357
+ case 'date_time_range':
358
+ case 'month_range':
359
+ values[field.name] = null;
360
+ break;
361
+ case 'autocomplete':
362
+ values[field.name] = field.multiple ? [] : null;
363
+ break;
364
+ default:
365
+ // For unknown types, use empty string as safe default
366
+ values[field.name] = '';
367
+ }
368
+ }
369
+ };
370
+
371
+ const forEachSection = (secs: FormBuilderSectionConfig<TFieldValues>[]) => {
372
+ for (const section of secs) {
373
+ if (section.tabs && section.tabs.length > 0) {
374
+ for (const tab of section.tabs) {
375
+ forEachSection(tab.sections);
376
+ }
377
+ }
378
+ processFields(section.fields ?? []);
379
+ }
380
+ };
381
+
382
+ forEachSection(sections);
383
+
384
+ return values;
385
+ }, [sections, defaultValues]);
386
+
387
+ // Create form with validation
388
+ const form = useForm<TFieldValues>({
389
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
390
+ resolver: zodResolver(generatedSchema as any) as unknown as import('react-hook-form').Resolver<TFieldValues, any, TFieldValues>,
391
+ defaultValues: generatedDefaultValues as unknown as import('react-hook-form').DefaultValues<TFieldValues>,
392
+ });
393
+
394
+ return {
395
+ form,
396
+ sections,
397
+ schema: generatedSchema,
398
+ };
399
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './components';
2
2
  export * from './utils';
3
3
  export * from './types';
4
+ export * from './hooks/useFormBuilder';
@@ -182,9 +182,15 @@ export interface FormBuilderSectionConfig<TFieldValues extends FieldValues = Fie
182
182
  }
183
183
 
184
184
  export interface FormBuilderProps<TFieldValues extends FieldValues = FieldValues> {
185
+ // Accept form from useFormBuilder hook
186
+ form?: UseFormReturn<TFieldValues>
185
187
  sections: Array<FormBuilderSectionConfig<TFieldValues>>
188
+
189
+ // Optional overrides (for backward compatibility)
186
190
  schema?: z.ZodType<TFieldValues>
187
191
  defaultValues?: DeepPartial<TFieldValues> | DefaultValues<TFieldValues> | null
192
+
193
+ // Handlers
188
194
  onSubmit: (data: TFieldValues) => void | Promise<void>
189
195
  onCancel?: () => void
190
196
  onReset?: () => void
@@ -193,6 +199,8 @@ export interface FormBuilderProps<TFieldValues extends FieldValues = FieldValues
193
199
  value: unknown,
194
200
  allValues: TFieldValues
195
201
  ) => void
202
+
203
+ // UI customization
196
204
  submitLabel?: string
197
205
  cancelLabel?: string
198
206
  resetLabel?: string
@@ -203,7 +211,6 @@ export interface FormBuilderProps<TFieldValues extends FieldValues = FieldValues
203
211
  showActions?: boolean
204
212
  customActions?: React.ReactNode
205
213
  showActionsSeparator?: boolean
206
- form?: UseFormReturn<TFieldValues>
207
214
  }
208
215
 
209
216
  // Re-export for external consumers that build custom section nodes
@@ -0,0 +1,120 @@
1
+ import { AlertCircle } from 'lucide-react';
2
+ import type { FieldErrors, FieldValues } from 'react-hook-form';
3
+ import type { ReactNode } from 'react';
4
+
5
+ import { cn } from '../../../shadcn/lib/utils';
6
+ import { Alert, AlertDescription, AlertTitle } from '../../../shadcn/ui/alert';
7
+
8
+ type ErrorMessage = {
9
+ path: string;
10
+ message: string;
11
+ };
12
+
13
+ type FormInfoErrorProps<TFieldValues extends FieldValues = FieldValues> = {
14
+ errors: FieldErrors<TFieldValues> | undefined;
15
+ title?: ReactNode;
16
+ description?: ReactNode;
17
+ className?: string;
18
+ showFieldPath?: boolean;
19
+ };
20
+
21
+ const ignoredKeys = new Set(['message', 'type', 'types', 'ref']);
22
+
23
+ function collectMessages(
24
+ errors: FieldErrors<FieldValues> | undefined,
25
+ parentPath: string[] = []
26
+ ): ErrorMessage[] {
27
+ if (!errors) return [];
28
+
29
+ const entries = Array.isArray(errors)
30
+ ? errors.map((value, index) => [String(index), value])
31
+ : Object.entries(errors as Record<string, unknown>);
32
+
33
+ const messages: ErrorMessage[] = [];
34
+
35
+ for (const [key, value] of entries) {
36
+ if (!value) continue;
37
+
38
+ const currentPath = typeof key === 'string' && key === 'root'
39
+ ? parentPath
40
+ : [...parentPath, String(key)];
41
+
42
+ if (typeof value === 'object') {
43
+ if ('message' in value && value.message) {
44
+ messages.push({
45
+ path: currentPath.filter(Boolean).join('.'),
46
+ message: String(value.message),
47
+ });
48
+ }
49
+
50
+ if ('types' in value && value.types) {
51
+ for (const msg of Object.values(value.types)) {
52
+ if (!msg) continue;
53
+ messages.push({
54
+ path: currentPath.filter(Boolean).join('.'),
55
+ message: String(msg),
56
+ });
57
+ }
58
+ }
59
+
60
+ if (value && typeof value === 'object') {
61
+ const source = Array.isArray(value)
62
+ ? value.map((nestedValue, index) => [String(index), nestedValue])
63
+ : Object.entries(value);
64
+
65
+ const nestedEntries = source.filter(
66
+ ([nestedKey]) => !ignoredKeys.has(nestedKey)
67
+ );
68
+
69
+ if (nestedEntries.length > 0) {
70
+ const nested: Record<string, unknown> = Object.fromEntries(nestedEntries);
71
+ messages.push(...collectMessages(nested as FieldErrors<FieldValues>, currentPath));
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ return messages;
78
+ }
79
+
80
+ export function FormInfoError<TFieldValues extends FieldValues = FieldValues>({
81
+ errors,
82
+ title,
83
+ description,
84
+ className,
85
+ showFieldPath = true,
86
+ }: FormInfoErrorProps<TFieldValues>) {
87
+ const messages = collectMessages(errors as FieldErrors<FieldValues> | undefined);
88
+
89
+ if (messages.length === 0) {
90
+ return null;
91
+ }
92
+
93
+ return (
94
+ <Alert variant="destructive" className={cn('gap-2', className)}>
95
+ <AlertCircle className="mt-1" />
96
+ <div className="flex flex-col gap-2">
97
+ <div>
98
+ <AlertTitle>
99
+ {title ?? 'Please review the following issues'}
100
+ </AlertTitle>
101
+ <AlertDescription>
102
+ {description ?? 'Some fields need your attention before continuing.'}
103
+ </AlertDescription>
104
+ </div>
105
+ <ul className="grid gap-1 text-sm text-destructive">
106
+ {messages.map(({ path, message }, index) => (
107
+ <li key={`${path}-${index}`} className="leading-snug">
108
+ {showFieldPath && path ? (
109
+ <span className="font-medium">{path}: </span>
110
+ ) : null}
111
+ <span>{message}</span>
112
+ </li>
113
+ ))}
114
+ </ul>
115
+ </div>
116
+ </Alert>
117
+ );
118
+ }
119
+
120
+ export default FormInfoError;
@@ -0,0 +1 @@
1
+ export * from './FormInfoError';