diff --git a/ROADMAP.md b/ROADMAP.md index cb273f3c5..a3cbd7132 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 @@ -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. diff --git a/packages/plugin-form/src/DrawerForm.tsx b/packages/plugin-form/src/DrawerForm.tsx index 6b8d32e7f..50bbcdfbe 100644 --- a/packages/plugin-form/src/DrawerForm.tsx +++ b/packages/plugin-form/src/DrawerForm.tsx @@ -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 = { + 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; @@ -328,23 +340,27 @@ export const DrawerForm: React.FC = ({ if (schema.sections?.length) { return (
- {schema.sections.map((section, index) => ( - - - - ))} + {schema.sections.map((section, index) => { + const sectionCols = section.columns || 1; + return ( + + + + ); + })}
); } @@ -352,13 +368,17 @@ export const DrawerForm: React.FC = ({ // 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 ( ); @@ -378,7 +398,7 @@ export const DrawerForm: React.FC = ({ )} -
+
{renderContent()}
diff --git a/packages/plugin-form/src/FormSection.tsx b/packages/plugin-form/src/FormSection.tsx index 778423b9b..2fd56a5e6 100644 --- a/packages/plugin-form/src/FormSection.tsx +++ b/packages/plugin-form/src/FormSection.tsx @@ -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; } /** @@ -78,6 +85,7 @@ export const FormSection: React.FC = ({ columns = 1, children, className, + gridClassName, }) => { const [isCollapsed, setIsCollapsed] = useState(initialCollapsed); @@ -133,7 +141,7 @@ export const FormSection: React.FC = ({ {/* Section Content */} {!isCollapsed && ( -
+
{children}
)} diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index 8e633c0ab..5254653df 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -105,6 +105,19 @@ const modalSizeClasses: Record = { 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 = { + 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', +}; + export const ModalForm: React.FC = ({ schema, dataSource, @@ -364,22 +377,26 @@ export const ModalForm: React.FC = ({ if (schema.sections?.length) { return (
- {schema.sections.map((section, index) => ( - - - - ))} + {schema.sections.map((section, index) => { + const sectionCols = section.columns || 1; + return ( + + + + ); + })}
); } @@ -387,13 +404,17 @@ export const ModalForm: React.FC = ({ // 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 ( ); @@ -411,7 +432,7 @@ export const ModalForm: React.FC = ({ )} -
+
{renderContent()}
diff --git a/packages/plugin-form/src/__tests__/MobileUX.test.tsx b/packages/plugin-form/src/__tests__/MobileUX.test.tsx index 7dea9e0aa..607643bb7 100644 --- a/packages/plugin-form/src/__tests__/MobileUX.test.tsx +++ b/packages/plugin-form/src/__tests__/MobileUX.test.tsx @@ -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( + + ); + + 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( + + ); + + 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'); + }); + + 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( + + ); + + 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(); + }); +});