深入指南
Angular Aria
概觀
這是一個無障礙的輸入欄位,會在使用者輸入時篩選並建議選項,幫助他們從清單中找到並選取所需的值。
app.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();
}
});
}
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',
];
app.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"
[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>
app.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;
}
app.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();
}
});
}
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',
];
app.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"
[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>
app.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;
}
app.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();
}
});
}
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',
];
app.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"
[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>
app.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 {
--win95-gray: #c0c0c0;
--win95-dark-gray: #808080;
--win95-light: #ffffff;
--win95-shadow: #000000;
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;
}
用法
當使用者需要從一大組選項中選取、而打字比捲動更快的情境下,自動完成的效果最好。在下列情況可以考慮使用自動完成:
- 選項清單很長(超過 20 個項目):打字縮小選擇範圍的速度,比捲動下拉清單更快
- 使用者知道自己在找什麼:他們可以輸入預期值的一部分(例如州名、產品或使用者名稱)
- 選項遵循可預測的模式:使用者可以猜到部分相符的內容(例如國家代碼、電子郵件網域或分類)
- 速度很重要:表單能受惠於快速選取,不必進行大量導覽
在下列情況避免使用自動完成:
- 清單少於 10 個選項:一般的下拉清單或單選按鈕群組能提供更好的可見性
- 使用者需要瀏覽選項:如果探索很重要,一開始就把所有選項都顯示出來
- 選項不熟悉:使用者無法輸入他們不知道清單裡存在的東西
功能
Angular 的自動完成提供完整無障礙的 combobox 實作,包含:
- 鍵盤導覽:用方向鍵瀏覽選項、用 Enter 選取、用 Escape 關閉
- 螢幕閱讀器支援:為輔助技術內建 ARIA 屬性
- 動態醒目提示行為:內建支援行內選取建議
- 以訊號為基礎的反應性:使用 Angular 訊號進行反應式狀態管理
- Popover API 整合:運用原生 HTML Popover API 達成最佳定位
- 雙向文字支援:自動處理由右至左(RTL)的語言
範例
自動選取模式
使用者輸入部分文字時,會期待立即得到確認,知道自己的輸入與某個可用選項相符。自動選取模式會在使用者輸入時,把輸入值更新為第一個篩選出來的選項,減少需要的按鍵次數,並即時回饋,讓使用者知道搜尋方向正確。
app.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();
}
});
}
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',
];
app.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"
[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>
app.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;
}
app.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();
}
});
}
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',
];
app.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"
[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>
app.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;
}
app.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();
}
});
}
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',
];
app.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"
[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>
app.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 {
--win95-gray: #c0c0c0;
--win95-dark-gray: #808080;
--win95-light: #ffffff;
--win95-shadow: #000000;
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;
}
手動選取模式
手動選取模式會在使用者瀏覽建議清單時,保持輸入的文字不變,避免自動更新造成混淆。只有當使用者用 Enter 或點選明確確認選擇時,輸入內容才會改變。
app.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',
];
app.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>
app.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;
}
app.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',
];
app.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>
app.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;
}
app.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',
];
app.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>
app.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 {
--win95-gray: #c0c0c0;
--win95-dark-gray: #808080;
--win95-light: #ffffff;
--win95-shadow: #000000;
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;
}
醒目提示模式
醒目提示模式讓使用者用方向鍵瀏覽選項,在瀏覽過程中不會改變輸入值,直到他們用 Enter 或點選明確選取新選項為止。
app.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',
];
app.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>
app.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;
}
app.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',
];
app.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>
app.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;
}
app.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',
];
app.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>
app.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 {
--win95-gray: #c0c0c0;
--win95-dark-gray: #808080;
--win95-light: #ffffff;
--win95-shadow: #000000;
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;
}
Signal Forms 整合
Angular Aria 可以與以訊號為基礎的 Signal Forms API 無縫整合。你可以把複雜的輸入封裝成可重複使用的自訂控制元件,實作 FormValueControl。
以下範例示範一個實作 FormValueControl<string> 的國家選取器元件,它使用 [formField] 繫結到父表單,並受到結構定義驗證規則的保護。
app.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;
});
});
}
app.html
<div class="form-container">
<country-selector [formField]="countryForm.country"></country-selector>
</div>
country-selector.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',
];
country-selector.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>
country-selector.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);
}
app.css
:host {
display: flex;
justify-content: center;
}
測試
自動完成模式可以使用 @angular/aria/combobox/testing 的 ComboboxHarness 與 @angular/aria/listbox/testing 的 ListboxHarness 組合來測試。
以下範例示範如何使用這些元件測試工具測試自動完成元件:
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 參考
如需詳細的 API 文件,請查看下列 API 參考: