Skip to content

Commit 1ab58ce

Browse files
authored
Merge pull request #707 from objectstack-ai/copilot/optimize-modalform-responsiveness
2 parents 1e1b4d1 + dc0fad3 commit 1ab58ce

File tree

5 files changed

+186
-15
lines changed

5 files changed

+186
-15
lines changed

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
6464

6565
### P1.2 Console — Forms & Data Collection
6666

67+
- [x] ModalForm responsive optimization: sections layout auto-upgrades modal size, slider for percent/progress fields, tablet 2-column layout
6768
- [ ] Camera capture for mobile file upload
6869
- [ ] Image cropping/rotation in file fields
6970
- [ ] Cloud storage integration (S3, Azure Blob) for file upload

packages/fields/src/remaining-widgets.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,32 @@ describe('Remaining Field Widgets', () => {
9292
fireEvent.change(input, { target: { value: '75' } });
9393
expect(handleChange).toHaveBeenCalledWith(0.75);
9494
});
95+
96+
it('slider renders and syncs with input value', () => {
97+
render(<PercentField {...baseProps} value={0.5} />);
98+
const slider = screen.getByTestId('percent-slider');
99+
expect(slider).toBeInTheDocument();
100+
});
101+
102+
it('slider does not fire onChange when disabled', () => {
103+
const handleChange = vi.fn();
104+
render(<PercentField {...baseProps} onChange={handleChange} value={0.5} disabled={true} />);
105+
const slider = screen.getByTestId('percent-slider');
106+
expect(slider).toBeInTheDocument();
107+
// The slider should be disabled, so no changes should fire
108+
expect(handleChange).not.toHaveBeenCalled();
109+
});
110+
111+
it('slider is not rendered in readonly mode', () => {
112+
render(<PercentField {...baseProps} value={0.5} readonly={true} />);
113+
expect(screen.queryByTestId('percent-slider')).not.toBeInTheDocument();
114+
});
115+
116+
it('handles null value gracefully for slider', () => {
117+
render(<PercentField {...baseProps} value={null as any} />);
118+
const slider = screen.getByTestId('percent-slider');
119+
expect(slider).toBeInTheDocument();
120+
});
95121
});
96122

97123
// 5. ImageField
Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import React from 'react';
2-
import { Input } from '@object-ui/components';
2+
import { Input, Slider } from '@object-ui/components';
33
import { FieldWidgetProps } from './types';
44

55
/**
66
* PercentField - Percentage input with configurable decimal precision
77
* Stores values as decimals (0-1) and displays as percentages (0-100%)
8+
* Includes a slider for interactive control.
89
*/
910
export function PercentField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetProps<number>) {
1011
const percentField = (field || (props as any).schema) as any;
@@ -21,6 +22,7 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c
2122

2223
// Convert between stored value (0-1) and display value (0-100)
2324
const displayValue = value != null ? (value * 100) : '';
25+
const sliderValue = value != null ? value * 100 : 0;
2426

2527
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
2628
if (e.target.value === '') {
@@ -32,22 +34,49 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c
3234
onChange(val as any);
3335
};
3436

37+
const handleSliderChange = (values: number[]) => {
38+
if (readonly || props.disabled) return;
39+
if (!Array.isArray(values) || values.length === 0) {
40+
onChange(null as any);
41+
return;
42+
}
43+
const raw = values[0];
44+
const nextValue = typeof raw === 'number' ? raw / 100 : null;
45+
onChange(nextValue as any);
46+
};
47+
48+
// Derive slider step from precision so slider granularity matches the input
49+
const sliderStep = Math.pow(10, -precision);
50+
3551
return (
36-
<div className="relative">
37-
<Input
38-
{...props}
39-
type="number"
40-
value={displayValue}
41-
onChange={handleChange}
42-
placeholder={percentField?.placeholder || '0'}
52+
<div className="space-y-2">
53+
<div className="relative">
54+
<Input
55+
{...props}
56+
type="number"
57+
value={displayValue}
58+
onChange={handleChange}
59+
placeholder={percentField?.placeholder || '0'}
60+
disabled={readonly || props.disabled}
61+
className={`pr-8 ${className || ''}`}
62+
step={Math.pow(10, -precision).toFixed(precision)}
63+
aria-invalid={!!errorMessage}
64+
/>
65+
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
66+
%
67+
</span>
68+
</div>
69+
<Slider
70+
value={[sliderValue]}
71+
onValueChange={handleSliderChange}
72+
min={0}
73+
max={100}
74+
step={sliderStep}
4375
disabled={readonly || props.disabled}
44-
className={`pr-8 ${className || ''}`}
45-
step={Math.pow(10, -precision).toFixed(precision)}
46-
aria-invalid={!!errorMessage}
76+
className="w-full"
77+
aria-label="Percentage"
78+
data-testid="percent-slider"
4779
/>
48-
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
49-
%
50-
</span>
5180
</div>
5281
);
5382
}

packages/plugin-form/src/ModalForm.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,13 @@ export const ModalForm: React.FC<ModalFormProps> = ({
147147
if (autoLayoutResult?.columns && autoLayoutResult.columns > 1) {
148148
return inferModalSize(autoLayoutResult.columns);
149149
}
150+
// Auto-upgrade for sections: use the max columns across all sections
151+
if (schema.sections?.length) {
152+
const maxCols = Math.max(...schema.sections.map(s => Number(s.columns) || 1));
153+
if (maxCols > 1) return inferModalSize(maxCols);
154+
}
150155
return 'default';
151-
}, [schema.modalSize, autoLayoutResult]);
156+
}, [schema.modalSize, autoLayoutResult, schema.sections]);
152157

153158
const sizeClass = modalSizeClasses[effectiveModalSize] || modalSizeClasses.default;
154159

packages/plugin-form/src/__tests__/MobileUX.test.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,3 +321,113 @@ describe('ModalForm Container Query Layout', () => {
321321
expect(gridEl).toBeNull();
322322
});
323323
});
324+
325+
describe('ModalForm Sections — Modal Size Auto-Upgrade', () => {
326+
it('auto-upgrades modal to lg when sections use 2-column layout', async () => {
327+
const mockDataSource = createMockDataSource();
328+
329+
render(
330+
<ModalForm
331+
schema={{
332+
type: 'object-form',
333+
formType: 'modal',
334+
objectName: 'events',
335+
mode: 'create',
336+
title: 'Create Task',
337+
open: true,
338+
sections: [
339+
{
340+
label: 'Task Information',
341+
columns: 2,
342+
fields: ['subject', 'start', 'end', 'location'],
343+
},
344+
{
345+
label: 'Details',
346+
columns: 1,
347+
fields: ['description'],
348+
},
349+
],
350+
}}
351+
dataSource={mockDataSource as any}
352+
/>
353+
);
354+
355+
await waitFor(() => {
356+
expect(screen.getByText('Create Task')).toBeInTheDocument();
357+
});
358+
359+
// Dialog should auto-upgrade to lg (max-w-2xl) because sections have columns: 2
360+
const dialogContent = document.querySelector('[role="dialog"]');
361+
expect(dialogContent).not.toBeNull();
362+
expect(dialogContent!.className).toContain('max-w-2xl');
363+
});
364+
365+
it('keeps default size when all sections use 1-column layout', async () => {
366+
const mockDataSource = createMockDataSource();
367+
368+
render(
369+
<ModalForm
370+
schema={{
371+
type: 'object-form',
372+
formType: 'modal',
373+
objectName: 'events',
374+
mode: 'create',
375+
title: 'Create Task',
376+
open: true,
377+
sections: [
378+
{
379+
label: 'Basic Info',
380+
columns: 1,
381+
fields: ['subject', 'start'],
382+
},
383+
],
384+
}}
385+
dataSource={mockDataSource as any}
386+
/>
387+
);
388+
389+
await waitFor(() => {
390+
expect(screen.getByText('Create Task')).toBeInTheDocument();
391+
});
392+
393+
// Dialog should remain at default size (max-w-lg)
394+
const dialogContent = document.querySelector('[role="dialog"]');
395+
expect(dialogContent).not.toBeNull();
396+
expect(dialogContent!.className).toContain('max-w-lg');
397+
});
398+
399+
it('respects explicit modalSize over section auto-upgrade', async () => {
400+
const mockDataSource = createMockDataSource();
401+
402+
render(
403+
<ModalForm
404+
schema={{
405+
type: 'object-form',
406+
formType: 'modal',
407+
objectName: 'events',
408+
mode: 'create',
409+
title: 'Create Task',
410+
open: true,
411+
modalSize: 'sm',
412+
sections: [
413+
{
414+
label: 'Task Information',
415+
columns: 2,
416+
fields: ['subject', 'start', 'end', 'location'],
417+
},
418+
],
419+
}}
420+
dataSource={mockDataSource as any}
421+
/>
422+
);
423+
424+
await waitFor(() => {
425+
expect(screen.getByText('Create Task')).toBeInTheDocument();
426+
});
427+
428+
// Explicit modalSize: 'sm' should override section auto-upgrade
429+
const dialogContent = document.querySelector('[role="dialog"]');
430+
expect(dialogContent).not.toBeNull();
431+
expect(dialogContent!.className).toContain('max-w-sm');
432+
});
433+
});

0 commit comments

Comments
 (0)