Skip to content

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)