From 069288aec5a16786cf6dc40f0d558cf9ee3b7e69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 12:55:28 +0000 Subject: [PATCH 1/5] Initial plan From 48c18131d3e44cb11215da490aac83ea762a38ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 13:04:40 +0000 Subject: [PATCH 2/5] feat: optimize ModalForm responsive experience for tablet/mobile - Fix project.view.ts: change string columns ('2','1') to numbers (2,1) - ModalForm: auto-upgrade modal size when sections have multi-column layout - PercentField: add interactive slider control alongside number input - Add 3 new tests for sections modal size auto-upgrade behavior - Update ROADMAP.md with optimization item under P1.2 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 1 + examples/crm/src/views/project.view.ts | 6 +- packages/fields/src/widgets/PercentField.tsx | 48 +++++--- packages/plugin-form/src/ModalForm.tsx | 7 +- .../src/__tests__/MobileUX.test.tsx | 110 ++++++++++++++++++ 5 files changed, 153 insertions(+), 19 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a45bda7fd..3c2d908ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -64,6 +64,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind ### P1.2 Console — Forms & Data Collection +- [x] ModalForm responsive optimization: sections layout auto-upgrades modal size, slider for percent/progress fields, tablet 2-column layout - [ ] Camera capture for mobile file upload - [ ] Image cropping/rotation in file fields - [ ] Cloud storage integration (S3, Azure Blob) for file upload diff --git a/examples/crm/src/views/project.view.ts b/examples/crm/src/views/project.view.ts index 2de660b69..e91da7c2a 100644 --- a/examples/crm/src/views/project.view.ts +++ b/examples/crm/src/views/project.view.ts @@ -39,17 +39,17 @@ export const ProjectView = { sections: [ { label: 'Task Information', - columns: '2' as const, + columns: 2 as const, fields: ['name', 'status', 'priority', 'color', 'manager', 'assignee'], }, { label: 'Timeline & Progress', - columns: '2' as const, + columns: 2 as const, fields: ['start_date', 'end_date', 'progress', 'estimated_hours', 'actual_hours'], }, { label: 'Details', - columns: '1' as const, + columns: 1 as const, collapsible: true, fields: ['dependencies', 'description'], }, diff --git a/packages/fields/src/widgets/PercentField.tsx b/packages/fields/src/widgets/PercentField.tsx index 314c4331b..2fb339b94 100644 --- a/packages/fields/src/widgets/PercentField.tsx +++ b/packages/fields/src/widgets/PercentField.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { Input } from '@object-ui/components'; +import { Input, Slider } from '@object-ui/components'; import { FieldWidgetProps } from './types'; /** * PercentField - Percentage input with configurable decimal precision * Stores values as decimals (0-1) and displays as percentages (0-100%) + * Includes a slider for interactive control. */ export function PercentField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetProps) { const percentField = (field || (props as any).schema) as any; @@ -21,6 +22,7 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c // Convert between stored value (0-1) and display value (0-100) const displayValue = value != null ? (value * 100) : ''; + const sliderValue = value != null ? value * 100 : 0; const handleChange = (e: React.ChangeEvent) => { if (e.target.value === '') { @@ -32,22 +34,38 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c onChange(val as any); }; + const handleSliderChange = (values: number[]) => { + onChange(values[0] / 100); + }; + return ( -
- +
+ + + % + +
+ - - % -
); } diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index 5254653df..68f53c034 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -147,8 +147,13 @@ export const ModalForm: React.FC = ({ if (autoLayoutResult?.columns && autoLayoutResult.columns > 1) { return inferModalSize(autoLayoutResult.columns); } + // Auto-upgrade for sections: use the max columns across all sections + if (schema.sections?.length) { + const maxCols = Math.max(...schema.sections.map(s => Number(s.columns) || 1)); + if (maxCols > 1) return inferModalSize(maxCols); + } return 'default'; - }, [schema.modalSize, autoLayoutResult]); + }, [schema.modalSize, autoLayoutResult, schema.sections]); const sizeClass = modalSizeClasses[effectiveModalSize] || modalSizeClasses.default; diff --git a/packages/plugin-form/src/__tests__/MobileUX.test.tsx b/packages/plugin-form/src/__tests__/MobileUX.test.tsx index 607643bb7..a0417da47 100644 --- a/packages/plugin-form/src/__tests__/MobileUX.test.tsx +++ b/packages/plugin-form/src/__tests__/MobileUX.test.tsx @@ -321,3 +321,113 @@ describe('ModalForm Container Query Layout', () => { expect(gridEl).toBeNull(); }); }); + +describe('ModalForm Sections — Modal Size Auto-Upgrade', () => { + it('auto-upgrades modal to lg when sections use 2-column layout', async () => { + const mockDataSource = createMockDataSource(); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Create Task')).toBeInTheDocument(); + }); + + // Dialog should auto-upgrade to lg (max-w-2xl) because sections have columns: 2 + const dialogContent = document.querySelector('[role="dialog"]'); + expect(dialogContent).not.toBeNull(); + expect(dialogContent!.className).toContain('max-w-2xl'); + }); + + it('keeps default size when all sections use 1-column layout', async () => { + const mockDataSource = createMockDataSource(); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Create Task')).toBeInTheDocument(); + }); + + // Dialog should remain at default size (max-w-lg) + const dialogContent = document.querySelector('[role="dialog"]'); + expect(dialogContent).not.toBeNull(); + expect(dialogContent!.className).toContain('max-w-lg'); + }); + + it('respects explicit modalSize over section auto-upgrade', async () => { + const mockDataSource = createMockDataSource(); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Create Task')).toBeInTheDocument(); + }); + + // Explicit modalSize: 'sm' should override section auto-upgrade + const dialogContent = document.querySelector('[role="dialog"]'); + expect(dialogContent).not.toBeNull(); + expect(dialogContent!.className).toContain('max-w-sm'); + }); +}); From ee8b5cf571567a1bca42e52a12648619b7b9dd3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 13:05:30 +0000 Subject: [PATCH 3/5] fix: add aria-label and readonly support to PercentField slider Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/fields/src/widgets/PercentField.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/fields/src/widgets/PercentField.tsx b/packages/fields/src/widgets/PercentField.tsx index 2fb339b94..feb2e55ac 100644 --- a/packages/fields/src/widgets/PercentField.tsx +++ b/packages/fields/src/widgets/PercentField.tsx @@ -62,8 +62,9 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c min={0} max={100} step={1} - disabled={props.disabled} + disabled={readonly || props.disabled} className="w-full" + aria-label="Percentage" data-testid="percent-slider" /> From 5df7bf311b0b3cd74c641b1fc1b281b1784e0b7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 13:40:14 +0000 Subject: [PATCH 4/5] fix: revert project.view.ts columns to strings to match @objectstack/spec type The @objectstack/spec type expects form section columns as string literals ("1" | "2" | "3" | "4"), not numbers. ModalForm.tsx already uses Number() conversion to handle both formats. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- examples/crm/src/views/project.view.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/crm/src/views/project.view.ts b/examples/crm/src/views/project.view.ts index e91da7c2a..2de660b69 100644 --- a/examples/crm/src/views/project.view.ts +++ b/examples/crm/src/views/project.view.ts @@ -39,17 +39,17 @@ export const ProjectView = { sections: [ { label: 'Task Information', - columns: 2 as const, + columns: '2' as const, fields: ['name', 'status', 'priority', 'color', 'manager', 'assignee'], }, { label: 'Timeline & Progress', - columns: 2 as const, + columns: '2' as const, fields: ['start_date', 'end_date', 'progress', 'estimated_hours', 'actual_hours'], }, { label: 'Details', - columns: 1 as const, + columns: '1' as const, collapsible: true, fields: ['dependencies', 'description'], }, From dc0fad395d38d04e45d50b2f241dde9c6380f883 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 13:52:03 +0000 Subject: [PATCH 5/5] fix: harden PercentField slider with defensive checks, precision-based step, and tests - handleSliderChange: add readonly/disabled guard, validate array input, type-check value before division - Slider step now derives from precision (default 0.01) instead of hardcoded 1, matching the input granularity - Add 4 unit tests: slider rendering, disabled state, readonly exclusion, null value handling Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../fields/src/remaining-widgets.test.tsx | 26 +++++++++++++++++++ packages/fields/src/widgets/PercentField.tsx | 14 ++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/fields/src/remaining-widgets.test.tsx b/packages/fields/src/remaining-widgets.test.tsx index e49ba49d5..32e6d847c 100644 --- a/packages/fields/src/remaining-widgets.test.tsx +++ b/packages/fields/src/remaining-widgets.test.tsx @@ -92,6 +92,32 @@ describe('Remaining Field Widgets', () => { fireEvent.change(input, { target: { value: '75' } }); expect(handleChange).toHaveBeenCalledWith(0.75); }); + + it('slider renders and syncs with input value', () => { + render(); + const slider = screen.getByTestId('percent-slider'); + expect(slider).toBeInTheDocument(); + }); + + it('slider does not fire onChange when disabled', () => { + const handleChange = vi.fn(); + render(); + const slider = screen.getByTestId('percent-slider'); + expect(slider).toBeInTheDocument(); + // The slider should be disabled, so no changes should fire + expect(handleChange).not.toHaveBeenCalled(); + }); + + it('slider is not rendered in readonly mode', () => { + render(); + expect(screen.queryByTestId('percent-slider')).not.toBeInTheDocument(); + }); + + it('handles null value gracefully for slider', () => { + render(); + const slider = screen.getByTestId('percent-slider'); + expect(slider).toBeInTheDocument(); + }); }); // 5. ImageField diff --git a/packages/fields/src/widgets/PercentField.tsx b/packages/fields/src/widgets/PercentField.tsx index feb2e55ac..cad7d9951 100644 --- a/packages/fields/src/widgets/PercentField.tsx +++ b/packages/fields/src/widgets/PercentField.tsx @@ -35,9 +35,19 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c }; const handleSliderChange = (values: number[]) => { - onChange(values[0] / 100); + if (readonly || props.disabled) return; + if (!Array.isArray(values) || values.length === 0) { + onChange(null as any); + return; + } + const raw = values[0]; + const nextValue = typeof raw === 'number' ? raw / 100 : null; + onChange(nextValue as any); }; + // Derive slider step from precision so slider granularity matches the input + const sliderStep = Math.pow(10, -precision); + return (
@@ -61,7 +71,7 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c onValueChange={handleSliderChange} min={0} max={100} - step={1} + step={sliderStep} disabled={readonly || props.disabled} className="w-full" aria-label="Percentage"