Forms
A form is a <form xo-submit> inside an xo-view. The request schema of the operation gives each field its type, its limits and its errors, and the record fills the fields.
Generated from the schema
<xo-form op="createPerson"> (inside an <xo-view>) draws every field of the request body: text, e-mail, a select for the enum, and the rich widgets chosen by the schema (x-xoong-widget in OpenAPI, or a hint). Submit it empty to see the validation messages, then add a person with an address that is already used (ada@example.org) to see a server error. The list refreshes by itself after a successful save.
Newest people
- {name}
{email}
<div class="split">
<xo-view><xo-form op="createPerson" submit="Add person"></xo-form></xo-view>
<xo-view get="listPeople" p-sort="-id" p-limit="5">
<h3>Newest people</h3>
<ul class="people"><li xo-each="items"><strong>{name}</strong><br><small>{email}</small></li></ul>
</xo-view>
</div>"PersonInput": {
"type": "object",
"required": ["name", "email"],
"properties": {
"name": { "type": "string", "minLength": 2, "maxLength": 40 },
"email": { "type": "string", "format": "email" },
"role": { "type": "string", "enum": ["admin", "editor", "viewer"] },
"topics": { "type": "array", "items": { "type": "string", "enum": ["web", "data", "design", "ops"] }, "x-xoong-widget": "xo-combobox" },
"keywords": { "type": "array", "items": { "type": "string" }, "x-xoong-widget": "xo-tags" },
"bio": { "type": "string", "x-xoong-widget": "xo-richtext" }
}
}{
"fields": {
"PersonInput.name": { "label": "Full name", "placeholder": "Ada Lovelace", "order": 1 },
"PersonInput.email": { "label": "E-mail", "placeholder": "ada@example.org", "order": 2 },
"PersonInput.role": { "order": 3 },
"PersonInput.topics": { "order": 4 },
"PersonInput.keywords": { "placeholder": "Type a keyword and press Enter", "order": 5 },
"PersonInput.bio": { "order": 6 }
}
}Written by hand
For a layout of your own, write the form. name is a path (title.en, items[0].x). The record fills the fields, errors appear after blur or submit, and $form tells the markup what is going on: dirty, valid, submitting, errors['email']. Put sections in <xo-tabs>: when a field on another tab is invalid, that tab opens before the focus moves there.
<xo-view get="getPerson" p-id="1">
<form xo-submit="updatePerson" xo-notice="Saved">
<xo-tabs label="Person">
<section xo-tab="General">
<label class="field">Name <input name="name" autocomplete="off"></label>
<span class="error" xo-if="$form.errors['name']">{$form.errors['name']}</span>
<label class="field">E-mail <input name="email" type="email"></label>
<span class="error" xo-if="$form.errors['email']">{$form.errors['email']}</span>
<label class="field">Role
<select name="role"><option>admin</option><option>editor</option><option>viewer</option></select>
</label>
</section>
<section xo-tab="Topics">
<xo-combobox name="topics" label="Topics" multiple>
<option value="web">Web</option><option value="data">Data</option>
<option value="design">Design</option><option value="ops">Operations</option>
</xo-combobox>
<xo-tags name="keywords" label="Keywords" placeholder="Add a keyword"></xo-tags>
</section>
<section xo-tab="Bio">
<xo-richtext name="bio" label="Bio" toolbar="compact"></xo-richtext>
</section>
</xo-tabs>
<p class="toolbar">
<button>Save</button>
<span class="hint" xo-if="$form.dirty">Unsaved changes</span>
<span class="hint" xo-if="$form.submitting" aria-busy="true">Saving…</span>
</p>
</form>
</xo-view>Clear the name or type a single letter, go to another tab and press Save: the first invalid field is revealed, and nothing is sent until the form is valid. The leave guard of <xo-router> uses the same dirty state.
Rows you can add, move and remove
An array of values or objects is edited with xo-each over the draft. xo-add appends a row, xo-remove removes one, xo-move moves it (the focus follows the row), and xo-drag on a handle lets a pointer drag it onto another row of the same list.
<xo-view get="getPerson" p-id="2">
<form xo-submit="updatePerson" xo-notice="Saved">
<fieldset>
<legend>Keywords</legend>
<div class="row" xo-each="keywords">
<span class="handle" xo-drag="keywords[{$index}]" title="Drag to reorder">⠿</span>
<input name="keywords[{$index}]" aria-label="Keyword">
<button type="button" xo-move="keywords[{$index}]:up" aria-label="Move up">↑</button>
<button type="button" xo-move="keywords[{$index}]:down" aria-label="Move down">↓</button>
<button type="button" xo-remove="keywords[{$index}]" aria-label="Remove">✕</button>
</div>
<button type="button" xo-add="keywords">Add keyword</button>
</fieldset>
<p class="toolbar"><button>Save</button> <span class="hint" xo-if="$form.dirty">Unsaved changes</span></p>
</form>
</xo-view>Your own rules
Schema rules always apply. Add rules the schema cannot express by path, with the loaded record at hand; a rule returns an empty string when the value is fine, or the message. Rules may be asynchronous, for example to ask the server whether a slug is free.
const form = document.querySelector('form');
form.rules = {
email: (value) => (value.endsWith('@example.org') ? '' : 'Use a company address'),
'*': (record) => (record.role === 'admin' && !record.topics.length ? { topics: 'Admins need a topic' } : {}),
};
form.serialize = (draft, body) => ({ ...body, name: body.name.trim() }); // the last word on the request bodyOn an invalid submit the form fires a cancelable xo-reveal event with the path, so tabs and collapsed sections can open before the focus moves, then xo-invalid.