Atlas Forms
Mobile Player Example
The mobile player renders every control in a single full-width column, enlarges touch targets, and adds a sticky bottom action bar. It is a practical template for field workers, inspection apps, and any scenario where users fill in forms on a phone.
Design Decisions
| Decision | Rationale |
|---|---|
| Single-column layout (100% width) | No horizontal scrolling; thumb-reachable on all phones |
| Minimum touch target 48 px | WCAG 2.5.5 Target Size; reduces mis-taps on small screens |
| Sticky bottom action bar | Submit always reachable without scrolling to the bottom |
| Large font size (16 px minimum) | Prevents iOS Safari from auto-zooming on input focus |
| Section title as collapsible accordion | Reduces vertical length; user reveals only the current section |
| Progress indicator at top | Shows % of visible controls that have non-empty values |
Complete Mobile Player
// src/players/MobilePlayer.tsx
import React, { useMemo, useState, useCallback } from 'react';
import { FormStateProvider } from '@atlas-forms/state-react';
import { ControlRenderer, useAtlasForm } from '@atlas-forms/player-components-react';
import { useFormVisibility } from '@atlas-forms/player-components-react';
import { FormEngine } from '@atlas-forms/form-engine-js';
import { parseSchema } from '@atlas-forms/schema-js';
import type { FormSchema, FormControl, FormSection } from '@atlas-forms/types-js';
// ── Helpers ─────────────────────────────────────────────────────────────────
function groupBySection(
controls: FormControl[],
sections: FormSection[]
): Map<string, FormControl[]> {
const map = new Map<string, FormControl[]>();
sections.forEach(s => map.set(s.id, []));
controls.forEach(c => {
const sid = c.sectionId ?? '__root__';
if (!map.has(sid)) map.set(sid, []);
map.get(sid)!.push(c);
});
return map;
}
function computeProgress(
controls: FormControl[],
values: Record<string, any>
): number {
if (controls.length === 0) return 100;
const filled = controls.filter(c => {
const v = values[c.id];
return v !== undefined && v !== null && v !== '';
}).length;
return Math.round((filled / controls.length) * 100);
}
// ── Inner component ──────────────────────────────────────────────────────────
const MobilePlayerInner: React.FC<{
onSubmit: (v: any) => Promise<void>;
onCancel?: () => void;
}> = ({ onSubmit, onCancel }) => {
const { engine, values, errors, isSubmitting, submitForm } = useAtlasForm();
const { isVisible, getVisibleControls } = useFormVisibility();
const schema = engine.getSchema();
const grouped = useMemo(() => groupBySection(schema.controls ?? [], schema.sections ?? []), [schema]);
const allVisible = useMemo(() => getVisibleControls(), [values]);
const progress = useMemo(() => computeProgress(allVisible, values), [allVisible, values]);
// Track which sections are expanded (all open by default)
const [expanded, setExpanded] = useState<Record<string, boolean>>(() => {
const init: Record<string, boolean> = {};
(schema.sections ?? []).forEach(s => { init[s.id] = true; });
return init;
});
const toggleSection = useCallback((id: string) => {
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
}, []);
const handleSubmit = async () => {
await submitForm();
const hasErrors = Object.keys(errors).length > 0;
if (!hasErrors) await onSubmit(values);
};
return (
<div style={{ paddingBottom: 80 /* space for sticky bar */ }}>
{/* Progress bar */}
<div style={{ padding: '12px 16px', background: '#f8f9fa', borderBottom: '1px solid #e5e7eb' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 13, color: '#6b7280' }}>Progress</span>
<span style={{ fontSize: 13, fontWeight: 600 }}>{progress}%</span>
</div>
<div style={{ height: 6, background: '#e5e7eb', borderRadius: 3 }}>
<div style={{
height: '100%',
width: `${progress}%`,
background: '#3b82f6',
borderRadius: 3,
transition: 'width 0.3s ease',
}} />
</div>
</div>
{/* Sections */}
{(schema.sections ?? []).map(section => {
const controls = (grouped.get(section.id) ?? []).filter(c => isVisible(c.id));
if (controls.length === 0) return null;
const isOpen = expanded[section.id] ?? true;
return (
<div key={section.id} style={{ marginBottom: 1 }}>
{/* Section header — touch target */}
<button
type="button"
onClick={() => toggleSection(section.id)}
style={{
width: '100%', minHeight: 48, padding: '12px 16px',
background: '#fff', border: 'none', borderBottom: '1px solid #e5e7eb',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
fontSize: 15, fontWeight: 600, cursor: 'pointer', textAlign: 'left',
}}
>
{section.title ?? section.id}
<span style={{ transform: isOpen ? 'rotate(180deg)' : 'none', transition: '0.2s' }}>▼</span>
</button>
{/* Controls */}
{isOpen && (
<div style={{ padding: '12px 16px', background: '#fff' }}>
{controls.map(control => (
<div key={control.id} style={{ marginBottom: 20 }}>
<ControlRenderer control={control} />
</div>
))}
</div>
)}
</div>
);
})}
{/* Error summary */}
{Object.keys(errors).length > 0 && (
<div style={{ margin: '12px 16px', padding: 12, background: '#fef2f2', border: '1px solid #fca5a5', borderRadius: 8 }}>
{Object.values(errors).map((msg, i) => (
<p key={i} style={{ margin: '4px 0', fontSize: 14, color: '#dc2626' }}>{msg}</p>
))}
</div>
)}
{/* Sticky action bar */}
<div style={{
position: 'fixed', bottom: 0, left: 0, right: 0,
padding: '12px 16px', background: '#fff',
borderTop: '1px solid #e5e7eb',
display: 'flex', gap: 10,
boxShadow: '0 -2px 8px rgba(0,0,0,0.08)',
}}>
{onCancel && (
<button
type="button"
onClick={onCancel}
style={{
flex: 1, minHeight: 48, border: '1px solid #d1d5db',
background: '#fff', borderRadius: 8, fontSize: 16, cursor: 'pointer',
}}
>
Cancel
</button>
)}
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
style={{
flex: 2, minHeight: 48, background: '#3b82f6',
color: '#fff', border: 'none', borderRadius: 8,
fontSize: 16, fontWeight: 600, cursor: 'pointer',
opacity: isSubmitting ? 0.7 : 1,
}}
>
{isSubmitting ? 'Saving...' : 'Submit'}
</button>
</div>
</div>
);
};
// ── Outer component ──────────────────────────────────────────────────────────
export const MobilePlayer: React.FC<{
schema: FormSchema | object;
initialValues?: Record<string, any>;
onSubmit: (v: any) => Promise<void>;
onCancel?: () => void;
}> = ({ schema: raw, initialValues = {}, onSubmit, onCancel }) => {
const schema = useMemo(() => parseSchema(raw as object), [raw]);
const engine = useMemo(() => new FormEngine(schema, initialValues), [schema, initialValues]);
return (
<FormStateProvider engine={engine} mode="edit">
<MobilePlayerInner onSubmit={onSubmit} onCancel={onCancel} />
</FormStateProvider>
);
};
Registering the Mobile Player
import { registerFormPlayer } from '@atlas-forms/player-registry';
import { MobilePlayer } from './MobilePlayer';
registerFormPlayer({
id: 'mobile',
label: 'Mobile',
description: 'Single-column layout with sticky action bar and collapsible sections. Optimised for small screens.',
component: MobilePlayer,
});
16 px Minimum Font Size
Keep all input labels and control text at 16 px or larger on mobile. iOS Safari automatically zooms into inputs when the font size is smaller than 16 px — this disrupts the layout and is frustrating for users. Set
font-size: 16px globally in your mobile player's stylesheet or inline styles.