From c1e88b75b4b0508035aa08969bf178b689939d1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:51:35 +0000 Subject: [PATCH 1/3] Initial plan From 37bf029abf8a1c58b4002c852696e8210d3caa40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 02:02:15 +0000 Subject: [PATCH 2/3] feat: add container query layout to ModalForm and DrawerForm for mobile single-column - Add @container to ModalForm/DrawerForm content wrappers - Use @md:grid-cols-2 (container query) instead of md:grid-cols-2 (viewport) - Add gridClassName prop to FormSection for container-query override - Add 3 new tests for container query behavior - Update ROADMAP.md with container query support status Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 5 +- packages/plugin-form/src/DrawerForm.tsx | 58 +++++--- packages/plugin-form/src/FormSection.tsx | 10 +- packages/plugin-form/src/ModalForm.tsx | 57 +++++--- .../src/__tests__/MobileUX.test.tsx | 126 ++++++++++++++++++ 5 files changed, 216 insertions(+), 40 deletions(-) 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..60b5460d1 100644 --- a/packages/plugin-form/src/__tests__/MobileUX.test.tsx +++ b/packages/plugin-form/src/__tests__/MobileUX.test.tsx @@ -191,3 +191,129 @@ describe('ModalForm Mobile UX', () => { expect(screen.queryByTestId('modal-form-footer')).not.toBeInTheDocument(); }); }); + +describe('ModalForm Container Query Layout', () => { + 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'); + 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'); + 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(); + // Should NOT use viewport-based md:grid-cols-2 + expect(gridEl!.className).not.toMatch(/(? { + // 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'); + expect(containerEl).not.toBeNull(); + const gridEl = containerEl!.querySelector('[class*="@md:grid-cols"]'); + expect(gridEl).toBeNull(); + }); +}); From 0b3a16624f89c4ce1e7327dbd37f04ca06d4b137 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 02:05:15 +0000 Subject: [PATCH 3/3] refactor: address review feedback - extract test selector constant, simplify assertion Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../plugin-form/src/__tests__/MobileUX.test.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/plugin-form/src/__tests__/MobileUX.test.tsx b/packages/plugin-form/src/__tests__/MobileUX.test.tsx index 60b5460d1..607643bb7 100644 --- a/packages/plugin-form/src/__tests__/MobileUX.test.tsx +++ b/packages/plugin-form/src/__tests__/MobileUX.test.tsx @@ -193,6 +193,9 @@ describe('ModalForm Mobile UX', () => { }); 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(); @@ -217,7 +220,7 @@ describe('ModalForm Container Query Layout', () => { // 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'); + const scrollArea = dialogContent!.querySelector(CONTAINER_SELECTOR); expect(scrollArea).not.toBeNull(); expect(scrollArea!.className).toContain('overflow-y-auto'); }); @@ -264,14 +267,15 @@ describe('ModalForm Container Query Layout', () => { // 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'); + 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(); - // Should NOT use viewport-based md:grid-cols-2 - expect(gridEl!.className).not.toMatch(/(? { @@ -311,7 +315,7 @@ describe('ModalForm Container Query Layout', () => { // Single column form should not have @md:grid-cols-2 const dialogContent = document.querySelector('[role="dialog"]'); - const containerEl = dialogContent!.querySelector('.\\@container'); + const containerEl = dialogContent!.querySelector(CONTAINER_SELECTOR); expect(containerEl).not.toBeNull(); const gridEl = containerEl!.querySelector('[class*="@md:grid-cols"]'); expect(gridEl).toBeNull();