diff --git a/changelog.md b/changelog.md
index 4c1460d6..8b94370f 100644
--- a/changelog.md
+++ b/changelog.md
@@ -6,6 +6,7 @@
- New _DataCard_ and _DataGroup_ components.
- Typography docs now cover font weight, line height and letter spacing scales, with improved Storybook examples.
+- `JsonFormsControls.layoutRenderers`: a JsonForms vertical layout with consistent spacing between fields.
### Changed
diff --git a/dev/src/App.tsx b/dev/src/App.tsx
index dd74376b..600518d1 100644
--- a/dev/src/App.tsx
+++ b/dev/src/App.tsx
@@ -20,6 +20,7 @@ import {
} from "@mui/material";
import {
Blocks as ComponentsIcon,
+ ClipboardList as FormsIcon,
Home as HomeIcon,
Layers as LayersIcon,
Menu as MenuIcon,
@@ -57,6 +58,8 @@ import { Breadcrumbs } from "../../src/components/navigation/Breadcrumbs";
import { Bar } from "../../src/components/controls/Bar";
import { Logo } from "../../src/components/controls/Logo";
+import { FormsPage } from "./FormsPage";
+
/* TABLE */
export const SimpleMuiTableExample = () => {
@@ -259,6 +262,11 @@ const navigation: Navigation = [
icon: ,
linkProps: { to: "/components", component: NavLink },
},
+ {
+ label: "Forms",
+ icon: ,
+ linkProps: { to: "/forms", component: NavLink },
+ },
{
label: "Colours",
icon: ,
@@ -330,6 +338,7 @@ const AppLayout = () => {
} />
} />
+ } />
} />
} />
} />
diff --git a/dev/src/FormsPage.tsx b/dev/src/FormsPage.tsx
new file mode 100644
index 00000000..7db8dee7
--- /dev/null
+++ b/dev/src/FormsPage.tsx
@@ -0,0 +1,487 @@
+import * as React from "react";
+import {
+ Autocomplete,
+ Box,
+ Button,
+ Checkbox,
+ FormControl,
+ FormControlLabel,
+ FormGroup,
+ FormHelperText,
+ FormLabel,
+ InputAdornment,
+ InputLabel,
+ MenuItem,
+ Radio,
+ RadioGroup,
+ Select,
+ Slider,
+ Stack,
+ Switch,
+ TextField,
+ Typography,
+} from "@mui/material";
+import { JsonForms } from "@jsonforms/react";
+import {
+ materialCells,
+ materialRenderers,
+} from "@jsonforms/material-renderers";
+import type { JsonSchema, UISchemaElement } from "@jsonforms/core";
+
+import { JsonFormsControls } from "../../src/index";
+
+const beamlines = ["I03", "I04", "I15-1", "I24", "B24", "DIAD"];
+
+const Section = ({
+ title,
+ description,
+ children,
+}: {
+ title: string;
+ description?: React.ReactNode;
+ children: React.ReactNode;
+}) => (
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+ {children}
+
+);
+
+const textFieldVariants = ["outlined", "filled", "standard"] as const;
+
+const MuiTextFields = () => (
+
+ {textFieldVariants.map((variant) => (
+
+
+ {variant}
+
+
+
+
+ keV,
+ },
+ }}
+ />
+
+
+
+
+
+ ))}
+
+);
+
+const MuiSelectionControls = () => {
+ const [beamline, setBeamline] = React.useState("I24");
+
+ return (
+
+
+
+ Beamline
+ setBeamline(event.target.value)}
+ >
+ {beamlines.map((b) => (
+
+ {b}
+
+ ))}
+
+
+
+ Read only
+
+ {beamlines.map((b) => (
+
+ {b}
+
+ ))}
+
+
+
+ Disabled
+
+ {beamlines.map((b) => (
+
+ {b}
+
+ ))}
+
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+
+ Energy (keV)
+
+
+
+
+
+
+
+ Detector options
+
+ }
+ label="Save raw frames"
+ />
+ }
+ label="Dark-field correction"
+ />
+ }
+ label="Disabled"
+ />
+
+
+
+ Scan type
+
+ } label="Step" />
+ } label="Fly" />
+ }
+ label="Grid"
+ disabled
+ />
+
+
+
+ Shutter
+ } label="Open shutter" />
+ Interlock not satisfied
+
+
+ Submit
+ Reset
+
+ Disabled
+
+
+
+
+ );
+};
+
+const jsonFormsRenderers = [
+ ...materialRenderers,
+ ...JsonFormsControls.layoutRenderers,
+];
+
+const jsonFormsSchema: JsonSchema = {
+ type: "object",
+ properties: {
+ sampleName: { type: "string", minLength: 3 },
+ proposal: { type: "string", pattern: "^[a-z]{2}[0-9]+$" },
+ beamline: { type: "string", enum: beamlines },
+ energy: { type: "number", minimum: 5, maximum: 25 },
+ exposures: { type: "integer", minimum: 1 },
+ scheduled: { type: "string", format: "date-time" },
+ saveRaw: { type: "boolean" },
+ notes: { type: "string" },
+ positions: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ label: { type: "string" },
+ x: { type: "number" },
+ y: { type: "number" },
+ },
+ },
+ },
+ },
+ required: ["sampleName", "proposal", "beamline"],
+};
+
+const jsonFormsUiSchema: UISchemaElement = {
+ type: "VerticalLayout",
+ elements: [
+ {
+ type: "HorizontalLayout",
+ elements: [
+ { type: "Control", scope: "#/properties/sampleName" },
+ { type: "Control", scope: "#/properties/proposal" },
+ { type: "Control", scope: "#/properties/beamline" },
+ ],
+ },
+ {
+ type: "HorizontalLayout",
+ elements: [
+ { type: "Control", scope: "#/properties/energy" },
+ { type: "Control", scope: "#/properties/exposures" },
+ { type: "Control", scope: "#/properties/scheduled" },
+ ],
+ },
+ { type: "Control", scope: "#/properties/saveRaw" },
+ {
+ type: "Control",
+ scope: "#/properties/notes",
+ options: { multi: true },
+ },
+ { type: "Control", scope: "#/properties/positions" },
+ ],
+} as UISchemaElement;
+
+const initialJsonFormsData = {
+ sampleName: "Lysozyme",
+ proposal: "cm12345",
+ beamline: "I24",
+ energy: 12.4,
+ exposures: 10,
+ scheduled: "2026-10-06T09:30:00.000Z",
+ saveRaw: true,
+ positions: [{ label: "P1", x: 0.1, y: 0.2 }],
+};
+
+const JsonFormsExample = () => {
+ const [data, setData] = React.useState(initialJsonFormsData);
+ const [readonly, setReadonly] = React.useState(false);
+
+ return (
+
+ setReadonly(event.target.checked)}
+ />
+ }
+ label="Read only form (uses JsonFormsControls)"
+ />
+ setData(data)}
+ />
+
+ {JSON.stringify(data, null, 2)}
+
+
+ );
+};
+
+const comparisonSchema: JsonSchema = {
+ type: "object",
+ properties: {
+ sampleId: { type: "string" },
+ status: { type: "string", enum: ["Queued", "Running", "Completed"] },
+ energy: { type: "number" },
+ scheduled: { type: "string", format: "date-time" },
+ saveRaw: { type: "boolean" },
+ },
+};
+
+const comparisonData = {
+ sampleId: "SAMPLE-0042",
+ status: "Queued",
+ energy: 12.4,
+ scheduled: "2026-10-06T09:30:00.000Z",
+ saveRaw: true,
+};
+
+const comparisonUiSchema = (controlExtras: object = {}) =>
+ ({
+ type: "VerticalLayout",
+ elements: Object.keys(comparisonData).map((key) => ({
+ type: "Control",
+ scope: `#/properties/${key}`,
+ ...controlExtras,
+ })),
+ }) as UISchemaElement;
+
+const comparisonColumns = [
+ {
+ title: "sci-react-ui text controls",
+ caption: "Form-wide readonly + JsonFormsControls.rendererControls",
+ uischema: comparisonUiSchema(),
+ renderers: [...jsonFormsRenderers, ...JsonFormsControls.rendererControls],
+ cells: [...materialCells, ...JsonFormsControls.cellControls],
+ readonly: true,
+ },
+ {
+ title: "Disabled",
+ caption: "DISABLE rule",
+ uischema: comparisonUiSchema({
+ // An empty schema always matches, so the rule always applies.
+ rule: { effect: "DISABLE", condition: { scope: "#", schema: {} } },
+ }),
+ renderers: jsonFormsRenderers,
+ cells: materialCells,
+ },
+];
+
+const ReadonlyVsDisabledExample = () => (
+
+ {comparisonColumns.map(({ title, caption, ...formProps }) => (
+
+ {title}
+
+ {caption}
+
+
+
+ ))}
+
+);
+
+export const FormsPage = () => (
+
+ Forms
+
+
+
+
+
+
+ Material renderers plus JsonFormsControls, as in the
+ Helpers/JsonForms guide. Toggle read only to swap disabled inputs for
+ the accessible text controls.
+ >
+ }
+ >
+
+
+
+
+ JsonForms' material renderers don't distinguish read only
+ from disabled: both render as disabled inputs. The sci-react-ui text
+ controls replace them for read-only forms.
+ >
+ }
+ >
+
+
+
+);
diff --git a/src/components/helpers/jsonforms/index.ts b/src/components/helpers/jsonforms/index.ts
index 1b4b013f..c8c7f756 100644
--- a/src/components/helpers/jsonforms/index.ts
+++ b/src/components/helpers/jsonforms/index.ts
@@ -12,6 +12,7 @@ import {
CellTextDateTimeControl,
CellTextDateTimeControlTester,
} from "./controls/CellTextDateTimeControl";
+import { VerticalLayout, VerticalLayoutTester } from "./layouts/VerticalLayout";
const rendererControls = [
{ renderer: TextControl, tester: TextControlTester },
@@ -21,8 +22,12 @@ const cellControls = [
{ cell: CellTextControl, tester: CellTextControlTester },
{ cell: CellTextDateTimeControl, tester: CellTextDateTimeControlTester },
];
+const layoutRenderers = [
+ { renderer: VerticalLayout, tester: VerticalLayoutTester },
+];
export const JsonFormsControls = {
rendererControls,
cellControls,
+ layoutRenderers,
};
diff --git a/src/components/helpers/jsonforms/layouts/VerticalLayout.test.tsx b/src/components/helpers/jsonforms/layouts/VerticalLayout.test.tsx
new file mode 100644
index 00000000..c2dbdc5b
--- /dev/null
+++ b/src/components/helpers/jsonforms/layouts/VerticalLayout.test.tsx
@@ -0,0 +1,58 @@
+import { render, screen } from "@testing-library/react";
+import { JsonForms } from "@jsonforms/react";
+import {
+ materialCells,
+ materialRenderers,
+} from "@jsonforms/material-renderers";
+import type { UISchemaElement } from "@jsonforms/core";
+
+import { VerticalLayout, VerticalLayoutTester } from "./VerticalLayout";
+
+const schema = {
+ type: "object",
+ properties: {
+ name: { type: "string" },
+ hidden: { type: "string" },
+ },
+};
+
+const uischema = {
+ type: "VerticalLayout",
+ elements: [
+ { type: "Control", scope: "#/properties/name" },
+ {
+ type: "Control",
+ scope: "#/properties/hidden",
+ rule: { effect: "HIDE", condition: { scope: "#", schema: {} } },
+ },
+ ],
+} as UISchemaElement;
+
+const renderForm = () =>
+ render(
+ ,
+ );
+
+describe("VerticalLayout", () => {
+ test("renders visible fields", () => {
+ renderForm();
+ expect(screen.getByLabelText("Name")).toBeInTheDocument();
+ expect(screen.queryByLabelText("Hidden")).not.toBeInTheDocument();
+ });
+
+ test("leaves hidden fields' wrappers empty so they take no gap", () => {
+ const { container } = renderForm();
+ const wrappers = container.querySelectorAll(".MuiStack-root > div");
+ expect(wrappers).toHaveLength(2);
+ expect(wrappers[1]).toBeEmptyDOMElement();
+ });
+});
diff --git a/src/components/helpers/jsonforms/layouts/VerticalLayout.tsx b/src/components/helpers/jsonforms/layouts/VerticalLayout.tsx
new file mode 100644
index 00000000..f065f284
--- /dev/null
+++ b/src/components/helpers/jsonforms/layouts/VerticalLayout.tsx
@@ -0,0 +1,49 @@
+import { JsonFormsDispatch, withJsonFormsLayoutProps } from "@jsonforms/react";
+import {
+ LayoutProps,
+ rankWith,
+ uiTypeIs,
+ VerticalLayout as VerticalLayoutSchema,
+} from "@jsonforms/core";
+import { Stack } from "@mui/material";
+
+// Material's own vertical layout (rank 1) stacks fields with no gap.
+const VerticalLayoutTester = rankWith(2, uiTypeIs("VerticalLayout"));
+
+const VerticalLayoutComponent = ({
+ uischema,
+ schema,
+ path,
+ enabled,
+ visible,
+ renderers,
+ cells,
+}: LayoutProps) => {
+ const { elements } = uischema as VerticalLayoutSchema;
+ if (!visible || elements.length === 0) return null;
+
+ return (
+ :empty": { display: "none" } }}
+ >
+ {elements.map((child, index) => (
+
+
+
+ ))}
+
+ );
+};
+
+const VerticalLayout = withJsonFormsLayoutProps(VerticalLayoutComponent);
+export { VerticalLayout, VerticalLayoutTester };
diff --git a/src/storybook/helpers/JsonForms.mdx b/src/storybook/helpers/JsonForms.mdx
index ee72f18d..8d7f66a1 100644
--- a/src/storybook/helpers/JsonForms.mdx
+++ b/src/storybook/helpers/JsonForms.mdx
@@ -52,6 +52,11 @@ import {Box, TextField, Typography} from "@mui/material";
You can also add your own renderers, make sure they have a higher rank than SciReactUI ones which are currently
set at 10, and 15
+ For consistent spacing between stacked fields (material's vertical layout has none), add the layout renderer:
+ ```jsx
+ renderers={[...materialRenderers, ...JsonFormsControls.layoutRenderers]}
+ ```
+
To style them, use these classes:
```css
.data-box {}