Skip to content

Commit 7f6d79f

Browse files
authored
Merge pull request #670 from objectstack-ai/copilot/optimize-modalform-layout
2 parents 9e74c3d + 0b3a166 commit 7f6d79f

File tree

5 files changed

+220
-40
lines changed

5 files changed

+220
-40
lines changed

ROADMAP.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ObjectUI Development Roadmap
22

3-
> **Last Updated:** February 20, 2026
3+
> **Last Updated:** February 21, 2026
44
> **Current Version:** v0.5.x
55
> **Spec Version:** @objectstack/spec v3.0.8
66
> **Client Version:** @objectstack/client v3.0.8
@@ -72,7 +72,8 @@ All 11 plugin views (Grid, Kanban, Form, Dashboard, Calendar, Timeline, List, De
7272

7373
- Base `DialogContent` upgraded to mobile-first layout: full-screen on mobile (`inset-0 h-[100dvh]`), centered on desktop (`sm:inset-auto sm:max-w-lg sm:rounded-lg`), close button touch target ≥ 44×44px (WCAG 2.5.5).
7474
- `MobileDialogContent` custom component for ModalForm with flex layout (sticky header + scrollable body + sticky footer).
75-
- ModalForm: skeleton loading state, sticky action buttons, form grid forced to 1-column on mobile (`md:` breakpoint for multi-column).
75+
- ModalForm: skeleton loading state, sticky action buttons, container-query-based grid layout (`@container` + `@md:grid-cols-2`) ensures single-column on narrow mobile modals regardless of viewport width.
76+
- DrawerForm: container-query-based grid layout matching ModalForm, responsive to actual drawer width.
7677
- Date/DateTime fields use native HTML5 inputs (`type="date"`, `type="datetime-local"`) for optimal mobile picker UX.
7778
- Form sections supported via `ModalFormSectionConfig` for visual field grouping.
7879
- Mobile card view optimizations for Opportunity list view:

packages/plugin-form/src/DrawerForm.tsx

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ import { SchemaRenderer } from '@object-ui/react';
2828
import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields';
2929
import { applyAutoLayout } from './autoLayout';
3030

31+
/**
32+
* Container-query-based grid classes for form field layout.
33+
* Uses @container / @md: / @2xl: / @4xl: variants so that the grid
34+
* responds to the drawer's actual width instead of the viewport.
35+
*/
36+
const CONTAINER_GRID_COLS: Record<number, string | undefined> = {
37+
1: undefined,
38+
2: 'grid gap-4 grid-cols-1 @md:grid-cols-2',
39+
3: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3',
40+
4: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4',
41+
};
42+
3143
export interface DrawerFormSectionConfig {
3244
name?: string;
3345
label?: string;
@@ -328,37 +340,45 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
328340
if (schema.sections?.length) {
329341
return (
330342
<div className="space-y-6">
331-
{schema.sections.map((section, index) => (
332-
<FormSection
333-
key={section.name || section.label || index}
334-
label={section.label}
335-
description={section.description}
336-
columns={section.columns || 1}
337-
>
338-
<SchemaRenderer
339-
schema={{
340-
...baseFormSchema,
341-
fields: buildSectionFields(section),
342-
showSubmit: index === schema.sections!.length - 1 && baseFormSchema.showSubmit,
343-
showCancel: index === schema.sections!.length - 1 && baseFormSchema.showCancel,
344-
}}
345-
/>
346-
</FormSection>
347-
))}
343+
{schema.sections.map((section, index) => {
344+
const sectionCols = section.columns || 1;
345+
return (
346+
<FormSection
347+
key={section.name || section.label || index}
348+
label={section.label}
349+
description={section.description}
350+
columns={sectionCols}
351+
gridClassName={CONTAINER_GRID_COLS[sectionCols]}
352+
>
353+
<SchemaRenderer
354+
schema={{
355+
...baseFormSchema,
356+
fields: buildSectionFields(section),
357+
showSubmit: index === schema.sections!.length - 1 && baseFormSchema.showSubmit,
358+
showCancel: index === schema.sections!.length - 1 && baseFormSchema.showCancel,
359+
}}
360+
/>
361+
</FormSection>
362+
);
363+
})}
348364
</div>
349365
);
350366
}
351367

352368
// Apply auto-layout for flat fields (infer columns + colSpan)
353369
const autoLayoutResult = applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode);
354370

355-
// Flat fields layout
371+
// Flat fields layout — use container-query grid classes so the form
372+
// responds to the drawer width, not the viewport width.
373+
const containerFieldClass = CONTAINER_GRID_COLS[autoLayoutResult.columns || 1];
374+
356375
return (
357376
<SchemaRenderer
358377
schema={{
359378
...baseFormSchema,
360379
fields: autoLayoutResult.fields,
361380
columns: autoLayoutResult.columns,
381+
...(containerFieldClass ? { fieldContainerClass: containerFieldClass } : {}),
362382
}}
363383
/>
364384
);
@@ -378,7 +398,7 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
378398
</SheetHeader>
379399
)}
380400

381-
<div className="py-4">
401+
<div className="@container py-4">
382402
{renderContent()}
383403
</div>
384404
</SheetContent>

packages/plugin-form/src/FormSection.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ export interface FormSectionProps {
5555
* Additional CSS classes
5656
*/
5757
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;
5865
}
5966

6067
/**
@@ -78,6 +85,7 @@ export const FormSection: React.FC<FormSectionProps> = ({
7885
columns = 1,
7986
children,
8087
className,
88+
gridClassName,
8189
}) => {
8290
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
8391

@@ -133,7 +141,7 @@ export const FormSection: React.FC<FormSectionProps> = ({
133141

134142
{/* Section Content */}
135143
{!isCollapsed && (
136-
<div className={cn('grid gap-4', gridCols[columns])}>
144+
<div className={cn('grid gap-4', gridClassName || gridCols[columns])}>
137145
{children}
138146
</div>
139147
)}

packages/plugin-form/src/ModalForm.tsx

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,19 @@ const modalSizeClasses: Record<string, string> = {
105105
full: 'max-w-[95vw] w-full',
106106
};
107107

108+
/**
109+
* Container-query-based grid classes for form field layout.
110+
* Uses @container / @md: / @2xl: / @4xl: variants so that the grid
111+
* responds to the modal's actual width instead of the viewport,
112+
* ensuring single-column on narrow mobile modals regardless of viewport size.
113+
*/
114+
const CONTAINER_GRID_COLS: Record<number, string | undefined> = {
115+
1: undefined, // let the form renderer use its default (space-y-4)
116+
2: 'grid gap-4 grid-cols-1 @md:grid-cols-2',
117+
3: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3',
118+
4: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4',
119+
};
120+
108121
export const ModalForm: React.FC<ModalFormProps> = ({
109122
schema,
110123
dataSource,
@@ -364,36 +377,44 @@ export const ModalForm: React.FC<ModalFormProps> = ({
364377
if (schema.sections?.length) {
365378
return (
366379
<div className="space-y-6">
367-
{schema.sections.map((section, index) => (
368-
<FormSection
369-
key={section.name || section.label || index}
370-
label={section.label}
371-
description={section.description}
372-
columns={section.columns || 1}
373-
>
374-
<SchemaRenderer
375-
schema={{
376-
...baseFormSchema,
377-
fields: buildSectionFields(section),
378-
// Actions are in the sticky footer, not inside sections
379-
}}
380-
/>
381-
</FormSection>
382-
))}
380+
{schema.sections.map((section, index) => {
381+
const sectionCols = section.columns || 1;
382+
return (
383+
<FormSection
384+
key={section.name || section.label || index}
385+
label={section.label}
386+
description={section.description}
387+
columns={sectionCols}
388+
gridClassName={CONTAINER_GRID_COLS[sectionCols]}
389+
>
390+
<SchemaRenderer
391+
schema={{
392+
...baseFormSchema,
393+
fields: buildSectionFields(section),
394+
// Actions are in the sticky footer, not inside sections
395+
}}
396+
/>
397+
</FormSection>
398+
);
399+
})}
383400
</div>
384401
);
385402
}
386403

387404
// Reuse pre-computed auto-layout result for flat fields
388405
const layoutResult = autoLayoutResult ?? applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode);
389406

390-
// Flat fields layout
407+
// Flat fields layout — use container-query grid classes so the form
408+
// responds to the modal width, not the viewport width.
409+
const containerFieldClass = CONTAINER_GRID_COLS[layoutResult.columns || 1];
410+
391411
return (
392412
<SchemaRenderer
393413
schema={{
394414
...baseFormSchema,
395415
fields: layoutResult.fields,
396416
columns: layoutResult.columns,
417+
...(containerFieldClass ? { fieldContainerClass: containerFieldClass } : {}),
397418
}}
398419
/>
399420
);
@@ -411,7 +432,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
411432
</DialogHeader>
412433
)}
413434

414-
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4">
435+
<div className="@container flex-1 overflow-y-auto px-4 sm:px-6 py-4">
415436
{renderContent()}
416437
</div>
417438

packages/plugin-form/src/__tests__/MobileUX.test.tsx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,133 @@ describe('ModalForm Mobile UX', () => {
191191
expect(screen.queryByTestId('modal-form-footer')).not.toBeInTheDocument();
192192
});
193193
});
194+
195+
describe('ModalForm Container Query Layout', () => {
196+
/** CSS selector for the @container query context element */
197+
const CONTAINER_SELECTOR = '.\\@container';
198+
199+
it('applies @container class on scrollable content area', async () => {
200+
const mockDataSource = createMockDataSource();
201+
202+
render(
203+
<ModalForm
204+
schema={{
205+
type: 'object-form',
206+
formType: 'modal',
207+
objectName: 'events',
208+
mode: 'create',
209+
title: 'Create Event',
210+
open: true,
211+
}}
212+
dataSource={mockDataSource as any}
213+
/>
214+
);
215+
216+
await waitFor(() => {
217+
expect(screen.getByText('Create Event')).toBeInTheDocument();
218+
});
219+
220+
// The scrollable content wrapper should be a @container query context
221+
const dialogContent = document.querySelector('[role="dialog"]');
222+
expect(dialogContent).not.toBeNull();
223+
const scrollArea = dialogContent!.querySelector(CONTAINER_SELECTOR);
224+
expect(scrollArea).not.toBeNull();
225+
expect(scrollArea!.className).toContain('overflow-y-auto');
226+
});
227+
228+
it('uses container-query grid classes for multi-column flat fields', async () => {
229+
// Mock schema with enough fields to trigger auto-layout 2-column
230+
const manyFieldsSchema = {
231+
name: 'contacts',
232+
fields: {
233+
name: { label: 'Name', type: 'text', required: true },
234+
email: { label: 'Email', type: 'email', required: false },
235+
phone: { label: 'Phone', type: 'phone', required: false },
236+
company: { label: 'Company', type: 'text', required: false },
237+
department: { label: 'Department', type: 'text', required: false },
238+
title: { label: 'Title', type: 'text', required: false },
239+
},
240+
};
241+
const mockDataSource = createMockDataSource();
242+
mockDataSource.getObjectSchema.mockResolvedValue(manyFieldsSchema);
243+
244+
render(
245+
<ModalForm
246+
schema={{
247+
type: 'object-form',
248+
formType: 'modal',
249+
objectName: 'contacts',
250+
mode: 'create',
251+
title: 'Create Contact',
252+
open: true,
253+
}}
254+
dataSource={mockDataSource as any}
255+
/>
256+
);
257+
258+
await waitFor(() => {
259+
expect(screen.getByText('Create Contact')).toBeInTheDocument();
260+
});
261+
262+
// Wait for fields to render
263+
await waitFor(() => {
264+
expect(screen.getByText('Name')).toBeInTheDocument();
265+
});
266+
267+
// The form field container should use container-query classes (@md:grid-cols-2)
268+
// instead of viewport-based classes (md:grid-cols-2)
269+
const dialogContent = document.querySelector('[role="dialog"]');
270+
const containerEl = dialogContent!.querySelector(CONTAINER_SELECTOR);
271+
expect(containerEl).not.toBeNull();
272+
273+
// Look for the grid container with @md:grid-cols-2
274+
const gridEl = containerEl!.querySelector('[class*="@md:grid-cols-2"]');
275+
expect(gridEl).not.toBeNull();
276+
expect(gridEl!.className).toContain('@md:grid-cols-2');
277+
// Should NOT use viewport-based md:grid-cols-2 (without @ prefix)
278+
expect(gridEl!.className).not.toContain(' md:grid-cols-2');
279+
});
280+
281+
it('single-column forms do not get container grid override', async () => {
282+
// Only 3 fields → auto-layout stays at 1 column
283+
const fewFieldsSchema = {
284+
name: 'notes',
285+
fields: {
286+
title: { label: 'Title', type: 'text', required: true },
287+
body: { label: 'Body', type: 'textarea', required: false },
288+
status: { label: 'Status', type: 'select', required: false, options: [{ value: 'draft', label: 'Draft' }] },
289+
},
290+
};
291+
const mockDataSource = createMockDataSource();
292+
mockDataSource.getObjectSchema.mockResolvedValue(fewFieldsSchema);
293+
294+
render(
295+
<ModalForm
296+
schema={{
297+
type: 'object-form',
298+
formType: 'modal',
299+
objectName: 'notes',
300+
mode: 'create',
301+
title: 'Create Note',
302+
open: true,
303+
}}
304+
dataSource={mockDataSource as any}
305+
/>
306+
);
307+
308+
await waitFor(() => {
309+
expect(screen.getByText('Create Note')).toBeInTheDocument();
310+
});
311+
312+
await waitFor(() => {
313+
expect(screen.getByText('Title')).toBeInTheDocument();
314+
});
315+
316+
// Single column form should not have @md:grid-cols-2
317+
const dialogContent = document.querySelector('[role="dialog"]');
318+
const containerEl = dialogContent!.querySelector(CONTAINER_SELECTOR);
319+
expect(containerEl).not.toBeNull();
320+
const gridEl = containerEl!.querySelector('[class*="@md:grid-cols"]');
321+
expect(gridEl).toBeNull();
322+
});
323+
});

0 commit comments

Comments
 (0)