Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions packages/fields/src/remaining-widgets.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<PercentField {...baseProps} value={0.5} />);
const slider = screen.getByTestId('percent-slider');
expect(slider).toBeInTheDocument();
});

it('slider does not fire onChange when disabled', () => {
const handleChange = vi.fn();
render(<PercentField {...baseProps} onChange={handleChange} value={0.5} disabled={true} />);
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(<PercentField {...baseProps} value={0.5} readonly={true} />);
expect(screen.queryByTestId('percent-slider')).not.toBeInTheDocument();
});

it('handles null value gracefully for slider', () => {
render(<PercentField {...baseProps} value={null as any} />);
const slider = screen.getByTestId('percent-slider');
expect(slider).toBeInTheDocument();
});
});

// 5. ImageField
Expand Down
57 changes: 43 additions & 14 deletions packages/fields/src/widgets/PercentField.tsx
Original file line number Diff line number Diff line change
@@ -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<number>) {
const percentField = (field || (props as any).schema) as any;
Expand All @@ -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<HTMLInputElement>) => {
if (e.target.value === '') {
Expand All @@ -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 (
<div className="relative">
<Input
{...props}
type="number"
value={displayValue}
onChange={handleChange}
placeholder={percentField?.placeholder || '0'}
<div className="space-y-2">
<div className="relative">
<Input
{...props}
type="number"
value={displayValue}
onChange={handleChange}
placeholder={percentField?.placeholder || '0'}
disabled={readonly || props.disabled}
className={`pr-8 ${className || ''}`}
step={Math.pow(10, -precision).toFixed(precision)}
aria-invalid={!!errorMessage}
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
%
</span>
</div>
<Slider
value={[sliderValue]}
onValueChange={handleSliderChange}
min={0}
max={100}
step={sliderStep}
disabled={readonly || props.disabled}
className={`pr-8 ${className || ''}`}
step={Math.pow(10, -precision).toFixed(precision)}
aria-invalid={!!errorMessage}
className="w-full"
aria-label="Percentage"
data-testid="percent-slider"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
%
</span>
</div>
);
}
7 changes: 6 additions & 1 deletion packages/plugin-form/src/ModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,13 @@ export const ModalForm: React.FC<ModalFormProps> = ({
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;

Expand Down
110 changes: 110 additions & 0 deletions packages/plugin-form/src/__tests__/MobileUX.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ModalForm
schema={{
type: 'object-form',
formType: 'modal',
objectName: 'events',
mode: 'create',
title: 'Create Task',
open: true,
sections: [
{
label: 'Task Information',
columns: 2,
fields: ['subject', 'start', 'end', 'location'],
},
{
label: 'Details',
columns: 1,
fields: ['description'],
},
],
}}
dataSource={mockDataSource as any}
/>
);

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(
<ModalForm
schema={{
type: 'object-form',
formType: 'modal',
objectName: 'events',
mode: 'create',
title: 'Create Task',
open: true,
sections: [
{
label: 'Basic Info',
columns: 1,
fields: ['subject', 'start'],
},
],
}}
dataSource={mockDataSource as any}
/>
);

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(
<ModalForm
schema={{
type: 'object-form',
formType: 'modal',
objectName: 'events',
mode: 'create',
title: 'Create Task',
open: true,
modalSize: 'sm',
sections: [
{
label: 'Task Information',
columns: 2,
fields: ['subject', 'start', 'end', 'location'],
},
],
}}
dataSource={mockDataSource as any}
/>
);

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