Skip to content

Contact Manager (In-Memory State)

Live islandRuns locally in this page

Page shell

<article>
  <contact-manager></contact-manager>
</article>
<script src="./build/cami.cdn.js"></script>
<!-- CDN version below -->
<!-- <script src="https://unpkg.com/cami@0.4/build/cami.cdn.js"></script> -->
<style>
  .container {
    display: flex;
    flex-direction: column;
    width: 330px;
    margin: auto;
  }
  .input-group {
    margin-bottom: 10px;
  }
  .contact-list {
    display: grid;
    gap: 6px;
    margin-bottom: 10px;
    max-height: 180px;
    overflow: auto;
    border: 1px solid gray;
    padding: 6px;
  }
  .contact-list button[aria-selected="true"] {
    font-weight: 700;
    outline: 2px solid currentColor;
  }
  .button-group {
    display: flex;
    justify-content: space-between;
    gap: 10px;
  }
  .validation-error {
    border: 1px solid red;
    padding: 10px;
    position: relative;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  .close-btn {
    cursor: pointer;
  }
</style>
<script type="module" src="./island.js"></script>

Island source

const { html, ReactiveElement } = cami
class NameManagerElement extends ReactiveElement {
    names = [
        { firstName: 'Hans', lastName: 'Zimmermann' },
        { firstName: 'Ruth', lastName: 'Huber' },
        { firstName: 'Heidi', lastName: 'Müller' },
    ];
    prefix = '';
    selectedName = null;
    firstName = '';
    lastName = '';
    validationError = '';
    get filteredNames() {
        const prefix = this.prefix.toLocaleLowerCase();
        return this.names.filter((contact) => contact.firstName.toLocaleLowerCase().startsWith(prefix)
            || contact.lastName.toLocaleLowerCase().startsWith(prefix));
    }
    validateName() {
        if (!this.firstName.trim() || !this.lastName.trim()) {
            this.validationError = 'Both first name and last name must be provided.';
            return false;
        }
        this.validationError = '';
        return true;
    }
    currentDraft() {
        return { firstName: this.firstName.trim(), lastName: this.lastName.trim() };
    }
    clearDraft() {
        this.firstName = '';
        this.lastName = '';
    }
    addName() {
        if (!this.validateName())
            return;
        this.names = [...this.names, this.currentDraft()];
        this.clearDraft();
    }
    updateName() {
        if (!this.selectedName || !this.validateName())
            return;
        const selected = this.selectedName;
        const replacement = this.currentDraft();
        this.names = this.names.map((contact) => contact === selected ? replacement : contact);
        this.selectedName = replacement;
        this.clearDraft();
    }
    deleteName() {
        if (!this.selectedName)
            return;
        this.names = this.names.filter((contact) => contact !== this.selectedName);
        this.selectedName = null;
    }
    selectName(contact) {
        this.selectedName = contact;
        this.firstName = this.selectedName?.firstName ?? '';
        this.lastName = this.selectedName?.lastName ?? '';
    }
    template() {
        return html `
      <div class="container">
        <label>Filter prefix:
          <input @input=${(event) => {
            this.prefix = event.currentTarget.value;
        }}>
        </label>
        <span>Contacts:</span>
        <div class="contact-list" role="listbox" aria-label="Contacts">
          ${this.filteredNames.map((contact) => html `
            <button
              type="button"
              role="option"
              aria-selected=${contact === this.selectedName}
              @click=${() => this.selectName(contact)}
            >${contact.firstName} ${contact.lastName}</button>
          `)}
        </div>
        <label>First Name:
          <input .value=${this.firstName} @input=${(event) => {
            this.firstName = event.currentTarget.value;
        }}>
        </label>
        <label>Last Name:
          <input .value=${this.lastName} @input=${(event) => {
            this.lastName = event.currentTarget.value;
        }}>
        </label>
        <div class="button-group">
          <button @click=${() => this.addName()}>Create</button>
          <button @click=${() => this.updateName()} ?disabled=${!this.selectedName}>Update</button>
          <button @click=${() => this.deleteName()} ?disabled=${!this.selectedName}>Delete</button>
        </div>
        ${this.validationError ? html `<p role="alert">${this.validationError}</p>` : ''}
      </div>
    `;
    }
}
customElements.define('contact-manager', NameManagerElement);
import { html, ReactiveElement } from 'cami'

interface Contact {
  firstName: string
  lastName: string
}

class NameManagerElement extends ReactiveElement {
  names: Contact[] = [
    { firstName: 'Hans', lastName: 'Zimmermann' },
    { firstName: 'Ruth', lastName: 'Huber' },
    { firstName: 'Heidi', lastName: 'Müller' },
  ]
  prefix: string = ''
  selectedName: Contact | null = null
  firstName: string = ''
  lastName: string = ''
  validationError: string = ''

  get filteredNames(): Contact[] {
    const prefix = this.prefix.toLocaleLowerCase()
    return this.names.filter((contact: Contact) =>
      contact.firstName.toLocaleLowerCase().startsWith(prefix)
      || contact.lastName.toLocaleLowerCase().startsWith(prefix),
    )
  }

  validateName(): boolean {
    if (!this.firstName.trim() || !this.lastName.trim()) {
      this.validationError = 'Both first name and last name must be provided.'
      return false
    }
    this.validationError = ''
    return true
  }

  currentDraft(): Contact {
    return { firstName: this.firstName.trim(), lastName: this.lastName.trim() }
  }

  clearDraft(): void {
    this.firstName = ''
    this.lastName = ''
  }

  addName(): void {
    if (!this.validateName()) return
    this.names = [...this.names, this.currentDraft()]
    this.clearDraft()
  }

  updateName(): void {
    if (!this.selectedName || !this.validateName()) return
    const selected = this.selectedName
    const replacement = this.currentDraft()
    this.names = this.names.map((contact: Contact) => contact === selected ? replacement : contact)
    this.selectedName = replacement
    this.clearDraft()
  }

  deleteName(): void {
    if (!this.selectedName) return
    this.names = this.names.filter((contact: Contact) => contact !== this.selectedName)
    this.selectedName = null
  }

  selectName(contact: Contact): void {
    this.selectedName = contact
    this.firstName = this.selectedName?.firstName ?? ''
    this.lastName = this.selectedName?.lastName ?? ''
  }

  template(): ReturnType<typeof html> {
    return html`
      <div class="container">
        <label>Filter prefix:
          <input @input=${(event: InputEvent) => {
            this.prefix = (event.currentTarget as HTMLInputElement).value
          }}>
        </label>
        <span>Contacts:</span>
        <div class="contact-list" role="listbox" aria-label="Contacts">
          ${this.filteredNames.map((contact: Contact) => html`
            <button
              type="button"
              role="option"
              aria-selected=${contact === this.selectedName}
              @click=${() => this.selectName(contact)}
            >${contact.firstName} ${contact.lastName}</button>
          `)}
        </div>
        <label>First Name:
          <input .value=${this.firstName} @input=${(event: InputEvent) => {
            this.firstName = (event.currentTarget as HTMLInputElement).value
          }}>
        </label>
        <label>Last Name:
          <input .value=${this.lastName} @input=${(event: InputEvent) => {
            this.lastName = (event.currentTarget as HTMLInputElement).value
          }}>
        </label>
        <div class="button-group">
          <button @click=${() => this.addName()}>Create</button>
          <button @click=${() => this.updateName()} ?disabled=${!this.selectedName}>Update</button>
          <button @click=${() => this.deleteName()} ?disabled=${!this.selectedName}>Delete</button>
        </div>
        ${this.validationError ? html`<p role="alert">${this.validationError}</p>` : ''}
      </div>
    `
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'contact-manager': NameManagerElement
  }
}

customElements.define('contact-manager', NameManagerElement)