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/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 314c4331b..cad7d9951 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,49 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c onChange(val as any); }; + const handleSliderChange = (values: number[]) => { + 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 ( -
- +
+ + + % + +
+ - - % -
); } 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'); + }); +});