Interactive Registration
This example shows async form validation with debounced input—checking email availability against an API as the user types.
Email lookups use https://cami-api.exe.xyz. Try user1@example.com to exercise the existing-user path.
Live islandRuns locally in this page
Page shell
<article>
<small>Try entering an email that is already taken, such as user1@example.com</small>
<registration-form></registration-form>
</article>
<script src="https://unpkg.com/cami@0.4/build/cami.cdn.js"></script>
<script type="module" src="./island.js"></script>
Island source
const { html, ReactiveElement } = cami
class RegistrationForm extends ReactiveElement {
email = '';
password = '';
emailStatus = 'idle';
submitted = null;
validationTimer;
get isPasswordValid() {
return this.password.length >= 8;
}
get canSubmit() {
return this.emailStatus === 'available' && this.isPasswordValid;
}
updateEmail(event) {
this.email = event.currentTarget.value.trim();
this.emailStatus = 'idle';
if (this.validationTimer !== undefined)
clearTimeout(this.validationTimer);
this.validationTimer = setTimeout(() => void this.checkEmailAvailability(), 350);
}
updatePassword(event) {
this.password = event.currentTarget.value;
}
async checkEmailAvailability() {
if (!this.email || !HTMLInputElement.prototype.checkValidity.call(Object.assign(document.createElement('input'), { type: 'email', value: this.email })))
return;
this.emailStatus = 'checking';
try {
const response = await fetch(`https://cami-api.exe.xyz/users?email=${encodeURIComponent(this.email)}`);
if (!response.ok)
throw new Error(`Lookup failed: ${response.status}`);
const users = await response.json();
this.emailStatus = users.length === 0 ? 'available' : 'taken';
}
catch {
this.emailStatus = 'error';
}
}
submit(event) {
event.preventDefault();
if (!this.canSubmit)
return;
this.submitted = { email: this.email, password: this.password };
}
template() {
return html `
<form @submit=${(event) => this.submit(event)}>
<label>Email
<input type="email" required .value=${this.email} @input=${(event) => this.updateEmail(event)}>
</label>
<small aria-live="polite">
${this.emailStatus === 'checking' ? 'Checking…' : ''}
${this.emailStatus === 'available' ? 'Email is available.' : ''}
${this.emailStatus === 'taken' ? 'Email is already registered.' : ''}
${this.emailStatus === 'error' ? 'Could not check the email.' : ''}
</small>
<label>Password
<input type="password" minlength="8" required .value=${this.password} @input=${(event) => this.updatePassword(event)}>
</label>
<button type="submit" ?disabled=${!this.canSubmit}>Register</button>
</form>
${this.submitted ? html `<p>Registered ${this.submitted.email} locally.</p>` : ''}
`;
}
}
customElements.define('registration-form', RegistrationForm);
import { html, ReactiveElement } from 'cami'
interface UserRecord {
id: number
email: string
}
interface RegistrationData {
email: string
password: string
}
type EmailStatus = 'idle' | 'checking' | 'available' | 'taken' | 'error'
class RegistrationForm extends ReactiveElement {
email: string = ''
password: string = ''
emailStatus: EmailStatus = 'idle'
submitted: RegistrationData | null = null
private validationTimer: ReturnType<typeof setTimeout> | undefined
get isPasswordValid(): boolean {
return this.password.length >= 8
}
get canSubmit(): boolean {
return this.emailStatus === 'available' && this.isPasswordValid
}
updateEmail(event: InputEvent): void {
this.email = (event.currentTarget as HTMLInputElement).value.trim()
this.emailStatus = 'idle'
if (this.validationTimer !== undefined) clearTimeout(this.validationTimer)
this.validationTimer = setTimeout(() => void this.checkEmailAvailability(), 350)
}
updatePassword(event: InputEvent): void {
this.password = (event.currentTarget as HTMLInputElement).value
}
async checkEmailAvailability(): Promise<void> {
if (!this.email || !HTMLInputElement.prototype.checkValidity.call(
Object.assign(document.createElement('input'), { type: 'email', value: this.email }),
)) return
this.emailStatus = 'checking'
try {
const response = await fetch(`https://cami-api.exe.xyz/users?email=${encodeURIComponent(this.email)}`)
if (!response.ok) throw new Error(`Lookup failed: ${response.status}`)
const users = await response.json() as UserRecord[]
this.emailStatus = users.length === 0 ? 'available' : 'taken'
} catch {
this.emailStatus = 'error'
}
}
submit(event: SubmitEvent): void {
event.preventDefault()
if (!this.canSubmit) return
this.submitted = { email: this.email, password: this.password }
}
template(): ReturnType<typeof html> {
return html`
<form @submit=${(event: SubmitEvent) => this.submit(event)}>
<label>Email
<input type="email" required .value=${this.email} @input=${(event: InputEvent) => this.updateEmail(event)}>
</label>
<small aria-live="polite">
${this.emailStatus === 'checking' ? 'Checking…' : ''}
${this.emailStatus === 'available' ? 'Email is available.' : ''}
${this.emailStatus === 'taken' ? 'Email is already registered.' : ''}
${this.emailStatus === 'error' ? 'Could not check the email.' : ''}
</small>
<label>Password
<input type="password" minlength="8" required .value=${this.password} @input=${(event: InputEvent) => this.updatePassword(event)}>
</label>
<button type="submit" ?disabled=${!this.canSubmit}>Register</button>
</form>
${this.submitted ? html`<p>Registered ${this.submitted.email} locally.</p>` : ''}
`
}
}
declare global {
interface HTMLElementTagNameMap {
'registration-form': RegistrationForm
}
}
customElements.define('registration-form', RegistrationForm)