Form builder
Fields from a config, sections you add and remove, and the data as JSON.
The fields come from a plain config array. The data lives in a store, and each address is one entry of an array in it, so adding or removing one renders only that section. A field shows only when its condition holds, and the preview prints the store as JSON.
Preview
Code
Save it as an .html file and open it in a browser. It loads
Signy and Iris CSS from the CDN, so there is nothing to install or build.
Show the full page
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Form builder - Signy example</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
<script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
</head>
<body>
<main class="container" id="app"></main>
<script>
const { Component, application, html, store, produce, For, Show, Switch, Match } = Signy;
// The form is data. A field with a when function shows only while it returns true.
const FIELDS = [
{ name: "name", label: "Full name", type: "text" },
{ name: "contact", label: "How should we reach you?", type: "select", options: ["Email", "Phone"] },
{ name: "email", label: "Email", type: "email", when: (data) => data.contact === "Email" },
{ name: "phone", label: "Phone", type: "tel", when: (data) => data.contact === "Phone" },
{ name: "newsletter", label: "Send me the newsletter", type: "checkbox" },
];
const ADDRESS_FIELDS = [
{ name: "street", label: "Street", type: "text" },
{ name: "city", label: "City", type: "text" },
{ name: "postcode", label: "Postcode", type: "text" },
];
let nextId = 2;
const emptyAddress = (id) => ({ id, street: "", city: "", postcode: "" });
class FormBuilder extends Component {
form = store({
name: "",
contact: "Email",
email: "",
phone: "",
newsletter: false,
addresses: [emptyAddress(1)],
});
// One input for a field config. write puts the new value in the store.
input(field, id, initial, write) {
return Switch({
fallback: html`
<label for=${id}>${field.label}</label>
<input id=${id} type=${field.type} value=${initial} onInput=${(e) => write(e.currentTarget.value)} />
`,
children: [
Match({
when: field.type === "select",
children: () => html`
<label for=${id}>${field.label}</label>
<select id=${id} onChange=${(e) => write(e.currentTarget.value)}>
${(field.options ?? []).map((option) => html`<option selected=${option === initial}>${option}</option>`)}
</select>
`,
}),
Match({
when: field.type === "checkbox",
children: () => html`
<label>
<input type="checkbox" checked=${initial} onChange=${(e) => write(e.currentTarget.checked)} />
${field.label}
</label>
`,
}),
],
});
}
// Looked up on each write, since removing an address moves the ones after it
indexOf(id) {
return this.form.state.addresses.findIndex((address) => address.id === id);
}
addAddress() {
this.form.set("addresses", produce((addresses) => {
addresses.push(emptyAddress(nextId++));
}));
}
removeAddress(id) {
this.form.set("addresses", (addresses) => addresses.filter((address) => address.id !== id));
}
render() {
return html`
<h1>Form builder</h1>
<div class="switcher">
<form onSubmit=${(e) => e.preventDefault()}>
${For({
each: FIELDS,
keyBy: "name",
children: (field) => Show({
when: () => !field.when || field.when(this.form.state),
children: html`
<div>${this.input(field, field.name, this.form.state[field.name], (value) => this.form.set(field.name, value))}</div>
`,
}),
})}
${For({
each: () => this.form.state.addresses,
keyBy: "id",
children: (address, index) => html`
<fieldset>
<legend>Address ${() => index() + 1}</legend>
${ADDRESS_FIELDS.map((field) => html`
<div>${this.input(field, `${field.name}-${address.id}`, address[field.name],
(value) => this.form.set("addresses", this.indexOf(address.id), field.name, value))}</div>
`)}
<button type="button" class="small outlined danger"
disabled=${() => this.form.state.addresses.length === 1}
onClick=${() => this.removeAddress(address.id)}>Remove this address</button>
</fieldset>
`,
})}
<button type="button" class="secondary outlined" onClick=${() => this.addAddress()}>Add an address</button>
</form>
<section>
<h2>Data</h2>
<pre><code>${() => JSON.stringify(this.form.state, null, 2)}</code></pre>
</section>
</div>
`;
}
}
application(FormBuilder).mount("#app");
</script>
</body>
</html>
What it uses
- Store with path writes and
produce() - For with
keyByfor the sections - Show for conditional fields
- Switch and Match to pick the input for each field type