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');
+ });
+});