Skip to content

Sign up form

Validation that waits for you to leave a field, and a submit that waits for the server.

Each field keeps its value and whether it was touched in signals. Computed values hold the errors, so a message shows only after you leave the field or press submit. The fake server takes a second and rejects one user name, to show an error that comes back from it.

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>Sign up form - 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, Signal, Computed, application, html, Show, createRef } = Signy;

      // A pretend server: answers after a second and knows two user names already
      const TAKEN = ["ada", "admin"];

      function register(data) {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            if (TAKEN.includes(data.username.toLowerCase())) {
              reject({ field: "username", message: "This user name is taken." });
            } else {
              resolve();
            }
          }, 1000);
        });
      }

      // 0 to 4, one point each for length, mixed case, a digit and a symbol
      function strength(password) {
        return [
          password.length >= 8,
          /[a-z]/.test(password) && /[A-Z]/.test(password),
          /\d/.test(password),
          /[^A-Za-z0-9]/.test(password),
        ].filter(Boolean).length;
      }

      const STRENGTH = ["Too weak", "Weak", "Fair", "Good", "Strong"];
      const FIELDS = ["username", "email", "password", "confirm"];

      class SignUp extends Component {
        values = Object.fromEntries(FIELDS.map((name) => [name, new Signal("")]));
        refs = Object.fromEntries(FIELDS.map((name) => [name, createRef()]));
        // A field shows its error only once you leave it or press submit
        touched = new Signal(new Set());
        serverErrors = new Signal({});
        busy = new Signal(false);
        done = new Signal(false);

        strength = new Computed(() => strength(this.values.password.value));

        errors = new Computed(() => {
          const value = (name) => this.values[name].value;
          return {
            username: !value("username")
              ? "Pick a user name."
              : !/^\w{3,20}$/.test(value("username"))
                ? "Use 3 to 20 letters, digits or underscores."
                : this.serverErrors.value.username,
            email: !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value("email")) ? "Enter an email address." : undefined,
            password: value("password").length < 8 ? "Use at least 8 characters." : undefined,
            confirm: value("confirm") !== value("password") ? "The passwords do not match." : undefined,
          };
        });

        touch(name) {
          this.touched.set((names) => new Set(names).add(name));
        }

        async submit(event) {
          event.preventDefault();
          this.touched.value = new Set(FIELDS);
          const errors = this.errors.value;
          const invalid = FIELDS.find((name) => errors[name]);
          if (invalid) {
            this.refs[invalid].current.focus();
            return;
          }
          this.busy.value = true;
          try {
            await register({ username: this.values.username.peek(), email: this.values.email.peek() });
            this.done.value = true;
          } catch (error) {
            this.serverErrors.value = { [error.field]: error.message };
            this.refs[error.field].current.focus();
          } finally {
            this.busy.value = false;
          }
        }

        field(name, label, type, autocomplete, hint) {
          const error = () => (this.touched.value.has(name) ? this.errors.value[name] : undefined);
          return html`
            <div>
              <label for=${name}>${label}</label>
              <input
                ref=${this.refs[name]}
                id=${name}
                name=${name}
                type=${type}
                autocomplete=${autocomplete}
                aria-describedby=${`${name}-help`}
                aria-invalid=${() => (error() ? "true" : null)}
                onInput=${(e) => {
                  this.values[name].value = e.currentTarget.value;
                  if (name === "username") this.serverErrors.value = {};
                }}
                onBlur=${() => this.touch(name)}
              />
              <small id=${`${name}-help`}>${() => error() ?? hint}</small>
            </div>
          `;
        }

        form() {
          return html`
            <form novalidate onSubmit=${(e) => this.submit(e)}>
              ${this.field("username", "User name", "text", "username", "Try ada to get an error from the server.")}
              ${this.field("email", "Email", "email", "email", "We only use it to sign you in.")}
              ${this.field("password", "Password", "password", "new-password", "At least 8 characters.")}
              <p>
                <meter min="0" max="4" low="2" high="3" optimum="4" aria-label="Password strength"
                  value=${() => this.strength.value}></meter>
                <small>${() => STRENGTH[this.strength.value]}</small>
              </p>
              ${this.field("confirm", "Repeat the password", "password", "new-password", "Type it once more.")}
              <button type="submit" class="primary" aria-busy=${() => String(this.busy.value)}
                disabled=${() => this.busy.value}>Create account</button>
            </form>
          `;
        }

        render() {
          return html`
            <article class="card">
              <header><h1>Create an account</h1></header>
              ${Show({
                when: () => this.done.value,
                fallback: this.form(),
                children: html`
                  <p class="alert success" role="status">
                    Welcome, ${() => this.values.username.value}! Your account is ready.
                  </p>
                `,
              })}
            </article>
          `;
        }
      }

      application(SignUp).mount("#app");
    </script>
  </body>
</html>

What it uses

  • Signals per field and Computed errors
  • Refs to focus the first invalid field
  • aria-invalid and aria-busy bound to signals
  • Show for the messages