Skip to content

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

Open the page on its own

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 keyBy for the sections
  • Show for conditional fields
  • Switch and Match to pick the input for each field type