@object-ui/plugin-form 3.0.3 → 3.1.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 +6 -6
- package/dist/index.js +1217 -861
- package/dist/index.umd.cjs +2 -2
- package/dist/plugin-form/src/EmbeddableForm.d.ts +49 -0
- package/dist/plugin-form/src/FormAnalytics.d.ts +38 -0
- package/dist/plugin-form/src/FormSection.d.ts +6 -0
- package/dist/plugin-form/src/autoLayout.d.ts +60 -0
- package/dist/plugin-form/src/index.d.ts +5 -0
- package/package.json +8 -8
- package/src/DrawerForm.tsx +49 -24
- package/src/EmbeddableForm.tsx +240 -0
- package/src/FormAnalytics.tsx +209 -0
- package/src/FormSection.tsx +9 -1
- package/src/ModalForm.tsx +145 -45
- package/src/ObjectForm.tsx +12 -4
- package/src/SplitForm.tsx +3 -2
- package/src/TabbedForm.tsx +3 -2
- package/src/WizardForm.tsx +3 -2
- package/src/__tests__/EmbeddableFormPrefill.test.tsx +186 -0
- package/src/__tests__/MobileUX.test.tsx +433 -0
- package/src/__tests__/NewVariants.test.tsx +196 -0
- package/src/__tests__/autoLayout.test.ts +342 -0
- package/src/autoLayout.ts +168 -0
- package/src/index.tsx +52 -0
|
@@ -0,0 +1,240 @@
|
|
|
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
|
+
* EmbeddableForm Component
|
|
11
|
+
*
|
|
12
|
+
* A standalone embeddable form that can be accessed without authentication.
|
|
13
|
+
* Designed for external data collection use cases (surveys, registrations, etc.).
|
|
14
|
+
*
|
|
15
|
+
* Features:
|
|
16
|
+
* - Renders from ObjectFormSchema or inline field definitions
|
|
17
|
+
* - No authentication required (public access)
|
|
18
|
+
* - URL prefill parameters support (?name=John&email=...)
|
|
19
|
+
* - Configurable branding (logo, colors, title)
|
|
20
|
+
* - Success/thank-you page after submission
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import React, { useState, useCallback, useMemo } from 'react';
|
|
24
|
+
import type { DataSource, FormField } from '@object-ui/types';
|
|
25
|
+
import { ObjectForm } from './ObjectForm';
|
|
26
|
+
|
|
27
|
+
export interface EmbeddableFormConfig {
|
|
28
|
+
/** Unique form ID */
|
|
29
|
+
formId: string;
|
|
30
|
+
/** Object name to create records in */
|
|
31
|
+
objectName: string;
|
|
32
|
+
/** Form title displayed at the top */
|
|
33
|
+
title?: string;
|
|
34
|
+
/** Form description / instructions */
|
|
35
|
+
description?: string;
|
|
36
|
+
/** Fields to include in the form (subset of object fields) */
|
|
37
|
+
fields?: string[];
|
|
38
|
+
/** Custom field definitions for inline forms */
|
|
39
|
+
customFields?: FormField[];
|
|
40
|
+
/** Branding configuration */
|
|
41
|
+
branding?: {
|
|
42
|
+
logo?: string;
|
|
43
|
+
primaryColor?: string;
|
|
44
|
+
backgroundColor?: string;
|
|
45
|
+
};
|
|
46
|
+
/** Thank you page configuration */
|
|
47
|
+
thankYouPage?: {
|
|
48
|
+
title?: string;
|
|
49
|
+
message?: string;
|
|
50
|
+
redirectUrl?: string;
|
|
51
|
+
redirectDelay?: number;
|
|
52
|
+
};
|
|
53
|
+
/** Allow multiple submissions */
|
|
54
|
+
allowMultiple?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface EmbeddableFormProps {
|
|
58
|
+
/** Form configuration */
|
|
59
|
+
config: EmbeddableFormConfig;
|
|
60
|
+
/** Data source for creating records */
|
|
61
|
+
dataSource?: DataSource;
|
|
62
|
+
/** URL search parameters for prefilling fields */
|
|
63
|
+
prefillParams?: Record<string, string>;
|
|
64
|
+
/** Additional CSS class */
|
|
65
|
+
className?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* EmbeddableForm — Standalone form for external data collection.
|
|
70
|
+
*
|
|
71
|
+
* Can be rendered at a public URL (e.g., `/forms/:formId`) without auth.
|
|
72
|
+
* Submissions create records in the specified object via DataSource.
|
|
73
|
+
*/
|
|
74
|
+
export const EmbeddableForm: React.FC<EmbeddableFormProps> = ({
|
|
75
|
+
config,
|
|
76
|
+
dataSource,
|
|
77
|
+
prefillParams,
|
|
78
|
+
className,
|
|
79
|
+
}) => {
|
|
80
|
+
const [submitted, setSubmitted] = useState(false);
|
|
81
|
+
const [submitting, setSubmitting] = useState(false);
|
|
82
|
+
const [error, setError] = useState<string | null>(null);
|
|
83
|
+
|
|
84
|
+
// Build initial data from URL prefill params or window.location.search
|
|
85
|
+
const initialData = useMemo(() => {
|
|
86
|
+
const data: Record<string, string> = {};
|
|
87
|
+
// Explicit prefillParams take priority
|
|
88
|
+
if (prefillParams) {
|
|
89
|
+
for (const [key, value] of Object.entries(prefillParams)) {
|
|
90
|
+
data[key] = value;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Also parse URL search parameters for prefilling (Phase 14 L2)
|
|
94
|
+
if (typeof window !== 'undefined') {
|
|
95
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
96
|
+
urlParams.forEach((value, key) => {
|
|
97
|
+
if (!(key in data)) {
|
|
98
|
+
data[key] = value;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return Object.keys(data).length > 0 ? data : undefined;
|
|
103
|
+
}, [prefillParams]);
|
|
104
|
+
|
|
105
|
+
const handleSubmit = useCallback(async (formData: Record<string, any>) => {
|
|
106
|
+
setSubmitting(true);
|
|
107
|
+
setError(null);
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
if (dataSource) {
|
|
111
|
+
await dataSource.create(config.objectName, formData);
|
|
112
|
+
}
|
|
113
|
+
setSubmitted(true);
|
|
114
|
+
|
|
115
|
+
// Handle redirect after delay
|
|
116
|
+
if (config.thankYouPage?.redirectUrl) {
|
|
117
|
+
const delay = config.thankYouPage.redirectDelay ?? 3000;
|
|
118
|
+
setTimeout(() => {
|
|
119
|
+
window.location.href = config.thankYouPage!.redirectUrl!;
|
|
120
|
+
}, delay);
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
setError(err instanceof Error ? err.message : 'Failed to submit form. Please try again.');
|
|
124
|
+
} finally {
|
|
125
|
+
setSubmitting(false);
|
|
126
|
+
}
|
|
127
|
+
}, [dataSource, config]);
|
|
128
|
+
|
|
129
|
+
const handleReset = useCallback(() => {
|
|
130
|
+
setSubmitted(false);
|
|
131
|
+
setError(null);
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
// Branding styles
|
|
135
|
+
const brandingStyle = useMemo(() => {
|
|
136
|
+
const style: React.CSSProperties = {};
|
|
137
|
+
if (config.branding?.backgroundColor) {
|
|
138
|
+
style.backgroundColor = config.branding.backgroundColor;
|
|
139
|
+
}
|
|
140
|
+
return style;
|
|
141
|
+
}, [config.branding]);
|
|
142
|
+
|
|
143
|
+
// Thank you page
|
|
144
|
+
if (submitted) {
|
|
145
|
+
const thankYou = config.thankYouPage;
|
|
146
|
+
return (
|
|
147
|
+
<div
|
|
148
|
+
className={`min-h-screen flex items-center justify-center p-4 ${className || ''}`}
|
|
149
|
+
style={brandingStyle}
|
|
150
|
+
>
|
|
151
|
+
<div className="max-w-md w-full bg-card rounded-lg shadow-lg p-8 text-center space-y-4">
|
|
152
|
+
<div className="text-4xl">✓</div>
|
|
153
|
+
<h2 className="text-xl font-semibold text-foreground">
|
|
154
|
+
{thankYou?.title || 'Thank You!'}
|
|
155
|
+
</h2>
|
|
156
|
+
<p className="text-muted-foreground">
|
|
157
|
+
{thankYou?.message || 'Your submission has been received successfully.'}
|
|
158
|
+
</p>
|
|
159
|
+
{config.allowMultiple && (
|
|
160
|
+
<button
|
|
161
|
+
onClick={handleReset}
|
|
162
|
+
className="mt-4 px-4 py-2 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors"
|
|
163
|
+
>
|
|
164
|
+
Submit Another Response
|
|
165
|
+
</button>
|
|
166
|
+
)}
|
|
167
|
+
{thankYou?.redirectUrl && (
|
|
168
|
+
<p className="text-xs text-muted-foreground">
|
|
169
|
+
Redirecting in {Math.ceil((thankYou.redirectDelay ?? 3000) / 1000)} seconds...
|
|
170
|
+
</p>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div
|
|
179
|
+
className={`min-h-screen flex items-center justify-center p-4 ${className || ''}`}
|
|
180
|
+
style={brandingStyle}
|
|
181
|
+
>
|
|
182
|
+
<div className="max-w-2xl w-full bg-card rounded-lg shadow-lg overflow-hidden">
|
|
183
|
+
{/* Header */}
|
|
184
|
+
<div
|
|
185
|
+
className="p-6 border-b"
|
|
186
|
+
style={config.branding?.primaryColor ? { borderBottomColor: config.branding.primaryColor } : undefined}
|
|
187
|
+
>
|
|
188
|
+
{config.branding?.logo && (
|
|
189
|
+
<img
|
|
190
|
+
src={config.branding.logo}
|
|
191
|
+
alt="Logo"
|
|
192
|
+
className="h-8 mb-4"
|
|
193
|
+
/>
|
|
194
|
+
)}
|
|
195
|
+
{config.title && (
|
|
196
|
+
<h1 className="text-xl font-semibold text-foreground">
|
|
197
|
+
{config.title}
|
|
198
|
+
</h1>
|
|
199
|
+
)}
|
|
200
|
+
{config.description && (
|
|
201
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
202
|
+
{config.description}
|
|
203
|
+
</p>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
{/* Form body */}
|
|
208
|
+
<div className="p-6">
|
|
209
|
+
{error && (
|
|
210
|
+
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/30 rounded-md text-sm text-destructive">
|
|
211
|
+
{error}
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
<ObjectForm
|
|
215
|
+
schema={{
|
|
216
|
+
type: 'object-form',
|
|
217
|
+
objectName: config.objectName,
|
|
218
|
+
mode: 'create',
|
|
219
|
+
fields: config.fields,
|
|
220
|
+
customFields: config.customFields,
|
|
221
|
+
initialData,
|
|
222
|
+
onSuccess: handleSubmit,
|
|
223
|
+
submitLabel: submitting ? 'Submitting...' : 'Submit',
|
|
224
|
+
}}
|
|
225
|
+
dataSource={dataSource}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{/* Footer */}
|
|
230
|
+
<div className="px-6 py-3 border-t bg-muted/20 text-center">
|
|
231
|
+
<p className="text-xs text-muted-foreground">
|
|
232
|
+
Powered by ObjectStack
|
|
233
|
+
</p>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export default EmbeddableForm;
|
|
@@ -0,0 +1,209 @@
|
|
|
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
|
+
* FormAnalytics Component
|
|
11
|
+
*
|
|
12
|
+
* Displays basic analytics for form submissions:
|
|
13
|
+
* - Total submissions count
|
|
14
|
+
* - Fill rate (completed vs started)
|
|
15
|
+
* - Average completion time
|
|
16
|
+
* - Field drop-off rates
|
|
17
|
+
*
|
|
18
|
+
* This is an L1 Foundation component for Phase 14.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import React, { useMemo } from 'react';
|
|
22
|
+
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@object-ui/components';
|
|
23
|
+
|
|
24
|
+
export interface FormSubmissionMetric {
|
|
25
|
+
/** Total number of form submissions */
|
|
26
|
+
totalSubmissions: number;
|
|
27
|
+
/** Number of forms started but not completed */
|
|
28
|
+
abandonedSubmissions?: number;
|
|
29
|
+
/** Average time to complete form in seconds */
|
|
30
|
+
avgCompletionTime?: number;
|
|
31
|
+
/** Submissions per day over the last N days */
|
|
32
|
+
dailySubmissions?: Array<{
|
|
33
|
+
date: string;
|
|
34
|
+
count: number;
|
|
35
|
+
}>;
|
|
36
|
+
/** Field-level drop-off data */
|
|
37
|
+
fieldDropOff?: Array<{
|
|
38
|
+
fieldName: string;
|
|
39
|
+
label: string;
|
|
40
|
+
completionRate: number;
|
|
41
|
+
}>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FormAnalyticsProps {
|
|
45
|
+
/** Form identifier */
|
|
46
|
+
formId: string;
|
|
47
|
+
/** Form title */
|
|
48
|
+
formTitle?: string;
|
|
49
|
+
/** Submission metrics data */
|
|
50
|
+
metrics: FormSubmissionMetric;
|
|
51
|
+
/** Additional CSS class */
|
|
52
|
+
className?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatDuration(seconds: number): string {
|
|
56
|
+
if (seconds < 60) return `${Math.round(seconds)}s`;
|
|
57
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
|
|
58
|
+
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* FormAnalytics — Basic form analytics dashboard showing submission metrics.
|
|
63
|
+
*
|
|
64
|
+
* Displays fill rate, completion time, and field-level drop-off data
|
|
65
|
+
* for embeddable forms and data collection workflows.
|
|
66
|
+
*/
|
|
67
|
+
export const FormAnalytics: React.FC<FormAnalyticsProps> = ({
|
|
68
|
+
formId,
|
|
69
|
+
formTitle,
|
|
70
|
+
metrics,
|
|
71
|
+
className,
|
|
72
|
+
}) => {
|
|
73
|
+
const fillRate = useMemo(() => {
|
|
74
|
+
if (!metrics.abandonedSubmissions) return 100;
|
|
75
|
+
const total = metrics.totalSubmissions + metrics.abandonedSubmissions;
|
|
76
|
+
if (total === 0) return 0;
|
|
77
|
+
return Math.round((metrics.totalSubmissions / total) * 100);
|
|
78
|
+
}, [metrics.totalSubmissions, metrics.abandonedSubmissions]);
|
|
79
|
+
|
|
80
|
+
// Find max count for bar chart scaling
|
|
81
|
+
const maxDaily = useMemo(() => {
|
|
82
|
+
if (!metrics.dailySubmissions?.length) return 1;
|
|
83
|
+
return Math.max(...metrics.dailySubmissions.map(d => d.count), 1);
|
|
84
|
+
}, [metrics.dailySubmissions]);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className={`space-y-4 ${className || ''}`}>
|
|
88
|
+
{/* Header */}
|
|
89
|
+
<div>
|
|
90
|
+
<h2 className="text-lg font-semibold text-foreground">
|
|
91
|
+
{formTitle || `Form Analytics`}
|
|
92
|
+
</h2>
|
|
93
|
+
<p className="text-sm text-muted-foreground">
|
|
94
|
+
Form ID: {formId}
|
|
95
|
+
</p>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{/* Metric cards */}
|
|
99
|
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
100
|
+
{/* Total Submissions */}
|
|
101
|
+
<Card>
|
|
102
|
+
<CardHeader className="pb-2">
|
|
103
|
+
<CardDescription>Total Submissions</CardDescription>
|
|
104
|
+
<CardTitle className="text-2xl">
|
|
105
|
+
{metrics.totalSubmissions.toLocaleString()}
|
|
106
|
+
</CardTitle>
|
|
107
|
+
</CardHeader>
|
|
108
|
+
</Card>
|
|
109
|
+
|
|
110
|
+
{/* Fill Rate */}
|
|
111
|
+
<Card>
|
|
112
|
+
<CardHeader className="pb-2">
|
|
113
|
+
<CardDescription>Fill Rate</CardDescription>
|
|
114
|
+
<CardTitle className="text-2xl">
|
|
115
|
+
{fillRate}%
|
|
116
|
+
</CardTitle>
|
|
117
|
+
</CardHeader>
|
|
118
|
+
<CardContent>
|
|
119
|
+
<div className="w-full bg-muted rounded-full h-2">
|
|
120
|
+
<div
|
|
121
|
+
className="bg-primary rounded-full h-2 transition-all"
|
|
122
|
+
style={{ width: `${fillRate}%` }}
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
</CardContent>
|
|
126
|
+
</Card>
|
|
127
|
+
|
|
128
|
+
{/* Avg Completion Time */}
|
|
129
|
+
<Card>
|
|
130
|
+
<CardHeader className="pb-2">
|
|
131
|
+
<CardDescription>Avg. Completion Time</CardDescription>
|
|
132
|
+
<CardTitle className="text-2xl">
|
|
133
|
+
{metrics.avgCompletionTime
|
|
134
|
+
? formatDuration(metrics.avgCompletionTime)
|
|
135
|
+
: '—'}
|
|
136
|
+
</CardTitle>
|
|
137
|
+
</CardHeader>
|
|
138
|
+
</Card>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Daily Submissions Chart */}
|
|
142
|
+
{metrics.dailySubmissions && metrics.dailySubmissions.length > 0 && (
|
|
143
|
+
<Card>
|
|
144
|
+
<CardHeader>
|
|
145
|
+
<CardTitle className="text-sm font-medium">
|
|
146
|
+
Daily Submissions
|
|
147
|
+
</CardTitle>
|
|
148
|
+
</CardHeader>
|
|
149
|
+
<CardContent>
|
|
150
|
+
<div className="flex items-end gap-1 h-32">
|
|
151
|
+
{metrics.dailySubmissions.map((day, idx) => (
|
|
152
|
+
<div key={idx} className="flex-1 flex flex-col items-center gap-1">
|
|
153
|
+
<div
|
|
154
|
+
className="w-full bg-primary/80 rounded-t transition-all hover:bg-primary"
|
|
155
|
+
style={{ height: `${(day.count / maxDaily) * 100}%`, minHeight: day.count > 0 ? '4px' : '0' }}
|
|
156
|
+
title={`${day.date}: ${day.count} submissions`}
|
|
157
|
+
/>
|
|
158
|
+
<span className="text-[10px] text-muted-foreground truncate w-full text-center">
|
|
159
|
+
{day.date.slice(-5)}
|
|
160
|
+
</span>
|
|
161
|
+
</div>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
</CardContent>
|
|
165
|
+
</Card>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{/* Field Drop-off */}
|
|
169
|
+
{metrics.fieldDropOff && metrics.fieldDropOff.length > 0 && (
|
|
170
|
+
<Card>
|
|
171
|
+
<CardHeader>
|
|
172
|
+
<CardTitle className="text-sm font-medium">
|
|
173
|
+
Field Completion Rates
|
|
174
|
+
</CardTitle>
|
|
175
|
+
<CardDescription>
|
|
176
|
+
Percentage of users who completed each field
|
|
177
|
+
</CardDescription>
|
|
178
|
+
</CardHeader>
|
|
179
|
+
<CardContent>
|
|
180
|
+
<div className="space-y-3">
|
|
181
|
+
{metrics.fieldDropOff.map((field, idx) => (
|
|
182
|
+
<div key={idx} className="space-y-1">
|
|
183
|
+
<div className="flex justify-between text-sm">
|
|
184
|
+
<span className="text-foreground">{field.label}</span>
|
|
185
|
+
<span className="text-muted-foreground">{field.completionRate}%</span>
|
|
186
|
+
</div>
|
|
187
|
+
<div className="w-full bg-muted rounded-full h-1.5">
|
|
188
|
+
<div
|
|
189
|
+
className={`rounded-full h-1.5 transition-all ${
|
|
190
|
+
field.completionRate >= 80
|
|
191
|
+
? 'bg-green-500'
|
|
192
|
+
: field.completionRate >= 50
|
|
193
|
+
? 'bg-yellow-500'
|
|
194
|
+
: 'bg-destructive'
|
|
195
|
+
}`}
|
|
196
|
+
style={{ width: `${field.completionRate}%` }}
|
|
197
|
+
/>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
</CardContent>
|
|
203
|
+
</Card>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export default FormAnalytics;
|
package/src/FormSection.tsx
CHANGED
|
@@ -55,6 +55,13 @@ export interface FormSectionProps {
|
|
|
55
55
|
* Additional CSS classes
|
|
56
56
|
*/
|
|
57
57
|
className?: string;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Override the default responsive grid classes.
|
|
61
|
+
* When provided, replaces the viewport-based grid-cols classes
|
|
62
|
+
* (e.g. with container-query-based classes like `@md:grid-cols-2`).
|
|
63
|
+
*/
|
|
64
|
+
gridClassName?: string;
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
/**
|
|
@@ -78,6 +85,7 @@ export const FormSection: React.FC<FormSectionProps> = ({
|
|
|
78
85
|
columns = 1,
|
|
79
86
|
children,
|
|
80
87
|
className,
|
|
88
|
+
gridClassName,
|
|
81
89
|
}) => {
|
|
82
90
|
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
|
|
83
91
|
|
|
@@ -133,7 +141,7 @@ export const FormSection: React.FC<FormSectionProps> = ({
|
|
|
133
141
|
|
|
134
142
|
{/* Section Content */}
|
|
135
143
|
{!isCollapsed && (
|
|
136
|
-
<div className={cn('grid gap-4', gridCols[columns])}>
|
|
144
|
+
<div className={cn('grid gap-4', gridClassName || gridCols[columns])}>
|
|
137
145
|
{children}
|
|
138
146
|
</div>
|
|
139
147
|
)}
|