Layout

Place fields side by side with row layouts.

By default every property of an object gets its own row. Add a layout to the object to put fields side by side.

layout only changes how the form looks. The data keeps the same shape as properties, so you can rearrange a form without touching the code that consumes its values.

Rows

layout is a list of rows. A list of property names renders those fields on one row, sharing the width equally.

optional
optional
<script setup lang="ts">
const schema = ref({
  type: 'object',
  required: [
    'firstname'
  ],
  layout: [
    [
      'firstname',
      'lastname'
    ]
  ],
  properties: {
    firstname: {
      title: 'First name',
      type: 'string',
      default: ''
    },
    lastname: {
      title: 'Last name',
      type: 'string',
      default: ''
    },
    email: {
      title: 'Email',
      type: 'string',
      default: ''
    }
  }
})
const model-value = ref({
  firstname: '',
  lastname: '',
  email: ''
})
</script>

<template>
  <SForm :model-value="model-value" :schema="schema" />
</template>

Properties that layout doesn't mention are rendered after the layout rows, full width, in their normal order. That's why email above needs no entry.

Widths

Use { "key": "...", "width": n } instead of a plain name to give a field a bigger share of its row. Widths are relative and default to 1, so 3 and 1 split a row ¾ and ¼.

optional
optional
<script setup lang="ts">
const schema = ref({
  type: 'object',
  required: [
    'street',
    'city'
  ],
  layout: [
    [
      {
        key: 'street',
        width: 3
      },
      'number'
    ],
    [
      'postcode',
      {
        key: 'city',
        width: 2
      }
    ]
  ],
  properties: {
    street: {
      title: 'Street',
      type: 'string',
      default: ''
    },
    number: {
      title: 'Number',
      type: 'string',
      default: ''
    },
    postcode: {
      title: 'Postcode',
      type: 'string',
      default: ''
    },
    city: {
      title: 'City',
      type: 'string',
      default: ''
    }
  }
})
const model-value = ref({
  street: '',
  number: '',
  postcode: '',
  city: ''
})
</script>

<template>
  <SForm :model-value="model-value" :schema="schema" />
</template>

Nested objects

layout applies to the object it's declared on, so each nested object can have its own.

{
  "type": "object",
  "required": ["guardian"],
  "properties": {
    "guardian": {
      "type": "object",
      "required": ["firstname", "email"],
      "layout": [["firstname", "lastname"], ["email", "phone"]],
      "properties": {
        "firstname": { "type": "string" },
        "lastname": { "type": "string" },
        "email": { "type": "string" },
        "phone": { "type": "string" }
      }
    }
  }
}

Rules

  • Each entry in layout is one row: either a list of fields, or a single field for a full-width row.
  • A field is a property name, or { "key": "<property>", "width": <number> }.
  • Rows render in layout order. Fields not in layout follow, full width, in properties order.
  • Unknown or repeated property names are skipped, with a warning in the console during development.
  • On small screens (below the sm breakpoint) rows stack vertically.

Migrating from SRow

Wrapping fields in an object with "renderer": "SRow" still works, but it's deprecated: the wrapper object also nests the fields' data, so firstname ends up at name.firstname.

{
  "type": "object",
  "required": ["name"],
  "properties": {
    "name": {
      "type": "object",
      "renderer": "SRow",
      "required": ["firstname"],
      "properties": {
        "firstname": { "type": "string" },
        "lastname": { "type": "string" }
      }
    }
  }
}

Move the fields up into the parent object, merge the wrapper's required into the parent's, and list the fields as a row in layout. Update any code that reads the old nested values.