Portal Community

Design Decisions

DecisionRationale
Single-column layout (100% width)No horizontal scrolling; thumb-reachable on all phones
Minimum touch target 48 pxWCAG 2.5.5 Target Size; reduces mis-taps on small screens
Sticky bottom action barSubmit 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 accordionReduces vertical length; user reveals only the current section
Progress indicator at topShows % 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.