@object-ui/plugin-form 0.5.0 → 3.0.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.
- package/.turbo/turbo-build.log +17 -6
- package/CHANGELOG.md +36 -0
- package/dist/index.js +1388 -283
- package/dist/index.umd.cjs +2 -2
- package/dist/packages/plugin-form/src/DrawerForm.d.ts +61 -0
- package/dist/packages/plugin-form/src/FormSection.d.ts +49 -0
- package/dist/packages/plugin-form/src/FormVariants.test.d.ts +0 -0
- package/dist/packages/plugin-form/src/ModalForm.d.ts +60 -0
- package/dist/packages/plugin-form/src/ObjectForm.stories.d.ts +23 -0
- package/dist/packages/plugin-form/src/SplitForm.d.ts +50 -0
- package/dist/packages/plugin-form/src/TabbedForm.d.ts +123 -0
- package/dist/packages/plugin-form/src/WizardForm.d.ts +112 -0
- package/dist/packages/plugin-form/src/__tests__/NewVariants.test.d.ts +8 -0
- package/dist/packages/plugin-form/src/__tests__/form-validation-submit.test.d.ts +8 -0
- package/dist/packages/plugin-form/src/index.d.ts +12 -0
- package/package.json +9 -9
- package/src/DrawerForm.tsx +385 -0
- package/src/FormSection.tsx +144 -0
- package/src/FormVariants.test.tsx +219 -0
- package/src/ModalForm.tsx +379 -0
- package/src/ObjectForm.msw.test.tsx +29 -2
- package/src/ObjectForm.stories.tsx +85 -0
- package/src/ObjectForm.tsx +206 -8
- package/src/SplitForm.tsx +299 -0
- package/src/TabbedForm.tsx +394 -0
- package/src/WizardForm.tsx +501 -0
- package/src/__tests__/NewVariants.test.tsx +488 -0
- package/src/__tests__/form-validation-submit.test.tsx +286 -0
- package/src/index.tsx +60 -3
- package/vitest.config.ts +12 -0
- package/vitest.setup.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/plugin-form",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Form plugin for Object UI",
|
|
@@ -16,23 +16,23 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"lucide-react": "^0.563.0",
|
|
19
|
-
"@object-ui/components": "0.
|
|
20
|
-
"@object-ui/core": "0.
|
|
21
|
-
"@object-ui/fields": "0.
|
|
22
|
-
"@object-ui/react": "0.
|
|
23
|
-
"@object-ui/types": "0.
|
|
19
|
+
"@object-ui/components": "3.0.0",
|
|
20
|
+
"@object-ui/core": "3.0.0",
|
|
21
|
+
"@object-ui/fields": "3.0.0",
|
|
22
|
+
"@object-ui/react": "3.0.0",
|
|
23
|
+
"@object-ui/types": "3.0.0"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"react": "^18.0.0 || ^19.0.0",
|
|
27
27
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@vitejs/plugin-react": "^5.1.
|
|
31
|
-
"msw": "^2.12.
|
|
30
|
+
"@vitejs/plugin-react": "^5.1.4",
|
|
31
|
+
"msw": "^2.12.10",
|
|
32
32
|
"typescript": "^5.9.3",
|
|
33
33
|
"vite": "^7.3.1",
|
|
34
34
|
"vite-plugin-dts": "^4.5.4",
|
|
35
|
-
"@object-ui/data-objectstack": "0.
|
|
35
|
+
"@object-ui/data-objectstack": "3.0.0"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "vite build",
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* DrawerForm Component
|
|
11
|
+
*
|
|
12
|
+
* A form variant that renders inside a slide-out Sheet (drawer) panel.
|
|
13
|
+
* Aligns with @objectstack/spec FormView type: 'drawer'
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
|
17
|
+
import type { FormField, DataSource } from '@object-ui/types';
|
|
18
|
+
import {
|
|
19
|
+
Sheet,
|
|
20
|
+
SheetContent,
|
|
21
|
+
SheetHeader,
|
|
22
|
+
SheetTitle,
|
|
23
|
+
SheetDescription,
|
|
24
|
+
cn,
|
|
25
|
+
} from '@object-ui/components';
|
|
26
|
+
import { FormSection } from './FormSection';
|
|
27
|
+
import { SchemaRenderer } from '@object-ui/react';
|
|
28
|
+
import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields';
|
|
29
|
+
|
|
30
|
+
export interface DrawerFormSectionConfig {
|
|
31
|
+
name?: string;
|
|
32
|
+
label?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
columns?: 1 | 2 | 3 | 4;
|
|
35
|
+
fields: (string | FormField)[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DrawerFormSchema {
|
|
39
|
+
type: 'object-form';
|
|
40
|
+
formType: 'drawer';
|
|
41
|
+
objectName: string;
|
|
42
|
+
mode: 'create' | 'edit' | 'view';
|
|
43
|
+
recordId?: string | number;
|
|
44
|
+
title?: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
sections?: DrawerFormSectionConfig[];
|
|
47
|
+
fields?: string[];
|
|
48
|
+
customFields?: FormField[];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the drawer is open.
|
|
52
|
+
* @default true
|
|
53
|
+
*/
|
|
54
|
+
open?: boolean;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Callback when open state changes.
|
|
58
|
+
*/
|
|
59
|
+
onOpenChange?: (open: boolean) => void;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Drawer side.
|
|
63
|
+
* @default 'right'
|
|
64
|
+
*/
|
|
65
|
+
drawerSide?: 'top' | 'bottom' | 'left' | 'right';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Drawer width (CSS value for left/right, or height for top/bottom).
|
|
69
|
+
* Applied via className overrides since Sheet uses cva variants.
|
|
70
|
+
* @default undefined (uses Sheet default)
|
|
71
|
+
*/
|
|
72
|
+
drawerWidth?: string;
|
|
73
|
+
|
|
74
|
+
// Common form props
|
|
75
|
+
showSubmit?: boolean;
|
|
76
|
+
submitText?: string;
|
|
77
|
+
showCancel?: boolean;
|
|
78
|
+
cancelText?: string;
|
|
79
|
+
initialValues?: Record<string, any>;
|
|
80
|
+
initialData?: Record<string, any>;
|
|
81
|
+
readOnly?: boolean;
|
|
82
|
+
layout?: 'vertical' | 'horizontal';
|
|
83
|
+
columns?: number;
|
|
84
|
+
onSuccess?: (data: any) => void | Promise<void>;
|
|
85
|
+
onError?: (error: Error) => void;
|
|
86
|
+
onCancel?: () => void;
|
|
87
|
+
className?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface DrawerFormProps {
|
|
91
|
+
schema: DrawerFormSchema;
|
|
92
|
+
dataSource?: DataSource;
|
|
93
|
+
className?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const DrawerForm: React.FC<DrawerFormProps> = ({
|
|
97
|
+
schema,
|
|
98
|
+
dataSource,
|
|
99
|
+
className,
|
|
100
|
+
}) => {
|
|
101
|
+
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
102
|
+
const [formFields, setFormFields] = useState<FormField[]>([]);
|
|
103
|
+
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
104
|
+
const [loading, setLoading] = useState(true);
|
|
105
|
+
const [error, setError] = useState<Error | null>(null);
|
|
106
|
+
|
|
107
|
+
const isOpen = schema.open !== false;
|
|
108
|
+
const side = schema.drawerSide || 'right';
|
|
109
|
+
|
|
110
|
+
// Fetch object schema
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const fetchSchema = async () => {
|
|
113
|
+
if (!dataSource) {
|
|
114
|
+
setLoading(false);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const data = await dataSource.getObjectSchema(schema.objectName);
|
|
119
|
+
setObjectSchema(data);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
setError(err as Error);
|
|
122
|
+
setLoading(false);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
fetchSchema();
|
|
126
|
+
}, [schema.objectName, dataSource]);
|
|
127
|
+
|
|
128
|
+
// Fetch initial data
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const fetchData = async () => {
|
|
131
|
+
if (schema.mode === 'create' || !schema.recordId) {
|
|
132
|
+
setFormData(schema.initialData || schema.initialValues || {});
|
|
133
|
+
setLoading(false);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!dataSource) {
|
|
138
|
+
setFormData(schema.initialData || schema.initialValues || {});
|
|
139
|
+
setLoading(false);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const data = await dataSource.findOne(schema.objectName, schema.recordId);
|
|
145
|
+
setFormData(data || {});
|
|
146
|
+
} catch (err) {
|
|
147
|
+
setError(err as Error);
|
|
148
|
+
} finally {
|
|
149
|
+
setLoading(false);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (objectSchema || !dataSource) {
|
|
154
|
+
fetchData();
|
|
155
|
+
}
|
|
156
|
+
}, [objectSchema, schema.mode, schema.recordId, schema.initialData, schema.initialValues, dataSource, schema.objectName]);
|
|
157
|
+
|
|
158
|
+
// Build form fields from section config
|
|
159
|
+
const buildSectionFields = useCallback((section: DrawerFormSectionConfig): FormField[] => {
|
|
160
|
+
const fields: FormField[] = [];
|
|
161
|
+
|
|
162
|
+
for (const fieldDef of section.fields) {
|
|
163
|
+
const fieldName = typeof fieldDef === 'string' ? fieldDef : fieldDef.name;
|
|
164
|
+
|
|
165
|
+
if (typeof fieldDef === 'object') {
|
|
166
|
+
fields.push(fieldDef);
|
|
167
|
+
} else if (objectSchema?.fields?.[fieldName]) {
|
|
168
|
+
const field = objectSchema.fields[fieldName];
|
|
169
|
+
fields.push({
|
|
170
|
+
name: fieldName,
|
|
171
|
+
label: field.label || fieldName,
|
|
172
|
+
type: mapFieldTypeToFormType(field.type),
|
|
173
|
+
required: field.required || false,
|
|
174
|
+
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
|
|
175
|
+
placeholder: field.placeholder,
|
|
176
|
+
description: field.help || field.description,
|
|
177
|
+
validation: buildValidationRules(field),
|
|
178
|
+
field: field,
|
|
179
|
+
options: field.options,
|
|
180
|
+
multiple: field.multiple,
|
|
181
|
+
});
|
|
182
|
+
} else {
|
|
183
|
+
fields.push({
|
|
184
|
+
name: fieldName,
|
|
185
|
+
label: fieldName,
|
|
186
|
+
type: 'input',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return fields;
|
|
192
|
+
}, [objectSchema, schema.readOnly, schema.mode]);
|
|
193
|
+
|
|
194
|
+
// Build fields from flat field list (when no sections provided)
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (!objectSchema && dataSource) return;
|
|
197
|
+
|
|
198
|
+
if (schema.customFields?.length) {
|
|
199
|
+
setFormFields(schema.customFields);
|
|
200
|
+
setLoading(false);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (schema.sections?.length) {
|
|
205
|
+
// Fields are built per-section in the render
|
|
206
|
+
setLoading(false);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!objectSchema) return;
|
|
211
|
+
|
|
212
|
+
const fieldsToShow = schema.fields || Object.keys(objectSchema.fields || {});
|
|
213
|
+
const generated: FormField[] = [];
|
|
214
|
+
|
|
215
|
+
for (const fieldName of fieldsToShow) {
|
|
216
|
+
const name = typeof fieldName === 'string' ? fieldName : (fieldName as any).name;
|
|
217
|
+
if (!name) continue;
|
|
218
|
+
const field = objectSchema.fields?.[name];
|
|
219
|
+
if (!field) continue;
|
|
220
|
+
|
|
221
|
+
generated.push({
|
|
222
|
+
name,
|
|
223
|
+
label: field.label || name,
|
|
224
|
+
type: mapFieldTypeToFormType(field.type),
|
|
225
|
+
required: field.required || false,
|
|
226
|
+
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
|
|
227
|
+
placeholder: field.placeholder,
|
|
228
|
+
description: field.help || field.description,
|
|
229
|
+
validation: buildValidationRules(field),
|
|
230
|
+
field: field,
|
|
231
|
+
options: field.options,
|
|
232
|
+
multiple: field.multiple,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
setFormFields(generated);
|
|
237
|
+
setLoading(false);
|
|
238
|
+
}, [objectSchema, schema.fields, schema.customFields, schema.sections, schema.readOnly, schema.mode, dataSource]);
|
|
239
|
+
|
|
240
|
+
// Handle form submission
|
|
241
|
+
const handleSubmit = useCallback(async (data: Record<string, any>) => {
|
|
242
|
+
if (!dataSource) {
|
|
243
|
+
if (schema.onSuccess) {
|
|
244
|
+
await schema.onSuccess(data);
|
|
245
|
+
}
|
|
246
|
+
// Close drawer on success
|
|
247
|
+
schema.onOpenChange?.(false);
|
|
248
|
+
return data;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
let result;
|
|
253
|
+
if (schema.mode === 'create') {
|
|
254
|
+
result = await dataSource.create(schema.objectName, data);
|
|
255
|
+
} else if (schema.mode === 'edit' && schema.recordId) {
|
|
256
|
+
result = await dataSource.update(schema.objectName, schema.recordId, data);
|
|
257
|
+
}
|
|
258
|
+
if (schema.onSuccess) {
|
|
259
|
+
await schema.onSuccess(result);
|
|
260
|
+
}
|
|
261
|
+
// Close drawer on success
|
|
262
|
+
schema.onOpenChange?.(false);
|
|
263
|
+
return result;
|
|
264
|
+
} catch (err) {
|
|
265
|
+
if (schema.onError) {
|
|
266
|
+
schema.onError(err as Error);
|
|
267
|
+
}
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
}, [schema, dataSource]);
|
|
271
|
+
|
|
272
|
+
// Handle cancel
|
|
273
|
+
const handleCancel = useCallback(() => {
|
|
274
|
+
if (schema.onCancel) {
|
|
275
|
+
schema.onCancel();
|
|
276
|
+
}
|
|
277
|
+
// Close drawer on cancel
|
|
278
|
+
schema.onOpenChange?.(false);
|
|
279
|
+
}, [schema]);
|
|
280
|
+
|
|
281
|
+
// Width style for the drawer content
|
|
282
|
+
const widthStyle = useMemo(() => {
|
|
283
|
+
if (!schema.drawerWidth) return undefined;
|
|
284
|
+
const isHorizontal = side === 'left' || side === 'right';
|
|
285
|
+
return isHorizontal
|
|
286
|
+
? { width: schema.drawerWidth, maxWidth: schema.drawerWidth }
|
|
287
|
+
: { height: schema.drawerWidth, maxHeight: schema.drawerWidth };
|
|
288
|
+
}, [schema.drawerWidth, side]);
|
|
289
|
+
|
|
290
|
+
const formLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal')
|
|
291
|
+
? schema.layout
|
|
292
|
+
: 'vertical';
|
|
293
|
+
|
|
294
|
+
// Build base form schema
|
|
295
|
+
const baseFormSchema = {
|
|
296
|
+
type: 'form' as const,
|
|
297
|
+
layout: formLayout,
|
|
298
|
+
defaultValues: formData,
|
|
299
|
+
submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
|
|
300
|
+
cancelLabel: schema.cancelText,
|
|
301
|
+
showSubmit: schema.showSubmit !== false && schema.mode !== 'view',
|
|
302
|
+
showCancel: schema.showCancel !== false,
|
|
303
|
+
onSubmit: handleSubmit,
|
|
304
|
+
onCancel: handleCancel,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const renderContent = () => {
|
|
308
|
+
if (error) {
|
|
309
|
+
return (
|
|
310
|
+
<div className="p-4 border border-red-300 bg-red-50 rounded-md">
|
|
311
|
+
<h3 className="text-red-800 font-semibold">Error loading form</h3>
|
|
312
|
+
<p className="text-red-600 text-sm mt-1">{error.message}</p>
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (loading) {
|
|
318
|
+
return (
|
|
319
|
+
<div className="p-8 text-center">
|
|
320
|
+
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
|
321
|
+
<p className="mt-2 text-sm text-gray-600">Loading form...</p>
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Sections layout
|
|
327
|
+
if (schema.sections?.length) {
|
|
328
|
+
return (
|
|
329
|
+
<div className="space-y-6">
|
|
330
|
+
{schema.sections.map((section, index) => (
|
|
331
|
+
<FormSection
|
|
332
|
+
key={section.name || section.label || index}
|
|
333
|
+
label={section.label}
|
|
334
|
+
description={section.description}
|
|
335
|
+
columns={section.columns || 1}
|
|
336
|
+
>
|
|
337
|
+
<SchemaRenderer
|
|
338
|
+
schema={{
|
|
339
|
+
...baseFormSchema,
|
|
340
|
+
fields: buildSectionFields(section),
|
|
341
|
+
showSubmit: index === schema.sections!.length - 1 && baseFormSchema.showSubmit,
|
|
342
|
+
showCancel: index === schema.sections!.length - 1 && baseFormSchema.showCancel,
|
|
343
|
+
}}
|
|
344
|
+
/>
|
|
345
|
+
</FormSection>
|
|
346
|
+
))}
|
|
347
|
+
</div>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Flat fields layout
|
|
352
|
+
return (
|
|
353
|
+
<SchemaRenderer
|
|
354
|
+
schema={{
|
|
355
|
+
...baseFormSchema,
|
|
356
|
+
fields: formFields,
|
|
357
|
+
columns: schema.columns,
|
|
358
|
+
}}
|
|
359
|
+
/>
|
|
360
|
+
);
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<Sheet open={isOpen} onOpenChange={schema.onOpenChange}>
|
|
365
|
+
<SheetContent
|
|
366
|
+
side={side}
|
|
367
|
+
className={cn('overflow-y-auto', className, schema.className)}
|
|
368
|
+
style={widthStyle}
|
|
369
|
+
>
|
|
370
|
+
{(schema.title || schema.description) && (
|
|
371
|
+
<SheetHeader>
|
|
372
|
+
{schema.title && <SheetTitle>{schema.title}</SheetTitle>}
|
|
373
|
+
{schema.description && <SheetDescription>{schema.description}</SheetDescription>}
|
|
374
|
+
</SheetHeader>
|
|
375
|
+
)}
|
|
376
|
+
|
|
377
|
+
<div className="py-4">
|
|
378
|
+
{renderContent()}
|
|
379
|
+
</div>
|
|
380
|
+
</SheetContent>
|
|
381
|
+
</Sheet>
|
|
382
|
+
);
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
export default DrawerForm;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* FormSection Component
|
|
11
|
+
*
|
|
12
|
+
* A form section component that groups fields together with optional
|
|
13
|
+
* collapsibility and multi-column layout. Aligns with @objectstack/spec FormSection.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { useState } from 'react';
|
|
17
|
+
import { ChevronDown, ChevronRight } from 'lucide-react';
|
|
18
|
+
import { cn } from '@object-ui/components';
|
|
19
|
+
|
|
20
|
+
export interface FormSectionProps {
|
|
21
|
+
/**
|
|
22
|
+
* Section title/label
|
|
23
|
+
*/
|
|
24
|
+
label?: string;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Section description
|
|
28
|
+
*/
|
|
29
|
+
description?: string;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether the section can be collapsed
|
|
33
|
+
* @default false
|
|
34
|
+
*/
|
|
35
|
+
collapsible?: boolean;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether the section is initially collapsed
|
|
39
|
+
* @default false
|
|
40
|
+
*/
|
|
41
|
+
collapsed?: boolean;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Number of columns for field layout
|
|
45
|
+
* @default 1
|
|
46
|
+
*/
|
|
47
|
+
columns?: 1 | 2 | 3 | 4;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Section children (form fields)
|
|
51
|
+
*/
|
|
52
|
+
children: React.ReactNode;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Additional CSS classes
|
|
56
|
+
*/
|
|
57
|
+
className?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* FormSection Component
|
|
62
|
+
*
|
|
63
|
+
* Groups form fields with optional header, collapsibility, and multi-column layout.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```tsx
|
|
67
|
+
* <FormSection label="Contact Details" columns={2} collapsible>
|
|
68
|
+
* <FormField name="firstName" />
|
|
69
|
+
* <FormField name="lastName" />
|
|
70
|
+
* </FormSection>
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export const FormSection: React.FC<FormSectionProps> = ({
|
|
74
|
+
label,
|
|
75
|
+
description,
|
|
76
|
+
collapsible = false,
|
|
77
|
+
collapsed: initialCollapsed = false,
|
|
78
|
+
columns = 1,
|
|
79
|
+
children,
|
|
80
|
+
className,
|
|
81
|
+
}) => {
|
|
82
|
+
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
|
|
83
|
+
|
|
84
|
+
const gridCols: Record<number, string> = {
|
|
85
|
+
1: 'grid-cols-1',
|
|
86
|
+
2: 'grid-cols-1 md:grid-cols-2',
|
|
87
|
+
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
|
88
|
+
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const handleToggle = () => {
|
|
92
|
+
if (collapsible) {
|
|
93
|
+
setIsCollapsed(!isCollapsed);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className={cn('form-section', className)}>
|
|
99
|
+
{/* Section Header */}
|
|
100
|
+
{(label || description) && (
|
|
101
|
+
<div
|
|
102
|
+
className={cn(
|
|
103
|
+
'flex items-start gap-2 mb-4',
|
|
104
|
+
collapsible && 'cursor-pointer select-none'
|
|
105
|
+
)}
|
|
106
|
+
onClick={handleToggle}
|
|
107
|
+
role={collapsible ? 'button' : undefined}
|
|
108
|
+
aria-expanded={collapsible ? !isCollapsed : undefined}
|
|
109
|
+
>
|
|
110
|
+
{collapsible && (
|
|
111
|
+
<span className="mt-0.5 text-muted-foreground">
|
|
112
|
+
{isCollapsed ? (
|
|
113
|
+
<ChevronRight className="h-4 w-4" />
|
|
114
|
+
) : (
|
|
115
|
+
<ChevronDown className="h-4 w-4" />
|
|
116
|
+
)}
|
|
117
|
+
</span>
|
|
118
|
+
)}
|
|
119
|
+
<div className="flex-1">
|
|
120
|
+
{label && (
|
|
121
|
+
<h3 className="text-base font-semibold text-foreground">
|
|
122
|
+
{label}
|
|
123
|
+
</h3>
|
|
124
|
+
)}
|
|
125
|
+
{description && (
|
|
126
|
+
<p className="text-sm text-muted-foreground mt-0.5">
|
|
127
|
+
{description}
|
|
128
|
+
</p>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{/* Section Content */}
|
|
135
|
+
{!isCollapsed && (
|
|
136
|
+
<div className={cn('grid gap-4', gridCols[columns])}>
|
|
137
|
+
{children}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export default FormSection;
|