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
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>