Vue d'ensemble
Un champ de saisie accessible qui filtre et suggère des options pendant la frappe, pour aider les utilisateurs à trouver et sélectionner des valeurs dans une liste.
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;
}
Utilisation
L'autocomplete est idéal lorsque les utilisateurs doivent choisir parmi un grand nombre d'options et que taper est plus rapide que faire défiler. Envisagez l'autocomplete lorsque :
- La liste d'options est longue (plus de 20 éléments) - Taper affine les choix plus vite que de faire défiler une liste déroulante
- Les utilisateurs savent ce qu'ils cherchent - Ils peuvent taper une partie de la valeur attendue (comme un nom d'État, un produit ou un nom d'utilisateur)
- Les options suivent des schémas prévisibles - Les utilisateurs peuvent deviner des correspondances partielles (comme des codes pays, des domaines d'e-mail ou des catégories)
- La rapidité compte - Les formulaires bénéficient d'une sélection rapide sans navigation fastidieuse
Évitez l'autocomplete lorsque :
- La liste compte moins de 10 options - Une liste déroulante classique ou un groupe de boutons radio offre une meilleure visibilité
- Les utilisateurs doivent parcourir les options - Si la découverte est importante, affichez toutes les options d'emblée
- Les options ne sont pas familières - Les utilisateurs ne peuvent pas taper ce dont ils ignorent la présence dans la liste
Fonctionnalités
L'autocomplete d'Angular fournit une implémentation de combobox entièrement accessible avec :
- Navigation au clavier - Parcourez les options avec les touches fléchées, sélectionnez avec Entrée, fermez avec Échap
- Prise en charge des lecteurs d'écran - Attributs ARIA intégrés pour les technologies d'assistance
- Comportement de mise en surbrillance dynamique - Prise en charge intégrée des suggestions de sélection en ligne
- Réactivité basée sur les signals - Gestion d'état réactive avec les signals d'Angular
- Intégration de l'API Popover - Exploite l'API Popover native du HTML pour un positionnement optimal
- Prise en charge du texte bidirectionnel - Gère automatiquement les langues de droite à gauche (RTL)
Exemples
Mode de sélection automatique
Les utilisateurs qui tapent un texte partiel attendent une confirmation immédiate que leur saisie correspond à une option disponible. Le mode de sélection automatique met à jour la valeur du champ pour qu'elle corresponde à la première option filtrée pendant la frappe, ce qui réduit le nombre de frappes nécessaires et indique instantanément que la recherche est sur la bonne voie.
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;
}
Mode de sélection manuelle
Le mode de sélection manuelle conserve le texte tapé inchangé pendant que les utilisateurs parcourent la liste de suggestions, ce qui évite la confusion due aux mises à jour automatiques. Le champ ne change que lorsque les utilisateurs confirment explicitement leur choix avec Entrée ou un clic.
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;
}
Mode de mise en surbrillance
Le mode de mise en surbrillance permet à l'utilisateur de parcourir les options avec les touches fléchées sans modifier la valeur du champ pendant la navigation, jusqu'à ce qu'il sélectionne explicitement une nouvelle option avec Entrée ou un clic.
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;
}
Intégration dans un formulaire Signal Forms
Angular Aria s'intègre parfaitement avec l'API Signal Forms, basée sur les signals. Vous pouvez encapsuler des champs complexes dans des composants de contrôle personnalisé réutilisables qui implémentent FormValueControl.
L'exemple suivant montre un composant de sélection de pays (country selector) qui implémente FormValueControl<string>, lié au formulaire parent avec [formField] et protégé par des règles de validation de schéma.
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;
}
Tests
Le pattern autocomplete peut être testé en combinant ComboboxHarness et ListboxHarness depuis @angular/aria/combobox/testing et @angular/aria/listbox/testing.
Voici un exemple d'utilisation des harnais pour tester un composant autocomplete :
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');
});
});
Référence de l'API
Pour une documentation détaillée de l'API, consultez les références d'API suivantes :