Skip to content
design
Components

Schema form

A form drawn from the schema bosund serves for a record, with its groups, priorities, inputs, conditions and constraints.

On this page
6 / 200
The primary vessel's details from bosund's own vessel schema: secondary and advanced groups, an MMSI that names its station, mast height and keel type that leave when the propulsion is motor, read-only timestamps, and each field saved on its own as it changes, the first save refused once.
Code
import type { Suggestion } from "@bosunkit/components/autocomplete";
import {
  SchemaForm,
  type FieldOption,
  type FieldState,
  type SchemaField,
  type FormSchema,
  type FormValues,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "The primary vessel's details as bosund serves them: eight groups of secondary and advanced fields, an MMSI that " +
  "names its station, the mast height and keel type that leave when the propulsion is motor, read-only timestamps, " +
  "help under its field, and each field saved on its own as it changes, the first save refused once.";

// SCHEMA and ENUM_OPTIONS are bosund's own: `GET /forms/vessel`, and `GET /forms/meta/<source>` for each enum source
// it names. A throwaway Go program printed them, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults and the
// fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor("vessel") and forms.GetMeta as JSON.
// bosund registers each option with its value as its label; ui/web looks `form.option.<source>.<value>` up first.
const SCHEMA: FormSchema = {
  entity: "vessel",
  package: "fleet",
  save_mode: "auto",
  groups: [
    { id: "identity", label: "identity", weight: 10 },
    { id: "radio", label: "radio", weight: 70 },
    { id: "hull", label: "hull", weight: 110 },
    { id: "dimensions", label: "dimensions", weight: 130 },
    { id: "ground_tackle", label: "ground tackle", weight: 190 },
    { id: "engine", label: "engine", weight: 250 },
    { id: "capacities", label: "capacities", weight: 280 },
    { id: "notes", label: "notes", weight: 320 },
  ],
  fields: [
    {
      name: "name",
      label: "Name",
      type: "string",
      priority: "primary",
      required: true,
      min: 1,
      max: 200,
      min_length: 1,
      max_length: 200,
      weight: 10,
      group: "identity",
      autofocus: true,
    },
    {
      name: "make",
      label: "Make",
      type: "autocomplete",
      priority: "secondary",
      max: 100,
      autocomplete: "vessel_makes",
      weight: 20,
      group: "identity",
      min_chars: 2,
    },
    {
      name: "model",
      label: "Model",
      type: "autocomplete",
      priority: "secondary",
      max: 100,
      autocomplete: "vessel_models",
      weight: 30,
      group: "identity",
      min_chars: 2,
    },
    {
      name: "hull",
      label: "Hull",
      type: "enum",
      priority: "secondary",
      one_of: ["monohull", "catamaran", "trimaran"],
      enum: "hull_types",
      weight: 35,
      group: "identity",
    },
    {
      name: "propulsion",
      label: "Propulsion",
      type: "enum",
      priority: "secondary",
      one_of: ["sail", "motor", "motorsailer"],
      enum: "propulsion_types",
      weight: 36,
      group: "identity",
    },
    { name: "is_primary", label: "Primary Vessel", type: "bool", priority: "secondary", weight: 50, group: "identity" },
    {
      name: "home_port",
      label: "Home Port",
      type: "autocomplete",
      priority: "secondary",
      max: 200,
      autocomplete: "ports",
      weight: 60,
      group: "identity",
      min_chars: 2,
    },
    {
      name: "mmsi",
      label: "MMSI",
      help: "9-digit Maritime Mobile Service Identity",
      type: "string",
      priority: "secondary",
      min_length: 9,
      max_length: 9,
      pattern: "^\\d{9}$",
      weight: 70,
      group: "radio",
      placeholder: "123456789",
      keyboard: "numeric",
    },
    {
      name: "call_sign",
      label: "Call Sign",
      type: "string",
      priority: "secondary",
      max: 10,
      max_length: 10,
      weight: 80,
      group: "radio",
      placeholder: "ZL1234",
    },
    {
      name: "flag",
      label: "Flag",
      help: "Country of registration",
      type: "string",
      priority: "advanced",
      max: 50,
      max_length: 50,
      weight: 90,
      group: "identity",
    },
    {
      name: "hin",
      label: "HIN",
      help: "Hull Identification Number",
      type: "string",
      priority: "advanced",
      max: 50,
      max_length: 50,
      weight: 100,
      group: "identity",
    },
    {
      name: "imo_number",
      label: "IMO Number",
      help: "IMO ship identification number",
      type: "string",
      priority: "advanced",
      max: 20,
      max_length: 20,
      weight: 101,
      group: "identity",
    },
    {
      name: "classification_society",
      label: "Classification Society",
      type: "string",
      priority: "advanced",
      max: 100,
      max_length: 100,
      weight: 102,
      group: "identity",
    },
    {
      name: "registered_owner",
      label: "Registered Owner",
      type: "string",
      priority: "advanced",
      max: 200,
      max_length: 200,
      weight: 103,
      group: "identity",
    },
    {
      name: "manager",
      label: "Manager",
      type: "string",
      priority: "advanced",
      max: 200,
      max_length: 200,
      weight: 104,
      group: "identity",
    },
    {
      name: "year_built",
      label: "Year Built",
      type: "int",
      priority: "advanced",
      min: 1800,
      max: 2100,
      weight: 105,
      group: "identity",
      keyboard: "numeric",
    },
    {
      name: "hull_material",
      label: "Hull Material",
      type: "enum",
      priority: "advanced",
      max: 50,
      enum: "hull_materials",
      weight: 110,
      group: "hull",
    },
    {
      name: "hull_color",
      label: "Hull Color",
      type: "string",
      priority: "advanced",
      max: 50,
      max_length: 50,
      weight: 120,
      group: "hull",
    },
    {
      name: "loa_m",
      label: "LOA",
      type: "float",
      priority: "secondary",
      min: 0,
      max: 500,
      weight: 130,
      group: "dimensions",
      unit: "m",
      keyboard: "decimal",
      decimal_places: 2,
    },
    {
      name: "registered_length_m",
      label: "Registered Length",
      type: "float",
      priority: "advanced",
      min: 0,
      max: 500,
      weight: 135,
      group: "dimensions",
      unit: "m",
      keyboard: "decimal",
      decimal_places: 2,
    },
    {
      name: "beam_m",
      label: "Beam",
      type: "float",
      priority: "secondary",
      min: 0,
      max: 100,
      weight: 140,
      group: "dimensions",
      unit: "m",
      keyboard: "decimal",
      decimal_places: 2,
    },
    {
      name: "draft_m",
      label: "Draft",
      help: "Critical for grounding avoidance",
      type: "float",
      priority: "secondary",
      critical: true,
      min: 0,
      max: 50,
      weight: 150,
      group: "dimensions",
      unit: "m",
      keyboard: "decimal",
      decimal_places: 2,
    },
    {
      name: "mast_height_m",
      label: "Mast Height",
      help: "Critical for bridge clearance",
      type: "float",
      priority: "secondary",
      critical: true,
      visible_if: "data.propulsion != 'motor'",
      min: 0,
      max: 100,
      weight: 160,
      group: "dimensions",
      unit: "m",
      keyboard: "decimal",
      decimal_places: 1,
    },
    {
      name: "displacement_kg",
      label: "Displacement",
      type: "float",
      priority: "advanced",
      min: 0,
      weight: 170,
      group: "dimensions",
      unit: "kg",
      keyboard: "numeric",
    },
    {
      name: "gross_tonnage",
      label: "Gross Tonnage",
      type: "float",
      priority: "advanced",
      min: 0,
      weight: 175,
      group: "dimensions",
      keyboard: "numeric",
    },
    {
      name: "depth_transducer_offset_m",
      label: "Depth Transducer Offset",
      help: "Depth below the waterline where the sounder transducer is mounted; lets bosun report true depth below the surface",
      type: "float",
      priority: "advanced",
      min: 0,
      max: 5,
      weight: 175,
      group: "dimensions",
      unit: "m",
      keyboard: "decimal",
      decimal_places: 2,
    },
    {
      name: "keel_type",
      label: "Keel Type",
      type: "enum",
      priority: "advanced",
      visible_if: "data.propulsion != 'motor'",
      max: 50,
      enum: "keel_types",
      weight: 180,
      group: "dimensions",
    },
    {
      name: "anchor_type",
      label: "Anchor Type",
      type: "enum",
      priority: "advanced",
      max: 50,
      enum: "anchor_types",
      weight: 190,
      group: "ground_tackle",
    },
    {
      name: "chain_length_m",
      label: "Chain Length",
      help: "For scope calculation",
      type: "float",
      priority: "advanced",
      min: 0,
      max: 500,
      weight: 200,
      group: "ground_tackle",
      unit: "m",
      keyboard: "decimal",
      decimal_places: 0,
    },
    {
      name: "chain_factor",
      label: "Chain Factor",
      help: "Typical scope ratio (e.g. 5:1)",
      type: "float",
      priority: "advanced",
      min: 1,
      max: 20,
      weight: 210,
      group: "ground_tackle",
      placeholder: "5",
      keyboard: "decimal",
      decimal_places: 1,
    },
    {
      name: "engine_type",
      label: "Engine Type",
      type: "enum",
      priority: "advanced",
      one_of: ["diesel", "petrol", "electric", "hybrid"],
      enum: "engine_types",
      weight: 250,
      group: "engine",
    },
    {
      name: "fuel_capacity_l",
      label: "Fuel Capacity",
      help: "For range planning",
      type: "float",
      priority: "secondary",
      min: 0,
      weight: 280,
      group: "capacities",
      unit: "L",
      keyboard: "numeric",
    },
    {
      name: "water_capacity_l",
      label: "Water Capacity",
      type: "float",
      priority: "secondary",
      min: 0,
      weight: 290,
      group: "capacities",
      unit: "L",
      keyboard: "numeric",
    },
    {
      name: "battery_capacity_ah",
      label: "Battery Capacity",
      type: "float",
      priority: "advanced",
      min: 0,
      weight: 300,
      group: "capacities",
      unit: "Ah",
      keyboard: "numeric",
    },
    {
      name: "nominal_voltage",
      label: "Nominal Voltage",
      help: "DC house bank voltage",
      type: "enum",
      value_type: "float",
      priority: "advanced",
      one_of: ["6", "12", "24", "32", "36", "42", "48"],
      enum: "nominal_voltages",
      weight: 305,
      group: "capacities",
      unit: "V",
    },
    {
      name: "notes",
      label: "Notes",
      type: "text",
      component: "textarea",
      priority: "advanced",
      weight: 320,
      group: "notes",
      rows: 3,
    },
    { name: "created_at", label: "Created At", type: "datetime", readonly: true, weight: 900, group: "notes" },
    { name: "updated_at", label: "Updated At", type: "datetime", readonly: true, weight: 910, group: "notes" },
  ],
  collection: "/fleet/vessels",
  item: "/fleet/vessels/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
  anchor_types: [
    { value: "cqr", label: "cqr" },
    { value: "delta", label: "delta" },
    { value: "rocna", label: "rocna" },
    { value: "mantus", label: "mantus" },
    { value: "bruce", label: "bruce" },
    { value: "danforth", label: "danforth" },
    { value: "fortress", label: "fortress" },
    { value: "spade", label: "spade" },
  ],
  engine_types: [
    { value: "diesel", label: "diesel" },
    { value: "petrol", label: "petrol" },
    { value: "electric", label: "electric" },
    { value: "hybrid", label: "hybrid" },
  ],
  hull_materials: [
    { value: "grp", label: "grp" },
    { value: "steel", label: "steel" },
    { value: "aluminum", label: "aluminum" },
    { value: "wood", label: "wood" },
    { value: "ferro", label: "ferro" },
    { value: "carbon", label: "carbon" },
  ],
  hull_types: [
    { value: "monohull", label: "monohull" },
    { value: "catamaran", label: "catamaran" },
    { value: "trimaran", label: "trimaran" },
  ],
  keel_types: [
    { value: "fin", label: "fin" },
    { value: "full", label: "full" },
    { value: "lifting", label: "lifting" },
    { value: "centerboard", label: "centerboard" },
    { value: "bulb", label: "bulb" },
    { value: "wing", label: "wing" },
  ],
  nominal_voltages: [
    { value: "6", label: "6" },
    { value: "12", label: "12" },
    { value: "24", label: "24" },
    { value: "32", label: "32" },
    { value: "36", label: "36" },
    { value: "42", label: "42" },
    { value: "48", label: "48" },
  ],
  propulsion_types: [
    { value: "sail", label: "sail" },
    { value: "motor", label: "motor" },
    { value: "motorsailer", label: "motorsailer" },
  ],
};

/** The page holds many forms, and each schema's autofocus field would pull it down to the last; examples drop it. */
const SHOWN: FormSchema = {
  ...SCHEMA,
  fields: SCHEMA.fields.map((field: SchemaField): SchemaField => ({ ...field, autofocus: false })),
};

/** The vessel as `GET /fleet/vessels/{id}` answers: a Hanse 460 with a single diesel. */
const ALTHEA: FormValues = {
  id: "7c1e4d2a-5b8f-4e61-9a3d-2f6b8c0e1a47",
  name: "Althea",
  make: "Hanse",
  model: "460",
  hull: "monohull",
  propulsion: "sail",
  home_port: "Palma de Mallorca",
  mmsi: "235012345",
  call_sign: "MXYZ5",
  flag: "United Kingdom",
  year_built: 2018,
  hull_material: "grp",
  hull_color: "White",
  loa_m: 14.4,
  beam_m: 4.4,
  draft_m: 2.25,
  mast_height_m: 21.5,
  displacement_kg: 12800,
  depth_transducer_offset_m: 0.45,
  keel_type: "bulb",
  anchor_type: "rocna",
  chain_length_m: 70,
  chain_factor: 5,
  engine_type: "diesel",
  fuel_capacity_l: 220,
  water_capacity_l: 450,
  battery_capacity_ah: 400,
  nominal_voltage: 12,
  notes: "Bow thruster breaker under the forward berth.",
  created_at: "2026-03-14T09:12:00Z",
  updated_at: "2026-10-09T17:45:00Z",
};

/**
 * Stands in for `POST /forms/vessel/eval`. The schema's two conditions are `data.propulsion != 'motor'`, on
 * mast_height_m and keel_type. With no propulsion the expression has no key to read and fails, and bosund shows a
 * field whose visible_if fails (reference/forms/eval.go), so both stay until the propulsion is motor.
 */
function evaluate(values: FormValues): Promise<Readonly<Record<string, FieldState>>> {
  const visible: boolean = values.propulsion !== "motor";
  const state: FieldState = { visible, required: false, editable: true, readonly: false };
  return Promise.resolve({ mast_height_m: state, keel_type: state });
}

/** What the vessel's autocomplete sources answer: names from the fleet's records and the place catalog. */
const SUGGESTIONS: Readonly<Record<string, readonly string[]>> = {
  vessel_makes: ["Hanse", "Hallberg-Rassy", "Highfield"],
  vessel_models: ["460", "HR 44", "CL 340"],
  ports: ["Palma de Mallorca", "Port d'Andratx", "Puerto Portals", "Port de Sóller", "Ciutadella", "Mahón"],
};

const suggestFor: SchemaFormProps["suggestFor"] =
  (source: string): ReturnType<SchemaFormProps["suggestFor"]> =>
  (query: string): Promise<readonly Suggestion[]> =>
    Promise.resolve(
      (SUGGESTIONS[source] ?? [])
        .filter((name: string): boolean => name.toLowerCase().includes(query.toLowerCase()))
        .map((name: string): Suggestion => ({ value: name, label: name })),
    );

function ignore(): void {
  // The example has nowhere to go back to.
}

export default function Showcase(): ReactElement {
  const [attempts, setAttempts]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);

  // The vessel schema saves each field on its own (save_mode auto): the first save is refused, the rest land.
  async function save(): Promise<void> {
    await new Promise<void>((resolve: () => void): void => {
      setTimeout(resolve, 700);
    });
    setAttempts((current: number): number => current + 1);
    if (attempts === 0) {
      throw new Error(
        "Couldn't save this field because Bosun didn't answer. Your change is kept here; leave the field to save it again.",
      );
    }
  }

  return (
    <SchemaForm
      id="vessel-details"
      schema={SHOWN}
      enumOptions={ENUM_OPTIONS}
      evaluate={evaluate}
      suggestFor={suggestFor}
      initial={ALTHEA}
      fixed={{ is_primary: true }}
      submitAction={save}
      onCancel={ignore}
    />
  );
}

A schema form draws a record's form from the schema bosund serves for it. bosund writes the schema from the form:"…" and validate:"…" tags on the record's Go struct (reference/forms), so a field's label, type, group, limits and conditions are written once, beside the field, and every form for that record follows them.

The form takes five things from bosund through its caller: the schema (GET /forms/{entity}), the options of each enum source it names (GET /forms/meta/{source}), the suggestions of each autocomplete source (GET /forms/autocomplete/{source}?q=), the name of a record whose id a field holds (GET /forms/autocomplete/{source}?value=), and the state of the fields that carry a CEL condition (POST /forms/{entity}/eval). The kit component asks for none of them: enumOptions, suggestFor, labelFor and evaluate are props, and the caller makes the requests. A save checks the schema's constraints in the browser first; bosund's own save of the record decides.

01The record describes its formA field's label, input, group and limits come from its struct tags. A form that needs a field drawn differently changes the tag, so every form for that record changes with it.
02bosund decides the conditionsThe form never evaluates CEL. It asks bosund 150 ms after the values stop changing, and while bosund has not answered, or cannot, every field keeps the state its schema gives it.
03The save is bosund'sThe browser refuses what the schema refuses, so the crew learn of a missing name before the request. bosund validates the record again, and only its answer says the record is saved.

How a schema field is drawn

The schema saysThe form draws
stringA text input; keyboard=tel or url makes it a tel or url input that keeps the text as typed
string with keyboard=emailThe email input, which offers the domain after the @
string with the e164 patternThe phone input, which saves E.164
string with the mmsi patternThe MMSI input, which names the station and saves the digits
string with component=vhf_channelThe VHF channel input, which writes the frequencies, refuses a channel with no voice, questions 16 unless purpose is monitored, and saves the designator
string with one_of and no enum sourceA select of the allowed values; more than ten, a combo box that narrows as the crew type
textA text area of rows lines that the crew drag taller
int, floatA number input with min, max, a step of decimal_places and its unit inside the field
float named lat, with a lon in its groupOne position input for both, in degrees and minutes, with the vessel's position from defaultSources.position, saving decimal degrees in each
int, float with component=hour_meterThe hour meter input, reading decimal hours or hours and minutes, saving the tenth
int with component=scale and a scaleThe scale picker of that scale's levels, saving the level's code
enum with component=scale, scale=douglasThe scale picker of the Douglas sea states, saving the state's word (calm_glassy … phenomenal)
int, float with unit m, L or ktsThe quantity input, typed in feet, gallons or km/h through its unit button and saved in the schema's unit, to decimal_places, rounded the way rounding says
int, float with unit ° from 0 to 360The bearing input, in the north reference names (°T when unset), saying the point from or to when sense is set, saving whole degrees
boolA switch
dateThe date picker on the UTC calendar, saving the ISO date
timeThe time picker, its unit printed as the reference
datetimeThe date and time input, read in UTC with UTC beside the time, saving the instant
enumA select of the schema's options, else the source's, else one_of, with "—" to clear it unless required; more than ten, a combo box that narrows as the crew type; value_type saves a number
autocompleteAn autocomplete that asks suggestFor after min_chars characters
autocomplete with autocomplete_idsA combobox that shows a record's name and saves its id, naming a held id through labelFor
array with enum, one_of or optionsA checkbox group of the choices, saving the ones ticked
arrayA text area with one item per line, or a JSON array when item_type is json
jsonA text area of JSON, parsed on save
componentThe kit input the field asks for: slider a slider with step, marks and a soft_max limit; checkbox a checkbox; segmented segments for two to five choices; cards radio cards with each choice's description; chips a list of words added with Enter, up to max_items; password a password input; yaml the YAML editor; date_of_birth the date of birth input, also for autofill=bday; combobox a list searched by typing; money a money input of the amount and the field currency_field names, drawn as one; rating a rating out of max; signature a signature input whose SVG the field holds
min_date, max_dateThe calendar's earliest and latest day: an ISO date, or the day the form opens
max_rowsThe most lines a text area grows to
copyable, with readonlyThe value as text with a copy button inside the box, copying the value as stored
defaultA new record's starting value, counted as unchanged: a literal of the field's type; now, the UTC date, time or date and time; position.lat and position.lon, from defaultSources.position; skipper, from defaultSources.skipper, its id in a crew_ids field and its name in any other; last, the value saved with the last new record, kept under lastValuesKey. A source that is unknown leaves the field empty
autofillWhat the browser may fill the field with: name, email, tel, bday and the like
A group's descriptionA line under the group's heading, at the top of its tab, and on its wizard step
unit, prefixText after or before the input
width quarter, third, half, fullThat share of the group's twelve columns; unset, the width its value needs (below)
priority secondary, advanced"More fields", open; "Advanced fields", closed; one "More fields" when a group has only one kind; a group with no primary field draws its secondary ones at once
criticalA bar at the field's edge
readonly, readonly_if, editable_ifA field the crew read and cannot change
required, required_ifAn asterisk, a save refused while it is empty, and the field drawn at once whatever its priority
visible_ifThe field leaves while the condition is false
help, descriptionBoth under the field, always shown
autofocusThe focus when the form opens, whatever the input
save_mode auto, on the form or fieldWhile a record is edited, the field saves on its own as it changes, with no Save button

How a field looks

Every field draws outlined: its name on the box's top edge, resting in the middle of an empty box, its description and help under it, its error in their place, a prefix such as Ch and a unit such as m inside the box, read after the value; a field with a prefix keeps its name on the edge. An amount of money is a money field (component: "money" with its currency_field), drawn as the money input with its currency, not a prefix. A phone number's extension is a text field of the same group with autofill: "tel-extension", drawn inside the phone input after the number and saved in its own field. A switch has no box and stands with its name beside it.

A field is checked as the crew leave it, once they have typed something: a wrong format or a value too long shows its message there, not at Save, and a field showing a message is checked again as they type, so the message changes as they fix it and leaves when it is fixed. An empty field they move past says nothing until Save. The kit's own inputs (email, phone, MMSI, the dates, the password) check themselves and keep their own messages. A text field with a max_length counts its characters in its hint line ("5 / 7") once the crew type, and near the limit for a long one. It never cuts what is typed or pasted: past the limit the count goes on ("9 / 7") and the field says how long it may be. A tab or wizard step whose required fields are all filled, with nothing to fix, is marked done.

A new record keeps its draft: given draftKey, what the crew type is kept in the browser until it is saved, and a form closed half-filled opens again as they left it, saying so, with Start over to empty it. A wizard's draft keeps the step too: it opens again on the step the crew left it on, with every step they had reached open to them. A record being edited keeps no draft. onDirtyChange says when the values start or stop differing from those the form opened with, so the dialog around it can ask before it discards them. With addAnother, a new record's form offers Save and add another, which saves through submitAction with { another: true } and empties the form for the next record, the focus on its first field.

How the fields are laid out

Each group's fields sit in rows of twelve columns, in their weight order. A field takes the share its width names, or else what its value needs:

The fieldIts share of the row
A switch, a number with its unit, a time, text up to 10 charactersA quarter
A date, a date and time, an MMSI, a list of short choices, text up to 24 charactersA third
A name or other text up to 200 characters, an email address, a phone number, a looked-up record, a list of longer choicesHalf
Long text, a list, JSON, text longer than 200 characters, a list of sentence-long choicesThe row

A row of several fields that ends short gives its spare columns to its last text or list field, so the row ends square; numbers, dates and switches keep their width, and so does a field alone on its row. The form measures its own width, not the screen's: narrower than 36em, as in a dialog or a side panel, two quarter fields share a row and anything wider takes the row. A group's label starts with a capital, since bosund writes it from the group's id in lower case.

How the groups are arranged

The schema's layout, or the form's layout prop over it, sets how the groups are arranged:

layoutThe form draws
autoTabs for a form of four or more groups and sixteen or more fields, which would otherwise run several screens; sections for the rest
sectionsEach group under its heading, one after another
tabsA tab for each group; on a form 48em wide or more with five or more groups, the tabs stand in a column beside the fields, as panes
wizardA step for each group while a record is created; a record being edited opens in tabs, so the crew go straight to the field they came to change

Fields no listed group holds are drawn above the tabs. A save the form refuses opens the tab that holds the first field to fix, and each tab with fields to fix says how many beside its name.

Ctrl+S, or Command+S on a Mac, submits the form from any of its fields as Enter does: it saves, or on a wizard step before the last, moves to the next step. The Save button names the keys in aria-keyshortcuts.

When to use it

Use a schema form for a record bosund describes with form tags, on a screen whose job is to create or edit that record: a crew member, a guest, a contact, a place, the vessel, its equipment, documents and sails, a maintenance task, an expense, a log entry. The entity list opens one in its dialog.

Build the form by hand when the screen is about something other than the record's fields:

  • a value set from a chart or an instrument, such as a passage plan's waypoints, which belong on the chart;
  • one setting saved on its own as the crew change it, which is a setting control;
  • a title or single value renamed in place, which is an inline edit;
  • a step of onboarding or of a conversation, where the order and the words matter more than the record;
  • an action that acts on the vessel at once, such as raising an anchor alarm, which is a button with a confirmation.

Examples

Every field type

Field types
6 / 200
2 November 2026
Occurred at (datetime)
UTC

9 October 2026, 17:45 UTC

Yachtmaster STCW etc
One field of each of the twelve types reference/forms knows, filled in, with the values a save sends shown below.
Code
import type { Suggestion } from "@bosunkit/components/autocomplete";
import {
  SchemaForm,
  type FieldOption,
  type FieldState,
  type FormSchema,
  type FormValues,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "One field of each of the twelve types reference/forms knows, filled in, with the values a save sends shown below.";

/**
 * No bosund schema uses all twelve types, and none uses date, time or json yet, so this one is assembled: each
 * field copies the field named in its comment, and the three unused types are written as their struct tags would
 * give them (`form:"type=date"`, `form:"type=time"`, and a map field, which reference/forms types as json).
 */
const SCHEMA: FormSchema = {
  entity: "field_types",
  groups: [{ id: "types", label: "field types", weight: 10 }],
  fields: [
    // vessel.name
    {
      name: "name",
      label: "Name (string)",
      type: "string",
      required: true,
      max_length: 200,
      weight: 10,
      group: "types",
    },
    // vessel.notes
    { name: "notes", label: "Notes (text)", type: "text", component: "textarea", rows: 3, weight: 20, group: "types" },
    // vessel.year_built
    {
      name: "year_built",
      label: "Year built (int)",
      type: "int",
      min: 1800,
      max: 2100,
      keyboard: "numeric",
      weight: 30,
      group: "types",
      width: "half",
    },
    // vessel.draft_m
    {
      name: "draft_m",
      label: "Draft (float)",
      type: "float",
      min: 0,
      max: 50,
      unit: "m",
      decimal_places: 2,
      keyboard: "decimal",
      weight: 40,
      group: "types",
      width: "half",
    },
    // crew_member.has_own_lifejacket
    { name: "has_own_lifejacket", label: "Own life jacket (bool)", type: "bool", weight: 50, group: "types" },
    // A `time.Time` tagged type=date: the day a haul-out is booked.
    { name: "haul_out", label: "Haul-out (date)", type: "date", weight: 60, group: "types", width: "half" },
    // A string tagged type=time: high water at the home port.
    { name: "high_water", label: "High water (time)", type: "time", weight: 70, group: "types", width: "half" },
    // expense.occurred_at
    { name: "occurred_at", label: "Occurred at (datetime)", type: "datetime", weight: 80, group: "types" },
    // vessel.hull
    {
      name: "hull",
      label: "Hull (enum)",
      type: "enum",
      enum: "hull_types",
      one_of: ["monohull", "catamaran", "trimaran"],
      weight: 90,
      group: "types",
      width: "half",
    },
    // vessel.home_port
    {
      name: "home_port",
      label: "Home port (autocomplete)",
      type: "autocomplete",
      autocomplete: "ports",
      min_chars: 2,
      weight: 100,
      group: "types",
      width: "half",
    },
    // crew_member.certifications
    {
      name: "certifications",
      label: "Certifications (array)",
      type: "array",
      item_type: "string",
      help: "Yachtmaster STCW etc",
      weight: 110,
      group: "types",
    },
    // A map field: the anchor watch's alarm limits.
    { name: "alarm_limits", label: "Alarm limits (json)", type: "json", weight: 120, group: "types" },
  ],
};

const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
  hull_types: [
    { value: "monohull", label: "monohull" },
    { value: "catamaran", label: "catamaran" },
    { value: "trimaran", label: "trimaran" },
  ],
};

const INITIAL: FormValues = {
  name: "Althea",
  notes: "Bow thruster breaker under the forward berth.",
  year_built: 2018,
  draft_m: 2.25,
  has_own_lifejacket: true,
  haul_out: "2026-11-02",
  high_water: "14:52",
  occurred_at: "2026-10-09T17:45:00Z",
  hull: "monohull",
  home_port: "Palma de Mallorca",
  certifications: ["Yachtmaster Offshore", "STCW Basic Safety"],
  alarm_limits: { drag_m: 15, gust_kn: 30 },
};

const PORTS: readonly string[] = ["Palma de Mallorca", "Port d'Andratx", "Puerto Portals", "Port de Sóller"];

const suggestFor: SchemaFormProps["suggestFor"] =
  (): ReturnType<SchemaFormProps["suggestFor"]> =>
  (query: string): Promise<readonly Suggestion[]> =>
    Promise.resolve(
      PORTS.filter((port: string): boolean => port.toLowerCase().includes(query.toLowerCase())).map(
        (port: string): Suggestion => ({ value: port, label: port }),
      ),
    );

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

export default function FieldTypes(): ReactElement {
  const [sent, setSent]: [FormValues | null, Dispatch<SetStateAction<FormValues | null>>] = useState<FormValues | null>(
    null,
  );
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
      <SchemaForm
        id="field-types"
        schema={SCHEMA}
        enumOptions={ENUM_OPTIONS}
        evaluate={noConditions}
        suggestFor={suggestFor}
        initial={INITIAL}
        submitAction={(values: FormValues): void => {
          setSent(values);
        }}
      />
      {sent !== null && (
        <pre
          style={{
            fontFamily: "var(--font-mono)",
            fontSize: 11,
            color: "var(--text-dim)",
            margin: 0,
            whiteSpace: "pre-wrap",
            overflowWrap: "anywhere",
          }}
        >
          {JSON.stringify(sent, null, 2)}
        </pre>
      )}
    </div>
  );
}

Groups, priorities and widths

Groups come in their weight order, and fields within a group in theirs. Fields with no group, or a group the schema does not list, come first.

Customs and immigration can clear the vessel in here.
Position
A new place: eleven groups in weight order, the identity fields always shown, the depths and weather under an open More fields, the swinging radius under the anchorage's closed Advanced fields, the marina and cost fields folded, and latitude and longitude, the two depths, and country and region in half-width pairs.
Code
import {
  SchemaForm,
  type FieldOption,
  type FieldState,
  type SchemaField,
  type FormSchema,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";

export const description: string =
  "A new place: eleven groups in weight order, the identity fields always shown, the depths and weather under an " +
  "open More fields, the swinging radius under the anchorage's closed Advanced fields, the marina and cost fields " +
  "folded, and latitude and longitude, the two depths, and country and region in half-width pairs.";

// bosund's `GET /forms/place`, and the options of the enum sources it names.
// A throwaway Go program printed SCHEMA and ENUM_OPTIONS, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults
// and the fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor and forms.GetMeta as JSON.
const SCHEMA: FormSchema = {
  entity: "place",
  package: "ops",
  save_mode: "auto",
  groups: [
    { id: "identity", label: "identity", weight: 10 },
    { id: "location", label: "location", weight: 30 },
    { id: "depths", label: "depths", weight: 70 },
    { id: "anchor", label: "anchor", weight: 90 },
    { id: "weather", label: "weather", weight: 120 },
    { id: "contact", label: "contact", weight: 150 },
    { id: "marina", label: "marina", weight: 190 },
    { id: "cost", label: "cost", weight: 230 },
    { id: "notes", label: "notes", weight: 260 },
    { id: "personal", label: "personal", weight: 270 },
    { id: "safety", label: "safety", weight: 320 },
  ],
  fields: [
    {
      name: "name",
      label: "Name",
      type: "string",
      priority: "primary",
      required: true,
      min: 1,
      max: 200,
      min_length: 1,
      max_length: 200,
      weight: 10,
      group: "identity",
      autofocus: true,
    },
    {
      name: "type",
      label: "Type",
      type: "enum",
      priority: "primary",
      required: true,
      one_of: ["anchorage", "marina", "port", "mooring", "fuel"],
      enum: "place_types",
      weight: 20,
      group: "identity",
    },
    {
      name: "is_port_of_entry",
      label: "Port of Entry",
      help: "Customs and immigration can clear the vessel in here.",
      type: "bool",
      priority: "primary",
      weight: 25,
      group: "identity",
    },
    {
      name: "country",
      label: "Country",
      type: "string",
      priority: "advanced",
      max: 100,
      max_length: 100,
      weight: 30,
      group: "location",
      width: "half",
    },
    {
      name: "region",
      label: "Region",
      type: "string",
      priority: "advanced",
      max: 100,
      max_length: 100,
      weight: 40,
      group: "location",
      width: "half",
    },
    {
      name: "lat",
      label: "Lat",
      type: "float",
      priority: "primary",
      required: true,
      min: -90,
      max: 90,
      weight: 50,
      group: "identity",
      width: "half",
      keyboard: "decimal",
      decimal_places: 6,
    },
    {
      name: "lon",
      label: "Lon",
      type: "float",
      priority: "primary",
      required: true,
      min: -180,
      max: 180,
      weight: 60,
      group: "identity",
      width: "half",
      keyboard: "decimal",
      decimal_places: 6,
    },
    {
      name: "depth_min_m",
      label: "Min Depth",
      help: "Grounding risk!",
      type: "float",
      priority: "secondary",
      critical: true,
      weight: 70,
      group: "depths",
      unit: "m",
      width: "half",
      keyboard: "decimal",
      decimal_places: 1,
    },
    {
      name: "depth_max_m",
      label: "Max Depth",
      type: "float",
      priority: "secondary",
      weight: 80,
      group: "depths",
      unit: "m",
      width: "half",
      keyboard: "decimal",
      decimal_places: 1,
    },
    {
      name: "holding_quality",
      label: "Holding",
      type: "enum",
      priority: "secondary",
      enum: "holding_quality",
      weight: 90,
      group: "anchor",
    },
    {
      name: "bottom_type",
      label: "Bottom",
      type: "enum",
      priority: "secondary",
      enum: "bottom_types",
      weight: 100,
      group: "anchor",
    },
    {
      name: "swinging_radius_m",
      label: "Swinging Radius",
      type: "float",
      priority: "advanced",
      weight: 110,
      group: "anchor",
      unit: "m",
      keyboard: "decimal",
      decimal_places: 0,
    },
    {
      name: "protected_from",
      label: "Protected From",
      type: "array",
      item_type: "string",
      priority: "secondary",
      weight: 120,
      group: "weather",
    },
    {
      name: "exposed_to",
      label: "Exposed To",
      type: "array",
      item_type: "string",
      priority: "secondary",
      critical: true,
      weight: 130,
      group: "weather",
    },
    {
      name: "max_wind_kts",
      label: "Max Wind",
      help: "Safety limit",
      type: "int",
      priority: "secondary",
      critical: true,
      weight: 140,
      group: "weather",
      unit: "kts",
      keyboard: "numeric",
    },
    {
      name: "vhf_channel",
      label: "VHF Channel",
      type: "string",
      priority: "secondary",
      weight: 150,
      group: "contact",
      placeholder: "16",
    },
    {
      name: "phone",
      label: "Phone",
      type: "string",
      priority: "advanced",
      weight: 160,
      group: "contact",
      keyboard: "tel",
    },
    {
      name: "email",
      label: "Email",
      type: "string",
      priority: "advanced",
      weight: 170,
      group: "contact",
      keyboard: "email",
    },
    {
      name: "website",
      label: "Website",
      type: "string",
      priority: "advanced",
      weight: 180,
      group: "contact",
      keyboard: "url",
    },
    {
      name: "berth_count",
      label: "Berths",
      type: "int",
      priority: "advanced",
      weight: 190,
      group: "marina",
      keyboard: "numeric",
    },
    {
      name: "max_loa_m",
      label: "Max LOA",
      type: "float",
      priority: "advanced",
      weight: 200,
      group: "marina",
      unit: "m",
      keyboard: "decimal",
      decimal_places: 1,
    },
    {
      name: "max_draft_m",
      label: "Max Draft",
      type: "float",
      priority: "advanced",
      weight: 210,
      group: "marina",
      unit: "m",
      keyboard: "decimal",
      decimal_places: 1,
    },
    {
      name: "facilities",
      label: "Facilities",
      type: "array",
      item_type: "string",
      priority: "advanced",
      weight: 220,
      group: "marina",
    },
    {
      name: "daily_rate",
      label: "Daily Rate",
      type: "float",
      priority: "advanced",
      weight: 230,
      group: "cost",
      keyboard: "decimal",
      decimal_places: 2,
    },
    {
      name: "currency",
      label: "Currency",
      type: "enum",
      priority: "advanced",
      enum: "currencies",
      weight: 240,
      group: "cost",
      width: "third",
    },
    { name: "rate_notes", label: "Rate Notes", type: "string", priority: "advanced", weight: 250, group: "cost" },
    {
      name: "approach_notes",
      label: "Approach Notes",
      type: "text",
      component: "textarea",
      priority: "advanced",
      weight: 260,
      group: "notes",
      rows: 3,
    },
    {
      name: "rating",
      label: "Rating",
      type: "int",
      priority: "advanced",
      min: 1,
      max: 5,
      weight: 270,
      group: "personal",
      keyboard: "numeric",
    },
    { name: "last_visited", label: "Last Visited", type: "datetime", readonly: true, weight: 280, group: "personal" },
    { name: "visit_count", label: "Visit Count", type: "int", readonly: true, weight: 290, group: "personal" },
    {
      name: "personal_notes",
      label: "Personal Notes",
      type: "text",
      component: "textarea",
      priority: "advanced",
      weight: 300,
      group: "personal",
      rows: 3,
    },
    { name: "recommended", label: "Recommended", type: "bool", priority: "advanced", weight: 310, group: "personal" },
    {
      name: "hazards",
      label: "Hazards",
      type: "text",
      component: "textarea",
      priority: "secondary",
      critical: true,
      weight: 320,
      group: "safety",
      rows: 2,
    },
    {
      name: "tags",
      label: "Tags",
      type: "array",
      item_type: "string",
      priority: "advanced",
      weight: 330,
      group: "personal",
    },
    { name: "created_at", label: "Created At", type: "datetime", readonly: true, weight: 900, group: "personal" },
    { name: "updated_at", label: "Updated At", type: "datetime", readonly: true, weight: 910, group: "personal" },
  ],
  collection: "/ops/places",
  item: "/ops/places/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
  bottom_types: [
    { value: "sand", label: "sand" },
    { value: "mud", label: "mud" },
    { value: "rock", label: "rock" },
    { value: "weed", label: "weed" },
    { value: "coral", label: "coral" },
    { value: "gravel", label: "gravel" },
    { value: "clay", label: "clay" },
  ],
  currencies: [
    { value: "EUR", label: "EUR" },
    { value: "USD", label: "USD" },
    { value: "GBP", label: "GBP" },
    { value: "AUD", label: "AUD" },
    { value: "CAD", label: "CAD" },
    { value: "NZD", label: "NZD" },
    { value: "JPY", label: "JPY" },
    { value: "CHF", label: "CHF" },
    { value: "NOK", label: "NOK" },
    { value: "SEK", label: "SEK" },
    { value: "DKK", label: "DKK" },
  ],
  holding_quality: [
    { value: "excellent", label: "excellent" },
    { value: "good", label: "good" },
    { value: "fair", label: "fair" },
    { value: "poor", label: "poor" },
  ],
  place_types: [
    { value: "anchorage", label: "anchorage" },
    { value: "marina", label: "marina" },
    { value: "mooring", label: "mooring" },
    { value: "fuel_dock", label: "fuel_dock" },
    { value: "town_quay", label: "town_quay" },
  ],
};

/** The page holds many forms, and each schema's autofocus field would pull it down to the last; examples drop it. */
const SHOWN: FormSchema = {
  ...SCHEMA,
  fields: SCHEMA.fields.map((field: SchemaField): SchemaField => ({ ...field, autofocus: false })),
};

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

const noSuggestions: SchemaFormProps["suggestFor"] =
  (): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
    Promise.resolve([]);

function ignore(): void {
  // The example saves nothing.
}

export default function GroupsAndPriorities(): ReactElement {
  return (
    <SchemaForm
      id="new-place"
      schema={SHOWN}
      enumOptions={ENUM_OPTIONS}
      evaluate={noConditions}
      suggestFor={noSuggestions}
      submitAction={ignore}
      submitLabel="Add place"
    />
  );
}

Conditions

A field's visible_if, required_if, editable_if and readonly_if are CEL expressions over data, the form's values, the actor's role, which bosund takes from the session, and the onboarding phase. A condition that fails to evaluate leaves the field shown and editable, and its required and read-only state as the schema sets them.

Ground tackle
Chain out plus the vessel's length; choose the rode first.
rpm
An anchoring record whose fields follow its CEL conditions: Chain out shows only for chain, the rope notes are required for rope and editable once a rode is chosen, the swing radius is read-only until then, and the test rpm shows and is required once the holding is tested.
Code
import {
  SchemaForm,
  type FieldOption,
  type FieldState,
  type FormSchema,
  type FormValues,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";

export const description: string =
  "An anchoring record whose fields follow its CEL conditions: Chain out shows only for chain, the rope notes are " +
  "required for rope and editable once a rode is chosen, the swing radius is read-only until then, and the test " +
  "rpm shows and is required once the holding is tested.";

/**
 * The vessel's two `visible_if` conditions are the only ones in bosund's schemas, so this schema is written to show
 * all four kinds. bosund evaluates them with cel-go over `data`, the form's values (reference/forms/eval.go).
 */
const SCHEMA: FormSchema = {
  entity: "anchoring",
  groups: [{ id: "tackle", label: "ground tackle", weight: 10 }],
  fields: [
    {
      name: "rode",
      label: "Rode",
      type: "enum",
      enum: "rode_kinds",
      required: true,
      weight: 10,
      group: "tackle",
      width: "half",
    },
    {
      name: "chain_out",
      label: "Chain out",
      type: "float",
      unit: "m",
      decimal_places: 0,
      max: 80,
      weight: 20,
      group: "tackle",
      width: "half",
      visible_if: 'data.rode == "chain"',
    },
    {
      name: "rode_notes",
      label: "Rope and chain lengths",
      type: "text",
      rows: 2,
      weight: 30,
      group: "tackle",
      placeholder: "For example: 10 m of chain, then 40 m of 16 mm nylon",
      required_if: 'data.rode == "rope"',
      editable_if: "has(data.rode)",
    },
    {
      name: "swing_radius",
      label: "Swing radius",
      type: "int",
      unit: "m",
      min: 10,
      weight: 40,
      group: "tackle",
      width: "half",
      readonly_if: "!has(data.rode)",
      help: "Chain out plus the vessel's length; choose the rode first.",
    },
    { name: "holding_tested", label: "Holding tested", type: "bool", weight: 50, group: "tackle", width: "half" },
    {
      name: "test_rpm",
      label: "Tested at",
      type: "int",
      unit: "rpm",
      min: 800,
      max: 3600,
      weight: 60,
      group: "tackle",
      width: "half",
      visible_if: "data.holding_tested == true",
      required_if: "data.holding_tested == true",
    },
    { name: "recorded_by", label: "Recorded by", type: "string", readonly: true, weight: 70, group: "tackle" },
  ],
};

const RODE_KINDS: Readonly<Record<string, readonly FieldOption[]>> = {
  rode_kinds: [
    { value: "chain", label: "All chain" },
    { value: "rope", label: "Chain and rope" },
  ],
};

/**
 * Stands in for `POST /forms/anchoring/eval`, which answers every field. A value the crew cleared is left out of the
 * request, so `has(data.rode)` is false for it; a comparison against a missing key fails, and bosund then shows the
 * field, keeps it editable, and leaves required and read-only at the schema's own (reference/forms/api/handlers.go).
 */
function evaluate(values: FormValues): Promise<Readonly<Record<string, FieldState>>> {
  const rode: unknown = values.rode;
  const rodeSet: boolean = rode !== undefined;
  const tested: boolean | undefined = values.holding_tested === undefined ? undefined : values.holding_tested === true;
  return Promise.resolve({
    // visible_if: data.rode == "chain"; fails, and shows the field, while no rode is set.
    chain_out: { visible: !rodeSet || rode === "chain", required: false, editable: true, readonly: false },
    // required_if: data.rode == "rope"; editable_if: has(data.rode).
    rode_notes: { visible: true, required: rode === "rope", editable: rodeSet, readonly: false },
    // readonly_if: !has(data.rode); a read-only field is not editable.
    swing_radius: { visible: true, required: false, editable: rodeSet, readonly: !rodeSet },
    // visible_if and required_if: data.holding_tested == true; fails, and shows the field, while unset.
    test_rpm: { visible: tested !== false, required: tested === true, editable: true, readonly: false },
  });
}

const noSuggestions: SchemaFormProps["suggestFor"] =
  (): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
    Promise.resolve([]);

function ignore(): void {
  // The example saves nothing.
}

export default function Conditions(): ReactElement {
  return (
    <SchemaForm
      id="conditions"
      schema={SCHEMA}
      enumOptions={RODE_KINDS}
      evaluate={evaluate}
      suggestFor={noSuggestions}
      initial={{ holding_tested: false, recorded_by: "Maya Lindqvist" }}
      submitAction={ignore}
    />
  );
}

Constraint validation

reference/forms has no CEL validation rules: the constraints come from validate tags, and CEL only decides a field's state. A refused save names each problem under its field, opens a folded section that holds one, and moves the focus to the first.

Identity
12 / 10
Values that break the schema's constraints: a press on Save names each problem under its field, opens the folded section that holds one, and sends nothing.
Code
import { SchemaForm, type FieldState, type FormSchema, type SchemaFormProps } from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";

export const description: string =
  "Values that break the schema's constraints: a press on Save names each problem under its field, opens the " +
  "folded section that holds one, and sends nothing.";

/**
 * Each field copies the constraints bosund's schemas give the field named in its comment; go-playground `validate`
 * tags set them (reference/forms/parse.go: `max=10` on a string sets max_length, `min`/`max` on a number its bounds,
 * `imo` the IMO pattern, `max=5` on a slice max_items).
 */
const SCHEMA: FormSchema = {
  entity: "vessel",
  groups: [{ id: "identity", label: "identity", weight: 10 }],
  fields: [
    // vessel.name: required, 1 to 200 characters.
    {
      name: "name",
      label: "Name",
      type: "string",
      required: true,
      min_length: 1,
      max_length: 200,
      weight: 10,
      group: "identity",
    },
    // vessel.call_sign: at most 10 characters.
    {
      name: "call_sign",
      label: "Call Sign",
      type: "string",
      max_length: 10,
      placeholder: "ZL1234",
      weight: 20,
      group: "identity",
      width: "half",
    },
    // `validate:"imo"`: IMO and seven digits.
    {
      name: "imo_number",
      label: "IMO Number",
      type: "string",
      pattern: "^IMO\\d{7}$",
      weight: 30,
      group: "identity",
      width: "half",
    },
    // vessel.year_built: 1800 to 2100, in the advanced fields.
    {
      name: "year_built",
      label: "Year Built",
      type: "int",
      min: 1800,
      max: 2100,
      keyboard: "numeric",
      priority: "advanced",
      weight: 40,
      group: "identity",
      width: "half",
    },
    // vessel.chain_factor: 1 to 20.
    {
      name: "chain_factor",
      label: "Chain Factor",
      type: "float",
      min: 1,
      max: 20,
      decimal_places: 1,
      keyboard: "decimal",
      priority: "advanced",
      weight: 50,
      group: "identity",
      width: "half",
    },
    // sail.reefs: at most five reef points, as numbers.
    {
      name: "reefs",
      label: "Reefs",
      type: "array",
      item_type: "float",
      max_items: 5,
      placeholder: "One reef depth per line",
      priority: "advanced",
      weight: 60,
      group: "identity",
    },
  ],
};

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

const noSuggestions: SchemaFormProps["suggestFor"] =
  (): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
    Promise.resolve([]);

function ignore(): void {
  // Nothing reaches it: every save is refused.
}

export default function Validation(): ReactElement {
  return (
    <SchemaForm
      id="validation"
      schema={SCHEMA}
      enumOptions={{}}
      evaluate={noConditions}
      suggestFor={noSuggestions}
      initial={{
        call_sign: "MXYZ5-ALTHEA",
        imo_number: "9074729",
        year_built: 1790,
        chain_factor: 0.5,
        reefs: [1.2, 2.4, 3.6, 4.8, 6, 7.2],
      }}
      submitAction={ignore}
    />
  );
}

Required field missing

Identity
Anchoring
Chain or rope
Emergency
Who to call in emergency
An empty form: a press on Save shows the message under the required Name field and saves nothing.
Code
import {
  SchemaForm,
  type FieldOption,
  type FieldState,
  type FormSchema,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";

export const description: string =
  "An empty form: a press on Save shows the message under the required Name field and saves nothing.";

const SCHEMA: FormSchema = {
  entity: "crew_member",
  package: "ops",
  groups: [
    { id: "identity", label: "identity", weight: 10 },
    { id: "anchoring", label: "anchoring", weight: 20 },
    { id: "emergency", label: "emergency", weight: 70 },
  ],
  fields: [
    {
      name: "name",
      label: "Name",
      type: "string",
      priority: "primary",
      required: true,
      min_length: 1,
      max_length: 200,
      weight: 10,
      group: "identity",
      placeholder: "Full name",
      width: "half",
    },
    {
      name: "role",
      label: "Role",
      type: "enum",
      priority: "primary",
      required: true,
      one_of: ["skipper", "mate", "crew", "guest"],
      enum: "crew_roles",
      weight: 20,
      group: "identity",
      width: "half",
    },
    {
      name: "home_port",
      label: "Home port",
      type: "autocomplete",
      priority: "primary",
      autocomplete: "places",
      min_chars: 2,
      weight: 25,
      group: "identity",
      placeholder: "Port",
    },
    {
      name: "nickname",
      label: "Nickname",
      type: "string",
      priority: "advanced",
      max_length: 100,
      weight: 30,
      group: "identity",
      placeholder: "Call sign",
    },
    {
      name: "date_of_birth",
      label: "Date of Birth",
      help: "For legal and medical purposes",
      type: "date",
      priority: "advanced",
      weight: 35,
      group: "identity",
    },
    {
      name: "kind",
      label: "Kind",
      help: "Chain or rope",
      type: "string",
      priority: "primary",
      weight: 10,
      group: "anchoring",
    },
    {
      name: "chain_length",
      label: "Chain length",
      type: "float",
      priority: "primary",
      weight: 20,
      group: "anchoring",
      unit: "m",
      decimal_places: 1,
      visible_if: 'data.kind == "chain"',
    },
    {
      name: "rode_notes",
      label: "Rode notes",
      type: "text",
      priority: "primary",
      weight: 30,
      group: "anchoring",
      required_if: 'data.kind == "rope"',
      editable_if: 'data.kind != ""',
    },
    {
      name: "emergency_contact_name",
      label: "Emergency Contact",
      help: "Who to call in emergency",
      type: "string",
      priority: "primary",
      critical: true,
      weight: 70,
      group: "emergency",
    },
    { name: "vessel_id", label: "Vessel", type: "string", priority: "primary", weight: 80, group: "emergency" },
  ],
};

const ROLES: readonly FieldOption[] = [
  { value: "skipper", label: "Skipper" },
  { value: "crew", label: "Crew" },
];

const FIELD_STATES: Readonly<Record<string, FieldState>> = {
  kind: { visible: true, required: false, editable: true, readonly: false },
  chain_length: { visible: false, required: false, editable: true, readonly: false },
  rode_notes: { visible: true, required: true, editable: false, readonly: false },
};

function ignore(): void {
  // The example saves nothing.
}

const noSuggestions: SchemaFormProps["suggestFor"] =
  (): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
    Promise.resolve([]);

export default function RequiredFieldMissing(): ReactElement {
  return (
    <SchemaForm
      schema={SCHEMA}
      enumOptions={{ crew_roles: ROLES }}
      evaluate={(): Promise<Readonly<Record<string, FieldState>>> => Promise.resolve(FIELD_STATES)}
      suggestFor={noSuggestions}
      fixed={{ vessel_id: "v-1" }}
      submitAction={ignore}
      onCancel={ignore}
    />
  );
}

Enum sources

Choices
V
DC house bank voltage
Where a choice field's options come from: its enum source, the schema's own options, or the allowed values when the source is unknown; a numeric choice saves a number.
Code
import {
  SchemaForm,
  type FieldOption,
  type FieldState,
  type FormSchema,
  type FormValues,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "Where a choice field's options come from: its enum source, the schema's own options, or the allowed values when " +
  "the source is unknown; a numeric choice saves a number.";

/**
 * Four fields from bosund's schemas, in one group and all primary so they show together, and one written for the
 * schema's own `options`, which no bosund schema sets yet.
 */
const SCHEMA: FormSchema = {
  entity: "choices",
  groups: [{ id: "choices", label: "choices", weight: 10 }],
  fields: [
    // crew_member.role: the crew_roles source lists two of the four values validation allows.
    {
      name: "role",
      label: "Role",
      type: "enum",
      required: true,
      one_of: ["skipper", "mate", "crew", "guest"],
      enum: "crew_roles",
      weight: 10,
      group: "choices",
      width: "half",
    },
    // crew_member.language: the languages source names each language in itself.
    {
      name: "language",
      label: "Language",
      type: "enum",
      one_of: ["en", "de", "fr", "es", "it", "nl"],
      enum: "languages",
      weight: 20,
      group: "choices",
      width: "half",
    },
    // crew_change.action: its bare `enum` tag names a source "true" that bosund does not register, so the allowed
    // values become the options.
    {
      name: "action",
      label: "Action",
      type: "enum",
      required: true,
      one_of: ["embark", "disembark"],
      enum: "true",
      weight: 30,
      group: "choices",
      width: "half",
    },
    // vessel.nominal_voltage: a float field shown as a choice, so value_type says to save a number.
    {
      name: "nominal_voltage",
      label: "Nominal Voltage",
      help: "DC house bank voltage",
      type: "enum",
      value_type: "float",
      one_of: ["6", "12", "24", "32", "36", "42", "48"],
      enum: "nominal_voltages",
      unit: "V",
      weight: 40,
      group: "choices",
      width: "half",
    },
    // The schema's own options, which win over any source.
    {
      name: "rode",
      label: "Rode",
      type: "enum",
      options: [
        { value: "chain", label: "All chain" },
        { value: "rope", label: "Chain and rope" },
      ],
      weight: 50,
      group: "choices",
    },
  ],
};

/** What `GET /forms/meta/<source>` answers for the sources the fields name. */
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
  crew_roles: [
    { value: "skipper", label: "skipper" },
    { value: "crew", label: "crew" },
  ],
  languages: [
    { value: "en", label: "English" },
    { value: "de", label: "Deutsch" },
    { value: "fr", label: "Français" },
    { value: "es", label: "Español" },
    { value: "it", label: "Italiano" },
    { value: "nl", label: "Nederlands" },
  ],
  nominal_voltages: ["6", "12", "24", "32", "36", "42", "48"].map((volts: string): FieldOption => ({
    value: volts,
    label: volts,
  })),
};

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

const noSuggestions: SchemaFormProps["suggestFor"] =
  (): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
    Promise.resolve([]);

export default function EnumSources(): ReactElement {
  const [sent, setSent]: [FormValues | null, Dispatch<SetStateAction<FormValues | null>>] = useState<FormValues | null>(
    null,
  );
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
      <SchemaForm
        id="enum-sources"
        schema={SCHEMA}
        enumOptions={ENUM_OPTIONS}
        evaluate={noConditions}
        suggestFor={noSuggestions}
        initial={{ role: "skipper", language: "nl", action: "embark", nominal_voltage: 24, rode: "chain" }}
        submitAction={(values: FormValues): void => {
          setSent(values);
        }}
      />
      {sent !== null && (
        <pre
          style={{
            fontFamily: "var(--font-mono)",
            fontSize: 11,
            color: "var(--text-dim)",
            margin: 0,
            whiteSpace: "pre-wrap",
            overflowWrap: "anywhere",
          }}
        >
          {JSON.stringify(sent, null, 2)}
        </pre>
      )}
    </div>
  );
}

Lists and JSON

Lists
Life-threatening reactions
Lists and JSON: allergies one per line, reef depths as numbers, inspection findings as a JSON array, and alarm limits as a JSON object; a save sends each as its JSON type.
Code
import {
  SchemaForm,
  type FieldState,
  type FormSchema,
  type FormValues,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "Lists and JSON: allergies one per line, reef depths as numbers, inspection findings as a JSON array, and alarm " +
  "limits as a JSON object; a save sends each as its JSON type.";

/**
 * Three array fields from bosund's schemas, in one group and all primary, and a JSON object field, which no bosund
 * schema has yet: a map or struct field is typed json (reference/forms/parse.go:237-238).
 */
const SCHEMA: FormSchema = {
  entity: "lists",
  groups: [{ id: "lists", label: "lists", weight: 10 }],
  fields: [
    // crew_member.allergies: strings.
    {
      name: "allergies",
      label: "Allergies",
      help: "Life-threatening reactions",
      type: "array",
      item_type: "string",
      critical: true,
      weight: 10,
      group: "lists",
    },
    // sail.reefs: numbers, at most five.
    { name: "reefs", label: "Reefs", type: "array", item_type: "float", max_items: 5, weight: 20, group: "lists" },
    // inspection.findings: objects, at most a hundred.
    {
      name: "findings",
      label: "Findings",
      type: "array",
      item_type: "json",
      max_items: 100,
      weight: 30,
      group: "lists",
    },
    // A map field: the anchor watch's alarm limits.
    { name: "alarm_limits", label: "Alarm limits", type: "json", weight: 40, group: "lists" },
  ],
};

const INITIAL: FormValues = {
  allergies: ["Penicillin", "Peanuts"],
  reefs: [1.2, 2.4, 3.6],
  findings: [{ code: "R-12", description: "Replace the forestay toggle pin", severity: "major", category: "rigging" }],
  alarm_limits: { drag_m: 15, gust_kn: 30 },
};

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

const noSuggestions: SchemaFormProps["suggestFor"] =
  (): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
    Promise.resolve([]);

export default function ArraysAndJson(): ReactElement {
  const [sent, setSent]: [FormValues | null, Dispatch<SetStateAction<FormValues | null>>] = useState<FormValues | null>(
    null,
  );
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
      <SchemaForm
        id="arrays-and-json"
        schema={SCHEMA}
        enumOptions={{}}
        evaluate={noConditions}
        suggestFor={noSuggestions}
        initial={INITIAL}
        submitAction={(values: FormValues): void => {
          setSent(values);
        }}
      />
      {sent !== null && (
        <pre
          style={{
            fontFamily: "var(--font-mono)",
            fontSize: 11,
            color: "var(--text-dim)",
            margin: 0,
            whiteSpace: "pre-wrap",
            overflowWrap: "anywhere",
          }}
        >
          {JSON.stringify(sent, null, 2)}
        </pre>
      )}
    </div>
  );
}

Units and prefixes

Units
kg
Ah
V
Ch
Units after the input, a prefix before it, and the decimal places that set the step: meters, kilograms, knots, amp-hours and volts from bosund's schemas, and Ch before a marina's VHF channel; an amount of money is a money field instead, with its currency.
Code
import {
  SchemaForm,
  type FieldOption,
  type FieldState,
  type FormSchema,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";

export const description: string =
  "Units after the input, a prefix before it, and the decimal places that set the step: meters, kilograms, knots, " +
  "amp-hours and volts from bosund's schemas, and Ch before a marina's VHF channel; an amount of money is a money " +
  "field instead, with its currency.";

/** Fields from bosund's schemas in one group, all primary; the daily rate's prefix is written in, as none sets one. */
const SCHEMA: FormSchema = {
  entity: "units",
  groups: [{ id: "units", label: "units", weight: 10 }],
  fields: [
    // vessel.draft_m: metres to two decimal places.
    {
      name: "draft_m",
      label: "Draft",
      type: "float",
      critical: true,
      min: 0,
      max: 50,
      unit: "m",
      keyboard: "decimal",
      decimal_places: 2,
      weight: 10,
      group: "units",
      width: "half",
    },
    // vessel.displacement_kg
    {
      name: "displacement_kg",
      label: "Displacement",
      type: "float",
      min: 0,
      unit: "kg",
      keyboard: "numeric",
      weight: 20,
      group: "units",
      width: "half",
    },
    // place.max_wind_kts: whole knots.
    {
      name: "max_wind_kts",
      label: "Max Wind",
      type: "int",
      unit: "kts",
      keyboard: "numeric",
      weight: 30,
      group: "units",
      width: "third",
    },
    // vessel.battery_capacity_ah
    {
      name: "battery_capacity_ah",
      label: "Battery Capacity",
      type: "float",
      min: 0,
      unit: "Ah",
      keyboard: "numeric",
      weight: 40,
      group: "units",
      width: "third",
    },
    // vessel.nominal_voltage: a choice with a unit.
    {
      name: "nominal_voltage",
      label: "Nominal Voltage",
      type: "enum",
      value_type: "float",
      enum: "nominal_voltages",
      unit: "V",
      weight: 50,
      group: "units",
      width: "third",
    },
    // contact.vhf_channel, as a number with `prefix=Ch` added.
    {
      name: "vhf_channel",
      label: "VHF Channel",
      type: "int",
      prefix: "Ch",
      keyboard: "numeric",
      min: 1,
      max: 88,
      weight: 60,
      group: "units",
      width: "half",
    },
  ],
};

const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
  nominal_voltages: ["6", "12", "24", "32", "36", "42", "48"].map((volts: string): FieldOption => ({
    value: volts,
    label: volts,
  })),
};

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

const noSuggestions: SchemaFormProps["suggestFor"] =
  (): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
    Promise.resolve([]);

function ignore(): void {
  // The example saves nothing.
}

export default function UnitsAndPrefixes(): ReactElement {
  return (
    <SchemaForm
      id="units"
      schema={SCHEMA}
      enumOptions={ENUM_OPTIONS}
      evaluate={noConditions}
      suggestFor={noSuggestions}
      initial={{
        draft_m: 2.25,
        displacement_kg: 12800,
        max_wind_kts: 25,
        battery_capacity_ah: 400,
        nominal_voltage: 12,
        vhf_channel: 9,
      }}
      submitAction={ignore}
    />
  );
}

Critical fields

Emergency
Who to call in emergency
Medical
Critical for transfusions
Ongoing conditions
Current medications
Life-threatening reactions
A crew member's emergency and medical groups: the fields bosund marks critical (the emergency contact, blood type, conditions, medications and allergies, the medical notes) carry a bar at their edge.
Code
import {
  SchemaForm,
  type FieldOption,
  type FieldState,
  type FormSchema,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";

export const description: string =
  "A crew member's emergency and medical groups: the fields bosund marks critical (the emergency contact, blood " +
  "type, conditions, medications and allergies, the medical notes) carry a bar at their edge.";

// The emergency and medical groups of bosund's `GET /forms/crew_member`, and the options of blood_types.
// A throwaway Go program printed SCHEMA and ENUM_OPTIONS, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults
// and the fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor and forms.GetMeta as JSON.
const SCHEMA: FormSchema = {
  entity: "crew_member",
  package: "ops",
  save_mode: "auto",
  groups: [
    { id: "emergency", label: "emergency", weight: 70 },
    { id: "medical", label: "medical", weight: 100 },
  ],
  fields: [
    {
      name: "emergency_contact_name",
      label: "Emergency Contact",
      help: "Who to call in emergency",
      type: "string",
      priority: "secondary",
      critical: true,
      weight: 70,
      group: "emergency",
    },
    {
      name: "emergency_contact_phone",
      label: "Emergency Phone",
      type: "string",
      priority: "secondary",
      critical: true,
      weight: 80,
      group: "emergency",
      keyboard: "tel",
    },
    {
      name: "emergency_contact_relationship",
      label: "Emergency Contact Relationship",
      type: "string",
      priority: "advanced",
      weight: 90,
      group: "emergency",
      placeholder: "Partner, parent, etc.",
    },
    {
      name: "blood_type",
      label: "Blood Type",
      help: "Critical for transfusions",
      type: "enum",
      priority: "secondary",
      critical: true,
      enum: "blood_types",
      weight: 100,
      group: "medical",
    },
    {
      name: "medical_conditions",
      label: "Medical Conditions",
      help: "Ongoing conditions",
      type: "array",
      item_type: "string",
      priority: "secondary",
      critical: true,
      weight: 110,
      group: "medical",
    },
    {
      name: "medications",
      label: "Medications",
      help: "Current medications",
      type: "array",
      item_type: "string",
      priority: "secondary",
      critical: true,
      weight: 120,
      group: "medical",
    },
    {
      name: "allergies",
      label: "Allergies",
      help: "Life-threatening reactions",
      type: "array",
      item_type: "string",
      priority: "secondary",
      critical: true,
      weight: 130,
      group: "medical",
    },
    {
      name: "dietary_requirements",
      label: "Dietary Requirements",
      help: "For provisioning",
      type: "array",
      item_type: "string",
      priority: "advanced",
      weight: 140,
      group: "medical",
    },
    {
      name: "medical_notes",
      label: "Medical Notes",
      type: "text",
      component: "textarea",
      priority: "advanced",
      critical: true,
      weight: 150,
      group: "medical",
      rows: 3,
    },
  ],
  collection: "/ops/crew",
  item: "/ops/crew/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
  blood_types: [
    { value: "A+", label: "A+" },
    { value: "A-", label: "A-" },
    { value: "B+", label: "B+" },
    { value: "B-", label: "B-" },
    { value: "AB+", label: "AB+" },
    { value: "AB-", label: "AB-" },
    { value: "O+", label: "O+" },
    { value: "O-", label: "O-" },
  ],
};

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

const noSuggestions: SchemaFormProps["suggestFor"] =
  (): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
    Promise.resolve([]);

function ignore(): void {
  // The example saves nothing.
}

export default function CriticalFields(): ReactElement {
  return (
    <SchemaForm
      id="critical"
      schema={SCHEMA}
      enumOptions={ENUM_OPTIONS}
      evaluate={noConditions}
      suggestFor={noSuggestions}
      initial={{
        emergency_contact_name: "Jonas Lindqvist",
        emergency_contact_phone: "+46 70 123 45 67",
        emergency_contact_relationship: "Brother",
        blood_type: "O-",
        allergies: ["Penicillin"],
        medications: ["Salbutamol inhaler, in the port cabin's locker"],
      }}
      submitAction={ignore}
    />
  );
}

Fixed fields

Values the page sets, such as the vessel a crew member belongs to, go in fixed: their fields are not drawn and every save carries them.

Identity
6 / 200
Editing the first vessel: the page fixes is_primary, so the Primary Vessel switch is not drawn, and each field's save carries true, shown below.
Code
import type { Suggestion } from "@bosunkit/components/autocomplete";
import {
  SchemaForm,
  type FieldOption,
  type FieldState,
  type SchemaField,
  type FormSchema,
  type FormValues,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "Editing the first vessel: the page fixes is_primary, so the Primary Vessel switch is not drawn, and each field's " +
  "save carries true, shown below.";

// The identity fields of bosund's `GET /forms/vessel`, and the options of the enum sources they name.
// A throwaway Go program printed SCHEMA and ENUM_OPTIONS, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults
// and the fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor and forms.GetMeta as JSON.
const SCHEMA: FormSchema = {
  entity: "vessel",
  package: "fleet",
  save_mode: "auto",
  groups: [{ id: "identity", label: "identity", weight: 10 }],
  fields: [
    {
      name: "name",
      label: "Name",
      type: "string",
      priority: "primary",
      required: true,
      min: 1,
      max: 200,
      min_length: 1,
      max_length: 200,
      weight: 10,
      group: "identity",
      autofocus: true,
    },
    {
      name: "make",
      label: "Make",
      type: "autocomplete",
      priority: "secondary",
      max: 100,
      autocomplete: "vessel_makes",
      weight: 20,
      group: "identity",
      min_chars: 2,
    },
    {
      name: "model",
      label: "Model",
      type: "autocomplete",
      priority: "secondary",
      max: 100,
      autocomplete: "vessel_models",
      weight: 30,
      group: "identity",
      min_chars: 2,
    },
    {
      name: "hull",
      label: "Hull",
      type: "enum",
      priority: "secondary",
      one_of: ["monohull", "catamaran", "trimaran"],
      enum: "hull_types",
      weight: 35,
      group: "identity",
    },
    {
      name: "propulsion",
      label: "Propulsion",
      type: "enum",
      priority: "secondary",
      one_of: ["sail", "motor", "motorsailer"],
      enum: "propulsion_types",
      weight: 36,
      group: "identity",
    },
    { name: "is_primary", label: "Primary Vessel", type: "bool", priority: "secondary", weight: 50, group: "identity" },
    {
      name: "home_port",
      label: "Home Port",
      type: "autocomplete",
      priority: "secondary",
      max: 200,
      autocomplete: "ports",
      weight: 60,
      group: "identity",
      min_chars: 2,
    },
  ],
  collection: "/fleet/vessels",
  item: "/fleet/vessels/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
  hull_types: [
    { value: "monohull", label: "monohull" },
    { value: "catamaran", label: "catamaran" },
    { value: "trimaran", label: "trimaran" },
  ],
  propulsion_types: [
    { value: "sail", label: "sail" },
    { value: "motor", label: "motor" },
    { value: "motorsailer", label: "motorsailer" },
  ],
};

/** The page holds many forms, and each schema's autofocus field would pull it down to the last; examples drop it. */
const SHOWN: FormSchema = {
  ...SCHEMA,
  fields: SCHEMA.fields.map((field: SchemaField): SchemaField => ({ ...field, autofocus: false })),
};

const PORTS: readonly string[] = ["Palma de Mallorca", "Port d'Andratx", "Puerto Portals", "Port de Sóller"];

const suggestFor: SchemaFormProps["suggestFor"] =
  (source: string): ReturnType<SchemaFormProps["suggestFor"]> =>
  (query: string): Promise<readonly Suggestion[]> =>
    Promise.resolve(
      (source === "ports" ? PORTS : [])
        .filter((port: string): boolean => port.toLowerCase().includes(query.toLowerCase()))
        .map((port: string): Suggestion => ({ value: port, label: port })),
    );

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

export default function FixedFields(): ReactElement {
  const [sent, setSent]: [FormValues | null, Dispatch<SetStateAction<FormValues | null>>] = useState<FormValues | null>(
    null,
  );
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
      <SchemaForm
        id="first-vessel"
        schema={SHOWN}
        enumOptions={ENUM_OPTIONS}
        evaluate={noConditions}
        suggestFor={suggestFor}
        initial={{ name: "Althea", make: "Hanse", model: "460", hull: "monohull", propulsion: "sail" }}
        fixed={{ is_primary: true }}
        submitAction={(values: FormValues): void => {
          setSent(values);
        }}
      />
      {sent !== null && (
        <pre
          style={{
            fontFamily: "var(--font-mono)",
            fontSize: 11,
            color: "var(--text-dim)",
            margin: 0,
            whiteSpace: "pre-wrap",
            overflowWrap: "anywhere",
          }}
        >
          {JSON.stringify(sent, null, 2)}
        </pre>
      )}
    </div>
  );
}

Saving each field as it changes

A schema whose save_mode is auto saves each field on its own while a record is edited: typed text once the typing pauses for 800 ms or the field loses the focus, and a switch, a list or a date as soon as it is chosen. Each save sends submitAction only that field's value with the fixed values, so the caller updates that field and no other. The field shows Saving… at the end of its label until the save resolves, then Saved; a refused save shows its reason under the field, keeps the typed value and leaves the focus where it is, and leaving the field tries again. A field's saves go one at a time, so the save that lands last always carries the newest value. A field's own save_mode decides for that field, and the Save button stays while any field drawn waits for it. A record being created has nothing to save into yet, so its form saves on Save.

Identity
14 / 200
Speak times as five p m instead of seventeen hundred
Preferences
A crew member's card, which bosund marks `save_mode: auto`: each field saves on its own, a typed one once typing pauses and a switch or a list at once, with Saving… and Saved at the end of its label. Type Jonas Lindqvist as the name to see a refused save.
Code
import {
  SchemaForm,
  type FieldOption,
  type FieldState,
  type FormSchema,
  type FormValues,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A crew member's card, which bosund marks `save_mode: auto`: each field saves on its own, a typed one once typing " +
  "pauses and a switch or a list at once, with Saving… and Saved at the end of its label. Type Jonas Lindqvist as " +
  "the name to see a refused save.";

// Fields of bosund's `GET /forms/crew_member` (save_mode auto, ops/forms.go), and the options of crew_roles.
const SCHEMA: FormSchema = {
  entity: "crew_member",
  package: "ops",
  save_mode: "auto",
  groups: [
    { id: "identity", label: "identity", weight: 10 },
    { id: "preferences", label: "preferences", weight: 195 },
  ],
  fields: [
    {
      name: "name",
      label: "Name",
      type: "string",
      priority: "primary",
      required: true,
      min_length: 1,
      max_length: 200,
      weight: 10,
      group: "identity",
      placeholder: "Full name",
    },
    {
      name: "role",
      label: "Role",
      type: "enum",
      priority: "primary",
      required: true,
      one_of: ["skipper", "mate", "crew", "guest"],
      enum: "crew_roles",
      weight: 20,
      group: "identity",
    },
    {
      name: "clock_12",
      label: "12-hour clock",
      help: "Speak times as five p m instead of seventeen hundred",
      type: "bool",
      priority: "primary",
      weight: 36,
      group: "identity",
    },
    {
      name: "watch_state",
      label: "Watch Duty",
      type: "string",
      priority: "primary",
      one_of: ["unknown", "active", "passive", "off", "sleeping", "incapacitated", "overboard"],
      weight: 195,
      group: "preferences",
    },
    {
      name: "notes",
      label: "Notes",
      type: "text",
      component: "textarea",
      priority: "primary",
      weight: 220,
      group: "preferences",
      rows: 3,
    },
  ],
  collection: "/ops/crew",
  item: "/ops/crew/{id}",
};

const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
  crew_roles: [
    { value: "skipper", label: "skipper" },
    { value: "crew", label: "crew" },
  ],
};

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

const noSuggestions: SchemaFormProps["suggestFor"] =
  (): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
    Promise.resolve([]);

function ignore(): void {
  // The example has nowhere to go back to.
}

export default function AutoSave(): ReactElement {
  const [sent, setSent]: [FormValues[], Dispatch<SetStateAction<FormValues[]>>] = useState<FormValues[]>([]);

  // bosund takes 600 ms to answer, and refuses a name another crew member already has.
  async function save(values: FormValues): Promise<void> {
    setSent((previous: FormValues[]): FormValues[] => [...previous, values].slice(-3));
    await new Promise<void>((resolve: () => void): void => {
      setTimeout(resolve, 600);
    });
    if (values["name"] === "Jonas Lindqvist") {
      throw new Error("Couldn't save the name: Jonas Lindqvist is already in the crew. Use another name.");
    }
  }

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
      <SchemaForm
        id="auto-save"
        schema={SCHEMA}
        enumOptions={ENUM_OPTIONS}
        evaluate={noConditions}
        suggestFor={noSuggestions}
        initial={{ name: "Maya Lindqvist", role: "crew", clock_12: false, watch_state: "off" }}
        fixed={{ vessel_id: "7c1e4d2a-5b8f-4e61-9a3d-2f6b8c0e1a47" }}
        submitAction={save}
        onCancel={ignore}
      />
      {sent.length > 0 && (
        <pre
          style={{
            fontFamily: "var(--font-mono)",
            fontSize: 11,
            color: "var(--text-dim)",
            margin: 0,
            whiteSpace: "pre-wrap",
            overflowWrap: "anywhere",
          }}
        >
          {sent.map((values: FormValues): string => JSON.stringify(values)).join("\n")}
        </pre>
      )}
    </div>
  );
}

Fields that hold a record's id

A field whose autocomplete source lists records by id (bosund's *_ids sources, which mark the field autocomplete_ids) holds the chosen record's id and shows its name. When the form opens it asks labelFor for the name of each id it holds; choosing a suggestion shows that suggestion's name. Typed text only searches: leaving the field without choosing puts the name back, and leaving it empty clears the id.

Assignment
Crew member doing the work
Yard or contractor doing the work. The skipper signs off their work
A maintenance task's assignment, whose fields hold the crew member's and the contractor's ids: each shows the name, from `labelFor` when the form opens and from the chosen suggestion after, and Save sends the ids.
Code
import type { Suggestion } from "@bosunkit/components/autocomplete";
import {
  SchemaForm,
  type FieldState,
  type FormSchema,
  type FormValues,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A maintenance task's assignment, whose fields hold the crew member's and the contractor's ids: each shows the " +
  "name, from `labelFor` when the form opens and from the chosen suggestion after, and Save sends the ids.";

// The assignment group of bosund's `GET /forms/maintenance_entry`: `autocomplete=crew_ids` and `contact_ids`, which
// bosund marks `autocomplete_ids`. suggestFor and labelFor stand in for `GET /forms/autocomplete/{source}?q=` and
// `?value=`.
const SCHEMA: FormSchema = {
  entity: "maintenance_entry",
  package: "fleet",
  save_mode: "explicit",
  groups: [{ id: "assignment", label: "assignment", weight: 40 }],
  fields: [
    {
      name: "assignee_crew_id",
      label: "Crew Member",
      help: "Crew member doing the work",
      type: "autocomplete",
      priority: "primary",
      autocomplete: "crew_ids",
      autocomplete_ids: true,
      min_chars: 1,
      weight: 42,
      group: "assignment",
    },
    {
      name: "assignee_contact_id",
      label: "Contractor",
      help: "Yard or contractor doing the work. The skipper signs off their work",
      type: "autocomplete",
      priority: "primary",
      autocomplete: "contact_ids",
      autocomplete_ids: true,
      min_chars: 1,
      weight: 44,
      group: "assignment",
    },
  ],
};

const RECORDS: Readonly<Record<string, readonly Suggestion[]>> = {
  crew_ids: [
    { value: "3f6c1a2e-5b8f-4e61-9a3d-2f6b8c0e1a01", label: "Maya Lindqvist" },
    { value: "3f6c1a2e-5b8f-4e61-9a3d-2f6b8c0e1a02", label: "Jonas Lindqvist" },
    { value: "3f6c1a2e-5b8f-4e61-9a3d-2f6b8c0e1a03", label: "Ines Moreau" },
  ],
  contact_ids: [
    { value: "8a2d4c6e-1f3b-4a5c-9e7d-0b1c2d3e4f01", label: "Marina Yard Horta" },
    { value: "8a2d4c6e-1f3b-4a5c-9e7d-0b1c2d3e4f02", label: "Lewmar Services" },
  ],
};

const suggestFor: SchemaFormProps["suggestFor"] =
  (source: string): ReturnType<SchemaFormProps["suggestFor"]> =>
  (query: string): Promise<readonly Suggestion[]> =>
    Promise.resolve(
      (RECORDS[source] ?? []).filter((record: Suggestion): boolean =>
        record.label.toLowerCase().includes(query.toLowerCase()),
      ),
    );

const labelFor: NonNullable<SchemaFormProps["labelFor"]> =
  (source: string): ReturnType<NonNullable<SchemaFormProps["labelFor"]>> =>
  (value: string): Promise<Suggestion | null> =>
    Promise.resolve(
      (RECORDS[source] ?? []).find((record: Suggestion): boolean => record.value === value) ?? null,
    );

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

export default function IdAutocomplete(): ReactElement {
  const [sent, setSent]: [FormValues | null, Dispatch<SetStateAction<FormValues | null>>] = useState<FormValues | null>(
    null,
  );
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
      <SchemaForm
        id="id-autocomplete"
        schema={SCHEMA}
        enumOptions={{}}
        evaluate={noConditions}
        suggestFor={suggestFor}
        labelFor={labelFor}
        initial={{ assignee_crew_id: "3f6c1a2e-5b8f-4e61-9a3d-2f6b8c0e1a01" }}
        submitAction={(values: FormValues): void => {
          setSent(values);
        }}
      />
      {sent !== null && (
        <pre
          style={{
            fontFamily: "var(--font-mono)",
            fontSize: 11,
            color: "var(--text-dim)",
            margin: 0,
            whiteSpace: "pre-wrap",
            overflowWrap: "anywhere",
          }}
        >
          {JSON.stringify(sent, null, 2)}
        </pre>
      )}
    </div>
  );
}

Maritime inputs

Inputs
Ship station, United Kingdom
12 March 1990
The kit's inputs a field's schema calls for: an email that offers the domain, a free-text phone, an E.164 phone with its country, an MMSI that names its station, and a date on the calendar.
Code
import {
  SchemaForm,
  type FieldState,
  type FormSchema,
  type FormValues,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "The kit's inputs a field's schema calls for: an email that offers the domain, a free-text phone, an E.164 phone " +
  "with its country, an MMSI that names its station, and a date on the calendar.";

/** Five fields from bosund's schemas, each with the validator or tag that calls for its input. */
const SCHEMA: FormSchema = {
  entity: "inputs",
  groups: [{ id: "inputs", label: "inputs", weight: 10 }],
  fields: [
    // crew_member.email: `validate:"email"` gives keyboard=email and the address pattern: EmailInput.
    {
      name: "email",
      label: "Email",
      type: "string",
      pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$",
      keyboard: "email",
      weight: 10,
      group: "inputs",
    },
    // crew_member.emergency_contact_phone: keyboard=tel with no pattern is free text, kept as typed: a `tel` input.
    {
      name: "emergency_contact_phone",
      label: "Emergency Phone",
      type: "string",
      keyboard: "tel",
      weight: 20,
      group: "inputs",
      width: "half",
    },
    // crew_member.phone: `validate:"omitempty,e164"` gives keyboard=tel and the E.164 pattern: PhoneInput, which
    // saves E.164.
    {
      name: "phone",
      label: "Phone",
      type: "string",
      pattern: "^\\+[1-9]\\d{1,14}$",
      keyboard: "tel",
      weight: 30,
      group: "inputs",
      width: "half",
    },
    // vessel.mmsi: `validate:"mmsi"` gives the nine-digit pattern: MmsiInput.
    {
      name: "mmsi",
      label: "MMSI",
      help: "9-digit Maritime Mobile Service Identity",
      type: "string",
      min_length: 9,
      max_length: 9,
      pattern: "^\\d{9}$",
      placeholder: "123456789",
      keyboard: "numeric",
      weight: 40,
      group: "inputs",
      width: "half",
    },
    // crew_member.date_of_birth: `form:"type=date"`: DatePicker.
    { name: "date_of_birth", label: "Date of Birth", type: "date", weight: 50, group: "inputs", width: "half" },
  ],
};

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

const noSuggestions: SchemaFormProps["suggestFor"] =
  (): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
    Promise.resolve([]);

export default function MaritimeInputs(): ReactElement {
  const [sent, setSent]: [FormValues | null, Dispatch<SetStateAction<FormValues | null>>] = useState<FormValues | null>(
    null,
  );
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
      <SchemaForm
        id="maritime-inputs"
        schema={SCHEMA}
        enumOptions={{}}
        evaluate={noConditions}
        suggestFor={noSuggestions}
        phoneCountry="GB"
        initial={{
          email: "maya@lindqvist.example",
          emergency_contact_phone: "+46 70 765 43 21 (evenings)",
          phone: "+46701234567",
          mmsi: "235012345",
          date_of_birth: "1990-03-12T00:00:00Z",
        }}
        submitAction={(values: FormValues): void => {
          setSent(values);
        }}
      />
      {sent !== null && (
        <pre
          style={{
            fontFamily: "var(--font-mono)",
            fontSize: 11,
            color: "var(--text-dim)",
            margin: 0,
            whiteSpace: "pre-wrap",
            overflowWrap: "anywhere",
          }}
        >
          {JSON.stringify(sent, null, 2)}
        </pre>
      )}
    </div>
  );
}

Autofocus

A form that opens from a button: the expense's category, which the schema marks autofocus, takes the focus, though it is a list and not a text input.
Code
import { Button } from "@bosunkit/components/button";
import {
  SchemaForm,
  type FieldOption,
  type FieldState,
  type FormSchema,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A form that opens from a button: the expense's category, which the schema marks autofocus, takes the focus, " +
  "though it is a list and not a text input.";

// The first fields of bosund's `GET /forms/expense`, and the options of the enum sources they name.
// A throwaway Go program printed SCHEMA and ENUM_OPTIONS, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults
// and the fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor and forms.GetMeta as JSON.
const SCHEMA: FormSchema = {
  entity: "expense",
  package: "ops",
  save_mode: "explicit",
  groups: [
    { id: "identity", label: "identity", weight: 10 },
    { id: "cost", label: "cost", weight: 50 },
  ],
  fields: [
    {
      name: "category",
      label: "Category",
      type: "enum",
      priority: "primary",
      required: true,
      one_of: [
        "fuel",
        "mooring",
        "provisioning",
        "maintenance",
        "repairs",
        "equipment",
        "insurance",
        "customs",
        "fees",
        "crew",
        "entertainment",
        "communication",
        "medical",
        "other",
      ],
      enum: "expense_categories",
      weight: 10,
      group: "identity",
      autofocus: true,
    },
    {
      name: "occurred_at",
      label: "Date",
      type: "date",
      priority: "primary",
      required: true,
      weight: 40,
      group: "identity",
    },
    {
      name: "amount",
      label: "Amount",
      type: "float",
      priority: "primary",
      min: 0,
      weight: 50,
      group: "cost",
      keyboard: "decimal",
      decimal_places: 2,
    },
    {
      name: "currency",
      label: "Currency",
      type: "enum",
      priority: "primary",
      required: true,
      min_length: 3,
      max_length: 3,
      enum: "currencies",
      weight: 60,
      group: "cost",
      width: "third",
    },
    {
      name: "description",
      label: "Description",
      type: "string",
      priority: "secondary",
      max: 500,
      max_length: 500,
      weight: 30,
      group: "identity",
    },
  ],
  collection: "/ops/expenses",
  item: "/ops/expenses/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
  currencies: [
    { value: "EUR", label: "EUR" },
    { value: "USD", label: "USD" },
    { value: "GBP", label: "GBP" },
    { value: "AUD", label: "AUD" },
    { value: "CAD", label: "CAD" },
    { value: "NZD", label: "NZD" },
    { value: "JPY", label: "JPY" },
    { value: "CHF", label: "CHF" },
    { value: "NOK", label: "NOK" },
    { value: "SEK", label: "SEK" },
    { value: "DKK", label: "DKK" },
  ],
  expense_categories: [
    { value: "fuel", label: "fuel" },
    { value: "mooring", label: "mooring" },
    { value: "provisioning", label: "provisioning" },
    { value: "maintenance", label: "maintenance" },
    { value: "insurance", label: "insurance" },
    { value: "customs", label: "customs" },
    { value: "crew", label: "crew" },
    { value: "charter", label: "charter" },
  ],
};

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

const noSuggestions: SchemaFormProps["suggestFor"] =
  (): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
    Promise.resolve([]);

export default function Autofocus(): ReactElement {
  const [open, setOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
  if (!open) {
    return (
      <Button
        variant="primary"
        onClick={(): void => {
          setOpen(true);
        }}
      >
        Add expense
      </Button>
    );
  }
  return (
    <SchemaForm
      id="autofocus"
      schema={SCHEMA}
      enumOptions={ENUM_OPTIONS}
      evaluate={noConditions}
      suggestFor={noSuggestions}
      submitAction={(): void => {
        setOpen(false);
      }}
      onCancel={(): void => {
        setOpen(false);
      }}
      submitLabel="Add expense"
    />
  );
}

A crew member

14 / 200
For legal and medical purposes
Speak times as five p m instead of seventeen hundred
A crew member as bosund describes one: eight groups, the email and phone with their own inputs, the critical emergency and medical fields, read-only watch and service dates, and the vessel's id fixed by the page.
Code
import {
  SchemaForm,
  type FieldOption,
  type FieldState,
  type SchemaField,
  type FormSchema,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";

export const description: string =
  "A crew member as bosund describes one: eight groups, the email and phone with their own inputs, the critical " +
  "emergency and medical fields, read-only watch and service dates, and the vessel's id fixed by the page.";

// bosund's `GET /forms/crew_member`, and the options of the enum sources it names. clothing_sizes and
// lifejacket_sizes are not registered in bosund, so the jacket, glove and life jacket sizes have no options.
// A throwaway Go program printed SCHEMA and ENUM_OPTIONS, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults
// and the fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor and forms.GetMeta as JSON.
const SCHEMA: FormSchema = {
  entity: "crew_member",
  package: "ops",
  save_mode: "auto",
  groups: [
    { id: "identity", label: "identity", weight: 10 },
    { id: "contact", label: "contact", weight: 40 },
    { id: "emergency", label: "emergency", weight: 70 },
    { id: "medical", label: "medical", weight: 100 },
    { id: "experience", label: "experience", weight: 160 },
    { id: "preferences", label: "preferences", weight: 195 },
    { id: "gear", label: "gear", weight: 230 },
    { id: "meta", label: "meta", weight: 900 },
  ],
  fields: [
    {
      name: "name",
      label: "Name",
      type: "string",
      priority: "primary",
      required: true,
      min: 1,
      max: 200,
      min_length: 1,
      max_length: 200,
      weight: 10,
      group: "identity",
      placeholder: "Full name",
      autofocus: true,
    },
    {
      name: "role",
      label: "Role",
      type: "enum",
      priority: "primary",
      required: true,
      one_of: ["skipper", "mate", "crew", "guest"],
      enum: "crew_roles",
      weight: 20,
      group: "identity",
    },
    {
      name: "nickname",
      label: "Nickname",
      type: "string",
      priority: "advanced",
      max: 100,
      max_length: 100,
      weight: 30,
      group: "identity",
      placeholder: "Call sign",
    },
    {
      name: "date_of_birth",
      label: "Date of Birth",
      help: "For legal and medical purposes",
      type: "date",
      priority: "secondary",
      weight: 35,
      group: "identity",
    },
    {
      name: "language",
      label: "Language",
      type: "enum",
      priority: "secondary",
      one_of: ["en", "de", "fr", "es", "it", "nl"],
      enum: "languages",
      weight: 35,
      group: "identity",
    },
    {
      name: "clock_12",
      label: "12-hour clock",
      help: "Speak times as five p m instead of seventeen hundred",
      type: "bool",
      priority: "secondary",
      weight: 36,
      group: "identity",
    },
    {
      name: "email",
      label: "Email",
      type: "string",
      priority: "secondary",
      pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$",
      weight: 40,
      group: "contact",
      keyboard: "email",
    },
    {
      name: "phone",
      label: "Phone",
      type: "string",
      priority: "secondary",
      pattern: "^\\+[1-9]\\d{1,14}$",
      weight: 50,
      group: "contact",
      keyboard: "tel",
    },
    {
      name: "nationality",
      label: "Nationality",
      help: "For border crossings",
      type: "string",
      priority: "advanced",
      max: 100,
      max_length: 100,
      weight: 60,
      group: "contact",
    },
    {
      name: "emergency_contact_name",
      label: "Emergency Contact",
      help: "Who to call in emergency",
      type: "string",
      priority: "secondary",
      critical: true,
      weight: 70,
      group: "emergency",
    },
    {
      name: "emergency_contact_phone",
      label: "Emergency Phone",
      type: "string",
      priority: "secondary",
      critical: true,
      weight: 80,
      group: "emergency",
      keyboard: "tel",
    },
    {
      name: "emergency_contact_relationship",
      label: "Emergency Contact Relationship",
      type: "string",
      priority: "advanced",
      weight: 90,
      group: "emergency",
      placeholder: "Partner, parent, etc.",
    },
    {
      name: "blood_type",
      label: "Blood Type",
      help: "Critical for transfusions",
      type: "enum",
      priority: "secondary",
      critical: true,
      enum: "blood_types",
      weight: 100,
      group: "medical",
    },
    {
      name: "medical_conditions",
      label: "Medical Conditions",
      help: "Ongoing conditions",
      type: "array",
      item_type: "string",
      priority: "secondary",
      critical: true,
      weight: 110,
      group: "medical",
    },
    {
      name: "medications",
      label: "Medications",
      help: "Current medications",
      type: "array",
      item_type: "string",
      priority: "secondary",
      critical: true,
      weight: 120,
      group: "medical",
    },
    {
      name: "allergies",
      label: "Allergies",
      help: "Life-threatening reactions",
      type: "array",
      item_type: "string",
      priority: "secondary",
      critical: true,
      weight: 130,
      group: "medical",
    },
    {
      name: "dietary_requirements",
      label: "Dietary Requirements",
      help: "For provisioning",
      type: "array",
      item_type: "string",
      priority: "advanced",
      weight: 140,
      group: "medical",
    },
    {
      name: "medical_notes",
      label: "Medical Notes",
      type: "text",
      component: "textarea",
      priority: "advanced",
      critical: true,
      weight: 150,
      group: "medical",
      rows: 3,
    },
    {
      name: "certifications",
      label: "Certifications",
      help: "Yachtmaster STCW etc",
      type: "array",
      item_type: "string",
      priority: "secondary",
      weight: 160,
      group: "experience",
    },
    {
      name: "experience_level",
      label: "Experience Level",
      type: "enum",
      priority: "advanced",
      enum: "experience_levels",
      weight: 170,
      group: "experience",
    },
    {
      name: "experience_notes",
      label: "Experience Notes",
      type: "text",
      component: "textarea",
      priority: "advanced",
      weight: 180,
      group: "experience",
      rows: 3,
    },
    {
      name: "sea_miles",
      label: "Sea Miles",
      help: "Logged sea miles",
      type: "int",
      priority: "advanced",
      weight: 190,
      group: "experience",
      keyboard: "numeric",
    },
    {
      name: "watch_state",
      label: "Watch Duty",
      type: "string",
      priority: "secondary",
      one_of: ["unknown", "active", "passive", "off", "sleeping", "incapacitated", "overboard"],
      weight: 195,
      group: "preferences",
    },
    { name: "watch_since", label: "Duty Since", type: "datetime", readonly: true, weight: 196, group: "preferences" },
    {
      name: "watch_preference",
      label: "Watch Preference",
      type: "string",
      priority: "advanced",
      weight: 200,
      group: "preferences",
    },
    {
      name: "bunk_assignment",
      label: "Bunk Assignment",
      type: "string",
      priority: "advanced",
      weight: 210,
      group: "preferences",
    },
    {
      name: "notes",
      label: "Notes",
      type: "text",
      component: "textarea",
      priority: "advanced",
      weight: 220,
      group: "preferences",
      rows: 4,
    },
    {
      name: "height",
      label: "Height",
      type: "string",
      priority: "advanced",
      weight: 230,
      group: "gear",
      placeholder: "5'10\" or 178cm",
    },
    {
      name: "weight",
      label: "Weight",
      type: "string",
      priority: "advanced",
      weight: 240,
      group: "gear",
      placeholder: "175 lbs or 80kg",
    },
    {
      name: "shoe_size",
      label: "Shoe Size",
      type: "string",
      priority: "advanced",
      weight: 250,
      group: "gear",
      placeholder: "EU 43 / US 10",
    },
    {
      name: "shoe_notes",
      label: "Shoe Notes",
      type: "string",
      priority: "advanced",
      weight: 251,
      group: "gear",
      placeholder: "Wide, orthotics, etc.",
    },
    {
      name: "jacket_size",
      label: "Jacket Size",
      type: "enum",
      priority: "advanced",
      enum: "clothing_sizes",
      weight: 260,
      group: "gear",
    },
    {
      name: "trouser_size",
      label: "Trouser Size",
      type: "string",
      priority: "advanced",
      weight: 270,
      group: "gear",
      placeholder: "32x32 / M",
    },
    {
      name: "glove_size",
      label: "Glove Size",
      type: "enum",
      priority: "advanced",
      enum: "clothing_sizes",
      weight: 275,
      group: "gear",
    },
    {
      name: "vision_needs",
      label: "Vision/Glasses",
      type: "string",
      priority: "advanced",
      weight: 277,
      group: "gear",
      placeholder: "Prescription, night vision issues",
    },
    {
      name: "has_own_lifejacket",
      label: "Has Own PFD",
      help: "Crew brings their own life jacket",
      type: "bool",
      priority: "secondary",
      weight: 278,
      group: "gear",
    },
    {
      name: "lifejacket_size",
      label: "Lifejacket Size",
      help: "For fitted PFD assignment",
      type: "enum",
      priority: "advanced",
      enum: "lifejacket_sizes",
      weight: 280,
      group: "gear",
    },
    {
      name: "lifejacket_last_serviced",
      label: "PFD Last Serviced",
      help: "When lifejacket was last serviced",
      type: "date",
      priority: "advanced",
      weight: 285,
      group: "gear",
    },
    {
      name: "lifejacket_service_due",
      label: "PFD Service Due",
      help: "Auto-calculated: last serviced + 12 months",
      type: "date",
      priority: "advanced",
      critical: true,
      readonly: true,
      weight: 290,
      group: "gear",
    },
    { name: "created_at", label: "Created At", type: "datetime", readonly: true, weight: 900, group: "meta" },
    { name: "updated_at", label: "Updated At", type: "datetime", readonly: true, weight: 910, group: "meta" },
  ],
  collection: "/ops/crew",
  item: "/ops/crew/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
  blood_types: [
    { value: "A+", label: "A+" },
    { value: "A-", label: "A-" },
    { value: "B+", label: "B+" },
    { value: "B-", label: "B-" },
    { value: "AB+", label: "AB+" },
    { value: "AB-", label: "AB-" },
    { value: "O+", label: "O+" },
    { value: "O-", label: "O-" },
  ],
  crew_roles: [
    { value: "skipper", label: "skipper" },
    { value: "crew", label: "crew" },
  ],
  experience_levels: [
    { value: "novice", label: "novice" },
    { value: "competent", label: "competent" },
    { value: "proficient", label: "proficient" },
    { value: "expert", label: "expert" },
  ],
  languages: [
    { value: "en", label: "English" },
    { value: "de", label: "Deutsch" },
    { value: "fr", label: "Français" },
    { value: "es", label: "Español" },
    { value: "it", label: "Italiano" },
    { value: "nl", label: "Nederlands" },
  ],
};

/** The page holds many forms, and each schema's autofocus field would pull it down to the last; examples drop it. */
const SHOWN: FormSchema = {
  ...SCHEMA,
  fields: SCHEMA.fields.map((field: SchemaField): SchemaField => ({ ...field, autofocus: false })),
};

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

const noSuggestions: SchemaFormProps["suggestFor"] =
  (): ReturnType<SchemaFormProps["suggestFor"]> => (): ReturnType<ReturnType<SchemaFormProps["suggestFor"]>> =>
    Promise.resolve([]);

function ignore(): void {
  // The example saves nothing.
}

export default function CrewMember(): ReactElement {
  return (
    <SchemaForm
      id="crew-member"
      schema={SHOWN}
      enumOptions={ENUM_OPTIONS}
      evaluate={noConditions}
      suggestFor={noSuggestions}
      initial={{
        name: "Maya Lindqvist",
        role: "crew",
        language: "en",
        email: "maya@lindqvist.example",
        phone: "+46701234567",
        emergency_contact_name: "Jonas Lindqvist",
        emergency_contact_phone: "+46 70 765 43 21",
        blood_type: "O-",
        allergies: ["Penicillin"],
        certifications: ["Yachtmaster Offshore", "STCW Basic Safety"],
        watch_state: "off",
        watch_since: "2026-10-11T04:00:00Z",
        has_own_lifejacket: true,
        lifejacket_last_serviced: "2026-03-01T00:00:00Z",
        lifejacket_service_due: "2027-03-01T00:00:00Z",
        created_at: "2026-04-02T10:15:00Z",
        updated_at: "2026-10-11T04:00:00Z",
      }}
      fixed={{ vessel_id: "7c1e4d2a-5b8f-4e61-9a3d-2f6b8c0e1a47" }}
      submitAction={ignore}
      onCancel={ignore}
    />
  );
}

A wizard for a new record

Step 1 of 3

Identity

A new crew member walked through a step for each group: the name and role the first step needs before the next, Back keeping what was typed, and Save on the last step sending every field.
Code
import { SchemaForm, type FieldState, type FormSchema, type FormValues } from "@bosunkit/components/schema-form";
import type { Suggestion } from "@bosunkit/components/autocomplete";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A new crew member walked through a step for each group: the name and role the first step needs before the " +
  "next, Back keeping what was typed, and Save on the last step sending every field.";

// Three groups of bosund's crew_member schema, with the form's layout set to a wizard.
const SCHEMA: FormSchema = {
  entity: "crew_member",
  layout: "wizard",
  groups: [
    { id: "identity", label: "identity", weight: 10 },
    { id: "contact", label: "contact", weight: 40 },
    { id: "emergency", label: "emergency", weight: 70 },
  ],
  fields: [
    { name: "name", label: "Name", type: "string", required: true, max_length: 100, weight: 10, group: "identity" },
    {
      name: "role",
      label: "Role",
      type: "enum",
      one_of: ["skipper", "crew", "guest"],
      required: true,
      weight: 20,
      group: "identity",
    },
    { name: "date_of_birth", label: "Date of Birth", type: "date", weight: 30, group: "identity" },
    { name: "email", label: "Email", type: "string", keyboard: "email", weight: 40, group: "contact" },
    { name: "phone", label: "Phone", type: "string", pattern: "^\\+[1-9]\\d{1,14}$", weight: 50, group: "contact" },
    {
      name: "emergency_contact_name",
      label: "Emergency Contact",
      type: "string",
      description: "Who to call in an emergency",
      critical: true,
      weight: 70,
      group: "emergency",
    },
    {
      name: "emergency_contact_phone",
      label: "Emergency Phone",
      type: "string",
      keyboard: "tel",
      critical: true,
      weight: 80,
      group: "emergency",
    },
  ],
};

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

function noSuggestions(): (query: string, signal: AbortSignal) => Promise<readonly Suggestion[]> {
  return (): Promise<readonly Suggestion[]> => Promise.resolve([]);
}

export default function NewCrewMember(): ReactElement {
  const [saved, setSaved]: [FormValues | null, Dispatch<SetStateAction<FormValues | null>>] =
    useState<FormValues | null>(null);
  return (
    <div style={{ display: "grid", gap: "var(--space-md)", width: "100%" }}>
      <SchemaForm
        id="new-crew-member"
        schema={SCHEMA}
        enumOptions={{}}
        evaluate={noConditions}
        suggestFor={noSuggestions}
        submitAction={(values: FormValues): void => {
          setSaved(values);
        }}
        phoneCountry="SE"
      />
      {saved === null ? null : (
        <p role="status" style={{ margin: 0 }}>
          Saved {String(saved.name)}.
        </p>
      )}
    </div>
  );
}

Inputs a field asks for

Departure

What the skipper checks before casting off.

CoastalWithin a day's sail of shelter.OffshoreMore than a day's sail from shelter.OceanWeeks out of reach of help.
Fuel
85 %

Preferred at most 90 %

Leave room for expansion in the heat.

Rig
sloopcutterketch
Aboard
ImpellerFan belt
Flags flown
  • Not a common or leaked passwordchecking
A departure check whose fields ask for the kit's inputs by their component: the fuel on a slider with a preferred maximum, the rig as segments, the passage as cards that say what each means, spares as chips, the flags flown as checkboxes, a date that cannot be in the past, and the guest network's password.
Code
import type { Suggestion } from "@bosunkit/components/autocomplete";
import { SchemaForm, type FieldState, type FormSchema } from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";

export const description: string =
  "A departure check whose fields ask for the kit's inputs by their component: the fuel on a slider with a preferred " +
  "maximum, the rig as segments, the passage as cards that say what each means, spares as chips, the flags flown as " +
  "checkboxes, a date that cannot be in the past, and the guest network's password.";

const SCHEMA: FormSchema = {
  entity: "departure_check",
  groups: [
    {
      id: "departure",
      label: "departure",
      weight: 10,
      description: "What the skipper checks before casting off.",
    },
    { id: "aboard", label: "aboard", weight: 20 },
  ],
  fields: [
    {
      name: "departs_on",
      label: "Departs on",
      type: "date",
      required: true,
      min_date: "today",
      weight: 10,
      group: "departure",
    },
    {
      name: "passage",
      label: "Passage",
      type: "enum",
      component: "cards",
      options: [
        { value: "coastal", label: "Coastal", description: "Within a day's sail of shelter." },
        { value: "offshore", label: "Offshore", description: "More than a day's sail from shelter." },
        { value: "ocean", label: "Ocean", description: "Weeks out of reach of help.", disabled: true },
      ],
      required: true,
      weight: 20,
      group: "departure",
    },
    {
      name: "fuel_pct",
      label: "Fuel",
      type: "int",
      component: "slider",
      min: 0,
      max: 100,
      step: 5,
      soft_max: 90,
      unit: "%",
      marks: [{ value: 0, label: "Empty" }, { value: 50 }, { value: 100, label: "Full" }],
      help: "Leave room for expansion in the heat.",
      weight: 30,
      group: "departure",
    },
    {
      name: "rig",
      label: "Rig",
      type: "enum",
      component: "segmented",
      one_of: ["sloop", "cutter", "ketch"],
      weight: 40,
      group: "departure",
    },
    {
      name: "spares",
      label: "Spares aboard",
      type: "array",
      item_type: "string",
      component: "chips",
      max_items: 8,
      placeholder: "Type a spare and press Enter",
      weight: 10,
      group: "aboard",
    },
    {
      name: "flags",
      label: "Flags flown",
      type: "array",
      item_type: "string",
      one_of: ["Ensign", "Courtesy", "Q", "Club burgee"],
      weight: 20,
      group: "aboard",
    },
    {
      name: "briefed",
      label: "Crew briefed on the safety equipment",
      type: "bool",
      component: "checkbox",
      required: true,
      weight: 30,
      group: "aboard",
    },
    {
      name: "guest_wifi",
      label: "Guest network password",
      type: "string",
      component: "password",
      weight: 40,
      group: "aboard",
    },
  ],
};

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

function noSuggestions(): (query: string, signal: AbortSignal) => Promise<readonly Suggestion[]> {
  return (): Promise<readonly Suggestion[]> => Promise.resolve([]);
}

function ignore(): void {
  // The example keeps nothing.
}

export default function ComponentInputs(): ReactElement {
  return (
    <SchemaForm
      id="departure-check"
      schema={SCHEMA}
      enumOptions={{}}
      evaluate={noConditions}
      suggestFor={noSuggestions}
      initial={{ fuel_pct: 85, rig: "sloop", spares: ["Impeller", "Fan belt"], flags: ["Ensign", "Courtesy"] }}
      submitAction={ignore}
    />
  );
}

A maintenance task

30 / 200
Specific system affected20 / 100
A maintenance task as bosund describes one: title, category, status, service item and action first, the assignment, timing and cost under More fields, and the sign-off fields that only bosund writes, read-only.
Code
import type { Suggestion } from "@bosunkit/components/autocomplete";
import {
  SchemaForm,
  type FieldOption,
  type FieldState,
  type SchemaField,
  type FormSchema,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";

export const description: string =
  "A maintenance task as bosund describes one: title, category, status, service item and action first, the " +
  "assignment, timing and cost under More fields, and the sign-off fields that only bosund writes, read-only.";

// bosund's `GET /forms/maintenance_entry`, and the options of the enum sources it names.
// A throwaway Go program printed SCHEMA and ENUM_OPTIONS, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults
// and the fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor and forms.GetMeta as JSON.
const SCHEMA: FormSchema = {
  entity: "maintenance_entry",
  package: "fleet",
  save_mode: "explicit",
  groups: [
    { id: "identity", label: "identity", weight: 10 },
    { id: "assignment", label: "assignment", weight: 42 },
    { id: "details", label: "details", weight: 50 },
    { id: "timing", label: "timing", weight: 60 },
    { id: "work", label: "work", weight: 120 },
    { id: "cost", label: "cost", weight: 140 },
    { id: "vendor", label: "vendor", weight: 160 },
    { id: "payment", label: "payment", weight: 180 },
    { id: "location", label: "location", weight: 200 },
    { id: "meta", label: "meta", weight: 210 },
  ],
  fields: [
    {
      name: "title",
      label: "Title",
      type: "string",
      priority: "primary",
      required: true,
      min: 1,
      max: 200,
      min_length: 1,
      max_length: 200,
      weight: 10,
      group: "identity",
      autofocus: true,
    },
    {
      name: "category",
      label: "Category",
      type: "enum",
      priority: "primary",
      required: true,
      one_of: ["engine", "hull", "rigging", "electrical", "plumbing", "safety", "sails", "deck", "interior", "other"],
      enum: "maintenance_categories",
      weight: 20,
      group: "identity",
    },
    {
      name: "system",
      label: "System",
      help: "Specific system affected",
      type: "string",
      priority: "secondary",
      max: 100,
      max_length: 100,
      weight: 30,
      group: "identity",
    },
    {
      name: "status",
      label: "Status",
      type: "enum",
      priority: "primary",
      required: true,
      one_of: ["pending", "scheduled", "in_progress", "deferred", "awaiting_signoff", "completed", "canceled"],
      enum: "maintenance_statuses",
      weight: 40,
      group: "identity",
    },
    {
      name: "assignee_crew_id",
      label: "Crew Member",
      help: "Crew member doing the work",
      type: "autocomplete",
      priority: "secondary",
      autocomplete: "crew_ids",
      weight: 42,
      group: "assignment",
      min_chars: 1,
    },
    {
      name: "assignee_contact_id",
      label: "Contractor",
      help: "Yard or contractor doing the work. The skipper signs off their work",
      type: "autocomplete",
      priority: "secondary",
      autocomplete: "contact_ids",
      weight: 44,
      group: "assignment",
      min_chars: 1,
    },
    {
      name: "signed_off_by_crew_id",
      label: "Signed Off By",
      type: "string",
      readonly: true,
      weight: 46,
      group: "assignment",
    },
    {
      name: "signed_off_at",
      label: "Signed Off At",
      type: "datetime",
      readonly: true,
      weight: 48,
      group: "assignment",
    },
    {
      name: "description",
      label: "Description",
      type: "text",
      component: "textarea",
      priority: "secondary",
      max: 5000,
      weight: 50,
      group: "details",
      rows: 3,
    },
    {
      name: "performed_at",
      label: "Performed At",
      type: "datetime",
      priority: "secondary",
      weight: 60,
      group: "timing",
    },
    {
      name: "engine_hours_at_service",
      label: "Engine Hours",
      help: "Engine hours when service performed",
      type: "float",
      priority: "advanced",
      min: 0,
      weight: 70,
      group: "timing",
      keyboard: "decimal",
      decimal_places: 1,
    },
    { name: "due_on", label: "Due On", type: "date", priority: "secondary", weight: 80, group: "timing" },
    {
      name: "due_hours",
      label: "Due At Engine Hours",
      help: "Engine hour reading at which the work is due",
      type: "float",
      priority: "advanced",
      min: 0,
      weight: 90,
      group: "timing",
      keyboard: "decimal",
      decimal_places: 1,
    },
    {
      name: "service_interval_hours",
      label: "Interval (hrs)",
      type: "float",
      priority: "advanced",
      min: 0,
      weight: 100,
      group: "timing",
      keyboard: "numeric",
    },
    {
      name: "service_interval_days",
      label: "Interval (days)",
      type: "int",
      priority: "advanced",
      min: 1,
      weight: 110,
      group: "timing",
      keyboard: "numeric",
    },
    {
      name: "service_item",
      label: "Schedule Item",
      help: "Which manual service this was; the reminder counts from it. Bosun fills it in when online — offline pick it here",
      type: "autocomplete",
      priority: "primary",
      max: 100,
      autocomplete: "service_items",
      weight: 112,
      group: "timing",
      min_chars: 1,
    },
    {
      name: "service_action",
      label: "Schedule Action",
      type: "enum",
      priority: "primary",
      one_of: ["replace", "check", "drain", "adjust"],
      enum: "service_actions",
      weight: 114,
      group: "timing",
    },
    {
      name: "engine_instance",
      label: "Engine",
      help: "Engine instance (0 port or single",
      type: "int",
      priority: "secondary",
      min: 0,
      weight: 116,
      group: "timing",
      keyboard: "numeric",
    },
    {
      name: "parts_used",
      label: "Parts Used",
      type: "array",
      item_type: "string",
      priority: "advanced",
      max_items: 50,
      weight: 120,
      group: "work",
    },
    {
      name: "labor_hours",
      label: "Labor Hours",
      type: "float",
      priority: "advanced",
      min: 0,
      weight: 130,
      group: "work",
      keyboard: "decimal",
      decimal_places: 1,
    },
    {
      name: "cost",
      label: "Cost",
      type: "float",
      priority: "secondary",
      min: 0,
      weight: 140,
      group: "cost",
      keyboard: "decimal",
      decimal_places: 2,
    },
    {
      name: "currency",
      label: "Currency",
      type: "enum",
      priority: "advanced",
      max: 3,
      enum: "currencies",
      weight: 150,
      group: "cost",
      width: "third",
    },
    {
      name: "vendor",
      label: "Vendor",
      type: "autocomplete",
      priority: "advanced",
      max: 200,
      autocomplete: "contacts",
      weight: 160,
      group: "vendor",
      min_chars: 2,
    },
    {
      name: "vendor_contact",
      label: "Vendor Contact",
      type: "string",
      priority: "advanced",
      max: 200,
      max_length: 200,
      weight: 170,
      group: "vendor",
    },
    {
      name: "invoice_number",
      label: "Invoice Number",
      type: "string",
      priority: "advanced",
      max: 100,
      max_length: 100,
      weight: 180,
      group: "payment",
    },
    {
      name: "payment_status",
      label: "Payment Status",
      type: "enum",
      priority: "advanced",
      one_of: ["pending", "paid", "partial"],
      enum: "payment_statuses",
      weight: 190,
      group: "payment",
    },
    {
      name: "location",
      label: "Location",
      help: "Where work was done",
      type: "autocomplete",
      priority: "advanced",
      max: 200,
      autocomplete: "places",
      weight: 200,
      group: "location",
      min_chars: 2,
    },
    {
      name: "notes",
      label: "Notes",
      type: "text",
      component: "textarea",
      priority: "advanced",
      max: 5000,
      weight: 210,
      group: "meta",
      rows: 3,
    },
    {
      name: "tags",
      label: "Tags",
      type: "array",
      item_type: "string",
      priority: "advanced",
      max_items: 20,
      weight: 220,
      group: "meta",
    },
    { name: "created_at", label: "Created At", type: "datetime", readonly: true, weight: 900, group: "meta" },
  ],
  collection: "/fleet/vessels/{vessel_id}/maintenance",
  item: "/fleet/maintenance/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
  currencies: [
    { value: "EUR", label: "EUR" },
    { value: "USD", label: "USD" },
    { value: "GBP", label: "GBP" },
    { value: "AUD", label: "AUD" },
    { value: "CAD", label: "CAD" },
    { value: "NZD", label: "NZD" },
    { value: "JPY", label: "JPY" },
    { value: "CHF", label: "CHF" },
    { value: "NOK", label: "NOK" },
    { value: "SEK", label: "SEK" },
    { value: "DKK", label: "DKK" },
  ],
  maintenance_categories: [
    { value: "engine", label: "engine" },
    { value: "hull", label: "hull" },
    { value: "rigging", label: "rigging" },
    { value: "electrical", label: "electrical" },
    { value: "plumbing", label: "plumbing" },
    { value: "safety", label: "safety" },
    { value: "sails", label: "sails" },
    { value: "deck", label: "deck" },
    { value: "interior", label: "interior" },
    { value: "other", label: "other" },
  ],
  maintenance_statuses: [
    { value: "pending", label: "pending" },
    { value: "scheduled", label: "scheduled" },
    { value: "in_progress", label: "in_progress" },
    { value: "deferred", label: "deferred" },
    { value: "awaiting_signoff", label: "awaiting_signoff" },
    { value: "completed", label: "completed" },
    { value: "canceled", label: "canceled" },
  ],
  payment_statuses: [
    { value: "pending", label: "pending" },
    { value: "paid", label: "paid" },
    { value: "partial", label: "partial" },
  ],
  service_actions: [
    { value: "replace", label: "replace" },
    { value: "check", label: "check" },
    { value: "drain", label: "drain" },
    { value: "adjust", label: "adjust" },
  ],
};

/** The page holds many forms, and each schema's autofocus field would pull it down to the last; examples drop it. */
const SHOWN: FormSchema = {
  ...SCHEMA,
  fields: SCHEMA.fields.map((field: SchemaField): SchemaField => ({ ...field, autofocus: false })),
};

/** What the autocomplete sources answer: the crew, contacts and places on record, and the engine's service items. */
const SOURCES: Readonly<Record<string, readonly Suggestion[]>> = {
  crew: [
    { value: "Maya Lindqvist", label: "Maya Lindqvist" },
    { value: "Tom Whitaker", label: "Tom Whitaker" },
  ],
  crew_ids: [
    { value: "c0a8012e-41f7-4b8e-9d3c-5e2a7b6f1d90", label: "Maya Lindqvist" },
    { value: "5d2f9b31-8a4e-4c7d-b1e6-0f3a9c8d2e75", label: "Tom Whitaker" },
  ],
  contacts: [
    { value: "Varadero Palma", label: "Varadero Palma" },
    { value: "Náutica Reig", label: "Náutica Reig" },
  ],
  contact_ids: [{ value: "9e4b7a12-3c6d-4f8e-a2b5-7d1c0e9f3a68", label: "Varadero Palma" }],
  places: [
    { value: "Palma de Mallorca", label: "Palma de Mallorca" },
    { value: "Port d'Andratx", label: "Port d'Andratx" },
  ],
  service_items: [
    { value: "Engine oil & filter", label: "Engine oil & filter" },
    { value: "Raw water impeller", label: "Raw water impeller" },
    { value: "Fuel filter", label: "Fuel filter" },
  ],
};

const suggestFor: SchemaFormProps["suggestFor"] =
  (source: string): ReturnType<SchemaFormProps["suggestFor"]> =>
  (query: string): Promise<readonly Suggestion[]> =>
    Promise.resolve(
      (SOURCES[source] ?? []).filter((suggestion: Suggestion): boolean =>
        suggestion.label.toLowerCase().includes(query.toLowerCase()),
      ),
    );

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

function ignore(): void {
  // The example saves nothing.
}

export default function MaintenanceTask(): ReactElement {
  return (
    <SchemaForm
      id="maintenance-task"
      schema={SHOWN}
      enumOptions={ENUM_OPTIONS}
      evaluate={noConditions}
      suggestFor={suggestFor}
      initial={{
        title: "Replace the raw water impeller",
        category: "engine",
        status: "scheduled",
        service_item: "Raw water impeller",
        service_action: "replace",
        system: "Yanmar 4JH80 cooling",
        engine_instance: 0,
        due_on: "2026-11-02T00:00:00Z",
        description: "Impeller vanes cracked at the last check; spare in the engine room locker.",
        cost: 64.5,
        currency: "EUR",
        created_at: "2026-10-08T08:30:00Z",
      }}
      submitAction={ignore}
      onCancel={ignore}
      submitLabel="Save task"
    />
  );
}

An expense

10 October 2026
A new expense as bosund describes one: category, time, amount and currency first, the place and vendor under More fields, the receipt, payment and fuel details folded, and the converted amount bosund works out, read-only.
Code
import type { Suggestion } from "@bosunkit/components/autocomplete";
import {
  SchemaForm,
  type FieldOption,
  type FieldState,
  type SchemaField,
  type FormSchema,
  type SchemaFormProps,
} from "@bosunkit/components/schema-form";
import type { ReactElement } from "react";

export const description: string =
  "A new expense as bosund describes one: category, time, amount and currency first, the place and vendor under " +
  "More fields, the receipt, payment and fuel details folded, and the converted amount bosund works out, read-only.";

// bosund's `GET /forms/expense`, and the options of the enum sources it names.
// A throwaway Go program printed SCHEMA and ENUM_OPTIONS, run with GOEXPERIMENT=simd: it calls forms.RegisterDefaults
// and the fleet, ops, log and planning RegisterSchemas, then encodes forms.SchemaFor and forms.GetMeta as JSON.
const SCHEMA: FormSchema = {
  entity: "expense",
  package: "ops",
  save_mode: "explicit",
  groups: [
    { id: "identity", label: "identity", weight: 10 },
    { id: "cost", label: "cost", weight: 50 },
    { id: "location", label: "location", weight: 100 },
    { id: "vendor", label: "vendor", weight: 110 },
    { id: "receipt", label: "receipt", weight: 130 },
    { id: "details", label: "details", weight: 160 },
    { id: "meta", label: "meta", weight: 180 },
  ],
  fields: [
    {
      name: "category",
      label: "Category",
      type: "enum",
      priority: "primary",
      required: true,
      one_of: [
        "fuel",
        "mooring",
        "provisioning",
        "maintenance",
        "repairs",
        "equipment",
        "insurance",
        "customs",
        "fees",
        "crew",
        "entertainment",
        "communication",
        "medical",
        "other",
      ],
      enum: "expense_categories",
      weight: 10,
      group: "identity",
      autofocus: true,
    },
    {
      name: "subcategory",
      label: "Subcategory",
      type: "string",
      priority: "secondary",
      max: 100,
      max_length: 100,
      weight: 20,
      group: "identity",
    },
    {
      name: "description",
      label: "Description",
      type: "string",
      priority: "secondary",
      max: 500,
      max_length: 500,
      weight: 30,
      group: "identity",
    },
    {
      name: "occurred_at",
      label: "Date",
      type: "date",
      priority: "primary",
      required: true,
      weight: 40,
      group: "identity",
    },
    {
      name: "amount",
      label: "Amount",
      type: "float",
      priority: "primary",
      min: 0,
      weight: 50,
      group: "cost",
      keyboard: "decimal",
      decimal_places: 2,
    },
    {
      name: "currency",
      label: "Currency",
      type: "enum",
      priority: "primary",
      required: true,
      min_length: 3,
      max_length: 3,
      enum: "currencies",
      weight: 60,
      group: "cost",
      width: "third",
    },
    { name: "amount_base", label: "Base Amount", type: "float", min: 0, readonly: true, weight: 70, group: "cost" },
    {
      name: "currency_base",
      label: "Base Currency",
      type: "string",
      max: 3,
      max_length: 3,
      readonly: true,
      weight: 80,
      group: "cost",
    },
    { name: "exchange_rate", label: "Exchange Rate", type: "float", min: 0, readonly: true, weight: 90, group: "cost" },
    {
      name: "location",
      label: "Location",
      type: "autocomplete",
      priority: "secondary",
      max: 200,
      autocomplete: "places",
      weight: 100,
      group: "location",
      min_chars: 2,
    },
    {
      name: "vendor",
      label: "Vendor",
      type: "autocomplete",
      priority: "secondary",
      max: 200,
      autocomplete: "contacts",
      weight: 110,
      group: "vendor",
      min_chars: 2,
    },
    {
      name: "vendor_contact",
      label: "Vendor Contact",
      type: "string",
      priority: "advanced",
      max: 200,
      max_length: 200,
      weight: 120,
      group: "vendor",
    },
    {
      name: "receipt_number",
      label: "Receipt Number",
      type: "string",
      priority: "advanced",
      max: 100,
      max_length: 100,
      weight: 130,
      group: "receipt",
    },
    {
      name: "payment_method",
      label: "Payment Method",
      type: "enum",
      priority: "advanced",
      one_of: ["cash", "card", "transfer", "boat_account"],
      enum: "payment_methods",
      weight: 140,
      group: "receipt",
    },
    {
      name: "paid_by",
      label: "Paid By",
      type: "autocomplete",
      priority: "advanced",
      max: 200,
      autocomplete: "crew",
      weight: 150,
      group: "receipt",
      min_chars: 1,
    },
    {
      name: "fuel_liters",
      label: "Fuel (L)",
      type: "float",
      priority: "advanced",
      min: 0,
      weight: 160,
      group: "details",
      unit: "L",
      keyboard: "decimal",
      decimal_places: 1,
    },
    {
      name: "fuel_type",
      label: "Fuel Type",
      type: "enum",
      priority: "advanced",
      one_of: ["diesel", "petrol", "lpg"],
      enum: "fuel_types",
      weight: 170,
      group: "details",
    },
    {
      name: "notes",
      label: "Notes",
      type: "text",
      component: "textarea",
      priority: "advanced",
      max: 2000,
      weight: 180,
      group: "meta",
      rows: 2,
    },
    {
      name: "tags",
      label: "Tags",
      type: "array",
      item_type: "string",
      priority: "advanced",
      max_items: 20,
      weight: 190,
      group: "meta",
    },
    { name: "created_at", label: "Created At", type: "datetime", readonly: true, weight: 900, group: "meta" },
  ],
  collection: "/ops/expenses",
  item: "/ops/expenses/{id}",
};
const ENUM_OPTIONS: Readonly<Record<string, readonly FieldOption[]>> = {
  currencies: [
    { value: "EUR", label: "EUR" },
    { value: "USD", label: "USD" },
    { value: "GBP", label: "GBP" },
    { value: "AUD", label: "AUD" },
    { value: "CAD", label: "CAD" },
    { value: "NZD", label: "NZD" },
    { value: "JPY", label: "JPY" },
    { value: "CHF", label: "CHF" },
    { value: "NOK", label: "NOK" },
    { value: "SEK", label: "SEK" },
    { value: "DKK", label: "DKK" },
  ],
  expense_categories: [
    { value: "fuel", label: "fuel" },
    { value: "mooring", label: "mooring" },
    { value: "provisioning", label: "provisioning" },
    { value: "maintenance", label: "maintenance" },
    { value: "insurance", label: "insurance" },
    { value: "customs", label: "customs" },
    { value: "crew", label: "crew" },
    { value: "charter", label: "charter" },
  ],
  fuel_types: [
    { value: "diesel", label: "diesel" },
    { value: "petrol", label: "petrol" },
    { value: "lpg", label: "lpg" },
  ],
  payment_methods: [
    { value: "cash", label: "cash" },
    { value: "card", label: "card" },
    { value: "transfer", label: "transfer" },
    { value: "account", label: "account" },
  ],
};

/** The page holds many forms, and each schema's autofocus field would pull it down to the last; examples drop it. */
const SHOWN: FormSchema = {
  ...SCHEMA,
  fields: SCHEMA.fields.map((field: SchemaField): SchemaField => ({ ...field, autofocus: false })),
};

/** What the autocomplete sources answer: the crew, contacts and places on record, and the engine's service items. */
const SOURCES: Readonly<Record<string, readonly Suggestion[]>> = {
  crew: [
    { value: "Maya Lindqvist", label: "Maya Lindqvist" },
    { value: "Tom Whitaker", label: "Tom Whitaker" },
  ],
  crew_ids: [
    { value: "c0a8012e-41f7-4b8e-9d3c-5e2a7b6f1d90", label: "Maya Lindqvist" },
    { value: "5d2f9b31-8a4e-4c7d-b1e6-0f3a9c8d2e75", label: "Tom Whitaker" },
  ],
  contacts: [
    { value: "Varadero Palma", label: "Varadero Palma" },
    { value: "Náutica Reig", label: "Náutica Reig" },
  ],
  contact_ids: [{ value: "9e4b7a12-3c6d-4f8e-a2b5-7d1c0e9f3a68", label: "Varadero Palma" }],
  places: [
    { value: "Palma de Mallorca", label: "Palma de Mallorca" },
    { value: "Port d'Andratx", label: "Port d'Andratx" },
  ],
  service_items: [
    { value: "Engine oil & filter", label: "Engine oil & filter" },
    { value: "Raw water impeller", label: "Raw water impeller" },
    { value: "Fuel filter", label: "Fuel filter" },
  ],
};

const suggestFor: SchemaFormProps["suggestFor"] =
  (source: string): ReturnType<SchemaFormProps["suggestFor"]> =>
  (query: string): Promise<readonly Suggestion[]> =>
    Promise.resolve(
      (SOURCES[source] ?? []).filter((suggestion: Suggestion): boolean =>
        suggestion.label.toLowerCase().includes(query.toLowerCase()),
      ),
    );

function noConditions(): Promise<Readonly<Record<string, FieldState>>> {
  return Promise.resolve({});
}

function ignore(): void {
  // The example saves nothing.
}

export default function Expense(): ReactElement {
  return (
    <SchemaForm
      id="expense"
      schema={SHOWN}
      enumOptions={ENUM_OPTIONS}
      evaluate={noConditions}
      suggestFor={suggestFor}
      initial={{ category: "fuel", occurred_at: "2026-10-10T16:20:00Z", currency: "EUR", fuel_type: "diesel" }}
      submitAction={ignore}
      onCancel={ignore}
      submitLabel="Add expense"
    />
  );
}

Props

PropTypeDefaultDescription
schemarequiredFormSchema–The form to render.
enumOptionsrequiredReadonly<Record<string, readonly FieldOption[]>>–Each enum source's options, keyed by the source a field names in enum.
evaluaterequired(values: FormValues) => Promise<Readonly<Record<string, FieldState>>>–The state of the fields with CEL conditions for these values, keyed by field name. Asked 150 ms after the values stop changing, only when a field has a condition. Reject when bosund could not evaluate: the fields keep their last state, all visible at first, and bosund validates the save.
suggestForrequired(source: string) => (query: string, signal: AbortSignal) => Promise<readonly Suggestion[]>–The suggestions an autocomplete field asks for, by its source.
labelFor((source: string) => (value: string, signal: AbortSignal) => Promise<Suggestion | null>) | undefined–The suggestion an id field's source has for a held id (bosund's GET /forms/autocomplete/{source}?value=), so the field shows the record's name; null when no record has it. Asked once per held id when the form opens.
initialReadonly<FormValues> | undefined–The values to edit; omitted when creating.
defaultsReadonly<FormValues> | undefined–The values a new record starts from, such as a duplicated record's without its id: the form creates, as without initial, and these count as unchanged. Such a form keeps no draft and restores none; unused with initial.
fixedReadonly<FormValues> | undefined–Values the page sets, such as the vessel's id: their fields are hidden and the values join every submission.
submitActionrequired(values: FormValues, details?: SubmitDetails) => Promise<void> | void–Called with the valid values on Save; a rejection's message shows above the buttons. An auto-saved field (see save_mode) calls it on its own with only its own value and the fixed values, so the caller updates only the fields it is given; a rejection's message shows under that field.
addAnotherboolean | undefinedfalseShows "Save and add another" beside Save while a record is created: it saves through submitAction with { another: true }, then empties the form for the next record, keeping the fixed values. The caller keeps its dialog open for that save.
draftKeystring | undefined–Keeps what the crew type into a new record in the browser under this key until it is saved, so a form closed half-filled opens again as they left it, with a line saying so and a way to start over. Records being edited keep no draft.
onDirtyChange((dirty: boolean) => void) | undefined–Called when the form's values start or stop differing from the ones it opened with.
defaultSourcesDefaultSources | undefined–What the schema's default tokens are filled from on a new record: the position, the skipper, the time.
moneySourcesMoneySources | undefined–The crew's own currency and the day's rates, for a money field's amount in another currency.
lastValuesKeystring | undefined–Keeps the values saved with a new record for the fields whose default is last in the browser under this key, and starts the next new record from them.
onCancel(() => void) | undefined–Shows a Cancel button that calls it; Done when every field saves on its own.
submitLabelstring | undefined–The submit button's text; "Save" when unset.
idstring | undefined–The form's id, which also starts its inputs' ids (<id>-<name>), so two forms on one page keep their fields apart. Unset, the inputs' ids are field-<name>.
phoneCountrystring | undefined–The country an E.164 phone field starts in, ISO 3166-1 alpha-2: the vessel's flag or home port, which the caller knows. A number already held shows in its own country. The phone input's own default (GB) when unset.
layout"auto" | FormLayout | undefined–How the groups are arranged, over the schema's layout; see FormSchema.layout.