Rettangoli Docs
Form

A schema-driven form component that composes Rettangoli input primitives.

Quickstart

<rtgl-view id="form-container"></rtgl-view>

<script>
  const form = document.createElement("rtgl-form");
  form.defaultValues = {
    email: "",
    age: 18,
  };

  form.form = {
    title: "Profile",
    description: "Edit account details",
    fields: [
      { name: "email", label: "Email", type: "input-text", required: true },
      { name: "age", label: "Age", type: "input-number", min: 0, max: 120 },
      { name: "notes", label: "Notes", type: "input-textarea", rows: 4 },
    ],
    actions: {
      buttons: [{ id: "save", label: "Save", variant: "pr", validate: true }],
    },
  };

  form.addEventListener("form-input", (e) => {
    console.log("form-input", e.detail); // { name, value, values }
  });

  form.addEventListener("form-change", (e) => {
    console.log("form-change", e.detail); // { name, value, values }
  });

  form.addEventListener("form-action", (e) => {
    console.log("form-action", e.detail); // { actionId, values, valid?, errors? }
  });

  document.getElementById("form-container").appendChild(form);
</script>

API

Name Attribute / Property Type Default
Form Schema form (property) object required
Initial Values defaultValues (property) object {}
Disabled disabled boolean false
Template Context context (property) object -
Padding p none | xs | sm | md | lg | xl md
Horizontal Padding ph none | xs | sm | md | lg | xl value of p
Vertical Padding pv none | xs | sm | md | lg | xl value of p
Bottom Spacer bottom-spacer / bottomSpacer non-negative pixels 0
Scrollbar Visibility sbv touch, always -

Use p="none" when the surrounding layout already provides the desired inset:

<rtgl-form p="none"></rtgl-form>

Use ph and pv when the two axes need different insets. Each axis overrides p independently:

<rtgl-form ph="md" pv="none"></rtgl-form>

Width

By default, rtgl-form fills the available width of its parent container.

Use w to set a fixed width.

<rtgl-view id="fixed-form-container"></rtgl-view>

<script>
  const form = document.createElement("rtgl-form");
  form.setAttribute("w", "420");

  form.form = {
    title: "Fixed Width Form",
    fields: [
      { name: "email", label: "Email", type: "input-text", required: true },
      { name: "notes", label: "Notes", type: "input-textarea", rows: 3 },
    ],
    actions: {
      buttons: [{ id: "save", label: "Save", variant: "pr" }],
    },
  };

  document.getElementById("fixed-form-container").appendChild(form);
</script>

Sticky Form Layout

Add the sticky boolean attribute when the form is inside a bounded-height surface. The form automatically fills that surface without requiring h="f"; the title and action row remain visible while the field region owns vertical scrolling.

<rtgl-form id="edit-form" sticky></rtgl-form>

<script>
  document.getElementById("edit-form").form = {
    title: "Edit Character",
    fields,
    actions: {
      buttons: [{ id: "save", label: "Save", variant: "pr" }],
    },
  };
</script>

For an inset mobile dialog that stays naturally sized for short forms and becomes bounded for long forms, place the form in a vertical overflow="hidden" surface and use md-layout="fixed-top" on rtgl-dialog.

Set bottom-spacer when the last field needs extra scroll room above a sticky action row. The pixel value is applied inside the field scroller, so it does not move the header or actions:

<rtgl-form sticky bottom-spacer="96"></rtgl-form>

Set sbv to keep the field scroller's scrollbar visible at rest: touch on devices whose primary input cannot hover (phones and tablets), or always on every device. It applies only to sticky forms; without sticky, put sbv on the scroll view that contains the form. See rtgl-view scrollbar visibility.

<rtgl-form sticky sbv="touch"></rtgl-form>

Without sticky, h="f" retains normal visible overflow. This lets a bounded ancestor with sv own scrolling and keeps lower fields and actions reachable.

Field Types

  • input-text
  • input-number
  • input-textarea
  • select
  • tag-select
  • color-picker
  • slider
  • slider-with-input
  • image
  • popover-input
  • checkbox
  • section
  • row
  • read-only-text
  • slot

Sections

Use type: "section" to group related fields under a labeled block. Section rows are display-only; the nested fields still participate in normal form values, validation, and events.

<rtgl-view id="section-form-container"></rtgl-view>

<script>
  const form = document.createElement("rtgl-form");
  form.setAttribute("w", "420");

  form.defaultValues = {
    profile: { firstName: "Ada", lastName: "Lovelace" },
    workspace: { role: "developer", access: "full" },
  };

  form.form = {
    title: "Team Member Setup",
    description: "Sections let you break larger forms into readable groups.",
    fields: [
      {
        type: "section",
        id: "profile",
        label: "Profile",
        description: "Basic identity details",
        action: {
          id: "add",
          icon: "plus",
          label: "Add profile field",
        },
        fields: [
          { name: "profile.firstName", type: "input-text", label: "First Name", required: true },
          { name: "profile.lastName", type: "input-text", label: "Last Name", required: true },
        ],
      },
      {
        type: "section",
        label: "Workspace Access",
        description: "Nested selects work the same inside sections.",
        fields: [
          {
            name: "workspace.role",
            type: "select",
            label: "Role",
            options: [
              { type: "section", label: "Build" },
              { label: "Developer", value: "developer", icon: "text", shortcut: "Code" },
              { label: "Designer", value: "designer", icon: "info", suffixText: "UI" },
              { type: "separator" },
              { type: "section", label: "Operations" },
              { label: "Manager", value: "manager", icon: "threeDots", suffixText: "Ops" },
            ],
          },
          {
            name: "workspace.access",
            type: "select",
            label: "Access",
            options: [
              { type: "section", label: "Permissions" },
              { label: "Full Access", value: "full" },
              { label: "Limited Access", value: "limited" },
            ],
          },
        ],
      },
    ],
    actions: {
      buttons: [{ id: "save", label: "Save", variant: "pr", validate: true }],
    },
  };

  document.getElementById("section-form-container").appendChild(form);
</script>

Sections may expose one icon action at the right edge of their header. Give both the section and action stable id values. action.icon defaults to plus, action.label provides the accessible name, and action.disabled disables the trigger.

Sections are separated automatically. A section has no top separator when it is the first visible form item; otherwise it has one by default. Set the field-level separator property to false to hide a separator or to true to force one, including on the first item.

{
  type: "section",
  label: "Advanced",
  separator: false,
  fields: [/* ... */],
}

The form owns the trigger but leaves its behavior to the consumer:

form.addEventListener("form-section-action", async (event) => {
  const { sectionId, actionId, position } = event.detail;
  if (sectionId !== "profile" || actionId !== "add") return;

  await appService.showDropdownMenu({
    items,
    x: position.x,
    y: position.y,
    place: "bs",
  });
});

position and anchorRect are viewport-coordinate snapshots from the action button. Use position for a menu directly below the button, or anchorRect when custom placement needs the full bounds.

Responsive Rows

Use a layout-only row field to give its visible fields equal-width columns. Rows stack into one column at md (768px) by default. Set rowStackAt on the form schema to change that default for every row:

form.form = {
  rowStackAt: "lg",
  fields: [
    {
      type: "row",
      fields: [
        { name: "firstName", type: "input-text", label: "First Name" },
        { name: "lastName", type: "input-text", label: "Last Name" },
      ],
    },
  ],
};

Supported values are sm, md, lg, xl, and none. A row can provide its own stackAt value to override the form default:

{
  type: "row",
  stackAt: "sm",
  fields: [/* ... */],
}

Events

Event Detail Description
form-input { name, value, values } Live value changes (typing/dragging).
form-change { name, value, values } Committed value changes (blur/select/toggle/release).
form-field-event { name, event, values } Field-specific interactions (for example image click/contextmenu).
form-section-action { sectionId, actionId, values, position, anchorRect } Section header action clicks with viewport anchor geometry.
form-action { actionId, values } or { actionId, values, valid, errors } Action button clicks; includes validation result when validate: true.

All events bubble.

Methods

Method Returns Description
getValues() object Returns current visible form values.
setValues(values) void Merges values into current state (setValues({ values }) is also supported).
validate() { valid, errors } Runs validation and shows inline errors.
reset() void Resets values to defaultValues and clears errors.

To apply new defaults after mount, update defaultValues and call reset():

form.defaultValues = { email: "[email protected]", age: 25 };
form.reset();

Custom Logic

  • Form schema is data-only (JSON/YAML serializable). Avoid embedding JavaScript functions in schema definitions.
  • Use event listeners (form-input, form-change, form-action, form-field-event, form-section-action) for custom behavior.
  • Use form methods (setValues, validate, reset) for imperative workflows.
  • For conditional fields, use $when expressions (or jempl $if forms). Hidden fields are excluded from emitted values.

Conditional + Jempl Examples

<rtgl-view id="conditional-form-container"></rtgl-view>

<script>
  const form = document.createElement("rtgl-form");
  form.setAttribute("w", "420");

  form.defaultValues = {
    profile: { name: "Ada", mode: "basic", email: "" },
    agree: false,
  };

  form.form = {
    title: "Onboarding: ${formValues.profile.name}",
    description: "Mode: ${formValues.profile.mode}",
    fields: [
      { name: "profile.name", type: "input-text", label: "Name", required: true },
      {
        name: "profile.mode",
        type: "select",
        label: "Mode",
        options: [
          { label: "Basic", value: "basic" },
          { label: "Advanced", value: "advanced" },
        ],
      },
      {
        name: "profile.email",
        type: "input-text",
        label: "Advanced Email",
        $when: "formValues.profile.mode == 'advanced'",
      },
      {
        name: "agree",
        type: "checkbox",
        content: "I agree to the terms and conditions",
      },
      {
        type: "read-only-text",
        content: "Thanks, ${formValues.profile.name}",
        $when: "formValues.agree",
      },
    ],
    actions: {
      buttons: [{ id: "submit", label: "Submit", variant: "pr", validate: true }],
    },
  };

  form.addEventListener("form-change", (e) => {
    console.log("visible values", e.detail.values);
  });

  document.getElementById("conditional-form-container").appendChild(form);
</script>

Advanced Context Examples

Schema-First Context Example

<rtgl-view id="context-advanced-container"></rtgl-view>

<script>
  const form = document.createElement("rtgl-form");
  form.setAttribute("w", "520");
  form.context = {
    workspaceName: "Campaign Ops",
    ownerName: "Alicia",
    planTier: "enterprise",
  };

  form.defaultValues = {
    publish: { mode: "draft", severity: "low", scheduledAt: "" },
    approvals: { enabled: false, owner: "", notes: "" },
  };

  form.form = {
    title: "${workspaceName} - ${ownerName}",
    description: "Mode: ${formValues.publish.mode} | Severity: ${formValues.publish.severity}",
    fields: [
      {
        name: "publish.mode",
        type: "select",
        label: "Publish mode",
        options: [
          { label: "Draft", value: "draft" },
          { label: "Live", value: "live" },
          { label: "Scheduled", value: "scheduled" },
        ],
      },
      {
        name: "publish.severity",
        type: "select",
        label: "Severity",
        options: [
          { label: "Low", value: "low" },
          { label: "High", value: "high" },
        ],
      },
      {
        name: "publish.scheduledAt",
        type: "input-text",
        label: "Scheduled at",
        placeholder: "2026-03-31 09:00",
        $when: "formValues.publish.mode == 'scheduled'",
      },
      {
        name: "approvals.enabled",
        type: "checkbox",
        content: "Require approval",
      },
      {
        name: "approvals.owner",
        type: "input-text",
        label: "Approver",
        $when: "formValues.approvals.enabled",
      },
      {
        name: "approvals.notes",
        type: "input-textarea",
        label: "Approval notes",
        rows: 3,
        $when: "formValues.approvals.enabled",
      },
      {
        type: "read-only-text",
        content: "Enterprise plan policy is active.",
        $when: "planTier == 'enterprise'",
      },
    ],
    actions: {
      buttons: [{ id: "submit", label: "Submit", variant: "pr", validate: true }],
    },
  };

  document.getElementById("context-advanced-container").appendChild(form);
</script>

Behavior

Behavior & precedence

  • defaultValues seeds internal state on mount.
  • Field name paths support dot notation (user.email) for nested objects; bracket array paths (items[0]) are not supported.
  • Updating defaultValues alone does not change current values until reset() is called.
  • Enter key submits the first footer action button except within textareas and section action buttons.