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-textinput-numberinput-textareaselecttag-selectcolor-pickersliderslider-with-inputimagepopover-inputcheckboxsectionrowread-only-textslot
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
$whenexpressions (or jempl$ifforms). Hidden fields are excluded from emittedvalues.
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
defaultValuesseeds internal state on mount.- Field
namepaths support dot notation (user.email) for nested objects; bracket array paths (items[0]) are not supported. - Updating
defaultValuesalone does not change current values untilreset()is called. - Enter key submits the first footer action button except within textareas and section action buttons.