آنلاین

Autocomplete

Overview

یک input field قابل‌دسترس که هنگام typing کاربر optionها را فیلتر و suggest می‌کند و به او کمک می‌کند valueها را از یک list پیدا و انتخاب کند.

ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';

@Component({
  selector: 'app-root[theme="basic-basic"], app-root:not([theme])',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
  clear() {
    this.query.set('');
    this.selectedOption.set([]);
    this.popupExpanded.set(false);
  }

  readonly listbox = viewChild(Listbox);
  readonly combobox = viewChild(Combobox);

  popupExpanded = signal(false);
  query = signal('');
  selectedOption = signal<string[]>([]);

  countries = computed(() =>
    ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())),
  );

  constructor() {
    afterRenderEffect(() => {
      if (this.combobox()?.expanded() === true) {
        this.listbox()?.scrollActiveItemIntoView();
      }
    });
  }

  onBlur() {
    this.commitSelection();
  }

  onCommit() {
    this.commitSelection();
    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }

  private commitSelection() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.query.set(selected[0]);
    } else {
      this.query.set('');
      this.selectedOption.set([]);
    }
  }
}

const ALL_COUNTRIES = [
  'Afghanistan',
  'Albania',
  'Algeria',
  'Andorra',
  'Angola',
  'Antigua and Barbuda',
  'Argentina',
  'Armenia',
  'Australia',
  'Austria',
  'Azerbaijan',
  'Bahamas',
  'Bahrain',
  'Bangladesh',
  'Barbados',
  'Belarus',
  'Belgium',
  'Belize',
  'Benin',
  'Bhutan',
  'Bolivia',
  'Bosnia and Herzegovina',
  'Botswana',
  'Brazil',
  'Brunei',
  'Bulgaria',
  'Burkina Faso',
  'Burundi',
  'Cabo Verde',
  'Cambodia',
  'Cameroon',
  'Canada',
  'Central African Republic',
  'Chad',
  'Chile',
  'China',
  'Colombia',
  'Comoros',
  'Congo (Congo-Brazzaville)',
  'Costa Rica',
  "Côte d'Ivoire",
  'Croatia',
  'Cuba',
  'Cyprus',
  'Czechia (Czech Republic)',
  'Democratic Republic of the Congo',
  'Denmark',
  'Djibouti',
  'Dominica',
  'Dominican Republic',
  'Ecuador',
  'Egypt',
  'El Salvador',
  'Equatorial Guinea',
  'Eritrea',
  'Estonia',
  'Eswatini (fmr. "Swaziland")',
  'Ethiopia',
  'Fiji',
  'Finland',
  'France',
  'Gabon',
  'Gambia',
  'Georgia',
  'Germany',
  'Ghana',
  'Greece',
  'Grenada',
  'Guatemala',
  'Guinea',
  'Guinea-Bissau',
  'Guyana',
  'Haiti',
  'Holy See',
  'Honduras',
  'Hungary',
  'Iceland',
  'India',
  'Indonesia',
  'Iran',
  'Iraq',
  'Ireland',
  'Israel',
  'Italy',
  'Jamaica',
  'Japan',
  'Jordan',
  'Kazakhstan',
  'Kenya',
  'Kiribati',
  'Kuwait',
  'Kyrgyzstan',
  'Laos',
  'Latvia',
  'Lebanon',
  'Lesotho',
  'Liberia',
  'Libya',
  'Liechtenstein',
  'Lithuania',
  'Luxembourg',
  'Madagascar',
  'Malawi',
  'Malaysia',
  'Maldives',
  'Mali',
  'Malta',
  'Marshall Islands',
  'Mauritania',
  'Mauritius',
  'Mexico',
  'Micronesia',
  'Moldova',
  'Monaco',
  'Mongolia',
  'Montenegro',
  'Morocco',
  'Mozambique',
  'Myanmar (formerly Burma)',
  'Namibia',
  'Nauru',
  'Nepal',
  'Netherlands',
  'New Zealand',
  'Nicaragua',
  'Niger',
  'Nigeria',
  'North Korea',
  'North Macedonia',
  'Norway',
  'Oman',
  'Pakistan',
  'Palau',
  'Palestine State',
  'Panama',
  'Papua New Guinea',
  'Paraguay',
  'Peru',
  'Philippines',
  'Poland',
  'Portugal',
  'Qatar',
  'Romania',
  'Russia',
  'Rwanda',
  'Saint Kitts and Nevis',
  'Saint Lucia',
  'Saint Vincent and the Grenadines',
  'Samoa',
  'San Marino',
  'Sao Tome and Principe',
  'Saudi Arabia',
  'Senegal',
  'Serbia',
  'Seychelles',
  'Sierra Leone',
  'Singapore',
  'Slovakia',
  'Slovenia',
  'Solomon Islands',
  'Somalia',
  'South Africa',
  'South Korea',
  'South Sudan',
  'Spain',
  'Sri Lanka',
  'Sudan',
  'Suriname',
  'Sweden',
  'Switzerland',
  'Syria',
  'Tajikistan',
  'Tanzania',
  'Thailand',
  'Timor-Leste',
  'Togo',
  'Tonga',
  'Trinidad and Tobago',
  'Tunisia',
  'Türkiye',
  'Turkmenistan',
  'Tuvalu',
  'Uganda',
  'Ukraine',
  'United Arab Emirates',
  'United Kingdom',
  'United States of America',
  'Uruguay',
  'Uzbekistan',
  'Vanuatu',
  'Venezuela',
  'Vietnam',
  'Yemen',
  'Zambia',
  'Zimbabwe',
];
html
<div class="autocomplete-container">
  <div #origin class="autocomplete-input-container">
    <span class="search-icon material-symbols-outlined" translate="no" aria-hidden="true"
      >search</span
    >
    <input
      #combobox="ngCombobox"
      ngCombobox
      class="autocomplete-input"
      placeholder="Select a country"
      [(value)]="query"
      [(expanded)]="popupExpanded"
      (click)="popupExpanded.set(true)"
      (focusout)="onBlur()"
    />
    <button
      type="button"
      class="clear-button"
      aria-label="Clear"
      (mousedown)="$event.preventDefault()"
      (click)="clear()"
    >
      <span class="clear-icon material-symbols-outlined" translate="no" aria-hidden="true"
        >close</span
      >
    </button>
  </div>

  <div aria-live="polite" class="cdk-visually-hidden">
    {{ countries().length === 0 ? 'No results found for ' + query() : '' }}
  </div>

  <ng-template
    [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
    [cdkConnectedOverlayOpen]="popupExpanded()"
  >
    <ng-template ngComboboxPopup [combobox]="combobox">
      <div class="popup">
        @if (countries().length === 0) {
          <div class="no-results">No results found</div>
        }
        <div
          #listbox="ngListbox"
          ngListbox
          ngComboboxWidget
          class="listbox"
          focusMode="activedescendant"
          [tabindex]="-1"
          [activeDescendant]="listbox.activeDescendant()"
          [(value)]="selectedOption"
          (click)="onCommit()"
          (keydown.enter)="onCommit()"
        >
          @for (country of countries(); track country) {
            <div class="option" ngOption [value]="country" [label]="country">
              <span class="option-label">{{ country }}</span>
              <span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
                >check</span
              >
            </div>
          }
        </div>
      </div>
    </ng-template>
  </ng-template>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

.autocomplete-container {
  display: flex;
  flex-direction: column;
  position: relative;
}

.autocomplete-input-container {
  display: flex;
  position: relative;
  align-items: center;
}

.material-symbols-outlined {
  font-size: 1.25rem;
  pointer-events: none;
}

.search-icon {
  left: 0.75rem;
  position: absolute;
  color: var(--quaternary-contrast);
}

.autocomplete-input {
  width: 13rem;
  font-size: 1rem;
  border-radius: 0.25rem;
  padding: 0.75rem 2.5rem 0.75rem 2.5rem;
  color: var(--primary-contrast);
  outline: none;
  border: 1px solid var(--quinary-contrast);
  background-color: var(--page-background);
}

.autocomplete-input:focus-visible {
  border-color: var(--hot-pink);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--hot-pink) 20%, transparent);
}

.autocomplete-input::placeholder {
  color: var(--quaternary-contrast);
}

.popup {
  width: 100%;
  margin-top: 8px;
  padding: 0.5rem;
  max-height: 11rem;
  border-radius: 0.5rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  box-shadow:
    0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
}

.no-results {
  padding: 1rem;
}

.listbox {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
  outline: none;
}

.option {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 2.25rem;
  border-radius: 0.5rem;
  outline: none;
}

.option:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.option[data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid var(--hot-pink);
}

.option[aria-selected='true'] {
  color: var(--hot-pink);
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}

.option:not([aria-selected='true']) .check-icon {
  display: none;
}

.option-label {
  flex: 1;
}

.check-icon {
  font-size: 0.9rem;
}

.clear-button {
  position: absolute;
  right: 0.75rem;
  width: 2rem;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background-color: transparent;
  border-radius: 0.25rem;
  cursor: pointer;
  color: var(--quaternary-contrast);
  padding: 0;
  margin: 0;
  outline: none;
  transition: background-color 0.15s ease;
}

.clear-button:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.clear-button:focus-visible {
  outline: 2px solid var(--hot-pink);
  outline-offset: -2px;
}

.clear-icon {
  font-size: 1.25rem;
}
ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';

@Component({
  selector: 'app-root[theme="basic-material"]',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
  clear() {
    this.query.set('');
    this.selectedOption.set([]);
    this.popupExpanded.set(false);
  }

  readonly listbox = viewChild(Listbox);
  readonly combobox = viewChild(Combobox);

  popupExpanded = signal(false);
  query = signal('');
  selectedOption = signal<string[]>([]);

  countries = computed(() =>
    ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())),
  );

  constructor() {
    afterRenderEffect(() => {
      if (this.combobox()?.expanded() === true) {
        this.listbox()?.scrollActiveItemIntoView();
      }
    });
  }

  onBlur() {
    this.commitSelection();
  }

  onCommit() {
    this.commitSelection();
    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }

  private commitSelection() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.query.set(selected[0]);
    } else {
      this.query.set('');
      this.selectedOption.set([]);
    }
  }
}

const ALL_COUNTRIES = [
  'Afghanistan',
  'Albania',
  'Algeria',
  'Andorra',
  'Angola',
  'Antigua and Barbuda',
  'Argentina',
  'Armenia',
  'Australia',
  'Austria',
  'Azerbaijan',
  'Bahamas',
  'Bahrain',
  'Bangladesh',
  'Barbados',
  'Belarus',
  'Belgium',
  'Belize',
  'Benin',
  'Bhutan',
  'Bolivia',
  'Bosnia and Herzegovina',
  'Botswana',
  'Brazil',
  'Brunei',
  'Bulgaria',
  'Burkina Faso',
  'Burundi',
  'Cabo Verde',
  'Cambodia',
  'Cameroon',
  'Canada',
  'Central African Republic',
  'Chad',
  'Chile',
  'China',
  'Colombia',
  'Comoros',
  'Congo (Congo-Brazzaville)',
  'Costa Rica',
  "Côte d'Ivoire",
  'Croatia',
  'Cuba',
  'Cyprus',
  'Czechia (Czech Republic)',
  'Democratic Republic of the Congo',
  'Denmark',
  'Djibouti',
  'Dominica',
  'Dominican Republic',
  'Ecuador',
  'Egypt',
  'El Salvador',
  'Equatorial Guinea',
  'Eritrea',
  'Estonia',
  'Eswatini (fmr. "Swaziland")',
  'Ethiopia',
  'Fiji',
  'Finland',
  'France',
  'Gabon',
  'Gambia',
  'Georgia',
  'Germany',
  'Ghana',
  'Greece',
  'Grenada',
  'Guatemala',
  'Guinea',
  'Guinea-Bissau',
  'Guyana',
  'Haiti',
  'Holy See',
  'Honduras',
  'Hungary',
  'Iceland',
  'India',
  'Indonesia',
  'Iran',
  'Iraq',
  'Ireland',
  'Israel',
  'Italy',
  'Jamaica',
  'Japan',
  'Jordan',
  'Kazakhstan',
  'Kenya',
  'Kiribati',
  'Kuwait',
  'Kyrgyzstan',
  'Laos',
  'Latvia',
  'Lebanon',
  'Lesotho',
  'Liberia',
  'Libya',
  'Liechtenstein',
  'Lithuania',
  'Luxembourg',
  'Madagascar',
  'Malawi',
  'Malaysia',
  'Maldives',
  'Mali',
  'Malta',
  'Marshall Islands',
  'Mauritania',
  'Mauritius',
  'Mexico',
  'Micronesia',
  'Moldova',
  'Monaco',
  'Mongolia',
  'Montenegro',
  'Morocco',
  'Mozambique',
  'Myanmar (formerly Burma)',
  'Namibia',
  'Nauru',
  'Nepal',
  'Netherlands',
  'New Zealand',
  'Nicaragua',
  'Niger',
  'Nigeria',
  'North Korea',
  'North Macedonia',
  'Norway',
  'Oman',
  'Pakistan',
  'Palau',
  'Palestine State',
  'Panama',
  'Papua New Guinea',
  'Paraguay',
  'Peru',
  'Philippines',
  'Poland',
  'Portugal',
  'Qatar',
  'Romania',
  'Russia',
  'Rwanda',
  'Saint Kitts and Nevis',
  'Saint Lucia',
  'Saint Vincent and the Grenadines',
  'Samoa',
  'San Marino',
  'Sao Tome and Principe',
  'Saudi Arabia',
  'Senegal',
  'Serbia',
  'Seychelles',
  'Sierra Leone',
  'Singapore',
  'Slovakia',
  'Slovenia',
  'Solomon Islands',
  'Somalia',
  'South Africa',
  'South Korea',
  'South Sudan',
  'Spain',
  'Sri Lanka',
  'Sudan',
  'Suriname',
  'Sweden',
  'Switzerland',
  'Syria',
  'Tajikistan',
  'Tanzania',
  'Thailand',
  'Timor-Leste',
  'Togo',
  'Tonga',
  'Trinidad and Tobago',
  'Tunisia',
  'Türkiye',
  'Turkmenistan',
  'Tuvalu',
  'Uganda',
  'Ukraine',
  'United Arab Emirates',
  'United Kingdom',
  'United States of America',
  'Uruguay',
  'Uzbekistan',
  'Vanuatu',
  'Venezuela',
  'Vietnam',
  'Yemen',
  'Zambia',
  'Zimbabwe',
];
html
<div class="autocomplete-container">
  <div #origin class="material-autocomplete">
    <span class="search-icon material-symbols-outlined" translate="no" aria-hidden="true"
      >search</span
    >
    <input
      #combobox="ngCombobox"
      ngCombobox
      class="autocomplete-input"
      placeholder="Select a country"
      [(value)]="query"
      [(expanded)]="popupExpanded"
      (click)="popupExpanded.set(true)"
      (focusout)="onBlur()"
    />
    <button
      type="button"
      class="clear-button"
      aria-label="Clear"
      (mousedown)="$event.preventDefault()"
      (click)="clear()"
    >
      <span class="clear-icon material-symbols-outlined" translate="no" aria-hidden="true"
        >close</span
      >
    </button>
  </div>

  <div aria-live="polite" class="cdk-visually-hidden">
    {{ countries().length === 0 ? 'No results found for ' + query() : '' }}
  </div>

  <ng-template
    [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
    [cdkConnectedOverlayOpen]="popupExpanded()"
  >
    <ng-template ngComboboxPopup [combobox]="combobox">
      <div class="popup">
        @if (countries().length === 0) {
          <div class="no-results">No results found</div>
        }
        <div
          #listbox="ngListbox"
          ngListbox
          ngComboboxWidget
          class="listbox"
          focusMode="activedescendant"
          [tabindex]="-1"
          [activeDescendant]="listbox.activeDescendant()"
          [(value)]="selectedOption"
          (click)="onCommit()"
          (keydown.enter)="onCommit()"
        >
          @for (country of countries(); track country) {
            <div class="option" ngOption [value]="country" [label]="country">
              <span class="option-label">{{ country }}</span>
              <span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
                >check</span
              >
            </div>
          }
        </div>
      </div>
    </ng-template>
  </ng-template>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
  --primary: var(--hot-pink);
}

.autocomplete-container {
  display: flex;
  flex-direction: column;
  position: relative;
}

.material-autocomplete {
  display: flex;
  position: relative;
  align-items: center;
}

.material-symbols-outlined {
  font-size: 1.25rem;
  pointer-events: none;
}

.search-icon {
  left: 0.75rem;
  position: absolute;
  color: var(--quaternary-contrast);
}

.autocomplete-input {
  width: 13rem;
  font-size: 1rem;
  border-radius: 3rem;
  padding: 0.75rem 2.5rem 0.75rem 2.5rem;
  color: var(--primary-contrast);
  outline: none;
  border: 1px solid var(--quinary-contrast);
  background-color: var(--page-background);
}

.autocomplete-input:focus-visible {
  border-color: var(--primary);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent);
}

.autocomplete-input::placeholder {
  color: var(--quaternary-contrast);
}

.popup {
  width: 100%;
  margin-top: 8px;
  padding: 0.5rem;
  max-height: 11rem;
  border-radius: 2rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  box-shadow:
    0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
}

.no-results {
  padding: 1rem;
}

.listbox {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
  outline: none;
}

.option {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 3rem;
  border-radius: 3rem;
  outline: none;
}

.option:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.option[data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid var(--primary);
}

.option[aria-selected='true'] {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}

.option:not([aria-selected='true']) .check-icon {
  display: none;
}

.option-label {
  flex: 1;
}

.check-icon {
  font-size: 0.9rem;
}

.clear-button {
  position: absolute;
  right: 0.75rem;
  width: 2rem;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background-color: transparent;
  border-radius: 50%;
  cursor: pointer;
  color: var(--quaternary-contrast);
  padding: 0;
  margin: 0;
  outline: none;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}

.clear-button:hover {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}

.clear-button:focus-visible {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 15%, transparent);
  outline: 2px solid var(--primary);
  outline-offset: -2px;
}

.clear-icon {
  font-size: 1.25rem;
}
ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';

@Component({
  selector: 'app-root[theme="basic-retro"]',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
  clear() {
    this.query.set('');
    this.selectedOption.set([]);
    this.popupExpanded.set(false);
  }

  readonly listbox = viewChild(Listbox);
  readonly combobox = viewChild(Combobox);

  popupExpanded = signal(false);
  query = signal('');
  selectedOption = signal<string[]>([]);

  countries = computed(() =>
    ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())),
  );

  constructor() {
    afterRenderEffect(() => {
      if (this.combobox()?.expanded() === true) {
        this.listbox()?.scrollActiveItemIntoView();
      }
    });
  }

  onBlur() {
    this.commitSelection();
  }

  onCommit() {
    this.commitSelection();
    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }

  private commitSelection() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.query.set(selected[0]);
    } else {
      this.query.set('');
      this.selectedOption.set([]);
    }
  }
}

const ALL_COUNTRIES = [
  'Afghanistan',
  'Albania',
  'Algeria',
  'Andorra',
  'Angola',
  'Antigua and Barbuda',
  'Argentina',
  'Armenia',
  'Australia',
  'Austria',
  'Azerbaijan',
  'Bahamas',
  'Bahrain',
  'Bangladesh',
  'Barbados',
  'Belarus',
  'Belgium',
  'Belize',
  'Benin',
  'Bhutan',
  'Bolivia',
  'Bosnia and Herzegovina',
  'Botswana',
  'Brazil',
  'Brunei',
  'Bulgaria',
  'Burkina Faso',
  'Burundi',
  'Cabo Verde',
  'Cambodia',
  'Cameroon',
  'Canada',
  'Central African Republic',
  'Chad',
  'Chile',
  'China',
  'Colombia',
  'Comoros',
  'Congo (Congo-Brazzaville)',
  'Costa Rica',
  "Côte d'Ivoire",
  'Croatia',
  'Cuba',
  'Cyprus',
  'Czechia (Czech Republic)',
  'Democratic Republic of the Congo',
  'Denmark',
  'Djibouti',
  'Dominica',
  'Dominican Republic',
  'Ecuador',
  'Egypt',
  'El Salvador',
  'Equatorial Guinea',
  'Eritrea',
  'Estonia',
  'Eswatini (fmr. "Swaziland")',
  'Ethiopia',
  'Fiji',
  'Finland',
  'France',
  'Gabon',
  'Gambia',
  'Georgia',
  'Germany',
  'Ghana',
  'Greece',
  'Grenada',
  'Guatemala',
  'Guinea',
  'Guinea-Bissau',
  'Guyana',
  'Haiti',
  'Holy See',
  'Honduras',
  'Hungary',
  'Iceland',
  'India',
  'Indonesia',
  'Iran',
  'Iraq',
  'Ireland',
  'Israel',
  'Italy',
  'Jamaica',
  'Japan',
  'Jordan',
  'Kazakhstan',
  'Kenya',
  'Kiribati',
  'Kuwait',
  'Kyrgyzstan',
  'Laos',
  'Latvia',
  'Lebanon',
  'Lesotho',
  'Liberia',
  'Libya',
  'Liechtenstein',
  'Lithuania',
  'Luxembourg',
  'Madagascar',
  'Malawi',
  'Malaysia',
  'Maldives',
  'Mali',
  'Malta',
  'Marshall Islands',
  'Mauritania',
  'Mauritius',
  'Mexico',
  'Micronesia',
  'Moldova',
  'Monaco',
  'Mongolia',
  'Montenegro',
  'Morocco',
  'Mozambique',
  'Myanmar (formerly Burma)',
  'Namibia',
  'Nauru',
  'Nepal',
  'Netherlands',
  'New Zealand',
  'Nicaragua',
  'Niger',
  'Nigeria',
  'North Korea',
  'North Macedonia',
  'Norway',
  'Oman',
  'Pakistan',
  'Palau',
  'Palestine State',
  'Panama',
  'Papua New Guinea',
  'Paraguay',
  'Peru',
  'Philippines',
  'Poland',
  'Portugal',
  'Qatar',
  'Romania',
  'Russia',
  'Rwanda',
  'Saint Kitts and Nevis',
  'Saint Lucia',
  'Saint Vincent and the Grenadines',
  'Samoa',
  'San Marino',
  'Sao Tome and Principe',
  'Saudi Arabia',
  'Senegal',
  'Serbia',
  'Seychelles',
  'Sierra Leone',
  'Singapore',
  'Slovakia',
  'Slovenia',
  'Solomon Islands',
  'Somalia',
  'South Africa',
  'South Korea',
  'South Sudan',
  'Spain',
  'Sri Lanka',
  'Sudan',
  'Suriname',
  'Sweden',
  'Switzerland',
  'Syria',
  'Tajikistan',
  'Tanzania',
  'Thailand',
  'Timor-Leste',
  'Togo',
  'Tonga',
  'Trinidad and Tobago',
  'Tunisia',
  'Türkiye',
  'Turkmenistan',
  'Tuvalu',
  'Uganda',
  'Ukraine',
  'United Arab Emirates',
  'United Kingdom',
  'United States of America',
  'Uruguay',
  'Uzbekistan',
  'Vanuatu',
  'Venezuela',
  'Vietnam',
  'Yemen',
  'Zambia',
  'Zimbabwe',
];
html
<div class="autocomplete-container">
  <div #origin class="retro-autocomplete">
    <span class="search-icon material-symbols-outlined" translate="no" aria-hidden="true"
      >search</span
    >
    <input
      #combobox="ngCombobox"
      ngCombobox
      class="autocomplete-input"
      placeholder="Select a country"
      [(value)]="query"
      [(expanded)]="popupExpanded"
      (click)="popupExpanded.set(true)"
      (focusout)="onBlur()"
    />
    <button
      type="button"
      class="clear-button"
      aria-label="Clear"
      (mousedown)="$event.preventDefault()"
      (click)="clear()"
    >
      <span class="clear-icon" aria-hidden="true">X</span>
    </button>
  </div>

  <div aria-live="polite" class="cdk-visually-hidden">
    {{ countries().length === 0 ? 'No results found for ' + query() : '' }}
  </div>

  <ng-template
    [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
    [cdkConnectedOverlayOpen]="popupExpanded()"
  >
    <ng-template ngComboboxPopup [combobox]="combobox">
      <div class="popup">
        @if (countries().length === 0) {
          <div class="no-results">No results found</div>
        }
        <div
          #listbox="ngListbox"
          ngListbox
          ngComboboxWidget
          class="listbox"
          focusMode="activedescendant"
          [tabindex]="-1"
          [activeDescendant]="listbox.activeDescendant()"
          [(value)]="selectedOption"
          (click)="onCommit()"
          (keydown.enter)="onCommit()"
        >
          @for (country of countries(); track country) {
            <div class="option" ngOption [value]="country" [label]="country">
              <span class="option-label">{{ country }}</span>
              <span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
                >check</span
              >
            </div>
          }
        </div>
      </div>
    </ng-template>
  </ng-template>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

:host {
  display: flex;
  justify-content: center;
  font-size: 0.6rem;
  font-family: 'Press Start 2P';

  --retro-button-color: #fff;
  --retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
  --retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
  --retro-elevated-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast);
  --retro-flat-shadow:
    4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
    -4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
  --retro-pressed-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-dark),
    inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}

.autocomplete-container {
  display: flex;
  flex-direction: column;
  position: relative;
}

.retro-autocomplete {
  display: flex;
  position: relative;
  align-items: center;
  box-shadow: var(--retro-flat-shadow);
  background-color: var(--retro-button-color);
}

.retro-autocomplete:focus-within {
  box-shadow: var(--retro-pressed-shadow);
}

.material-symbols-outlined {
  font-size: 1.25rem;
  pointer-events: none;
}

.search-icon {
  left: 0.75rem;
  position: absolute;
  color: #000;
  z-index: 1;
}

.autocomplete-input {
  width: 15rem;
  font-size: 0.6rem;
  border-radius: 0;
  font-family: 'Press Start 2P';
  word-spacing: -5px;
  padding: 0.75rem 2.5rem 0.75rem 2.5rem;
  color: #000;
  border: none;
  background-color: transparent;
  box-shadow: none;
  outline: none;
}

.autocomplete-input::placeholder {
  color: #000;
  opacity: 0.7;
}

.popup {
  width: 100%;
  margin-top: 20px;
  padding: 0.5rem;
  max-height: 11rem;
  border-radius: 0;
  background-color: var(--septenary-contrast);
  box-shadow: var(--retro-flat-shadow);
}

.no-results {
  padding: 1rem;
}

.listbox {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
  outline: none;
}

.option {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 2.25rem;
  border-radius: 0;
  outline: none;
}

.option:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.option[data-active='true'] {
  outline-offset: -2px;
  outline: 2px dashed var(--hot-pink);
}

.option[aria-selected='true'] {
  color: var(--hot-pink);
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}

.option:not([aria-selected='true']) .check-icon {
  display: none;
}

.option-label {
  flex: 1;
}

.check-icon {
  font-size: 0.9rem;
}

.clear-button {
  position: absolute;
  right: 0.75rem;
  width: 30px;
  height: 30px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--win95-gray);
  color: var(--win95-shadow);
  border-style: solid;
  border-width: 1px;
  border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
  box-shadow: 1px 1px 0 var(--win95-shadow);
  cursor: pointer;
  padding: 0;
  margin: 0;
  outline: none;
  font-family: 'Press Start 2P';
  font-size: 0.6rem;
  user-select: none;
}

.clear-button:hover {
  background: #e0e0e0;
  outline: 1px dashed var(--win95-dark-gray);
  outline-offset: 1px;
}

.clear-button:active {
  border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
  box-shadow: 1px 1px 0 var(--win95-shadow) inset;
}

.clear-button:focus-visible {
  outline: 2px dashed var(--hot-pink);
  outline-offset: 4px;
}

.clear-icon {
  display: flex;
  align-items: center;
  justify-content: center;
}

Usage

Autocomplete زمانی بهترین انتخاب است که کاربران باید از میان مجموعه بزرگی از optionها انتخاب کنند و typing سریع‌تر از scrolling است. در این موارد استفاده از autocomplete را در نظر بگیرید:

  • Option list طولانی است \(بیش از 20 item\) - typing انتخاب‌ها را سریع‌تر از scrolling در dropdown محدود می‌کند.
  • کاربران می‌دانند دنبال چه هستند - می‌توانند بخشی از value مورد انتظار را type کنند، مثل نام state، product یا username.
  • Optionها patternهای قابل‌پیش‌بینی دارند - کاربران می‌توانند matchهای جزئی را حدس بزنند، مثل country codeها، email domainها یا categoryها.
  • سرعت مهم است - formها از انتخاب سریع بدون navigation زیاد سود می‌برند.

از autocomplete پرهیز کنید وقتی:

  • list کمتر از 10 option دارد - یک dropdown معمولی یا radio group visibility بهتری فراهم می‌کند.
  • کاربران باید optionها را browse کنند - اگر discovery مهم است، همه optionها را از ابتدا نشان دهید.
  • optionها ناآشنا هستند - کاربران نمی‌توانند چیزی را type کنند که نمی‌دانند در list وجود دارد.

قابلیت‌ها

Autocomplete در Angular یک implementation کامل و accessible از combobox فراهم می‌کند با:

  • Keyboard Navigation - با arrow keyها بین optionها navigate کنید، با Enter انتخاب کنید و با Escape ببندید.
  • Screen Reader Support - attributeهای ARIA داخلی برای assistive technologyها.
  • Dynamic Highlight Behavior - پشتیبانی داخلی از suggestionهای inline selection.
  • Signal-Based Reactivity - مدیریت state reactive با Angular signals.
  • Popover API Integration - استفاده از HTML Popover API native برای positioning بهتر.
  • Bidirectional Text Support - مدیریت خودکار زبان‌های راست‌به‌چپ \(RTL\).

مثال‌ها

Auto-select mode

کاربرانی که متن جزئی type می‌کنند انتظار دارند سریع تأیید بگیرند که inputشان با یک option موجود match می‌شود. Auto-select mode هنگام typing کاربر، مقدار input را با اولین option فیلترشده match می‌کند، تعداد keystrokeهای لازم را کاهش می‌دهد و feedback فوری می‌دهد که search آن‌ها در مسیر درست است.

ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';

@Component({
  selector: 'app-root[theme="basic-basic"], app-root:not([theme])',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
  clear() {
    this.query.set('');
    this.selectedOption.set([]);
    this.popupExpanded.set(false);
  }

  readonly listbox = viewChild(Listbox);
  readonly combobox = viewChild(Combobox);

  popupExpanded = signal(false);
  query = signal('');
  selectedOption = signal<string[]>([]);

  countries = computed(() =>
    ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())),
  );

  constructor() {
    afterRenderEffect(() => {
      if (this.combobox()?.expanded() === true) {
        this.listbox()?.scrollActiveItemIntoView();
      }
    });
  }

  onBlur() {
    this.commitSelection();
  }

  onCommit() {
    this.commitSelection();
    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }

  private commitSelection() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.query.set(selected[0]);
    } else {
      this.query.set('');
      this.selectedOption.set([]);
    }
  }
}

const ALL_COUNTRIES = [
  'Afghanistan',
  'Albania',
  'Algeria',
  'Andorra',
  'Angola',
  'Antigua and Barbuda',
  'Argentina',
  'Armenia',
  'Australia',
  'Austria',
  'Azerbaijan',
  'Bahamas',
  'Bahrain',
  'Bangladesh',
  'Barbados',
  'Belarus',
  'Belgium',
  'Belize',
  'Benin',
  'Bhutan',
  'Bolivia',
  'Bosnia and Herzegovina',
  'Botswana',
  'Brazil',
  'Brunei',
  'Bulgaria',
  'Burkina Faso',
  'Burundi',
  'Cabo Verde',
  'Cambodia',
  'Cameroon',
  'Canada',
  'Central African Republic',
  'Chad',
  'Chile',
  'China',
  'Colombia',
  'Comoros',
  'Congo (Congo-Brazzaville)',
  'Costa Rica',
  "Côte d'Ivoire",
  'Croatia',
  'Cuba',
  'Cyprus',
  'Czechia (Czech Republic)',
  'Democratic Republic of the Congo',
  'Denmark',
  'Djibouti',
  'Dominica',
  'Dominican Republic',
  'Ecuador',
  'Egypt',
  'El Salvador',
  'Equatorial Guinea',
  'Eritrea',
  'Estonia',
  'Eswatini (fmr. "Swaziland")',
  'Ethiopia',
  'Fiji',
  'Finland',
  'France',
  'Gabon',
  'Gambia',
  'Georgia',
  'Germany',
  'Ghana',
  'Greece',
  'Grenada',
  'Guatemala',
  'Guinea',
  'Guinea-Bissau',
  'Guyana',
  'Haiti',
  'Holy See',
  'Honduras',
  'Hungary',
  'Iceland',
  'India',
  'Indonesia',
  'Iran',
  'Iraq',
  'Ireland',
  'Israel',
  'Italy',
  'Jamaica',
  'Japan',
  'Jordan',
  'Kazakhstan',
  'Kenya',
  'Kiribati',
  'Kuwait',
  'Kyrgyzstan',
  'Laos',
  'Latvia',
  'Lebanon',
  'Lesotho',
  'Liberia',
  'Libya',
  'Liechtenstein',
  'Lithuania',
  'Luxembourg',
  'Madagascar',
  'Malawi',
  'Malaysia',
  'Maldives',
  'Mali',
  'Malta',
  'Marshall Islands',
  'Mauritania',
  'Mauritius',
  'Mexico',
  'Micronesia',
  'Moldova',
  'Monaco',
  'Mongolia',
  'Montenegro',
  'Morocco',
  'Mozambique',
  'Myanmar (formerly Burma)',
  'Namibia',
  'Nauru',
  'Nepal',
  'Netherlands',
  'New Zealand',
  'Nicaragua',
  'Niger',
  'Nigeria',
  'North Korea',
  'North Macedonia',
  'Norway',
  'Oman',
  'Pakistan',
  'Palau',
  'Palestine State',
  'Panama',
  'Papua New Guinea',
  'Paraguay',
  'Peru',
  'Philippines',
  'Poland',
  'Portugal',
  'Qatar',
  'Romania',
  'Russia',
  'Rwanda',
  'Saint Kitts and Nevis',
  'Saint Lucia',
  'Saint Vincent and the Grenadines',
  'Samoa',
  'San Marino',
  'Sao Tome and Principe',
  'Saudi Arabia',
  'Senegal',
  'Serbia',
  'Seychelles',
  'Sierra Leone',
  'Singapore',
  'Slovakia',
  'Slovenia',
  'Solomon Islands',
  'Somalia',
  'South Africa',
  'South Korea',
  'South Sudan',
  'Spain',
  'Sri Lanka',
  'Sudan',
  'Suriname',
  'Sweden',
  'Switzerland',
  'Syria',
  'Tajikistan',
  'Tanzania',
  'Thailand',
  'Timor-Leste',
  'Togo',
  'Tonga',
  'Trinidad and Tobago',
  'Tunisia',
  'Türkiye',
  'Turkmenistan',
  'Tuvalu',
  'Uganda',
  'Ukraine',
  'United Arab Emirates',
  'United Kingdom',
  'United States of America',
  'Uruguay',
  'Uzbekistan',
  'Vanuatu',
  'Venezuela',
  'Vietnam',
  'Yemen',
  'Zambia',
  'Zimbabwe',
];
html
<div class="autocomplete-container">
  <div #origin class="autocomplete-input-container">
    <span class="search-icon material-symbols-outlined" translate="no" aria-hidden="true"
      >search</span
    >
    <input
      #combobox="ngCombobox"
      ngCombobox
      class="autocomplete-input"
      placeholder="Select a country"
      [(value)]="query"
      [(expanded)]="popupExpanded"
      (click)="popupExpanded.set(true)"
      (focusout)="onBlur()"
    />
    <button
      type="button"
      class="clear-button"
      aria-label="Clear"
      (mousedown)="$event.preventDefault()"
      (click)="clear()"
    >
      <span class="clear-icon material-symbols-outlined" translate="no" aria-hidden="true"
        >close</span
      >
    </button>
  </div>

  <div aria-live="polite" class="cdk-visually-hidden">
    {{ countries().length === 0 ? 'No results found for ' + query() : '' }}
  </div>

  <ng-template
    [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
    [cdkConnectedOverlayOpen]="popupExpanded()"
  >
    <ng-template ngComboboxPopup [combobox]="combobox">
      <div class="popup">
        @if (countries().length === 0) {
          <div class="no-results">No results found</div>
        }
        <div
          #listbox="ngListbox"
          ngListbox
          ngComboboxWidget
          class="listbox"
          focusMode="activedescendant"
          [tabindex]="-1"
          [activeDescendant]="listbox.activeDescendant()"
          [(value)]="selectedOption"
          (click)="onCommit()"
          (keydown.enter)="onCommit()"
        >
          @for (country of countries(); track country) {
            <div class="option" ngOption [value]="country" [label]="country">
              <span class="option-label">{{ country }}</span>
              <span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
                >check</span
              >
            </div>
          }
        </div>
      </div>
    </ng-template>
  </ng-template>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

.autocomplete-container {
  display: flex;
  flex-direction: column;
  position: relative;
}

.autocomplete-input-container {
  display: flex;
  position: relative;
  align-items: center;
}

.material-symbols-outlined {
  font-size: 1.25rem;
  pointer-events: none;
}

.search-icon {
  left: 0.75rem;
  position: absolute;
  color: var(--quaternary-contrast);
}

.autocomplete-input {
  width: 13rem;
  font-size: 1rem;
  border-radius: 0.25rem;
  padding: 0.75rem 2.5rem 0.75rem 2.5rem;
  color: var(--primary-contrast);
  outline: none;
  border: 1px solid var(--quinary-contrast);
  background-color: var(--page-background);
}

.autocomplete-input:focus-visible {
  border-color: var(--hot-pink);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--hot-pink) 20%, transparent);
}

.autocomplete-input::placeholder {
  color: var(--quaternary-contrast);
}

.popup {
  width: 100%;
  margin-top: 8px;
  padding: 0.5rem;
  max-height: 11rem;
  border-radius: 0.5rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  box-shadow:
    0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
}

.no-results {
  padding: 1rem;
}

.listbox {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
  outline: none;
}

.option {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 2.25rem;
  border-radius: 0.5rem;
  outline: none;
}

.option:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.option[data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid var(--hot-pink);
}

.option[aria-selected='true'] {
  color: var(--hot-pink);
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}

.option:not([aria-selected='true']) .check-icon {
  display: none;
}

.option-label {
  flex: 1;
}

.check-icon {
  font-size: 0.9rem;
}

.clear-button {
  position: absolute;
  right: 0.75rem;
  width: 2rem;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background-color: transparent;
  border-radius: 0.25rem;
  cursor: pointer;
  color: var(--quaternary-contrast);
  padding: 0;
  margin: 0;
  outline: none;
  transition: background-color 0.15s ease;
}

.clear-button:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.clear-button:focus-visible {
  outline: 2px solid var(--hot-pink);
  outline-offset: -2px;
}

.clear-icon {
  font-size: 1.25rem;
}
ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';

@Component({
  selector: 'app-root[theme="basic-material"]',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
  clear() {
    this.query.set('');
    this.selectedOption.set([]);
    this.popupExpanded.set(false);
  }

  readonly listbox = viewChild(Listbox);
  readonly combobox = viewChild(Combobox);

  popupExpanded = signal(false);
  query = signal('');
  selectedOption = signal<string[]>([]);

  countries = computed(() =>
    ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())),
  );

  constructor() {
    afterRenderEffect(() => {
      if (this.combobox()?.expanded() === true) {
        this.listbox()?.scrollActiveItemIntoView();
      }
    });
  }

  onBlur() {
    this.commitSelection();
  }

  onCommit() {
    this.commitSelection();
    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }

  private commitSelection() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.query.set(selected[0]);
    } else {
      this.query.set('');
      this.selectedOption.set([]);
    }
  }
}

const ALL_COUNTRIES = [
  'Afghanistan',
  'Albania',
  'Algeria',
  'Andorra',
  'Angola',
  'Antigua and Barbuda',
  'Argentina',
  'Armenia',
  'Australia',
  'Austria',
  'Azerbaijan',
  'Bahamas',
  'Bahrain',
  'Bangladesh',
  'Barbados',
  'Belarus',
  'Belgium',
  'Belize',
  'Benin',
  'Bhutan',
  'Bolivia',
  'Bosnia and Herzegovina',
  'Botswana',
  'Brazil',
  'Brunei',
  'Bulgaria',
  'Burkina Faso',
  'Burundi',
  'Cabo Verde',
  'Cambodia',
  'Cameroon',
  'Canada',
  'Central African Republic',
  'Chad',
  'Chile',
  'China',
  'Colombia',
  'Comoros',
  'Congo (Congo-Brazzaville)',
  'Costa Rica',
  "Côte d'Ivoire",
  'Croatia',
  'Cuba',
  'Cyprus',
  'Czechia (Czech Republic)',
  'Democratic Republic of the Congo',
  'Denmark',
  'Djibouti',
  'Dominica',
  'Dominican Republic',
  'Ecuador',
  'Egypt',
  'El Salvador',
  'Equatorial Guinea',
  'Eritrea',
  'Estonia',
  'Eswatini (fmr. "Swaziland")',
  'Ethiopia',
  'Fiji',
  'Finland',
  'France',
  'Gabon',
  'Gambia',
  'Georgia',
  'Germany',
  'Ghana',
  'Greece',
  'Grenada',
  'Guatemala',
  'Guinea',
  'Guinea-Bissau',
  'Guyana',
  'Haiti',
  'Holy See',
  'Honduras',
  'Hungary',
  'Iceland',
  'India',
  'Indonesia',
  'Iran',
  'Iraq',
  'Ireland',
  'Israel',
  'Italy',
  'Jamaica',
  'Japan',
  'Jordan',
  'Kazakhstan',
  'Kenya',
  'Kiribati',
  'Kuwait',
  'Kyrgyzstan',
  'Laos',
  'Latvia',
  'Lebanon',
  'Lesotho',
  'Liberia',
  'Libya',
  'Liechtenstein',
  'Lithuania',
  'Luxembourg',
  'Madagascar',
  'Malawi',
  'Malaysia',
  'Maldives',
  'Mali',
  'Malta',
  'Marshall Islands',
  'Mauritania',
  'Mauritius',
  'Mexico',
  'Micronesia',
  'Moldova',
  'Monaco',
  'Mongolia',
  'Montenegro',
  'Morocco',
  'Mozambique',
  'Myanmar (formerly Burma)',
  'Namibia',
  'Nauru',
  'Nepal',
  'Netherlands',
  'New Zealand',
  'Nicaragua',
  'Niger',
  'Nigeria',
  'North Korea',
  'North Macedonia',
  'Norway',
  'Oman',
  'Pakistan',
  'Palau',
  'Palestine State',
  'Panama',
  'Papua New Guinea',
  'Paraguay',
  'Peru',
  'Philippines',
  'Poland',
  'Portugal',
  'Qatar',
  'Romania',
  'Russia',
  'Rwanda',
  'Saint Kitts and Nevis',
  'Saint Lucia',
  'Saint Vincent and the Grenadines',
  'Samoa',
  'San Marino',
  'Sao Tome and Principe',
  'Saudi Arabia',
  'Senegal',
  'Serbia',
  'Seychelles',
  'Sierra Leone',
  'Singapore',
  'Slovakia',
  'Slovenia',
  'Solomon Islands',
  'Somalia',
  'South Africa',
  'South Korea',
  'South Sudan',
  'Spain',
  'Sri Lanka',
  'Sudan',
  'Suriname',
  'Sweden',
  'Switzerland',
  'Syria',
  'Tajikistan',
  'Tanzania',
  'Thailand',
  'Timor-Leste',
  'Togo',
  'Tonga',
  'Trinidad and Tobago',
  'Tunisia',
  'Türkiye',
  'Turkmenistan',
  'Tuvalu',
  'Uganda',
  'Ukraine',
  'United Arab Emirates',
  'United Kingdom',
  'United States of America',
  'Uruguay',
  'Uzbekistan',
  'Vanuatu',
  'Venezuela',
  'Vietnam',
  'Yemen',
  'Zambia',
  'Zimbabwe',
];
html
<div class="autocomplete-container">
  <div #origin class="material-autocomplete">
    <span class="search-icon material-symbols-outlined" translate="no" aria-hidden="true"
      >search</span
    >
    <input
      #combobox="ngCombobox"
      ngCombobox
      class="autocomplete-input"
      placeholder="Select a country"
      [(value)]="query"
      [(expanded)]="popupExpanded"
      (click)="popupExpanded.set(true)"
      (focusout)="onBlur()"
    />
    <button
      type="button"
      class="clear-button"
      aria-label="Clear"
      (mousedown)="$event.preventDefault()"
      (click)="clear()"
    >
      <span class="clear-icon material-symbols-outlined" translate="no" aria-hidden="true"
        >close</span
      >
    </button>
  </div>

  <div aria-live="polite" class="cdk-visually-hidden">
    {{ countries().length === 0 ? 'No results found for ' + query() : '' }}
  </div>

  <ng-template
    [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
    [cdkConnectedOverlayOpen]="popupExpanded()"
  >
    <ng-template ngComboboxPopup [combobox]="combobox">
      <div class="popup">
        @if (countries().length === 0) {
          <div class="no-results">No results found</div>
        }
        <div
          #listbox="ngListbox"
          ngListbox
          ngComboboxWidget
          class="listbox"
          focusMode="activedescendant"
          [tabindex]="-1"
          [activeDescendant]="listbox.activeDescendant()"
          [(value)]="selectedOption"
          (click)="onCommit()"
          (keydown.enter)="onCommit()"
        >
          @for (country of countries(); track country) {
            <div class="option" ngOption [value]="country" [label]="country">
              <span class="option-label">{{ country }}</span>
              <span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
                >check</span
              >
            </div>
          }
        </div>
      </div>
    </ng-template>
  </ng-template>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
  --primary: var(--hot-pink);
}

.autocomplete-container {
  display: flex;
  flex-direction: column;
  position: relative;
}

.material-autocomplete {
  display: flex;
  position: relative;
  align-items: center;
}

.material-symbols-outlined {
  font-size: 1.25rem;
  pointer-events: none;
}

.search-icon {
  left: 0.75rem;
  position: absolute;
  color: var(--quaternary-contrast);
}

.autocomplete-input {
  width: 13rem;
  font-size: 1rem;
  border-radius: 3rem;
  padding: 0.75rem 2.5rem 0.75rem 2.5rem;
  color: var(--primary-contrast);
  outline: none;
  border: 1px solid var(--quinary-contrast);
  background-color: var(--page-background);
}

.autocomplete-input:focus-visible {
  border-color: var(--primary);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent);
}

.autocomplete-input::placeholder {
  color: var(--quaternary-contrast);
}

.popup {
  width: 100%;
  margin-top: 8px;
  padding: 0.5rem;
  max-height: 11rem;
  border-radius: 2rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  box-shadow:
    0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
}

.no-results {
  padding: 1rem;
}

.listbox {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
  outline: none;
}

.option {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 3rem;
  border-radius: 3rem;
  outline: none;
}

.option:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.option[data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid var(--primary);
}

.option[aria-selected='true'] {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}

.option:not([aria-selected='true']) .check-icon {
  display: none;
}

.option-label {
  flex: 1;
}

.check-icon {
  font-size: 0.9rem;
}

.clear-button {
  position: absolute;
  right: 0.75rem;
  width: 2rem;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background-color: transparent;
  border-radius: 50%;
  cursor: pointer;
  color: var(--quaternary-contrast);
  padding: 0;
  margin: 0;
  outline: none;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}

.clear-button:hover {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}

.clear-button:focus-visible {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 15%, transparent);
  outline: 2px solid var(--primary);
  outline-offset: -2px;
}

.clear-icon {
  font-size: 1.25rem;
}
ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';

@Component({
  selector: 'app-root[theme="basic-retro"]',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
  clear() {
    this.query.set('');
    this.selectedOption.set([]);
    this.popupExpanded.set(false);
  }

  readonly listbox = viewChild(Listbox);
  readonly combobox = viewChild(Combobox);

  popupExpanded = signal(false);
  query = signal('');
  selectedOption = signal<string[]>([]);

  countries = computed(() =>
    ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())),
  );

  constructor() {
    afterRenderEffect(() => {
      if (this.combobox()?.expanded() === true) {
        this.listbox()?.scrollActiveItemIntoView();
      }
    });
  }

  onBlur() {
    this.commitSelection();
  }

  onCommit() {
    this.commitSelection();
    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }

  private commitSelection() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.query.set(selected[0]);
    } else {
      this.query.set('');
      this.selectedOption.set([]);
    }
  }
}

const ALL_COUNTRIES = [
  'Afghanistan',
  'Albania',
  'Algeria',
  'Andorra',
  'Angola',
  'Antigua and Barbuda',
  'Argentina',
  'Armenia',
  'Australia',
  'Austria',
  'Azerbaijan',
  'Bahamas',
  'Bahrain',
  'Bangladesh',
  'Barbados',
  'Belarus',
  'Belgium',
  'Belize',
  'Benin',
  'Bhutan',
  'Bolivia',
  'Bosnia and Herzegovina',
  'Botswana',
  'Brazil',
  'Brunei',
  'Bulgaria',
  'Burkina Faso',
  'Burundi',
  'Cabo Verde',
  'Cambodia',
  'Cameroon',
  'Canada',
  'Central African Republic',
  'Chad',
  'Chile',
  'China',
  'Colombia',
  'Comoros',
  'Congo (Congo-Brazzaville)',
  'Costa Rica',
  "Côte d'Ivoire",
  'Croatia',
  'Cuba',
  'Cyprus',
  'Czechia (Czech Republic)',
  'Democratic Republic of the Congo',
  'Denmark',
  'Djibouti',
  'Dominica',
  'Dominican Republic',
  'Ecuador',
  'Egypt',
  'El Salvador',
  'Equatorial Guinea',
  'Eritrea',
  'Estonia',
  'Eswatini (fmr. "Swaziland")',
  'Ethiopia',
  'Fiji',
  'Finland',
  'France',
  'Gabon',
  'Gambia',
  'Georgia',
  'Germany',
  'Ghana',
  'Greece',
  'Grenada',
  'Guatemala',
  'Guinea',
  'Guinea-Bissau',
  'Guyana',
  'Haiti',
  'Holy See',
  'Honduras',
  'Hungary',
  'Iceland',
  'India',
  'Indonesia',
  'Iran',
  'Iraq',
  'Ireland',
  'Israel',
  'Italy',
  'Jamaica',
  'Japan',
  'Jordan',
  'Kazakhstan',
  'Kenya',
  'Kiribati',
  'Kuwait',
  'Kyrgyzstan',
  'Laos',
  'Latvia',
  'Lebanon',
  'Lesotho',
  'Liberia',
  'Libya',
  'Liechtenstein',
  'Lithuania',
  'Luxembourg',
  'Madagascar',
  'Malawi',
  'Malaysia',
  'Maldives',
  'Mali',
  'Malta',
  'Marshall Islands',
  'Mauritania',
  'Mauritius',
  'Mexico',
  'Micronesia',
  'Moldova',
  'Monaco',
  'Mongolia',
  'Montenegro',
  'Morocco',
  'Mozambique',
  'Myanmar (formerly Burma)',
  'Namibia',
  'Nauru',
  'Nepal',
  'Netherlands',
  'New Zealand',
  'Nicaragua',
  'Niger',
  'Nigeria',
  'North Korea',
  'North Macedonia',
  'Norway',
  'Oman',
  'Pakistan',
  'Palau',
  'Palestine State',
  'Panama',
  'Papua New Guinea',
  'Paraguay',
  'Peru',
  'Philippines',
  'Poland',
  'Portugal',
  'Qatar',
  'Romania',
  'Russia',
  'Rwanda',
  'Saint Kitts and Nevis',
  'Saint Lucia',
  'Saint Vincent and the Grenadines',
  'Samoa',
  'San Marino',
  'Sao Tome and Principe',
  'Saudi Arabia',
  'Senegal',
  'Serbia',
  'Seychelles',
  'Sierra Leone',
  'Singapore',
  'Slovakia',
  'Slovenia',
  'Solomon Islands',
  'Somalia',
  'South Africa',
  'South Korea',
  'South Sudan',
  'Spain',
  'Sri Lanka',
  'Sudan',
  'Suriname',
  'Sweden',
  'Switzerland',
  'Syria',
  'Tajikistan',
  'Tanzania',
  'Thailand',
  'Timor-Leste',
  'Togo',
  'Tonga',
  'Trinidad and Tobago',
  'Tunisia',
  'Türkiye',
  'Turkmenistan',
  'Tuvalu',
  'Uganda',
  'Ukraine',
  'United Arab Emirates',
  'United Kingdom',
  'United States of America',
  'Uruguay',
  'Uzbekistan',
  'Vanuatu',
  'Venezuela',
  'Vietnam',
  'Yemen',
  'Zambia',
  'Zimbabwe',
];
html
<div class="autocomplete-container">
  <div #origin class="retro-autocomplete">
    <span class="search-icon material-symbols-outlined" translate="no" aria-hidden="true"
      >search</span
    >
    <input
      #combobox="ngCombobox"
      ngCombobox
      class="autocomplete-input"
      placeholder="Select a country"
      [(value)]="query"
      [(expanded)]="popupExpanded"
      (click)="popupExpanded.set(true)"
      (focusout)="onBlur()"
    />
    <button
      type="button"
      class="clear-button"
      aria-label="Clear"
      (mousedown)="$event.preventDefault()"
      (click)="clear()"
    >
      <span class="clear-icon" aria-hidden="true">X</span>
    </button>
  </div>

  <div aria-live="polite" class="cdk-visually-hidden">
    {{ countries().length === 0 ? 'No results found for ' + query() : '' }}
  </div>

  <ng-template
    [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
    [cdkConnectedOverlayOpen]="popupExpanded()"
  >
    <ng-template ngComboboxPopup [combobox]="combobox">
      <div class="popup">
        @if (countries().length === 0) {
          <div class="no-results">No results found</div>
        }
        <div
          #listbox="ngListbox"
          ngListbox
          ngComboboxWidget
          class="listbox"
          focusMode="activedescendant"
          [tabindex]="-1"
          [activeDescendant]="listbox.activeDescendant()"
          [(value)]="selectedOption"
          (click)="onCommit()"
          (keydown.enter)="onCommit()"
        >
          @for (country of countries(); track country) {
            <div class="option" ngOption [value]="country" [label]="country">
              <span class="option-label">{{ country }}</span>
              <span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
                >check</span
              >
            </div>
          }
        </div>
      </div>
    </ng-template>
  </ng-template>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

:host {
  display: flex;
  justify-content: center;
  font-size: 0.6rem;
  font-family: 'Press Start 2P';

  --retro-button-color: #fff;
  --retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
  --retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
  --retro-elevated-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast);
  --retro-flat-shadow:
    4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
    -4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
  --retro-pressed-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-dark),
    inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}

.autocomplete-container {
  display: flex;
  flex-direction: column;
  position: relative;
}

.retro-autocomplete {
  display: flex;
  position: relative;
  align-items: center;
  box-shadow: var(--retro-flat-shadow);
  background-color: var(--retro-button-color);
}

.retro-autocomplete:focus-within {
  box-shadow: var(--retro-pressed-shadow);
}

.material-symbols-outlined {
  font-size: 1.25rem;
  pointer-events: none;
}

.search-icon {
  left: 0.75rem;
  position: absolute;
  color: #000;
  z-index: 1;
}

.autocomplete-input {
  width: 15rem;
  font-size: 0.6rem;
  border-radius: 0;
  font-family: 'Press Start 2P';
  word-spacing: -5px;
  padding: 0.75rem 2.5rem 0.75rem 2.5rem;
  color: #000;
  border: none;
  background-color: transparent;
  box-shadow: none;
  outline: none;
}

.autocomplete-input::placeholder {
  color: #000;
  opacity: 0.7;
}

.popup {
  width: 100%;
  margin-top: 20px;
  padding: 0.5rem;
  max-height: 11rem;
  border-radius: 0;
  background-color: var(--septenary-contrast);
  box-shadow: var(--retro-flat-shadow);
}

.no-results {
  padding: 1rem;
}

.listbox {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
  outline: none;
}

.option {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 2.25rem;
  border-radius: 0;
  outline: none;
}

.option:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.option[data-active='true'] {
  outline-offset: -2px;
  outline: 2px dashed var(--hot-pink);
}

.option[aria-selected='true'] {
  color: var(--hot-pink);
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}

.option:not([aria-selected='true']) .check-icon {
  display: none;
}

.option-label {
  flex: 1;
}

.check-icon {
  font-size: 0.9rem;
}

.clear-button {
  position: absolute;
  right: 0.75rem;
  width: 30px;
  height: 30px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--win95-gray);
  color: var(--win95-shadow);
  border-style: solid;
  border-width: 1px;
  border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
  box-shadow: 1px 1px 0 var(--win95-shadow);
  cursor: pointer;
  padding: 0;
  margin: 0;
  outline: none;
  font-family: 'Press Start 2P';
  font-size: 0.6rem;
  user-select: none;
}

.clear-button:hover {
  background: #e0e0e0;
  outline: 1px dashed var(--win95-dark-gray);
  outline-offset: 1px;
}

.clear-button:active {
  border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
  box-shadow: 1px 1px 0 var(--win95-shadow) inset;
}

.clear-button:focus-visible {
  outline: 2px dashed var(--hot-pink);
  outline-offset: 4px;
}

.clear-icon {
  display: flex;
  align-items: center;
  justify-content: center;
}

Manual selection mode

Manual selection mode هنگام navigate کردن کاربران در suggestion list، متن typed‌شده را بدون تغییر نگه می‌دارد و از گیج شدن کاربر به خاطر updateهای خودکار جلوگیری می‌کند. input فقط زمانی تغییر می‌کند که کاربر انتخاب خود را صریحاً با Enter یا click تأیید کند.

ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';

@Component({
  selector: 'app-root[theme="manual-basic"], app-root:not([theme])',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
  clear() {
    this.query.set('');
    this.selectedOption.set([]);
    this.popupExpanded.set(false);
  }

  readonly listbox = viewChild(Listbox);
  readonly combobox = viewChild(Combobox);

  popupExpanded = signal(false);
  query = signal('');
  selectedOption = signal<string[]>([]);

  countries = computed(() =>
    ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())),
  );

  constructor() {
    afterRenderEffect(() => {
      if (this.combobox()?.expanded() === true) {
        this.listbox()?.scrollActiveItemIntoView();
      }
    });
  }

  onCommit() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.query.set(selected[0]);
    }
    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }
}

const ALL_COUNTRIES = [
  'Afghanistan',
  'Albania',
  'Algeria',
  'Andorra',
  'Angola',
  'Antigua and Barbuda',
  'Argentina',
  'Armenia',
  'Australia',
  'Austria',
  'Azerbaijan',
  'Bahamas',
  'Bahrain',
  'Bangladesh',
  'Barbados',
  'Belarus',
  'Belgium',
  'Belize',
  'Benin',
  'Bhutan',
  'Bolivia',
  'Bosnia and Herzegovina',
  'Botswana',
  'Brazil',
  'Brunei',
  'Bulgaria',
  'Burkina Faso',
  'Burundi',
  'Cabo Verde',
  'Cambodia',
  'Cameroon',
  'Canada',
  'Central African Republic',
  'Chad',
  'Chile',
  'China',
  'Colombia',
  'Comoros',
  'Congo (Congo-Brazzaville)',
  'Costa Rica',
  "Côte d'Ivoire",
  'Croatia',
  'Cuba',
  'Cyprus',
  'Czechia (Czech Republic)',
  'Democratic Republic of the Congo',
  'Denmark',
  'Djibouti',
  'Dominica',
  'Dominican Republic',
  'Ecuador',
  'Egypt',
  'El Salvador',
  'Equatorial Guinea',
  'Eritrea',
  'Estonia',
  'Eswatini (fmr. "Swaziland")',
  'Ethiopia',
  'Fiji',
  'Finland',
  'France',
  'Gabon',
  'Gambia',
  'Georgia',
  'Germany',
  'Ghana',
  'Greece',
  'Grenada',
  'Guatemala',
  'Guinea',
  'Guinea-Bissau',
  'Guyana',
  'Haiti',
  'Holy See',
  'Honduras',
  'Hungary',
  'Iceland',
  'India',
  'Indonesia',
  'Iran',
  'Iraq',
  'Ireland',
  'Israel',
  'Italy',
  'Jamaica',
  'Japan',
  'Jordan',
  'Kazakhstan',
  'Kenya',
  'Kiribati',
  'Kuwait',
  'Kyrgyzstan',
  'Laos',
  'Latvia',
  'Lebanon',
  'Lesotho',
  'Liberia',
  'Libya',
  'Liechtenstein',
  'Lithuania',
  'Luxembourg',
  'Madagascar',
  'Malawi',
  'Malaysia',
  'Maldives',
  'Mali',
  'Malta',
  'Marshall Islands',
  'Mauritania',
  'Mauritius',
  'Mexico',
  'Micronesia',
  'Moldova',
  'Monaco',
  'Mongolia',
  'Montenegro',
  'Morocco',
  'Mozambique',
  'Myanmar (formerly Burma)',
  'Namibia',
  'Nauru',
  'Nepal',
  'Netherlands',
  'New Zealand',
  'Nicaragua',
  'Niger',
  'Nigeria',
  'North Korea',
  'North Macedonia',
  'Norway',
  'Oman',
  'Pakistan',
  'Palau',
  'Palestine State',
  'Panama',
  'Papua New Guinea',
  'Paraguay',
  'Peru',
  'Philippines',
  'Poland',
  'Portugal',
  'Qatar',
  'Romania',
  'Russia',
  'Rwanda',
  'Saint Kitts and Nevis',
  'Saint Lucia',
  'Saint Vincent and the Grenadines',
  'Samoa',
  'San Marino',
  'Sao Tome and Principe',
  'Saudi Arabia',
  'Senegal',
  'Serbia',
  'Seychelles',
  'Sierra Leone',
  'Singapore',
  'Slovakia',
  'Slovenia',
  'Solomon Islands',
  'Somalia',
  'South Africa',
  'South Korea',
  'South Sudan',
  'Spain',
  'Sri Lanka',
  'Sudan',
  'Suriname',
  'Sweden',
  'Switzerland',
  'Syria',
  'Tajikistan',
  'Tanzania',
  'Thailand',
  'Timor-Leste',
  'Togo',
  'Tonga',
  'Trinidad and Tobago',
  'Tunisia',
  'Türkiye',
  'Turkmenistan',
  'Tuvalu',
  'Uganda',
  'Ukraine',
  'United Arab Emirates',
  'United Kingdom',
  'United States of America',
  'Uruguay',
  'Uzbekistan',
  'Vanuatu',
  'Venezuela',
  'Vietnam',
  'Yemen',
  'Zambia',
  'Zimbabwe',
];
html
<div class="autocomplete-container">
  <div #origin class="autocomplete-input-container">
    <span class="search-icon material-symbols-outlined" translate="no" aria-hidden="true"
      >search</span
    >
    <input
      #combobox="ngCombobox"
      ngCombobox
      class="autocomplete-input"
      placeholder="Select a country"
      [(value)]="query"
      [(expanded)]="popupExpanded"
      (click)="popupExpanded.set(true)"
    />
    <button
      type="button"
      class="clear-button"
      aria-label="Clear"
      (mousedown)="$event.preventDefault()"
      (click)="clear()"
    >
      <span class="clear-icon material-symbols-outlined" translate="no" aria-hidden="true"
        >close</span
      >
    </button>
  </div>

  <div aria-live="polite" class="cdk-visually-hidden">
    {{ countries().length === 0 ? 'No results found for ' + query() : '' }}
  </div>

  <ng-template
    [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
    [cdkConnectedOverlayOpen]="popupExpanded()"
  >
    <ng-template ngComboboxPopup [combobox]="combobox">
      <div class="popup">
        @if (countries().length === 0) {
          <div class="no-results">No results found</div>
        }
        <div
          #listbox="ngListbox"
          ngListbox
          ngComboboxWidget
          class="listbox"
          focusMode="activedescendant"
          selectionMode="explicit"
          [tabindex]="-1"
          [activeDescendant]="listbox.activeDescendant()"
          [(value)]="selectedOption"
          (click)="onCommit()"
          (keydown.enter)="onCommit()"
        >
          @for (country of countries(); track country) {
            <div class="option" ngOption [value]="country" [label]="country">
              <span class="option-label">{{ country }}</span>
              <span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
                >check</span
              >
            </div>
          }
        </div>
      </div>
    </ng-template>
  </ng-template>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

.autocomplete-container {
  display: flex;
  flex-direction: column;
  position: relative;
}

.autocomplete-input-container {
  display: flex;
  position: relative;
  align-items: center;
}

.material-symbols-outlined {
  font-size: 1.25rem;
  pointer-events: none;
}

.search-icon {
  left: 0.75rem;
  position: absolute;
  color: var(--quaternary-contrast);
}

.autocomplete-input {
  width: 13rem;
  font-size: 1rem;
  border-radius: 0.25rem;
  padding: 0.75rem 2.5rem 0.75rem 2.5rem;
  color: var(--primary-contrast);
  outline: none;
  border: 1px solid var(--quinary-contrast);
  background-color: var(--page-background);
}

.autocomplete-input:focus-visible {
  border-color: var(--hot-pink);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--hot-pink) 20%, transparent);
}

.autocomplete-input::placeholder {
  color: var(--quaternary-contrast);
}

.popup {
  width: 100%;
  margin-top: 8px;
  padding: 0.5rem;
  max-height: 11rem;
  border-radius: 0.5rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  box-shadow:
    0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
}

.no-results {
  padding: 1rem;
}

.listbox {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
  outline: none;
}

.option {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 2.25rem;
  border-radius: 0.5rem;
  outline: none;
}

.option:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.option[data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid var(--hot-pink);
}

.option[aria-selected='true'] {
  color: var(--hot-pink);
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}

.option:not([aria-selected='true']) .check-icon {
  display: none;
}

.option-label {
  flex: 1;
}

.check-icon {
  font-size: 0.9rem;
}

.clear-button {
  position: absolute;
  right: 0.75rem;
  width: 2rem;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background-color: transparent;
  border-radius: 0.25rem;
  cursor: pointer;
  color: var(--quaternary-contrast);
  padding: 0;
  margin: 0;
  outline: none;
  transition: background-color 0.15s ease;
}

.clear-button:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.clear-button:focus-visible {
  outline: 2px solid var(--hot-pink);
  outline-offset: -2px;
}

.clear-icon {
  font-size: 1.25rem;
}
ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';

@Component({
  selector: 'app-root[theme="manual-material"]',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
  clear() {
    this.query.set('');
    this.selectedOption.set([]);
    this.popupExpanded.set(false);
  }

  readonly listbox = viewChild(Listbox);
  readonly combobox = viewChild(Combobox);

  popupExpanded = signal(false);
  query = signal('');
  selectedOption = signal<string[]>([]);

  countries = computed(() =>
    ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())),
  );

  constructor() {
    afterRenderEffect(() => {
      if (this.combobox()?.expanded() === true) {
        this.listbox()?.scrollActiveItemIntoView();
      }
    });
  }

  onCommit() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.query.set(selected[0]);
    }
    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }
}

const ALL_COUNTRIES = [
  'Afghanistan',
  'Albania',
  'Algeria',
  'Andorra',
  'Angola',
  'Antigua and Barbuda',
  'Argentina',
  'Armenia',
  'Australia',
  'Austria',
  'Azerbaijan',
  'Bahamas',
  'Bahrain',
  'Bangladesh',
  'Barbados',
  'Belarus',
  'Belgium',
  'Belize',
  'Benin',
  'Bhutan',
  'Bolivia',
  'Bosnia and Herzegovina',
  'Botswana',
  'Brazil',
  'Brunei',
  'Bulgaria',
  'Burkina Faso',
  'Burundi',
  'Cabo Verde',
  'Cambodia',
  'Cameroon',
  'Canada',
  'Central African Republic',
  'Chad',
  'Chile',
  'China',
  'Colombia',
  'Comoros',
  'Congo (Congo-Brazzaville)',
  'Costa Rica',
  "Côte d'Ivoire",
  'Croatia',
  'Cuba',
  'Cyprus',
  'Czechia (Czech Republic)',
  'Democratic Republic of the Congo',
  'Denmark',
  'Djibouti',
  'Dominica',
  'Dominican Republic',
  'Ecuador',
  'Egypt',
  'El Salvador',
  'Equatorial Guinea',
  'Eritrea',
  'Estonia',
  'Eswatini (fmr. "Swaziland")',
  'Ethiopia',
  'Fiji',
  'Finland',
  'France',
  'Gabon',
  'Gambia',
  'Georgia',
  'Germany',
  'Ghana',
  'Greece',
  'Grenada',
  'Guatemala',
  'Guinea',
  'Guinea-Bissau',
  'Guyana',
  'Haiti',
  'Holy See',
  'Honduras',
  'Hungary',
  'Iceland',
  'India',
  'Indonesia',
  'Iran',
  'Iraq',
  'Ireland',
  'Israel',
  'Italy',
  'Jamaica',
  'Japan',
  'Jordan',
  'Kazakhstan',
  'Kenya',
  'Kiribati',
  'Kuwait',
  'Kyrgyzstan',
  'Laos',
  'Latvia',
  'Lebanon',
  'Lesotho',
  'Liberia',
  'Libya',
  'Liechtenstein',
  'Lithuania',
  'Luxembourg',
  'Madagascar',
  'Malawi',
  'Malaysia',
  'Maldives',
  'Mali',
  'Malta',
  'Marshall Islands',
  'Mauritania',
  'Mauritius',
  'Mexico',
  'Micronesia',
  'Moldova',
  'Monaco',
  'Mongolia',
  'Montenegro',
  'Morocco',
  'Mozambique',
  'Myanmar (formerly Burma)',
  'Namibia',
  'Nauru',
  'Nepal',
  'Netherlands',
  'New Zealand',
  'Nicaragua',
  'Niger',
  'Nigeria',
  'North Korea',
  'North Macedonia',
  'Norway',
  'Oman',
  'Pakistan',
  'Palau',
  'Palestine State',
  'Panama',
  'Papua New Guinea',
  'Paraguay',
  'Peru',
  'Philippines',
  'Poland',
  'Portugal',
  'Qatar',
  'Romania',
  'Russia',
  'Rwanda',
  'Saint Kitts and Nevis',
  'Saint Lucia',
  'Saint Vincent and the Grenadines',
  'Samoa',
  'San Marino',
  'Sao Tome and Principe',
  'Saudi Arabia',
  'Senegal',
  'Serbia',
  'Seychelles',
  'Sierra Leone',
  'Singapore',
  'Slovakia',
  'Slovenia',
  'Solomon Islands',
  'Somalia',
  'South Africa',
  'South Korea',
  'South Sudan',
  'Spain',
  'Sri Lanka',
  'Sudan',
  'Suriname',
  'Sweden',
  'Switzerland',
  'Syria',
  'Tajikistan',
  'Tanzania',
  'Thailand',
  'Timor-Leste',
  'Togo',
  'Tonga',
  'Trinidad and Tobago',
  'Tunisia',
  'Türkiye',
  'Turkmenistan',
  'Tuvalu',
  'Uganda',
  'Ukraine',
  'United Arab Emirates',
  'United Kingdom',
  'United States of America',
  'Uruguay',
  'Uzbekistan',
  'Vanuatu',
  'Venezuela',
  'Vietnam',
  'Yemen',
  'Zambia',
  'Zimbabwe',
];
html
<div class="autocomplete-container">
  <div #origin class="material-autocomplete">
    <span class="search-icon material-symbols-outlined" translate="no" aria-hidden="true"
      >search</span
    >
    <input
      #combobox="ngCombobox"
      ngCombobox
      class="autocomplete-input"
      placeholder="Select a country"
      [(value)]="query"
      [(expanded)]="popupExpanded"
      (click)="popupExpanded.set(true)"
    />
    <button
      type="button"
      class="clear-button"
      aria-label="Clear"
      (mousedown)="$event.preventDefault()"
      (click)="clear()"
    >
      <span class="clear-icon material-symbols-outlined" translate="no" aria-hidden="true"
        >close</span
      >
    </button>
  </div>

  <div aria-live="polite" class="cdk-visually-hidden">
    {{ countries().length === 0 ? 'No results found for ' + query() : '' }}
  </div>

  <ng-template
    [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
    [cdkConnectedOverlayOpen]="popupExpanded()"
  >
    <ng-template ngComboboxPopup [combobox]="combobox">
      <div class="popup">
        @if (countries().length === 0) {
          <div class="no-results">No results found</div>
        }
        <div
          #listbox="ngListbox"
          ngListbox
          ngComboboxWidget
          class="listbox"
          focusMode="activedescendant"
          selectionMode="explicit"
          [tabindex]="-1"
          [activeDescendant]="listbox.activeDescendant()"
          [(value)]="selectedOption"
          (click)="onCommit()"
          (keydown.enter)="onCommit()"
        >
          @for (country of countries(); track country) {
            <div class="option" ngOption [value]="country" [label]="country">
              <span class="option-label">{{ country }}</span>
              <span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
                >check</span
              >
            </div>
          }
        </div>
      </div>
    </ng-template>
  </ng-template>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
  --primary: var(--hot-pink);
}

.autocomplete-container {
  display: flex;
  flex-direction: column;
  position: relative;
}

.material-autocomplete {
  display: flex;
  position: relative;
  align-items: center;
}

.material-symbols-outlined {
  font-size: 1.25rem;
  pointer-events: none;
}

.search-icon {
  left: 0.75rem;
  position: absolute;
  color: var(--quaternary-contrast);
}

.autocomplete-input {
  width: 13rem;
  font-size: 1rem;
  border-radius: 3rem;
  padding: 0.75rem 2.5rem 0.75rem 2.5rem;
  color: var(--primary-contrast);
  outline: none;
  border: 1px solid var(--quinary-contrast);
  background-color: var(--page-background);
}

.autocomplete-input:focus-visible {
  border-color: var(--primary);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent);
}

.autocomplete-input::placeholder {
  color: var(--quaternary-contrast);
}

.popup {
  width: 100%;
  margin-top: 8px;
  padding: 0.5rem;
  max-height: 11rem;
  border-radius: 2rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  box-shadow:
    0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
}

.no-results {
  padding: 1rem;
}

.listbox {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
  outline: none;
}

.option {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 3rem;
  border-radius: 3rem;
  outline: none;
}

.option:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.option[data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid var(--primary);
}

.option[aria-selected='true'] {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}

.option:not([aria-selected='true']) .check-icon {
  display: none;
}

.option-label {
  flex: 1;
}

.check-icon {
  font-size: 0.9rem;
}

.clear-button {
  position: absolute;
  right: 0.75rem;
  width: 2rem;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background-color: transparent;
  border-radius: 50%;
  cursor: pointer;
  color: var(--quaternary-contrast);
  padding: 0;
  margin: 0;
  outline: none;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}

.clear-button:hover {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}

.clear-button:focus-visible {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 15%, transparent);
  outline: 2px solid var(--primary);
  outline-offset: -2px;
}

.clear-icon {
  font-size: 1.25rem;
}
ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';

@Component({
  selector: 'app-root[theme="manual-retro"]',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
  clear() {
    this.query.set('');
    this.selectedOption.set([]);
    this.popupExpanded.set(false);
  }

  readonly listbox = viewChild(Listbox);
  readonly combobox = viewChild(Combobox);

  popupExpanded = signal(false);
  query = signal('');
  selectedOption = signal<string[]>([]);

  countries = computed(() =>
    ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())),
  );

  constructor() {
    afterRenderEffect(() => {
      if (this.combobox()?.expanded() === true) {
        this.listbox()?.scrollActiveItemIntoView();
      }
    });
  }

  onCommit() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.query.set(selected[0]);
    }
    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }
}

const ALL_COUNTRIES = [
  'Afghanistan',
  'Albania',
  'Algeria',
  'Andorra',
  'Angola',
  'Antigua and Barbuda',
  'Argentina',
  'Armenia',
  'Australia',
  'Austria',
  'Azerbaijan',
  'Bahamas',
  'Bahrain',
  'Bangladesh',
  'Barbados',
  'Belarus',
  'Belgium',
  'Belize',
  'Benin',
  'Bhutan',
  'Bolivia',
  'Bosnia and Herzegovina',
  'Botswana',
  'Brazil',
  'Brunei',
  'Bulgaria',
  'Burkina Faso',
  'Burundi',
  'Cabo Verde',
  'Cambodia',
  'Cameroon',
  'Canada',
  'Central African Republic',
  'Chad',
  'Chile',
  'China',
  'Colombia',
  'Comoros',
  'Congo (Congo-Brazzaville)',
  'Costa Rica',
  "Côte d'Ivoire",
  'Croatia',
  'Cuba',
  'Cyprus',
  'Czechia (Czech Republic)',
  'Democratic Republic of the Congo',
  'Denmark',
  'Djibouti',
  'Dominica',
  'Dominican Republic',
  'Ecuador',
  'Egypt',
  'El Salvador',
  'Equatorial Guinea',
  'Eritrea',
  'Estonia',
  'Eswatini (fmr. "Swaziland")',
  'Ethiopia',
  'Fiji',
  'Finland',
  'France',
  'Gabon',
  'Gambia',
  'Georgia',
  'Germany',
  'Ghana',
  'Greece',
  'Grenada',
  'Guatemala',
  'Guinea',
  'Guinea-Bissau',
  'Guyana',
  'Haiti',
  'Holy See',
  'Honduras',
  'Hungary',
  'Iceland',
  'India',
  'Indonesia',
  'Iran',
  'Iraq',
  'Ireland',
  'Israel',
  'Italy',
  'Jamaica',
  'Japan',
  'Jordan',
  'Kazakhstan',
  'Kenya',
  'Kiribati',
  'Kuwait',
  'Kyrgyzstan',
  'Laos',
  'Latvia',
  'Lebanon',
  'Lesotho',
  'Liberia',
  'Libya',
  'Liechtenstein',
  'Lithuania',
  'Luxembourg',
  'Madagascar',
  'Malawi',
  'Malaysia',
  'Maldives',
  'Mali',
  'Malta',
  'Marshall Islands',
  'Mauritania',
  'Mauritius',
  'Mexico',
  'Micronesia',
  'Moldova',
  'Monaco',
  'Mongolia',
  'Montenegro',
  'Morocco',
  'Mozambique',
  'Myanmar (formerly Burma)',
  'Namibia',
  'Nauru',
  'Nepal',
  'Netherlands',
  'New Zealand',
  'Nicaragua',
  'Niger',
  'Nigeria',
  'North Korea',
  'North Macedonia',
  'Norway',
  'Oman',
  'Pakistan',
  'Palau',
  'Palestine State',
  'Panama',
  'Papua New Guinea',
  'Paraguay',
  'Peru',
  'Philippines',
  'Poland',
  'Portugal',
  'Qatar',
  'Romania',
  'Russia',
  'Rwanda',
  'Saint Kitts and Nevis',
  'Saint Lucia',
  'Saint Vincent and the Grenadines',
  'Samoa',
  'San Marino',
  'Sao Tome and Principe',
  'Saudi Arabia',
  'Senegal',
  'Serbia',
  'Seychelles',
  'Sierra Leone',
  'Singapore',
  'Slovakia',
  'Slovenia',
  'Solomon Islands',
  'Somalia',
  'South Africa',
  'South Korea',
  'South Sudan',
  'Spain',
  'Sri Lanka',
  'Sudan',
  'Suriname',
  'Sweden',
  'Switzerland',
  'Syria',
  'Tajikistan',
  'Tanzania',
  'Thailand',
  'Timor-Leste',
  'Togo',
  'Tonga',
  'Trinidad and Tobago',
  'Tunisia',
  'Türkiye',
  'Turkmenistan',
  'Tuvalu',
  'Uganda',
  'Ukraine',
  'United Arab Emirates',
  'United Kingdom',
  'United States of America',
  'Uruguay',
  'Uzbekistan',
  'Vanuatu',
  'Venezuela',
  'Vietnam',
  'Yemen',
  'Zambia',
  'Zimbabwe',
];
html
<div class="autocomplete-container">
  <div #origin class="retro-autocomplete">
    <span class="search-icon material-symbols-outlined" translate="no" aria-hidden="true"
      >search</span
    >
    <input
      #combobox="ngCombobox"
      ngCombobox
      class="autocomplete-input"
      placeholder="Select a country"
      [(value)]="query"
      [(expanded)]="popupExpanded"
      (click)="popupExpanded.set(true)"
    />
    <button
      type="button"
      class="clear-button"
      aria-label="Clear"
      (mousedown)="$event.preventDefault()"
      (click)="clear()"
    >
      <span class="clear-icon" aria-hidden="true">X</span>
    </button>
  </div>

  <div aria-live="polite" class="cdk-visually-hidden">
    {{ countries().length === 0 ? 'No results found for ' + query() : '' }}
  </div>

  <ng-template
    [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
    [cdkConnectedOverlayOpen]="popupExpanded()"
  >
    <ng-template ngComboboxPopup [combobox]="combobox">
      <div class="popup">
        @if (countries().length === 0) {
          <div class="no-results">No results found</div>
        }
        <div
          #listbox="ngListbox"
          ngListbox
          ngComboboxWidget
          class="listbox"
          focusMode="activedescendant"
          selectionMode="explicit"
          [tabindex]="-1"
          [activeDescendant]="listbox.activeDescendant()"
          [(value)]="selectedOption"
          (click)="onCommit()"
          (keydown.enter)="onCommit()"
        >
          @for (country of countries(); track country) {
            <div class="option" ngOption [value]="country" [label]="country">
              <span class="option-label">{{ country }}</span>
              <span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
                >check</span
              >
            </div>
          }
        </div>
      </div>
    </ng-template>
  </ng-template>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

:host {
  display: flex;
  justify-content: center;
  font-size: 0.6rem;
  font-family: 'Press Start 2P';

  --retro-button-color: #fff;
  --retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
  --retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
  --retro-elevated-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast);
  --retro-flat-shadow:
    4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
    -4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
  --retro-pressed-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-dark),
    inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}

.autocomplete-container {
  display: flex;
  flex-direction: column;
  position: relative;
}

.retro-autocomplete {
  display: flex;
  position: relative;
  align-items: center;
  box-shadow: var(--retro-flat-shadow);
  background-color: var(--retro-button-color);
}

.retro-autocomplete:focus-within {
  box-shadow: var(--retro-pressed-shadow);
}

.material-symbols-outlined {
  font-size: 1.25rem;
  pointer-events: none;
}

.search-icon {
  left: 0.75rem;
  position: absolute;
  color: #000;
  z-index: 1;
}

.autocomplete-input {
  width: 15rem;
  font-size: 0.6rem;
  border-radius: 0;
  font-family: 'Press Start 2P';
  word-spacing: -5px;
  padding: 0.75rem 2.5rem 0.75rem 2.5rem;
  color: #000;
  border: none;
  background-color: transparent;
  box-shadow: none;
  outline: none;
}

.autocomplete-input::placeholder {
  color: #000;
  opacity: 0.7;
}

.popup {
  width: 100%;
  margin-top: 20px;
  padding: 0.5rem;
  max-height: 11rem;
  border-radius: 0;
  background-color: var(--septenary-contrast);
  box-shadow: var(--retro-flat-shadow);
}

.no-results {
  padding: 1rem;
}

.listbox {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
  outline: none;
}

.option {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 2.25rem;
  border-radius: 0;
  outline: none;
}

.option:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.option[data-active='true'] {
  outline-offset: -2px;
  outline: 2px dashed var(--hot-pink);
}

.option[aria-selected='true'] {
  color: var(--hot-pink);
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}

.option:not([aria-selected='true']) .check-icon {
  display: none;
}

.option-label {
  flex: 1;
}

.check-icon {
  font-size: 0.9rem;
}

.clear-button {
  position: absolute;
  right: 0.75rem;
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--win95-gray);
  color: var(--win95-shadow);
  border-style: solid;
  border-width: 1px;
  border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
  box-shadow: 1px 1px 0 var(--win95-shadow);
  cursor: pointer;
  padding: 0;
  margin: 0;
  outline: none;
  font-family: 'Press Start 2P';
  font-size: 0.6rem;
  user-select: none;
}

.clear-button:hover {
  background: #e0e0e0;
  outline: 1px dashed var(--win95-dark-gray);
  outline-offset: 1px;
}

.clear-button:active {
  border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
  box-shadow: 1px 1px 0 var(--win95-shadow) inset;
}

.clear-button:focus-visible {
  outline: 2px dashed var(--hot-pink);
  outline-offset: 4px;
}

.clear-icon {
  display: flex;
  align-items: center;
  justify-content: center;
}

Highlight mode

Highlight mode به کاربر اجازه می‌دهد با arrow keyها بین optionها navigate کند، بدون اینکه هنگام browse کردن مقدار input تغییر کند؛ تا زمانی که صریحاً با Enter یا click یک option جدید انتخاب کند.

ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, effect, signal, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';

@Component({
  selector: 'app-root[theme="highlight-basic"], app-root:not([theme])',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
  clear() {
    this.query.set('');
    this.selectedOption.set([]);
    this.popupExpanded.set(false);
    this.navigated.set(false);
  }

  readonly listbox = viewChild(Listbox);
  readonly combobox = viewChild(Combobox);

  popupExpanded = signal(false);
  query = signal('');
  selectedOption = signal<string[]>([]);
  navigated = signal(false);

  countries = computed(() =>
    ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())),
  );

  constructor() {
    afterRenderEffect(() => {
      if (this.combobox()?.expanded() === true) {
        this.listbox()?.scrollActiveItemIntoView();
      }
    });

    effect(() => {
      if (!this.popupExpanded()) {
        this.navigated.set(false);
      }
    });
  }

  onCommit() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.query.set(selected[0]);
    } else {
      this.query.set('');
    }
    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }
}

const ALL_COUNTRIES = [
  'Afghanistan',
  'Albania',
  'Algeria',
  'Andorra',
  'Angola',
  'Antigua and Barbuda',
  'Argentina',
  'Armenia',
  'Australia',
  'Austria',
  'Azerbaijan',
  'Bahamas',
  'Bahrain',
  'Bangladesh',
  'Barbados',
  'Belarus',
  'Belgium',
  'Belize',
  'Benin',
  'Bhutan',
  'Bolivia',
  'Bosnia and Herzegovina',
  'Botswana',
  'Brazil',
  'Brunei',
  'Bulgaria',
  'Burkina Faso',
  'Burundi',
  'Cabo Verde',
  'Cambodia',
  'Cameroon',
  'Canada',
  'Central African Republic',
  'Chad',
  'Chile',
  'China',
  'Colombia',
  'Comoros',
  'Congo (Congo-Brazzaville)',
  'Costa Rica',
  "Côte d'Ivoire",
  'Croatia',
  'Cuba',
  'Cyprus',
  'Czechia (Czech Republic)',
  'Democratic Republic of the Congo',
  'Denmark',
  'Djibouti',
  'Dominica',
  'Dominican Republic',
  'Ecuador',
  'Egypt',
  'El Salvador',
  'Equatorial Guinea',
  'Eritrea',
  'Estonia',
  'Eswatini (fmr. "Swaziland")',
  'Ethiopia',
  'Fiji',
  'Finland',
  'France',
  'Gabon',
  'Gambia',
  'Georgia',
  'Germany',
  'Ghana',
  'Greece',
  'Grenada',
  'Guatemala',
  'Guinea',
  'Guinea-Bissau',
  'Guyana',
  'Haiti',
  'Holy See',
  'Honduras',
  'Hungary',
  'Iceland',
  'India',
  'Indonesia',
  'Iran',
  'Iraq',
  'Ireland',
  'Israel',
  'Italy',
  'Jamaica',
  'Japan',
  'Jordan',
  'Kazakhstan',
  'Kenya',
  'Kiribati',
  'Kuwait',
  'Kyrgyzstan',
  'Laos',
  'Latvia',
  'Lebanon',
  'Lesotho',
  'Liberia',
  'Libya',
  'Liechtenstein',
  'Lithuania',
  'Luxembourg',
  'Madagascar',
  'Malawi',
  'Malaysia',
  'Maldives',
  'Mali',
  'Malta',
  'Marshall Islands',
  'Mauritania',
  'Mauritius',
  'Mexico',
  'Micronesia',
  'Moldova',
  'Monaco',
  'Mongolia',
  'Montenegro',
  'Morocco',
  'Mozambique',
  'Myanmar (formerly Burma)',
  'Namibia',
  'Nauru',
  'Nepal',
  'Netherlands',
  'New Zealand',
  'Nicaragua',
  'Niger',
  'Nigeria',
  'North Korea',
  'North Macedonia',
  'Norway',
  'Oman',
  'Pakistan',
  'Palau',
  'Palestine State',
  'Panama',
  'Papua New Guinea',
  'Paraguay',
  'Peru',
  'Philippines',
  'Poland',
  'Portugal',
  'Qatar',
  'Romania',
  'Russia',
  'Rwanda',
  'Saint Kitts and Nevis',
  'Saint Lucia',
  'Saint Vincent and the Grenadines',
  'Samoa',
  'San Marino',
  'Sao Tome and Principe',
  'Saudi Arabia',
  'Senegal',
  'Serbia',
  'Seychelles',
  'Sierra Leone',
  'Singapore',
  'Slovakia',
  'Slovenia',
  'Solomon Islands',
  'Somalia',
  'South Africa',
  'South Korea',
  'South Sudan',
  'Spain',
  'Sri Lanka',
  'Sudan',
  'Suriname',
  'Sweden',
  'Switzerland',
  'Syria',
  'Tajikistan',
  'Tanzania',
  'Thailand',
  'Timor-Leste',
  'Togo',
  'Tonga',
  'Trinidad and Tobago',
  'Tunisia',
  'Türkiye',
  'Turkmenistan',
  'Tuvalu',
  'Uganda',
  'Ukraine',
  'United Arab Emirates',
  'United Kingdom',
  'United States of America',
  'Uruguay',
  'Uzbekistan',
  'Vanuatu',
  'Venezuela',
  'Vietnam',
  'Yemen',
  'Zambia',
  'Zimbabwe',
];
html
<div class="autocomplete-container">
  <div #origin class="autocomplete-input-container">
    <span class="search-icon material-symbols-outlined" translate="no" aria-hidden="true"
      >search</span
    >
    <input
      #combobox="ngCombobox"
      ngCombobox
      class="autocomplete-input"
      placeholder="Select a country"
      [(value)]="query"
      [(expanded)]="popupExpanded"
      (click)="popupExpanded.set(true)"
      (keydown.arrowdown)="navigated.set(true)"
      (keydown.arrowup)="navigated.set(true)"
      [inlineSuggestion]="query() || navigated() ? selectedOption()[0] : undefined"
    />
    <button
      type="button"
      class="clear-button"
      aria-label="Clear"
      (mousedown)="$event.preventDefault()"
      (click)="clear()"
    >
      <span class="clear-icon material-symbols-outlined" translate="no" aria-hidden="true"
        >close</span
      >
    </button>
  </div>

  <div aria-live="polite" class="cdk-visually-hidden">
    {{ countries().length === 0 ? 'No results found for ' + query() : '' }}
  </div>

  <ng-template
    [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
    [cdkConnectedOverlayOpen]="popupExpanded()"
  >
    <ng-template ngComboboxPopup [combobox]="combobox">
      <div class="popup">
        @if (countries().length === 0) {
          <div class="no-results">No results found</div>
        }
        <div
          #listbox="ngListbox"
          ngListbox
          ngComboboxWidget
          class="listbox"
          focusMode="activedescendant"
          [tabindex]="-1"
          [activeDescendant]="listbox.activeDescendant()"
          [(value)]="selectedOption"
          (click)="onCommit()"
          (keydown.enter)="onCommit()"
        >
          @for (country of countries(); track country) {
            <div class="option" ngOption [value]="country" [label]="country">
              <span class="option-label">{{ country }}</span>
              <span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
                >check</span
              >
            </div>
          }
        </div>
      </div>
    </ng-template>
  </ng-template>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

.autocomplete-container {
  display: flex;
  flex-direction: column;
  position: relative;
}

.autocomplete-input-container {
  display: flex;
  position: relative;
  align-items: center;
}

.material-symbols-outlined {
  font-size: 1.25rem;
  pointer-events: none;
}

.search-icon {
  left: 0.75rem;
  position: absolute;
  color: var(--quaternary-contrast);
}

.autocomplete-input {
  width: 13rem;
  font-size: 1rem;
  border-radius: 0.25rem;
  padding: 0.75rem 2.5rem 0.75rem 2.5rem;
  color: var(--primary-contrast);
  outline: none;
  border: 1px solid var(--quinary-contrast);
  background-color: var(--page-background);
}

.autocomplete-input:focus-visible {
  border-color: var(--hot-pink);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--hot-pink) 20%, transparent);
}

.autocomplete-input::placeholder {
  color: var(--quaternary-contrast);
}

.popup {
  width: 100%;
  margin-top: 8px;
  padding: 0.5rem;
  max-height: 11rem;
  border-radius: 0.5rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  box-shadow:
    0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
}

.no-results {
  padding: 1rem;
}

.listbox {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
  outline: none;
}

.option {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 2.25rem;
  border-radius: 0.5rem;
  outline: none;
}

.option:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.option[data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid var(--hot-pink);
}

.option[aria-selected='true'] {
  color: var(--hot-pink);
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}

.option:not([aria-selected='true']) .check-icon {
  display: none;
}

.option-label {
  flex: 1;
}

.check-icon {
  font-size: 0.9rem;
}

.clear-button {
  position: absolute;
  right: 0.75rem;
  width: 2rem;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background-color: transparent;
  border-radius: 0.25rem;
  cursor: pointer;
  color: var(--quaternary-contrast);
  padding: 0;
  margin: 0;
  outline: none;
  transition: background-color 0.15s ease;
}

.clear-button:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.clear-button:focus-visible {
  outline: 2px solid var(--hot-pink);
  outline-offset: -2px;
}

.clear-icon {
  font-size: 1.25rem;
}
ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, effect, signal, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';

@Component({
  selector: 'app-root[theme="highlight-material"]',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
  clear() {
    this.query.set('');
    this.selectedOption.set([]);
    this.popupExpanded.set(false);
    this.navigated.set(false);
  }

  readonly listbox = viewChild(Listbox);
  readonly combobox = viewChild(Combobox);

  popupExpanded = signal(false);
  query = signal('');
  selectedOption = signal<string[]>([]);
  navigated = signal(false);

  countries = computed(() =>
    ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())),
  );

  constructor() {
    afterRenderEffect(() => {
      if (this.combobox()?.expanded() === true) {
        this.listbox()?.scrollActiveItemIntoView();
      }
    });

    effect(() => {
      if (!this.popupExpanded()) {
        this.navigated.set(false);
      }
    });
  }

  onCommit() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.query.set(selected[0]);
    } else {
      this.query.set('');
    }
    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }
}

const ALL_COUNTRIES = [
  'Afghanistan',
  'Albania',
  'Algeria',
  'Andorra',
  'Angola',
  'Antigua and Barbuda',
  'Argentina',
  'Armenia',
  'Australia',
  'Austria',
  'Azerbaijan',
  'Bahamas',
  'Bahrain',
  'Bangladesh',
  'Barbados',
  'Belarus',
  'Belgium',
  'Belize',
  'Benin',
  'Bhutan',
  'Bolivia',
  'Bosnia and Herzegovina',
  'Botswana',
  'Brazil',
  'Brunei',
  'Bulgaria',
  'Burkina Faso',
  'Burundi',
  'Cabo Verde',
  'Cambodia',
  'Cameroon',
  'Canada',
  'Central African Republic',
  'Chad',
  'Chile',
  'China',
  'Colombia',
  'Comoros',
  'Congo (Congo-Brazzaville)',
  'Costa Rica',
  "Côte d'Ivoire",
  'Croatia',
  'Cuba',
  'Cyprus',
  'Czechia (Czech Republic)',
  'Democratic Republic of the Congo',
  'Denmark',
  'Djibouti',
  'Dominica',
  'Dominican Republic',
  'Ecuador',
  'Egypt',
  'El Salvador',
  'Equatorial Guinea',
  'Eritrea',
  'Estonia',
  'Eswatini (fmr. "Swaziland")',
  'Ethiopia',
  'Fiji',
  'Finland',
  'France',
  'Gabon',
  'Gambia',
  'Georgia',
  'Germany',
  'Ghana',
  'Greece',
  'Grenada',
  'Guatemala',
  'Guinea',
  'Guinea-Bissau',
  'Guyana',
  'Haiti',
  'Holy See',
  'Honduras',
  'Hungary',
  'Iceland',
  'India',
  'Indonesia',
  'Iran',
  'Iraq',
  'Ireland',
  'Israel',
  'Italy',
  'Jamaica',
  'Japan',
  'Jordan',
  'Kazakhstan',
  'Kenya',
  'Kiribati',
  'Kuwait',
  'Kyrgyzstan',
  'Laos',
  'Latvia',
  'Lebanon',
  'Lesotho',
  'Liberia',
  'Libya',
  'Liechtenstein',
  'Lithuania',
  'Luxembourg',
  'Madagascar',
  'Malawi',
  'Malaysia',
  'Maldives',
  'Mali',
  'Malta',
  'Marshall Islands',
  'Mauritania',
  'Mauritius',
  'Mexico',
  'Micronesia',
  'Moldova',
  'Monaco',
  'Mongolia',
  'Montenegro',
  'Morocco',
  'Mozambique',
  'Myanmar (formerly Burma)',
  'Namibia',
  'Nauru',
  'Nepal',
  'Netherlands',
  'New Zealand',
  'Nicaragua',
  'Niger',
  'Nigeria',
  'North Korea',
  'North Macedonia',
  'Norway',
  'Oman',
  'Pakistan',
  'Palau',
  'Palestine State',
  'Panama',
  'Papua New Guinea',
  'Paraguay',
  'Peru',
  'Philippines',
  'Poland',
  'Portugal',
  'Qatar',
  'Romania',
  'Russia',
  'Rwanda',
  'Saint Kitts and Nevis',
  'Saint Lucia',
  'Saint Vincent and the Grenadines',
  'Samoa',
  'San Marino',
  'Sao Tome and Principe',
  'Saudi Arabia',
  'Senegal',
  'Serbia',
  'Seychelles',
  'Sierra Leone',
  'Singapore',
  'Slovakia',
  'Slovenia',
  'Solomon Islands',
  'Somalia',
  'South Africa',
  'South Korea',
  'South Sudan',
  'Spain',
  'Sri Lanka',
  'Sudan',
  'Suriname',
  'Sweden',
  'Switzerland',
  'Syria',
  'Tajikistan',
  'Tanzania',
  'Thailand',
  'Timor-Leste',
  'Togo',
  'Tonga',
  'Trinidad and Tobago',
  'Tunisia',
  'Türkiye',
  'Turkmenistan',
  'Tuvalu',
  'Uganda',
  'Ukraine',
  'United Arab Emirates',
  'United Kingdom',
  'United States of America',
  'Uruguay',
  'Uzbekistan',
  'Vanuatu',
  'Venezuela',
  'Vietnam',
  'Yemen',
  'Zambia',
  'Zimbabwe',
];
html
<div class="autocomplete-container">
  <div #origin class="material-autocomplete">
    <span class="search-icon material-symbols-outlined" translate="no" aria-hidden="true"
      >search</span
    >
    <input
      #combobox="ngCombobox"
      ngCombobox
      class="autocomplete-input"
      placeholder="Select a country"
      [(value)]="query"
      [(expanded)]="popupExpanded"
      (click)="popupExpanded.set(true)"
      (keydown.arrowdown)="navigated.set(true)"
      (keydown.arrowup)="navigated.set(true)"
      [inlineSuggestion]="query() || navigated() ? selectedOption()[0] : undefined"
    />
    <button
      type="button"
      class="clear-button"
      aria-label="Clear"
      (mousedown)="$event.preventDefault()"
      (click)="clear()"
    >
      <span class="clear-icon material-symbols-outlined" translate="no" aria-hidden="true"
        >close</span
      >
    </button>
  </div>

  <div aria-live="polite" class="cdk-visually-hidden">
    {{ countries().length === 0 ? 'No results found for ' + query() : '' }}
  </div>

  <ng-template
    [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
    [cdkConnectedOverlayOpen]="popupExpanded()"
  >
    <ng-template ngComboboxPopup [combobox]="combobox">
      <div class="popup">
        @if (countries().length === 0) {
          <div class="no-results">No results found</div>
        }
        <div
          #listbox="ngListbox"
          ngListbox
          ngComboboxWidget
          class="listbox"
          focusMode="activedescendant"
          [tabindex]="-1"
          [activeDescendant]="listbox.activeDescendant()"
          [(value)]="selectedOption"
          (click)="onCommit()"
          (keydown.enter)="onCommit()"
        >
          @for (country of countries(); track country) {
            <div class="option" ngOption [value]="country" [label]="country">
              <span class="option-label">{{ country }}</span>
              <span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
                >check</span
              >
            </div>
          }
        </div>
      </div>
    </ng-template>
  </ng-template>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
  --primary: var(--hot-pink);
}

.autocomplete-container {
  display: flex;
  flex-direction: column;
  position: relative;
}

.material-autocomplete {
  display: flex;
  position: relative;
  align-items: center;
}

.material-symbols-outlined {
  font-size: 1.25rem;
  pointer-events: none;
}

.search-icon {
  left: 0.75rem;
  position: absolute;
  color: var(--quaternary-contrast);
}

.autocomplete-input {
  width: 13rem;
  font-size: 1rem;
  border-radius: 3rem;
  padding: 0.75rem 2.5rem 0.75rem 2.5rem;
  color: var(--primary-contrast);
  outline: none;
  border: 1px solid var(--quinary-contrast);
  background-color: var(--page-background);
}

.autocomplete-input:focus-visible {
  border-color: var(--primary);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent);
}

.autocomplete-input::placeholder {
  color: var(--quaternary-contrast);
}

.popup {
  width: 100%;
  margin-top: 8px;
  padding: 0.5rem;
  max-height: 11rem;
  border-radius: 2rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  box-shadow:
    0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
}

.no-results {
  padding: 1rem;
}

.listbox {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
  outline: none;
}

.option {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 3rem;
  border-radius: 3rem;
  outline: none;
}

.option:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.option[data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid var(--primary);
}

.option[aria-selected='true'] {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}

.option:not([aria-selected='true']) .check-icon {
  display: none;
}

.option-label {
  flex: 1;
}

.check-icon {
  font-size: 0.9rem;
}

.clear-button {
  position: absolute;
  right: 0.75rem;
  width: 2rem;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background-color: transparent;
  border-radius: 50%;
  cursor: pointer;
  color: var(--quaternary-contrast);
  padding: 0;
  margin: 0;
  outline: none;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}

.clear-button:hover {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}

.clear-button:focus-visible {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 15%, transparent);
  outline: 2px solid var(--primary);
  outline-offset: -2px;
}

.clear-icon {
  font-size: 1.25rem;
}
ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, effect, signal, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';

@Component({
  selector: 'app-root[theme="highlight-retro"]',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
  clear() {
    this.query.set('');
    this.selectedOption.set([]);
    this.popupExpanded.set(false);
    this.navigated.set(false);
  }

  readonly listbox = viewChild(Listbox);
  readonly combobox = viewChild(Combobox);

  popupExpanded = signal(false);
  query = signal('');
  selectedOption = signal<string[]>([]);
  navigated = signal(false);

  countries = computed(() =>
    ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())),
  );

  constructor() {
    afterRenderEffect(() => {
      if (this.combobox()?.expanded() === true) {
        this.listbox()?.scrollActiveItemIntoView();
      }
    });

    effect(() => {
      if (!this.popupExpanded()) {
        this.navigated.set(false);
      }
    });
  }

  onCommit() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.query.set(selected[0]);
    } else {
      this.query.set('');
    }
    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }
}

const ALL_COUNTRIES = [
  'Afghanistan',
  'Albania',
  'Algeria',
  'Andorra',
  'Angola',
  'Antigua and Barbuda',
  'Argentina',
  'Armenia',
  'Australia',
  'Austria',
  'Azerbaijan',
  'Bahamas',
  'Bahrain',
  'Bangladesh',
  'Barbados',
  'Belarus',
  'Belgium',
  'Belize',
  'Benin',
  'Bhutan',
  'Bolivia',
  'Bosnia and Herzegovina',
  'Botswana',
  'Brazil',
  'Brunei',
  'Bulgaria',
  'Burkina Faso',
  'Burundi',
  'Cabo Verde',
  'Cambodia',
  'Cameroon',
  'Canada',
  'Central African Republic',
  'Chad',
  'Chile',
  'China',
  'Colombia',
  'Comoros',
  'Congo (Congo-Brazzaville)',
  'Costa Rica',
  "Côte d'Ivoire",
  'Croatia',
  'Cuba',
  'Cyprus',
  'Czechia (Czech Republic)',
  'Democratic Republic of the Congo',
  'Denmark',
  'Djibouti',
  'Dominica',
  'Dominican Republic',
  'Ecuador',
  'Egypt',
  'El Salvador',
  'Equatorial Guinea',
  'Eritrea',
  'Estonia',
  'Eswatini (fmr. "Swaziland")',
  'Ethiopia',
  'Fiji',
  'Finland',
  'France',
  'Gabon',
  'Gambia',
  'Georgia',
  'Germany',
  'Ghana',
  'Greece',
  'Grenada',
  'Guatemala',
  'Guinea',
  'Guinea-Bissau',
  'Guyana',
  'Haiti',
  'Holy See',
  'Honduras',
  'Hungary',
  'Iceland',
  'India',
  'Indonesia',
  'Iran',
  'Iraq',
  'Ireland',
  'Israel',
  'Italy',
  'Jamaica',
  'Japan',
  'Jordan',
  'Kazakhstan',
  'Kenya',
  'Kiribati',
  'Kuwait',
  'Kyrgyzstan',
  'Laos',
  'Latvia',
  'Lebanon',
  'Lesotho',
  'Liberia',
  'Libya',
  'Liechtenstein',
  'Lithuania',
  'Luxembourg',
  'Madagascar',
  'Malawi',
  'Malaysia',
  'Maldives',
  'Mali',
  'Malta',
  'Marshall Islands',
  'Mauritania',
  'Mauritius',
  'Mexico',
  'Micronesia',
  'Moldova',
  'Monaco',
  'Mongolia',
  'Montenegro',
  'Morocco',
  'Mozambique',
  'Myanmar (formerly Burma)',
  'Namibia',
  'Nauru',
  'Nepal',
  'Netherlands',
  'New Zealand',
  'Nicaragua',
  'Niger',
  'Nigeria',
  'North Korea',
  'North Macedonia',
  'Norway',
  'Oman',
  'Pakistan',
  'Palau',
  'Palestine State',
  'Panama',
  'Papua New Guinea',
  'Paraguay',
  'Peru',
  'Philippines',
  'Poland',
  'Portugal',
  'Qatar',
  'Romania',
  'Russia',
  'Rwanda',
  'Saint Kitts and Nevis',
  'Saint Lucia',
  'Saint Vincent and the Grenadines',
  'Samoa',
  'San Marino',
  'Sao Tome and Principe',
  'Saudi Arabia',
  'Senegal',
  'Serbia',
  'Seychelles',
  'Sierra Leone',
  'Singapore',
  'Slovakia',
  'Slovenia',
  'Solomon Islands',
  'Somalia',
  'South Africa',
  'South Korea',
  'South Sudan',
  'Spain',
  'Sri Lanka',
  'Sudan',
  'Suriname',
  'Sweden',
  'Switzerland',
  'Syria',
  'Tajikistan',
  'Tanzania',
  'Thailand',
  'Timor-Leste',
  'Togo',
  'Tonga',
  'Trinidad and Tobago',
  'Tunisia',
  'Türkiye',
  'Turkmenistan',
  'Tuvalu',
  'Uganda',
  'Ukraine',
  'United Arab Emirates',
  'United Kingdom',
  'United States of America',
  'Uruguay',
  'Uzbekistan',
  'Vanuatu',
  'Venezuela',
  'Vietnam',
  'Yemen',
  'Zambia',
  'Zimbabwe',
];
html
<div class="autocomplete-container">
  <div #origin class="retro-autocomplete">
    <span class="search-icon material-symbols-outlined" translate="no" aria-hidden="true"
      >search</span
    >
    <input
      #combobox="ngCombobox"
      ngCombobox
      class="autocomplete-input"
      placeholder="Select a country"
      [(value)]="query"
      [(expanded)]="popupExpanded"
      (click)="popupExpanded.set(true)"
      (keydown.arrowdown)="navigated.set(true)"
      (keydown.arrowup)="navigated.set(true)"
      [inlineSuggestion]="query() || navigated() ? selectedOption()[0] : undefined"
    />
    <button
      type="button"
      class="clear-button"
      aria-label="Clear"
      (mousedown)="$event.preventDefault()"
      (click)="clear()"
    >
      <span class="clear-icon" aria-hidden="true">X</span>
    </button>
  </div>

  <div aria-live="polite" class="cdk-visually-hidden">
    {{ countries().length === 0 ? 'No results found for ' + query() : '' }}
  </div>

  <ng-template
    [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
    [cdkConnectedOverlayOpen]="popupExpanded()"
  >
    <ng-template ngComboboxPopup [combobox]="combobox">
      <div class="popup">
        @if (countries().length === 0) {
          <div class="no-results">No results found</div>
        }
        <div
          #listbox="ngListbox"
          ngListbox
          ngComboboxWidget
          class="listbox"
          focusMode="activedescendant"
          [tabindex]="-1"
          [activeDescendant]="listbox.activeDescendant()"
          [(value)]="selectedOption"
          (click)="onCommit()"
          (keydown.enter)="onCommit()"
        >
          @for (country of countries(); track country) {
            <div class="option" ngOption [value]="country" [label]="country">
              <span class="option-label">{{ country }}</span>
              <span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
                >check</span
              >
            </div>
          }
        </div>
      </div>
    </ng-template>
  </ng-template>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

:host {
  display: flex;
  justify-content: center;
  font-size: 0.6rem;
  font-family: 'Press Start 2P';

  --retro-button-color: #fff;
  --retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
  --retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
  --retro-elevated-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast);
  --retro-flat-shadow:
    4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
    -4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
  --retro-pressed-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-dark),
    inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}

.autocomplete-container {
  display: flex;
  flex-direction: column;
  position: relative;
}

.retro-autocomplete {
  display: flex;
  position: relative;
  align-items: center;
  box-shadow: var(--retro-flat-shadow);
  background-color: var(--retro-button-color);
}

.retro-autocomplete:focus-within {
  box-shadow: var(--retro-pressed-shadow);
}

.material-symbols-outlined {
  font-size: 1.25rem;
  pointer-events: none;
}

.search-icon {
  left: 0.75rem;
  position: absolute;
  color: #000;
  z-index: 1;
}

.autocomplete-input {
  width: 15rem;
  font-size: 0.6rem;
  border-radius: 0;
  font-family: 'Press Start 2P';
  word-spacing: -5px;
  padding: 0.75rem 2.5rem 0.75rem 2.5rem;
  color: #000;
  border: none;
  background-color: transparent;
  box-shadow: none;
  outline: none;
}

.autocomplete-input::placeholder {
  color: #000;
  opacity: 0.7;
}

.popup {
  width: 100%;
  margin-top: 20px;
  padding: 0.5rem;
  max-height: 11rem;
  border-radius: 0;
  background-color: var(--septenary-contrast);
  box-shadow: var(--retro-flat-shadow);
}

.no-results {
  padding: 1rem;
}

.listbox {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
  outline: none;
}

.option {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 2.25rem;
  border-radius: 0;
  outline: none;
}

.option:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.option[data-active='true'] {
  outline-offset: -2px;
  outline: 2px dashed var(--hot-pink);
}

.option[aria-selected='true'] {
  color: var(--hot-pink);
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}

.option:not([aria-selected='true']) .check-icon {
  display: none;
}

.option-label {
  flex: 1;
}

.check-icon {
  font-size: 0.9rem;
}

.clear-button {
  position: absolute;
  right: 0.75rem;
  width: 30px;
  height: 30px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--win95-gray);
  color: var(--win95-shadow);
  border-style: solid;
  border-width: 1px;
  border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
  box-shadow: 1px 1px 0 var(--win95-shadow);
  cursor: pointer;
  padding: 0;
  margin: 0;
  outline: none;
  font-family: 'Press Start 2P';
  font-size: 0.6rem;
  user-select: none;
}

.clear-button:hover {
  background: #e0e0e0;
  outline: 1px dashed var(--win95-dark-gray);
  outline-offset: 1px;
}

.clear-button:active {
  border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
  box-shadow: 1px 1px 0 var(--win95-shadow) inset;
}

.clear-button:focus-visible {
  outline: 2px dashed var(--hot-pink);
  outline-offset: 4px;
}

.clear-icon {
  display: flex;
  align-items: center;
  justify-content: center;
}

Integration با Signal Forms

Angular Aria به صورت seamless با API مبتنی بر signal یعنی Signal Forms integrate می‌شود. می‌توانید inputهای پیچیده را داخل کامپوننت‌های custom control قابل‌استفاده مجدد encapsulate کنید که FormValueControl را پیاده‌سازی می‌کنند.

مثال زیر یک کامپوننت country selector را نشان می‌دهد که FormValueControl<string> را پیاده‌سازی می‌کند، با [formField] به form والد bind شده و با ruleهای schema validation محافظت می‌شود.

ts
import {Component, signal} from '@angular/core';
import {form, FormField, required, validate} from '@angular/forms/signals';
import {ALL_COUNTRIES, CountrySelector} from './country-selector';

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [CountrySelector, FormField],
})
export class App {
  // Signal Forms setup
  model = signal({country: ''});
  countryForm = form(this.model, (p) => {
    required(p.country, {message: 'Country selection is required'});

    validate(p.country, (ctx) => {
      const value = ctx.value();
      if (value && !ALL_COUNTRIES.includes(value)) {
        return {
          kind: 'invalidCountry',
          message: 'Please select a valid country.',
        };
      }
      return null;
    });
  });
}
html
<div class="form-container">
  <country-selector [formField]="countryForm.country"></country-selector>
</div>
ts
import {Component, computed, input, model, output, signal, viewChild} from '@angular/core';
import {FormValueControl, ValidationError} from '@angular/forms/signals';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';

@Component({
  selector: 'country-selector',
  templateUrl: 'country-selector.html',
  styleUrl: 'country-selector.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class CountrySelector implements FormValueControl<string> {
  readonly combobox = viewChild(Combobox);

  // FormValueControl implementation
  readonly value = model.required<string>();
  readonly touched = input<boolean>(false);
  readonly invalid = input<boolean>(false);
  readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
  readonly touch = output<void>();

  popupExpanded = signal(false);
  selectedOption = signal<string[]>([]);

  filteredCountries = computed(() => {
    const query = this.value()?.toLowerCase() ?? '';
    return ALL_COUNTRIES.filter((c) => c.toLowerCase().startsWith(query));
  });

  onCommit() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.value.set(selected[0]);
    }

    // Notify the parent form field directive that this control has been interacted with
    this.touch.emit();

    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }

  clear() {
    this.value.set('');
    this.selectedOption.set([]);
    this.popupExpanded.set(false);
  }
}

export const ALL_COUNTRIES = [
  'Afghanistan',
  'Albania',
  'Algeria',
  'Andorra',
  'Angola',
  'Antigua and Barbuda',
  'Argentina',
  'Armenia',
  'Australia',
  'Austria',
  'Azerbaijan',
  'Bahamas',
  'Bahrain',
  'Bangladesh',
  'Barbados',
  'Belarus',
  'Belgium',
  'Belize',
  'Benin',
  'Bhutan',
  'Bolivia',
  'Bosnia and Herzegovina',
  'Botswana',
  'Brazil',
  'Brunei',
  'Bulgaria',
  'Burkina Faso',
  'Burundi',
  'Cabo Verde',
  'Cambodia',
  'Cameroon',
  'Canada',
  'Central African Republic',
  'Chad',
  'Chile',
  'China',
  'Colombia',
  'Comoros',
  'Congo (Congo-Brazzaville)',
  'Costa Rica',
  "Côte d'Ivoire",
  'Croatia',
  'Cuba',
  'Cyprus',
  'Czechia (Czech Republic)',
  'Democratic Republic of the Congo',
  'Denmark',
  'Djibouti',
  'Dominica',
  'Dominican Republic',
  'Ecuador',
  'Egypt',
  'El Salvador',
  'Equatorial Guinea',
  'Eritrea',
  'Estonia',
  'Eswatini (fmr. "Swaziland")',
  'Ethiopia',
  'Fiji',
  'Finland',
  'France',
  'Gabon',
  'Gambia',
  'Georgia',
  'Germany',
  'Ghana',
  'Greece',
  'Grenada',
  'Guatemala',
  'Guinea',
  'Guinea-Bissau',
  'Guyana',
  'Haiti',
  'Holy See',
  'Honduras',
  'Hungary',
  'Iceland',
  'India',
  'Indonesia',
  'Iran',
  'Iraq',
  'Ireland',
  'Israel',
  'Italy',
  'Jamaica',
  'Japan',
  'Jordan',
  'Kazakhstan',
  'Kenya',
  'Kiribati',
  'Kuwait',
  'Kyrgyzstan',
  'Laos',
  'Latvia',
  'Lebanon',
  'Lesotho',
  'Liberia',
  'Libya',
  'Liechtenstein',
  'Lithuania',
  'Luxembourg',
  'Madagascar',
  'Malawi',
  'Malaysia',
  'Maldives',
  'Mali',
  'Malta',
  'Marshall Islands',
  'Mauritania',
  'Mauritius',
  'Mexico',
  'Micronesia',
  'Moldova',
  'Monaco',
  'Mongolia',
  'Montenegro',
  'Morocco',
  'Mozambique',
  'Myanmar (formerly Burma)',
  'Namibia',
  'Nauru',
  'Nepal',
  'Netherlands',
  'New Zealand',
  'Nicaragua',
  'Niger',
  'Nigeria',
  'North Korea',
  'North Macedonia',
  'Norway',
  'Oman',
  'Pakistan',
  'Palau',
  'Palestine State',
  'Panama',
  'Papua New Guinea',
  'Paraguay',
  'Peru',
  'Philippines',
  'Poland',
  'Portugal',
  'Qatar',
  'Romania',
  'Russia',
  'Rwanda',
  'Saint Kitts and Nevis',
  'Saint Lucia',
  'Saint Vincent and the Grenadines',
  'Samoa',
  'San Marino',
  'Sao Tome and Principe',
  'Saudi Arabia',
  'Senegal',
  'Serbia',
  'Seychelles',
  'Sierra Leone',
  'Singapore',
  'Slovakia',
  'Slovenia',
  'Solomon Islands',
  'Somalia',
  'South Africa',
  'South Korea',
  'South Sudan',
  'Spain',
  'Sri Lanka',
  'Sudan',
  'Suriname',
  'Sweden',
  'Switzerland',
  'Syria',
  'Tajikistan',
  'Tanzania',
  'Thailand',
  'Timor-Leste',
  'Togo',
  'Tonga',
  'Trinidad and Tobago',
  'Tunisia',
  'Türkiye',
  'Turkmenistan',
  'Tuvalu',
  'Uganda',
  'Ukraine',
  'United Arab Emirates',
  'United Kingdom',
  'United States of America',
  'Uruguay',
  'Uzbekistan',
  'Vanuatu',
  'Venezuela',
  'Vietnam',
  'Yemen',
  'Zambia',
  'Zimbabwe',
];
html
<div class="autocomplete-container">
  <div #origin class="autocomplete-input-container">
    <span class="search-icon material-symbols-outlined" translate="no" aria-hidden="true"
      >search</span
    >
    <input
      #combobox="ngCombobox"
      ngCombobox
      class="autocomplete-input"
      [class.invalid]="touched() && invalid()"
      placeholder="Select a country"
      [value]="value()"
      (input)="value.set($any($event.target).value)"
      [(expanded)]="popupExpanded"
      (click)="popupExpanded.set(true)"
    />
    <button
      type="button"
      class="clear-button"
      aria-label="Clear"
      (mousedown)="$event.preventDefault()"
      (click)="clear()"
    >
      <span class="clear-icon material-symbols-outlined" translate="no" aria-hidden="true"
        >close</span
      >
    </button>
  </div>

  <ng-template
    [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
    [cdkConnectedOverlayOpen]="popupExpanded()"
  >
    <ng-template ngComboboxPopup [combobox]="combobox">
      <div class="popup">
        @if (filteredCountries().length === 0) {
          <div class="no-results">No results found</div>
        }
        <div
          #listbox="ngListbox"
          ngListbox
          ngComboboxWidget
          class="listbox"
          focusMode="activedescendant"
          selectionMode="explicit"
          [tabindex]="-1"
          [activeDescendant]="listbox.activeDescendant()"
          [(value)]="selectedOption"
          (click)="onCommit()"
          (keydown.enter)="onCommit()"
        >
          @for (country of filteredCountries(); track country) {
            <div class="option" ngOption [value]="country" [label]="country">
              <span class="option-label">{{ country }}</span>
              <span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
                >check</span
              >
            </div>
          }
        </div>
      </div>
    </ng-template>
  </ng-template>

  @if (touched() && invalid() && errors().length > 0) {
    <div class="error-message">
      <span class="material-symbols-outlined" translate="no" aria-hidden="true">error</span>
      {{ errors()[0].message }}
    </div>
  }
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: block;
  font-family: var(--inter-font);
}

.autocomplete-container {
  display: flex;
  flex-direction: column;
  position: relative;
}

.autocomplete-input-container {
  display: flex;
  position: relative;
  align-items: center;
}

.material-symbols-outlined {
  font-size: 1.25rem;
  pointer-events: none;
}

.search-icon {
  left: 0.75rem;
  position: absolute;
  color: var(--quaternary-contrast);
}

.autocomplete-input {
  width: 13rem;
  font-size: 1rem;
  border-radius: 0.25rem;
  padding: 0.75rem 2.5rem 0.75rem 2.5rem;
  color: var(--primary-contrast);
  outline: none;
  border: 1px solid var(--quinary-contrast);
  background-color: var(--page-background);
}

.autocomplete-input:focus-visible {
  border-color: var(--hot-pink);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--hot-pink) 20%, transparent);
}

.autocomplete-input::placeholder {
  color: var(--quaternary-contrast);
}

.popup {
  width: 100%;
  margin-top: 8px;
  padding: 0.5rem;
  max-height: 11rem;
  border-radius: 0.5rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  box-shadow:
    0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
}

.no-results {
  padding: 1rem;
}

.listbox {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
  outline: none;
}

.option {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 2.25rem;
  border-radius: 0.5rem;
  outline: none;
}

.option:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.option[data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid var(--hot-pink);
}

.option[aria-selected='true'] {
  color: var(--hot-pink);
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}

.option:not([aria-selected='true']) .check-icon {
  display: none;
}

.option-label {
  flex: 1;
}

.check-icon {
  font-size: 0.9rem;
}

.clear-button {
  position: absolute;
  right: 0.75rem;
  width: 2rem;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background-color: transparent;
  border-radius: 0.25rem;
  cursor: pointer;
  color: var(--quaternary-contrast);
  padding: 0;
  margin: 0;
  outline: none;
  transition: background-color 0.15s ease;
}

.clear-button:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

.clear-button:focus-visible {
  outline: 2px solid var(--hot-pink);
  outline-offset: -2px;
}

.clear-icon {
  font-size: 1.25rem;
}

.error-message {
  display: flex;
  align-items: center;
  gap: 4px;
  color: var(--error-color, #e15241);
  font-size: 0.85rem;
  margin-top: 6px;
}

.error-message span {
  font-size: 1rem;
}

.autocomplete-input.invalid {
  border-color: var(--error-color, #e15241);
}
css
:host {
  display: flex;
  justify-content: center;
}

Testing

pattern مربوط به autocomplete را می‌توان با ترکیبی از ComboboxHarness و ListboxHarness از @angular/aria/combobox/testing و @angular/aria/listbox/testing test کرد. این نمونه نشان می‌دهد چطور از harnessها برای test کردن یک کامپوننت autocomplete استفاده کنید:

ts
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {HarnessLoader} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {ComboboxHarness} from '@angular/aria/combobox/testing';
import {ListboxHarness} from '@angular/aria/listbox/testing';
import {MyAutocompleteComponent} from './my-autocomplete'; // Your component

describe('MyAutocompleteComponent', () => {
  let fixture: ComponentFixture<MyAutocompleteComponent>;
  let loader: HarnessLoader;

  beforeEach(async () => {
    TestBed.configureTestingModule({
      imports: [MyAutocompleteComponent],
    });

    fixture = TestBed.createComponent(MyAutocompleteComponent);
    await fixture.whenStable();
    loader = TestbedHarnessEnvironment.loader(fixture);
  });

  it('should filter options based on input', async () => {
    const combobox = await loader.getHarness(ComboboxHarness);

    // Type in the input to trigger filtering
    await combobox.setValue('ap');
    expect(await combobox.isOpen()).toBe(true);

    // Get the listbox harness from the popup
    const listbox = await combobox.getPopupWidget(ListboxHarness);
    const options = await listbox.getOptions();

    // Verify options are filtered (e.g., 'Apple', 'Apricot')
    expect(options.length).toBe(2);
    expect(await options[0].getText()).toBe('Apple');

    // Select the first option
    await options[0].click();

    // Verify the input value is updated and popup is closed
    expect(await combobox.isOpen()).toBe(false);
    expect(await combobox.getValue()).toBe('Apple');
  });
});

API reference

برای مستندات دقیق API، referenceهای API زیر را بررسی کنید: