Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ObjectUI Development Roadmap

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

- 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).
- `MobileDialogContent` custom component for ModalForm with flex layout (sticky header + scrollable body + sticky footer).
- ModalForm: skeleton loading state, sticky action buttons, form grid forced to 1-column on mobile (`md:` breakpoint for multi-column).
- 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.
- DrawerForm: container-query-based grid layout matching ModalForm, responsive to actual drawer width.
- Date/DateTime fields use native HTML5 inputs (`type="date"`, `type="datetime-local"`) for optimal mobile picker UX.
- Form sections supported via `ModalFormSectionConfig` for visual field grouping.

Expand Down
58 changes: 39 additions & 19 deletions packages/plugin-form/src/DrawerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ import { SchemaRenderer } from '@object-ui/react';
import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields';
import { applyAutoLayout } from './autoLayout';

/**
* Container-query-based grid classes for form field layout.
* Uses @container / @md: / @2xl: / @4xl: variants so that the grid
* responds to the drawer's actual width instead of the viewport.
*/
const CONTAINER_GRID_COLS: Record<number, string | undefined> = {
1: undefined,
2: 'grid gap-4 grid-cols-1 @md:grid-cols-2',
3: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3',
4: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4',
};

export interface DrawerFormSectionConfig {
name?: string;
label?: string;
Expand Down Expand Up @@ -328,37 +340,45 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
if (schema.sections?.length) {
return (
<div className="space-y-6">
{schema.sections.map((section, index) => (
<FormSection
key={section.name || section.label || index}
label={section.label}
description={section.description}
columns={section.columns || 1}
>
<SchemaRenderer
schema={{
...baseFormSchema,
fields: buildSectionFields(section),
showSubmit: index === schema.sections!.length - 1 && baseFormSchema.showSubmit,
showCancel: index === schema.sections!.length - 1 && baseFormSchema.showCancel,
}}
/>
</FormSection>
))}
{schema.sections.map((section, index) => {
const sectionCols = section.columns || 1;
return (
<FormSection
key={section.name || section.label || index}
label={section.label}
description={section.description}
columns={sectionCols}
gridClassName={CONTAINER_GRID_COLS[sectionCols]}
>
<SchemaRenderer
schema={{
...baseFormSchema,
fields: buildSectionFields(section),
showSubmit: index === schema.sections!.length - 1 && baseFormSchema.showSubmit,
showCancel: index === schema.sections!.length - 1 && baseFormSchema.showCancel,
}}
/>
</FormSection>
);
})}
</div>
);
}

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

// Flat fields layout
// Flat fields layout — use container-query grid classes so the form
// responds to the drawer width, not the viewport width.
const containerFieldClass = CONTAINER_GRID_COLS[autoLayoutResult.columns || 1];

return (
<SchemaRenderer
schema={{
...baseFormSchema,
fields: autoLayoutResult.fields,
columns: autoLayoutResult.columns,
...(containerFieldClass ? { fieldContainerClass: containerFieldClass } : {}),
}}
/>
);
Expand All @@ -378,7 +398,7 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
</SheetHeader>
)}

<div className="py-4">
<div className="@container py-4">
{renderContent()}
</div>
</SheetContent>
Expand Down
10 changes: 9 additions & 1 deletion packages/plugin-form/src/FormSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ export interface FormSectionProps {
* Additional CSS classes
*/
className?: string;

/**
* Override the default responsive grid classes.
* When provided, replaces the viewport-based grid-cols classes
* (e.g. with container-query-based classes like `@md:grid-cols-2`).
*/
gridClassName?: string;
}

/**
Expand All @@ -78,6 +85,7 @@ export const FormSection: React.FC<FormSectionProps> = ({
columns = 1,
children,
className,
gridClassName,
}) => {
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);

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

{/* Section Content */}
{!isCollapsed && (
<div className={cn('grid gap-4', gridCols[columns])}>
<div className={cn('grid gap-4', gridClassName || gridCols[columns])}>
{children}
Comment on lines +144 to 145
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gridClassName is treated as an override, but it’s still combined with the hard-coded 'grid gap-4'. This both prevents callers from changing the gap and can produce duplicated classes (e.g. passing 'grid gap-4 ...' from ModalForm/DrawerForm results in 'grid gap-4 grid gap-4 ...'). Consider making gridClassName a full override (use it instead of the default cn('grid gap-4', gridCols[columns])), or rename it to indicate it only overrides the grid-cols portion and strip grid/gap from the values passed in.

Copilot uses AI. Check for mistakes.
</div>
)}
Expand Down
57 changes: 39 additions & 18 deletions packages/plugin-form/src/ModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,19 @@ const modalSizeClasses: Record<string, string> = {
full: 'max-w-[95vw] w-full',
};

/**
* Container-query-based grid classes for form field layout.
* Uses @container / @md: / @2xl: / @4xl: variants so that the grid
* responds to the modal's actual width instead of the viewport,
* ensuring single-column on narrow mobile modals regardless of viewport size.
*/
const CONTAINER_GRID_COLS: Record<number, string | undefined> = {
1: undefined, // let the form renderer use its default (space-y-4)
2: 'grid gap-4 grid-cols-1 @md:grid-cols-2',
3: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3',
4: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4',
};
Comment on lines +108 to +119
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CONTAINER_GRID_COLS is duplicated in both ModalForm and DrawerForm. To avoid the two mappings drifting over time (breakpoints/classes changing in one place but not the other), consider extracting this constant to a shared module (e.g. packages/plugin-form/src/containerGridCols.ts) and importing it from both components.

Copilot uses AI. Check for mistakes.

export const ModalForm: React.FC<ModalFormProps> = ({
schema,
dataSource,
Expand Down Expand Up @@ -364,36 +377,44 @@ export const ModalForm: React.FC<ModalFormProps> = ({
if (schema.sections?.length) {
return (
<div className="space-y-6">
{schema.sections.map((section, index) => (
<FormSection
key={section.name || section.label || index}
label={section.label}
description={section.description}
columns={section.columns || 1}
>
<SchemaRenderer
schema={{
...baseFormSchema,
fields: buildSectionFields(section),
// Actions are in the sticky footer, not inside sections
}}
/>
</FormSection>
))}
{schema.sections.map((section, index) => {
const sectionCols = section.columns || 1;
return (
<FormSection
key={section.name || section.label || index}
label={section.label}
description={section.description}
columns={sectionCols}
gridClassName={CONTAINER_GRID_COLS[sectionCols]}
>
<SchemaRenderer
schema={{
...baseFormSchema,
fields: buildSectionFields(section),
// Actions are in the sticky footer, not inside sections
}}
/>
</FormSection>
);
})}
</div>
);
}

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

// Flat fields layout
// Flat fields layout — use container-query grid classes so the form
// responds to the modal width, not the viewport width.
const containerFieldClass = CONTAINER_GRID_COLS[layoutResult.columns || 1];

return (
<SchemaRenderer
schema={{
...baseFormSchema,
fields: layoutResult.fields,
columns: layoutResult.columns,
...(containerFieldClass ? { fieldContainerClass: containerFieldClass } : {}),
}}
/>
);
Expand All @@ -411,7 +432,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
</DialogHeader>
)}

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

Expand Down
130 changes: 130 additions & 0 deletions packages/plugin-form/src/__tests__/MobileUX.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,133 @@ describe('ModalForm Mobile UX', () => {
expect(screen.queryByTestId('modal-form-footer')).not.toBeInTheDocument();
});
});

describe('ModalForm Container Query Layout', () => {
/** CSS selector for the @container query context element */
const CONTAINER_SELECTOR = '.\\@container';

it('applies @container class on scrollable content area', async () => {
const mockDataSource = createMockDataSource();

render(
<ModalForm
schema={{
type: 'object-form',
formType: 'modal',
objectName: 'events',
mode: 'create',
title: 'Create Event',
open: true,
}}
dataSource={mockDataSource as any}
/>
);

await waitFor(() => {
expect(screen.getByText('Create Event')).toBeInTheDocument();
});

// The scrollable content wrapper should be a @container query context
const dialogContent = document.querySelector('[role="dialog"]');
expect(dialogContent).not.toBeNull();
const scrollArea = dialogContent!.querySelector(CONTAINER_SELECTOR);
expect(scrollArea).not.toBeNull();
expect(scrollArea!.className).toContain('overflow-y-auto');
});

it('uses container-query grid classes for multi-column flat fields', async () => {
// Mock schema with enough fields to trigger auto-layout 2-column
const manyFieldsSchema = {
name: 'contacts',
fields: {
name: { label: 'Name', type: 'text', required: true },
email: { label: 'Email', type: 'email', required: false },
phone: { label: 'Phone', type: 'phone', required: false },
company: { label: 'Company', type: 'text', required: false },
department: { label: 'Department', type: 'text', required: false },
title: { label: 'Title', type: 'text', required: false },
},
};
const mockDataSource = createMockDataSource();
mockDataSource.getObjectSchema.mockResolvedValue(manyFieldsSchema);

render(
<ModalForm
schema={{
type: 'object-form',
formType: 'modal',
objectName: 'contacts',
mode: 'create',
title: 'Create Contact',
open: true,
}}
dataSource={mockDataSource as any}
/>
);

await waitFor(() => {
expect(screen.getByText('Create Contact')).toBeInTheDocument();
});

// Wait for fields to render
await waitFor(() => {
expect(screen.getByText('Name')).toBeInTheDocument();
});

// The form field container should use container-query classes (@md:grid-cols-2)
// instead of viewport-based classes (md:grid-cols-2)
const dialogContent = document.querySelector('[role="dialog"]');
const containerEl = dialogContent!.querySelector(CONTAINER_SELECTOR);
expect(containerEl).not.toBeNull();

// Look for the grid container with @md:grid-cols-2
const gridEl = containerEl!.querySelector('[class*="@md:grid-cols-2"]');
expect(gridEl).not.toBeNull();
expect(gridEl!.className).toContain('@md:grid-cols-2');
// Should NOT use viewport-based md:grid-cols-2 (without @ prefix)
expect(gridEl!.className).not.toContain(' md:grid-cols-2');
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion can miss a regression because it only checks for ' md:grid-cols-2' (with a leading space). If the class list contains 'md:grid-cols-2' at the beginning (or without a preceding space), the test will still pass. Suggest checking for 'md:grid-cols-2' more robustly (e.g. tokenizing classes and ensuring the md:grid-cols-2 token is absent, while still allowing @md:grid-cols-2).

Suggested change
expect(gridEl!.className).not.toContain(' md:grid-cols-2');
const classTokens = gridEl!.className.split(/\s+/);
expect(classTokens).not.toContain('md:grid-cols-2');

Copilot uses AI. Check for mistakes.
});

it('single-column forms do not get container grid override', async () => {
// Only 3 fields → auto-layout stays at 1 column
const fewFieldsSchema = {
name: 'notes',
fields: {
title: { label: 'Title', type: 'text', required: true },
body: { label: 'Body', type: 'textarea', required: false },
status: { label: 'Status', type: 'select', required: false, options: [{ value: 'draft', label: 'Draft' }] },
},
};
const mockDataSource = createMockDataSource();
mockDataSource.getObjectSchema.mockResolvedValue(fewFieldsSchema);

render(
<ModalForm
schema={{
type: 'object-form',
formType: 'modal',
objectName: 'notes',
mode: 'create',
title: 'Create Note',
open: true,
}}
dataSource={mockDataSource as any}
/>
);

await waitFor(() => {
expect(screen.getByText('Create Note')).toBeInTheDocument();
});

await waitFor(() => {
expect(screen.getByText('Title')).toBeInTheDocument();
});

// Single column form should not have @md:grid-cols-2
const dialogContent = document.querySelector('[role="dialog"]');
const containerEl = dialogContent!.querySelector(CONTAINER_SELECTOR);
expect(containerEl).not.toBeNull();
const gridEl = containerEl!.querySelector('[class*="@md:grid-cols"]');
expect(gridEl).toBeNull();
});
});