Combobox
Genel Bakış
Bir tetikleyici eleman (metin girişi, buton veya div gibi) ile bir açılır pencereyi koordine eden, otomatik tamamlama, select ve multiselect kalıpları için temel yönerge sağlayan yönerge.
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 {
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;
}
Kullanım
Combobox, etkileşimli bir tetikleyici eleman (metin girişi, buton veya div gibi) ile bir açılır pencereyi koordine eden temel yönergedir. Otomatik tamamlama, select ve multiselect kalıpları için temel oluşturur. Şu durumlarda combobox'ı doğrudan kullanmayı düşünün:
- Özel otomatik tamamlama kalıpları oluşturma - Özelleştirilmiş filtreleme veya öneri davranışı oluşturma
- Özel seçim bileşenleri oluşturma - Benzersiz gereksinimleri olan açılır menüler geliştirme
- Girişi açılır pencereyle koordine etme - Metin girişini listbox, tree veya dialog içeriğiyle eşleştirme
- Özel filtreleme uygulama - Eşleşen seçenekleri kullanıcı tarafında filtreleme ve düzenleme
Bunun yerine belgelendirilmiş kalıpları kullanın:
- Filtrelemeli standart otomatik tamamlama gerektiğinde - Kullanıma hazır örnekler için Autocomplete kalıbına bakın
- Tek seçimli açılır menüler gerektiğinde - Eksiksiz açılır menü uygulaması için Select kalıbına bakın
- Çoklu seçimli açılır menüler gerektiğinde - Kompakt görüntüyle çoklu seçim için Multiselect kalıbına bakın
NOTE: Autocomplete, Select ve Multiselect rehberleri, bu yönergeyi belirli kullanım durumları için Listbox ile birleştiren belgelendirilmiş kalıpları gösterir.
Özellikler
Angular'ın combobox'ı, tam erişilebilir bir giriş-açılır pencere koordinasyon sistemi sağlar:
- Açılır Pencereli Tetikleyici Eleman - Tetikleyici elemanı açılır pencere içeriğiyle koordine eder
- Esnek Koordinasyon - Standart yerleşimlerle (listbox, tree, grid veya dialog) sorunsuz entegre olur
- Klavye Navigasyonu - Ok tuşları, Enter, Escape işlemesi
- Ekran Okuyucu Desteği - role="combobox" ve aria-expanded dahil yerleşik ARIA nitelikleri
- Açılır Pencere Yönetimi - Kullanıcı etkileşimine dayalı otomatik göster/gizle
- Sinyal Tabanlı Reaktivite - Angular sinyalleri kullanan reaktif durum yönetimi
Örnekler
Otomatik Tamamlama
Kullanıcılar yazarken seçenekleri filtreleyen ve öneren, bir listeden değer bulmalarına ve seçmelerine yardımcı olan erişilebilir bir giriş alanı.
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();
}
});
}
onBlur() {
this.commitSelection();
}
onCommit() {
this.commitSelection();
this.popupExpanded.set(false);
this.combobox()?.element.focus();
}
private commitSelection() {
const selected = this.selectedOption();
if (selected.length > 0) {
this.query.set(selected[0]);
} else {
this.query.set('');
this.selectedOption.set([]);
}
}
}
const ALL_COUNTRIES = [
'Afghanistan',
'Albania',
'Algeria',
'Andorra',
'Angola',
'Antigua and Barbuda',
'Argentina',
'Armenia',
'Australia',
'Austria',
'Azerbaijan',
'Bahamas',
'Bahrain',
'Bangladesh',
'Barbados',
'Belarus',
'Belgium',
'Belize',
'Benin',
'Bhutan',
'Bolivia',
'Bosnia and Herzegovina',
'Botswana',
'Brazil',
'Brunei',
'Bulgaria',
'Burkina Faso',
'Burundi',
'Cabo Verde',
'Cambodia',
'Cameroon',
'Canada',
'Central African Republic',
'Chad',
'Chile',
'China',
'Colombia',
'Comoros',
'Congo (Congo-Brazzaville)',
'Costa Rica',
"Côte d'Ivoire",
'Croatia',
'Cuba',
'Cyprus',
'Czechia (Czech Republic)',
'Democratic Republic of the Congo',
'Denmark',
'Djibouti',
'Dominica',
'Dominican Republic',
'Ecuador',
'Egypt',
'El Salvador',
'Equatorial Guinea',
'Eritrea',
'Estonia',
'Eswatini (fmr. "Swaziland")',
'Ethiopia',
'Fiji',
'Finland',
'France',
'Gabon',
'Gambia',
'Georgia',
'Germany',
'Ghana',
'Greece',
'Grenada',
'Guatemala',
'Guinea',
'Guinea-Bissau',
'Guyana',
'Haiti',
'Holy See',
'Honduras',
'Hungary',
'Iceland',
'India',
'Indonesia',
'Iran',
'Iraq',
'Ireland',
'Israel',
'Italy',
'Jamaica',
'Japan',
'Jordan',
'Kazakhstan',
'Kenya',
'Kiribati',
'Kuwait',
'Kyrgyzstan',
'Laos',
'Latvia',
'Lebanon',
'Lesotho',
'Liberia',
'Libya',
'Liechtenstein',
'Lithuania',
'Luxembourg',
'Madagascar',
'Malawi',
'Malaysia',
'Maldives',
'Mali',
'Malta',
'Marshall Islands',
'Mauritania',
'Mauritius',
'Mexico',
'Micronesia',
'Moldova',
'Monaco',
'Mongolia',
'Montenegro',
'Morocco',
'Mozambique',
'Myanmar (formerly Burma)',
'Namibia',
'Nauru',
'Nepal',
'Netherlands',
'New Zealand',
'Nicaragua',
'Niger',
'Nigeria',
'North Korea',
'North Macedonia',
'Norway',
'Oman',
'Pakistan',
'Palau',
'Palestine State',
'Panama',
'Papua New Guinea',
'Paraguay',
'Peru',
'Philippines',
'Poland',
'Portugal',
'Qatar',
'Romania',
'Russia',
'Rwanda',
'Saint Kitts and Nevis',
'Saint Lucia',
'Saint Vincent and the Grenadines',
'Samoa',
'San Marino',
'Sao Tome and Principe',
'Saudi Arabia',
'Senegal',
'Serbia',
'Seychelles',
'Sierra Leone',
'Singapore',
'Slovakia',
'Slovenia',
'Solomon Islands',
'Somalia',
'South Africa',
'South Korea',
'South Sudan',
'Spain',
'Sri Lanka',
'Sudan',
'Suriname',
'Sweden',
'Switzerland',
'Syria',
'Tajikistan',
'Tanzania',
'Thailand',
'Timor-Leste',
'Togo',
'Tonga',
'Trinidad and Tobago',
'Tunisia',
'Türkiye',
'Turkmenistan',
'Tuvalu',
'Uganda',
'Ukraine',
'United Arab Emirates',
'United Kingdom',
'United States of America',
'Uruguay',
'Uzbekistan',
'Vanuatu',
'Venezuela',
'Vietnam',
'Yemen',
'Zambia',
'Zimbabwe',
];
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)"
(focusout)="onBlur()"
/>
<button
type="button"
class="clear-button"
aria-label="Clear"
(mousedown)="$event.preventDefault()"
(click)="clear()"
>
<span class="clear-icon material-symbols-outlined" translate="no" aria-hidden="true"
>close</span
>
</button>
</div>
<div aria-live="polite" class="cdk-visually-hidden">
{{ countries().length === 0 ? 'No results found for ' + query() : '' }}
</div>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="popup">
@if (countries().length === 0) {
<div class="no-results">No results found</div>
}
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
class="listbox"
focusMode="activedescendant"
[tabindex]="-1"
[activeDescendant]="listbox.activeDescendant()"
[(value)]="selectedOption"
(click)="onCommit()"
(keydown.enter)="onCommit()"
>
@for (country of countries(); track country) {
<div class="option" ngOption [value]="country" [label]="country">
<span class="option-label">{{ country }}</span>
<span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
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();
}
});
}
onBlur() {
this.commitSelection();
}
onCommit() {
this.commitSelection();
this.popupExpanded.set(false);
this.combobox()?.element.focus();
}
private commitSelection() {
const selected = this.selectedOption();
if (selected.length > 0) {
this.query.set(selected[0]);
} else {
this.query.set('');
this.selectedOption.set([]);
}
}
}
const ALL_COUNTRIES = [
'Afghanistan',
'Albania',
'Algeria',
'Andorra',
'Angola',
'Antigua and Barbuda',
'Argentina',
'Armenia',
'Australia',
'Austria',
'Azerbaijan',
'Bahamas',
'Bahrain',
'Bangladesh',
'Barbados',
'Belarus',
'Belgium',
'Belize',
'Benin',
'Bhutan',
'Bolivia',
'Bosnia and Herzegovina',
'Botswana',
'Brazil',
'Brunei',
'Bulgaria',
'Burkina Faso',
'Burundi',
'Cabo Verde',
'Cambodia',
'Cameroon',
'Canada',
'Central African Republic',
'Chad',
'Chile',
'China',
'Colombia',
'Comoros',
'Congo (Congo-Brazzaville)',
'Costa Rica',
"Côte d'Ivoire",
'Croatia',
'Cuba',
'Cyprus',
'Czechia (Czech Republic)',
'Democratic Republic of the Congo',
'Denmark',
'Djibouti',
'Dominica',
'Dominican Republic',
'Ecuador',
'Egypt',
'El Salvador',
'Equatorial Guinea',
'Eritrea',
'Estonia',
'Eswatini (fmr. "Swaziland")',
'Ethiopia',
'Fiji',
'Finland',
'France',
'Gabon',
'Gambia',
'Georgia',
'Germany',
'Ghana',
'Greece',
'Grenada',
'Guatemala',
'Guinea',
'Guinea-Bissau',
'Guyana',
'Haiti',
'Holy See',
'Honduras',
'Hungary',
'Iceland',
'India',
'Indonesia',
'Iran',
'Iraq',
'Ireland',
'Israel',
'Italy',
'Jamaica',
'Japan',
'Jordan',
'Kazakhstan',
'Kenya',
'Kiribati',
'Kuwait',
'Kyrgyzstan',
'Laos',
'Latvia',
'Lebanon',
'Lesotho',
'Liberia',
'Libya',
'Liechtenstein',
'Lithuania',
'Luxembourg',
'Madagascar',
'Malawi',
'Malaysia',
'Maldives',
'Mali',
'Malta',
'Marshall Islands',
'Mauritania',
'Mauritius',
'Mexico',
'Micronesia',
'Moldova',
'Monaco',
'Mongolia',
'Montenegro',
'Morocco',
'Mozambique',
'Myanmar (formerly Burma)',
'Namibia',
'Nauru',
'Nepal',
'Netherlands',
'New Zealand',
'Nicaragua',
'Niger',
'Nigeria',
'North Korea',
'North Macedonia',
'Norway',
'Oman',
'Pakistan',
'Palau',
'Palestine State',
'Panama',
'Papua New Guinea',
'Paraguay',
'Peru',
'Philippines',
'Poland',
'Portugal',
'Qatar',
'Romania',
'Russia',
'Rwanda',
'Saint Kitts and Nevis',
'Saint Lucia',
'Saint Vincent and the Grenadines',
'Samoa',
'San Marino',
'Sao Tome and Principe',
'Saudi Arabia',
'Senegal',
'Serbia',
'Seychelles',
'Sierra Leone',
'Singapore',
'Slovakia',
'Slovenia',
'Solomon Islands',
'Somalia',
'South Africa',
'South Korea',
'South Sudan',
'Spain',
'Sri Lanka',
'Sudan',
'Suriname',
'Sweden',
'Switzerland',
'Syria',
'Tajikistan',
'Tanzania',
'Thailand',
'Timor-Leste',
'Togo',
'Tonga',
'Trinidad and Tobago',
'Tunisia',
'Türkiye',
'Turkmenistan',
'Tuvalu',
'Uganda',
'Ukraine',
'United Arab Emirates',
'United Kingdom',
'United States of America',
'Uruguay',
'Uzbekistan',
'Vanuatu',
'Venezuela',
'Vietnam',
'Yemen',
'Zambia',
'Zimbabwe',
];
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)"
(focusout)="onBlur()"
/>
<button
type="button"
class="clear-button"
aria-label="Clear"
(mousedown)="$event.preventDefault()"
(click)="clear()"
>
<span class="clear-icon material-symbols-outlined" translate="no" aria-hidden="true"
>close</span
>
</button>
</div>
<div aria-live="polite" class="cdk-visually-hidden">
{{ countries().length === 0 ? 'No results found for ' + query() : '' }}
</div>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="popup">
@if (countries().length === 0) {
<div class="no-results">No results found</div>
}
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
class="listbox"
focusMode="activedescendant"
[tabindex]="-1"
[activeDescendant]="listbox.activeDescendant()"
[(value)]="selectedOption"
(click)="onCommit()"
(keydown.enter)="onCommit()"
>
@for (country of countries(); track country) {
<div class="option" ngOption [value]="country" [label]="country">
<span class="option-label">{{ country }}</span>
<span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
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();
}
});
}
onBlur() {
this.commitSelection();
}
onCommit() {
this.commitSelection();
this.popupExpanded.set(false);
this.combobox()?.element.focus();
}
private commitSelection() {
const selected = this.selectedOption();
if (selected.length > 0) {
this.query.set(selected[0]);
} else {
this.query.set('');
this.selectedOption.set([]);
}
}
}
const ALL_COUNTRIES = [
'Afghanistan',
'Albania',
'Algeria',
'Andorra',
'Angola',
'Antigua and Barbuda',
'Argentina',
'Armenia',
'Australia',
'Austria',
'Azerbaijan',
'Bahamas',
'Bahrain',
'Bangladesh',
'Barbados',
'Belarus',
'Belgium',
'Belize',
'Benin',
'Bhutan',
'Bolivia',
'Bosnia and Herzegovina',
'Botswana',
'Brazil',
'Brunei',
'Bulgaria',
'Burkina Faso',
'Burundi',
'Cabo Verde',
'Cambodia',
'Cameroon',
'Canada',
'Central African Republic',
'Chad',
'Chile',
'China',
'Colombia',
'Comoros',
'Congo (Congo-Brazzaville)',
'Costa Rica',
"Côte d'Ivoire",
'Croatia',
'Cuba',
'Cyprus',
'Czechia (Czech Republic)',
'Democratic Republic of the Congo',
'Denmark',
'Djibouti',
'Dominica',
'Dominican Republic',
'Ecuador',
'Egypt',
'El Salvador',
'Equatorial Guinea',
'Eritrea',
'Estonia',
'Eswatini (fmr. "Swaziland")',
'Ethiopia',
'Fiji',
'Finland',
'France',
'Gabon',
'Gambia',
'Georgia',
'Germany',
'Ghana',
'Greece',
'Grenada',
'Guatemala',
'Guinea',
'Guinea-Bissau',
'Guyana',
'Haiti',
'Holy See',
'Honduras',
'Hungary',
'Iceland',
'India',
'Indonesia',
'Iran',
'Iraq',
'Ireland',
'Israel',
'Italy',
'Jamaica',
'Japan',
'Jordan',
'Kazakhstan',
'Kenya',
'Kiribati',
'Kuwait',
'Kyrgyzstan',
'Laos',
'Latvia',
'Lebanon',
'Lesotho',
'Liberia',
'Libya',
'Liechtenstein',
'Lithuania',
'Luxembourg',
'Madagascar',
'Malawi',
'Malaysia',
'Maldives',
'Mali',
'Malta',
'Marshall Islands',
'Mauritania',
'Mauritius',
'Mexico',
'Micronesia',
'Moldova',
'Monaco',
'Mongolia',
'Montenegro',
'Morocco',
'Mozambique',
'Myanmar (formerly Burma)',
'Namibia',
'Nauru',
'Nepal',
'Netherlands',
'New Zealand',
'Nicaragua',
'Niger',
'Nigeria',
'North Korea',
'North Macedonia',
'Norway',
'Oman',
'Pakistan',
'Palau',
'Palestine State',
'Panama',
'Papua New Guinea',
'Paraguay',
'Peru',
'Philippines',
'Poland',
'Portugal',
'Qatar',
'Romania',
'Russia',
'Rwanda',
'Saint Kitts and Nevis',
'Saint Lucia',
'Saint Vincent and the Grenadines',
'Samoa',
'San Marino',
'Sao Tome and Principe',
'Saudi Arabia',
'Senegal',
'Serbia',
'Seychelles',
'Sierra Leone',
'Singapore',
'Slovakia',
'Slovenia',
'Solomon Islands',
'Somalia',
'South Africa',
'South Korea',
'South Sudan',
'Spain',
'Sri Lanka',
'Sudan',
'Suriname',
'Sweden',
'Switzerland',
'Syria',
'Tajikistan',
'Tanzania',
'Thailand',
'Timor-Leste',
'Togo',
'Tonga',
'Trinidad and Tobago',
'Tunisia',
'Türkiye',
'Turkmenistan',
'Tuvalu',
'Uganda',
'Ukraine',
'United Arab Emirates',
'United Kingdom',
'United States of America',
'Uruguay',
'Uzbekistan',
'Vanuatu',
'Venezuela',
'Vietnam',
'Yemen',
'Zambia',
'Zimbabwe',
];
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)"
(focusout)="onBlur()"
/>
<button
type="button"
class="clear-button"
aria-label="Clear"
(mousedown)="$event.preventDefault()"
(click)="clear()"
>
<span class="clear-icon" aria-hidden="true">X</span>
</button>
</div>
<div aria-live="polite" class="cdk-visually-hidden">
{{ countries().length === 0 ? 'No results found for ' + query() : '' }}
</div>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="popup">
@if (countries().length === 0) {
<div class="no-results">No results found</div>
}
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
class="listbox"
focusMode="activedescendant"
[tabindex]="-1"
[activeDescendant]="listbox.activeDescendant()"
[(value)]="selectedOption"
(click)="onCommit()"
(keydown.enter)="onCommit()"
>
@for (country of countries(); track country) {
<div class="option" ngOption [value]="country" [label]="country">
<span class="option-label">{{ country }}</span>
<span class="check-icon material-symbols-outlined" translate="no" aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
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 {
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;
}
Filtreleme, seçenekler listesini reaktif olarak filtreleyen bir sinyali güncelleyerek kullanıcı tarafında yönetilir. Kullanıcılar ok tuşlarıyla gezinir ve Enter veya tıklama ile seçer. Bu, özel seçim mantığı için tam kontrol ve maksimum esneklik sağlar. Eksiksiz filtreleme kalıpları ve örnekleri için Autocomplete rehberine bakın.
Salt Okunur Modu
Tek seçimli açılır menüler oluşturmak için klavye navigasyonu ve ekran okuyucu desteği ile salt okunur combobox'ı listbox ile birleştiren bir kalıp.
app.ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root[theme="icons-basic"], app-root:not([theme])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
readonly listbox = viewChild(Listbox);
readonly selectedValues = signal<string[]>([]);
readonly popupExpanded = signal(false);
readonly displayIcon = computed(() => {
const val = this.selectedValues()[0];
const label = this.labels.find((label) => label.value === val);
return label ? label.icon : '';
});
readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly labels = [
{value: 'Important', icon: 'label'},
{value: 'Starred', icon: 'star'},
{value: 'Work', icon: 'work'},
{value: 'Personal', icon: 'person'},
{value: 'To Do', icon: 'checklist'},
{value: 'Later', icon: 'schedule'},
{value: 'Read', icon: 'menu_book'},
{value: 'Travel', icon: 'flight'},
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
>
<span class="combobox-label">
<span class="selected-label-icon material-symbols-outlined" translate="no" aria-hidden="true">{{
displayIcon()
}}</span>
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup-container">
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
[tabindex]="-1"
focusMode="activedescendant"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
(click)="onCommit()"
(keydown.enter)="onCommit()"
(keydown.space)="onCommit()"
>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
cursor: pointer;
padding: 0 3.5rem;
height: 2.5rem;
box-sizing: border-box;
width: 14rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover {
background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select:focus,
.select:focus-within {
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 0.5rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
margin: 1px;
padding: 0 1rem;
min-height: 2.25rem;
border-radius: 0.5rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
app.ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root[theme="icons-material"], app-root:not([theme])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
readonly listbox = viewChild(Listbox);
readonly selectedValues = signal<string[]>([]);
readonly popupExpanded = signal(false);
readonly displayIcon = computed(() => {
const val = this.selectedValues()[0];
const label = this.labels.find((label) => label.value === val);
return label ? label.icon : '';
});
readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly labels = [
{value: 'Important', icon: 'label'},
{value: 'Starred', icon: 'star'},
{value: 'Work', icon: 'work'},
{value: 'Personal', icon: 'person'},
{value: 'To Do', icon: 'checklist'},
{value: 'Later', icon: 'schedule'},
{value: 'Read', icon: 'menu_book'},
{value: 'Travel', icon: 'flight'},
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
>
<span class="combobox-label">
<span class="selected-label-icon material-symbols-outlined" translate="no" aria-hidden="true">{{
displayIcon()
}}</span>
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup-container">
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
[tabindex]="-1"
focusMode="activedescendant"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
(click)="onCommit()"
(keydown.enter)="onCommit()"
(keydown.space)="onCommit()"
>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
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);
--on-primary: var(--page-background);
}
.docs-light-mode {
--on-primary: #fff;
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
border-radius: 3rem;
color: var(--on-primary);
background-color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
cursor: pointer;
height: 3rem;
padding: 0 3.5rem;
box-sizing: border-box;
width: 14rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover {
background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select:focus,
.select:focus-within {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 2rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 13rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 13rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
min-height: 3rem;
border-radius: 3rem;
}
[ngOption]:hover,
[ngOption][data-active='true'] {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid var(--primary);
}
[ngOption][aria-selected='true'] {
color: var(--primary);
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
app.ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root[theme="icons-retro"], app-root:not([theme])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
readonly listbox = viewChild(Listbox);
readonly selectedValues = signal<string[]>([]);
readonly popupExpanded = signal(false);
readonly displayIcon = computed(() => {
const val = this.selectedValues()[0];
const label = this.labels.find((label) => label.value === val);
return label ? label.icon : '';
});
readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly labels = [
{value: 'Important', icon: 'label'},
{value: 'Starred', icon: 'star'},
{value: 'Work', icon: 'work'},
{value: 'Personal', icon: 'person'},
{value: 'To Do', icon: 'checklist'},
{value: 'Later', icon: 'schedule'},
{value: 'Read', icon: 'menu_book'},
{value: 'Travel', icon: 'flight'},
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
>
<span class="combobox-label">
<span class="selected-label-icon material-symbols-outlined" translate="no" aria-hidden="true">{{
displayIcon()
}}</span>
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup-container">
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
[tabindex]="-1"
focusMode="activedescendant"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
(click)="onCommit()"
(keydown.enter)="onCommit()"
(keydown.space)="onCommit()"
>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
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 {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--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-clickable-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), 8px 8px 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);
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
cursor: pointer;
padding: 0 4.5rem;
height: 2.5rem;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover,
.select:focus,
.select:focus-within {
transform: translate(1px, 1px);
}
.select:active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--gray-50));
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select[aria-expanded='false']:focus,
.select[aria-expanded='false']:focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px dashed var(--hot-pink);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
Metin girişi olmadan bir açılır menü tetikleme, ana tetikleyici olarak bir buton kullanılarak veya giriş tetikleyicisine yerel HTML readonly niteliği uygulanarak gerçekleştirilebilir. Açılır pencere tıklama veya ok tuşlarıyla açılır.
Bu yapılandırma, Select ve Multiselect kalıpları için temeli sağlar. Tetikleyiciler ve katman konumlandırmasıyla eksiksiz açılır menü uygulamaları için bu rehberlere bakın.
Datepicker grid
Combobox, erişilebilir datepicker'lar oluşturmak için iki boyutlu bir grid ile koordine olabilir. Kullanıcılar takvim grid tablosundaki tarihler arasında yönlü ok tuşlarıyla gezinir ve seçimi tıklama, Enter veya Boşluk tuşu ile onaylar.
app.ts
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
Component,
computed,
effect,
inject,
signal,
Signal,
untracked,
viewChild,
viewChildren,
WritableSignal,
ElementRef,
} from '@angular/core';
import {
DateAdapter,
MAT_DATE_FORMATS,
MatDateFormats,
provideNativeDateAdapter,
} from '@angular/material/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {OverlayModule} from '@angular/cdk/overlay';
import {A11yModule} from '@angular/cdk/a11y';
const DAYS_PER_WEEK = 7;
interface CalendarCell<D = any> {
displayName: string;
ariaLabel: string;
date: D;
selected: boolean;
}
/** @title Combobox with Datepicker Grid. */
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
providers: [provideNativeDateAdapter()],
imports: [
Grid,
GridRow,
GridCell,
GridCellWidget,
Combobox,
ComboboxPopup,
ComboboxWidget,
OverlayModule,
A11yModule,
],
})
export class App<D> {
private readonly _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
private readonly _dateFormats = inject<MatDateFormats>(MAT_DATE_FORMATS, {optional: true})!;
private readonly _dayButtons = viewChildren(GridCellWidget); // Dynamic capture of grid cell button widgets
readonly grid = viewChild(Grid);
readonly gridTable = viewChild<ElementRef<HTMLElement>>('gridTable');
readonly comboboxInput = viewChild<ElementRef<HTMLInputElement>>('comboboxInput');
readonly selection = signal('');
readonly popupExpanded = signal(false);
readonly viewMonth: WritableSignal<D> = signal(this._dateAdapter.today());
private readonly _activeDate: WritableSignal<D> = signal(this._dateAdapter.today());
// Track the target date that must receive focus post-render
readonly focusTargetDate = signal<D | null>(null);
// Helper to identify the current focus target in templates
isFocusTarget(date: D): boolean {
const target = this.focusTargetDate();
return target ? this._dateAdapter.compareDate(date, target) === 0 : false;
}
constructor() {
// Safe, post-render focus restoration loop
effect(() => {
const target = this.focusTargetDate();
if (!target) return;
// Grab dynamic dependency on day buttons list query
const buttons = this._dayButtons();
// Locate the focus button marked with our target attribute
const targetBtn = buttons.find(
(btn) => btn.element.getAttribute('data-focus-target') === 'true',
);
if (targetBtn) {
targetBtn.element.focus();
// Schedule cleanup in separate microtask to avoid circular signal write errors
Promise.resolve().then(() => {
untracked(() => this.focusTargetDate.set(null));
});
}
});
}
readonly monthYearLabel: Signal<string> = computed(() =>
this._dateAdapter
.format(this.viewMonth(), this._dateFormats.display.monthYearLabel)
.toLocaleUpperCase(),
);
readonly activeMonthAnnouncement = computed(
() =>
`Showing ${this._dateAdapter.format(this.viewMonth(), this._dateFormats.display.monthYearLabel)}`,
);
private readonly _firstWeekOffset: Signal<number> = computed(() => {
const firstOfMonth = this._dateAdapter.createDate(
this._dateAdapter.getYear(this.viewMonth()),
this._dateAdapter.getMonth(this.viewMonth()),
1,
);
return (
(DAYS_PER_WEEK +
this._dateAdapter.getDayOfWeek(firstOfMonth) -
this._dateAdapter.getFirstDayOfWeek()) %
DAYS_PER_WEEK
);
});
readonly prevMonthNumDays: Signal<number> = computed(() =>
this._dateAdapter.getNumDaysInMonth(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)),
);
readonly daysFromPrevMonth: Signal<number[]> = computed(() => {
const days: number[] = [];
for (let i = this._firstWeekOffset() - 1; i >= 0; i--) {
days.push(this.prevMonthNumDays() - i);
}
return days;
});
// Calculate the trailing empty days from the next month reactively to complete the final calendar grid week row.
readonly daysInNextMonth: Signal<number[]> = computed(() => {
const activeWeeks = this.weeks();
const lastWeekLength = activeWeeks[activeWeeks.length - 1]?.length || 0;
const trailingCount = lastWeekLength > 0 ? 7 - lastWeekLength : 0;
const days: number[] = [];
for (let i = 1; i <= trailingCount; i++) {
days.push(i);
}
return days;
});
// Shift the weekday names array reactively to align with the localized starting day of the week.
readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => {
const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();
const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow');
const longWeekdays = this._dateAdapter.getDayOfWeekNames('long');
const weekdays = longWeekdays.map((long, i) => {
return {long, narrow: narrowWeekdays[i]};
});
return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek));
});
// Reconstruct the two-dimensional week-by-week calendar grid whenever the month or selection changes.
readonly weeks = computed(() => {
this._activeDate(); // Create dependency on active date
const viewMonth = this.viewMonth();
const daysInMonth = this._dateAdapter.getNumDaysInMonth(viewMonth);
const dateNames = this._dateAdapter.getDateNames();
const weeks: CalendarCell[][] = [[]];
for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) {
if (cell == DAYS_PER_WEEK) {
weeks.push([]);
cell = 0;
}
const date = this._dateAdapter.createDate(
this._dateAdapter.getYear(viewMonth),
this._dateAdapter.getMonth(viewMonth),
i + 1,
);
const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel);
weeks[weeks.length - 1].push({
displayName: dateNames[i],
ariaLabel,
date,
selected: this._dateAdapter.compareDate(date, this._activeDate()) === 0,
});
}
return weeks;
});
nextMonth(): void {
this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1));
}
prevMonth(): void {
this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1));
}
selectDate(cell: CalendarCell<D>, event?: Event): void {
if (event) {
event.preventDefault();
event.stopPropagation();
}
const formatted = this._dateAdapter.format(cell.date, this._dateFormats.display.dateInput);
this.selection.set(formatted);
this._activeDate.set(cell.date);
// Synchronously restore focus to the trigger input element before destroying popup to avoid drop
this.comboboxInput()?.nativeElement.focus();
this.popupExpanded.set(false);
}
// Parse and reconcile dynamic input typing to calendar state
onInputInput(value: string): void {
const parsedDate = this._dateAdapter.parse(value, this._dateFormats.display.dateInput);
if (parsedDate && this._dateAdapter.isValid(parsedDate)) {
this._activeDate.set(parsedDate);
this.viewMonth.set(parsedDate);
}
}
// Handle keyboard inputs on the trigger input field.
onInputKeydown(event: KeyboardEvent) {
// Pressing Enter parses the input text and updates the selected date.
if (event.key === 'Enter') {
const value = this.selection();
const parsedDate = this._dateAdapter.parse(value, this._dateFormats.display.dateInput);
if (parsedDate && this._dateAdapter.isValid(parsedDate)) {
this._activeDate.set(parsedDate);
this.viewMonth.set(parsedDate);
this.popupExpanded.set(false);
}
// Pressing ArrowDown shifts focus into the active cell of the calendar grid.
} else if (event.key === 'ArrowDown' && this.popupExpanded()) {
setTimeout(() => {
const tableEl = this.gridTable()?.nativeElement;
if (tableEl) {
const tabbable = tableEl.querySelector('[tabindex="0"]') as HTMLElement;
(tabbable || tableEl).focus();
}
});
}
}
// Safe W3C calendar grid boundaries keys navigation checks
onGridKeydown(event: KeyboardEvent): void {
const arrowUp = event.key === 'ArrowUp';
const arrowDown = event.key === 'ArrowDown';
const arrowLeft = event.key === 'ArrowLeft';
const arrowRight = event.key === 'ArrowRight';
const pageUp = event.key === 'PageUp';
const pageDown = event.key === 'PageDown';
const homeKey = event.key === 'Home';
const endKey = event.key === 'End';
if (
!arrowUp &&
!arrowDown &&
!arrowLeft &&
!arrowRight &&
!pageUp &&
!pageDown &&
!homeKey &&
!endKey
) {
return;
}
// Extract the day number of the currently focused button cell
const targetEl = event.target as HTMLElement;
const dayAttr = targetEl.getAttribute('data-day');
if (!dayAttr) return;
const day = Number(dayAttr);
const year = this._dateAdapter.getYear(this.viewMonth());
const month = this._dateAdapter.getMonth(this.viewMonth());
const viewMonthNumDays = this._dateAdapter.getNumDaysInMonth(this.viewMonth());
// Reconstitute focused cell date Adapter entity
const currentFocusedDate = this._dateAdapter.createDate(year, month, day);
let targetDate: D | null = null;
// W3C APG Standard calendar keyboard rules
switch (event.key) {
case 'ArrowLeft':
// Day 1 boundary crossing: jump to the last day of the previous month
if (day === 1) {
targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, -1);
}
break;
case 'ArrowRight':
// Last day boundary crossing: jump to the first day of the next month
if (day === viewMonthNumDays) {
targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, 1);
}
break;
case 'ArrowUp':
// First week boundary crossing: jump back 7 days to the previous month
if (day <= 7) {
targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, -7);
}
break;
case 'ArrowDown':
// Last week boundary crossing: jump forward 7 days to the next month
if (day > viewMonthNumDays - 7) {
targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, 7);
}
break;
case 'PageUp':
// Shift back 12 months on Control-PageUp, otherwise shift back 1 month
targetDate = this._dateAdapter.addCalendarMonths(
currentFocusedDate,
event.ctrlKey ? -12 : -1,
);
break;
case 'PageDown':
// Shift forward 12 months on Control-PageDown, otherwise shift forward 1 month
targetDate = this._dateAdapter.addCalendarMonths(
currentFocusedDate,
event.ctrlKey ? 12 : 1,
);
break;
case 'Home':
// Jump to the 1st of the current month
targetDate = this._dateAdapter.createDate(year, month, 1);
break;
case 'End':
// Jump to the last day of the current month
targetDate = this._dateAdapter.createDate(year, month, viewMonthNumDays);
break;
}
if (targetDate) {
// Mute downstream event listeners inside the grid parent to prevent roving races
event.preventDefault();
event.stopImmediatePropagation();
this.navigateToDate(targetDate);
}
}
navigateToDate(targetDate: D): void {
const currentMonth = this._dateAdapter.getMonth(this.viewMonth());
const currentYear = this._dateAdapter.getYear(this.viewMonth());
const targetMonth = this._dateAdapter.getMonth(targetDate);
const targetYear = this._dateAdapter.getYear(targetDate);
const monthShift = currentMonth !== targetMonth || currentYear !== targetYear;
if (monthShift) {
// 1. Focus stable table container to stop focus drop to body (prevent overlay crash)
this.gridTable()?.nativeElement.focus();
// 2. Reset active grid state synchronously to avoid focus hijacking (Solution B)
const gridBehavior = this.grid()?._pattern.gridBehavior;
if (gridBehavior) {
gridBehavior.focusBehavior.activeCell.set(undefined);
gridBehavior.focusBehavior.activeCoords.set({row: -1, col: -1});
}
// 3. Set target state so the reactive effect knows what to grab post-render
this.focusTargetDate.set(targetDate);
// 4. Perform reactive month view transition
this.viewMonth.set(targetDate);
} else {
// Same month traversal: just set the target and the constructor effect will fire immediately
this.focusTargetDate.set(targetDate);
}
}
handleWidgetKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
this.comboboxInput()?.nativeElement.focus();
this.popupExpanded.set(false);
event.preventDefault();
event.stopPropagation();
}
}
}
app.html
<!--
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
-->
<div>
<div class="example-combobox-container no-active-outline">
<div #origin class="example-combobox-input-container">
<span class="material-symbols-outlined example-icon example-search-icon">calendar_month</span>
<!-- Synchronize typing changes reactively with onInputInput -->
<input
#comboboxInput
ngCombobox
#combobox="ngCombobox"
class="example-combobox-input"
placeholder="Pick a date..."
[(value)]="selection"
(input)="onInputInput(comboboxInput.value)"
[(expanded)]="popupExpanded"
aria-describedby="date-format-hint"
(keydown)="onInputKeydown($event)"
(click)="popupExpanded.set(true)"
/>
</div>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: false}"
[cdkConnectedOverlayOpen]="popupExpanded()"
(overlayOutsideClick)="popupExpanded.set(false)"
>
<ng-template ngComboboxPopup [combobox]="combobox" popupType="dialog">
<div class="example-popover">
<!-- Silence CDK focus yanking to protect keyboard typing with [cdkTrapFocusAutoCapture]="false" -->
<div
ngComboboxWidget
class="example-datepicker-popup"
cdkTrapFocus
[cdkTrapFocusAutoCapture]="false"
(keydown)="handleWidgetKeydown($event)"
>
<!-- Visually hidden live region for screen reader announcements -->
<div aria-live="polite" class="cdk-visually-hidden">
{{ activeMonthAnnouncement() }}
</div>
<div class="example-datepicker-header">
<button
type="button"
class="example-datepicker-nav-button"
(click)="prevMonth()"
aria-label="Previous Month"
>
<span class="material-symbols-outlined">chevron_left</span>
</button>
<div class="example-datepicker-title">{{ monthYearLabel() }}</div>
<button
type="button"
class="example-datepicker-nav-button"
(click)="nextMonth()"
aria-label="Next Month"
>
<span class="material-symbols-outlined">chevron_right</span>
</button>
</div>
<!-- Bind Arrow boundary checks to grid table -->
<table
#gridTable
tabindex="-1"
ngGrid
#grid="ngGrid"
class="example-datepicker-grid"
colWrap="continuous"
rowWrap="nowrap"
[enableSelection]="true"
selectionMode="explicit"
(keydown)="onGridKeydown($event)"
>
<thead>
<tr>
<!-- Dynamic weekday loop to prevent localization first-day-of-week shifting bug -->
@for (day of weekdays(); track day.long) {
<th
role="columnheader"
scope="col"
class="example-datepicker-cell example-datepicker-weekday"
[attr.abbr]="day.long"
>
{{ day.narrow }}
</th>
}
</tr>
</thead>
<tbody>
@for (week of weeks(); track $index) {
<tr ngGridRow>
@if ($first) {
@for (day of daysFromPrevMonth(); track $index) {
<td
class="example-datepicker-cell example-datepicker-empty"
ngGridCell
[disabled]="true"
aria-hidden="true"
[tabindex]="-1"
>
{{ day }}
</td>
}
}
@for (day of week; track $index) {
<td class="example-datepicker-cell" ngGridCell [selected]="day.selected">
<!-- Duplicate selected state inside day button to allow clear NVDA/VoiceOver announcements -->
<button
ngGridCellWidget
type="button"
class="example-datepicker-day-button"
[attr.data-day]="day.displayName"
[attr.data-focus-target]="isFocusTarget(day.date)"
[attr.aria-label]="day.ariaLabel + (day.selected ? ', Selected' : '')"
(click)="selectDate(day, $event)"
(keydown.enter)="selectDate(day, $event)"
(keydown.space)="selectDate(day, $event)"
>
{{ day.displayName }}
</button>
</td>
}
@if ($last && week.length < 7) {
@for (day of daysInNextMonth(); track $index) {
<td
class="example-datepicker-cell example-datepicker-empty"
ngGridCell
[disabled]="true"
aria-hidden="true"
[tabindex]="-1"
>
{{ day }}
</td>
}
}
</tr>
}
</tbody>
</table>
</div>
</div>
</ng-template>
</ng-template>
</div>
<div id="date-format-hint" class="example-combobox-hint">Format: MM/DD/YYYY</div>
</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, sans-serif);
--border-color: var(--quaternary-contrast, #e0e0e0);
}
/* Universal/Basic Trigger Layout Styles */
.example-combobox-container {
position: relative;
width: 15rem;
display: flex;
flex-direction: column;
border: 1px solid var(--quinary-contrast, #e0e0e0);
border-radius: 0.25rem;
background-color: var(--page-background, #ffffff);
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.example-combobox-input-container {
display: flex;
position: relative;
align-items: center;
border-radius: 0.25rem;
}
.example-icon {
width: 24px;
height: 24px;
font-size: 24px;
color: var(--primary-contrast, #1a1a1a);
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
display: grid;
place-items: center;
pointer-events: none;
}
.example-search-icon {
padding: 0 0.5rem;
position: absolute;
opacity: 0.8;
}
.example-combobox-input {
border-radius: 0.25rem;
width: 100%;
border: none;
outline: none;
font-size: 1rem;
padding: 0.7rem 1rem 0.7rem 2.5rem;
background-color: transparent;
color: var(--primary-contrast, #1a1a1a);
}
.example-combobox-container:focus-within {
border-color: var(--hot-pink, #ff007f);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--hot-pink, #ff007f) 20%, transparent);
}
/* Overlay Bounding Popover */
.example-popover {
margin: 0;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
background-color: var(--septenary-contrast, #ffffff);
overflow: hidden;
}
.example-datepicker-popup {
padding: 16px;
width: 320px;
max-height: none;
overflow: visible;
background-color: transparent;
border: none;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}
.example-datepicker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 12px;
}
.example-datepicker-title {
font-weight: 600;
font-size: 0.9rem;
color: var(--primary-contrast, #1a1a1a);
}
.example-datepicker-nav-button {
background-color: transparent;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--primary-contrast, #1a1a1a);
transition: background-color 0.2s ease;
}
.example-datepicker-nav-button:hover {
background-color: var(--senary-contrast, #f0f0f0);
}
.example-datepicker-nav-button:focus {
outline-offset: -1px;
outline: 1px solid color-mix(in srgb, var(--hot-pink, #ff007f) 60%, transparent);
}
.example-datepicker-grid {
width: 100%;
border-collapse: collapse;
}
.example-datepicker-cell {
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
padding: 0;
}
.example-datepicker-weekday {
font-size: 0.75rem;
font-weight: 500;
color: var(--secondary-contrast, #707070);
padding-bottom: 8px;
}
.example-datepicker-empty {
color: var(--senary-contrast, #c0c0c0);
font-size: 0.8rem;
}
.example-datepicker-day-button {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background-color: transparent;
cursor: pointer;
font-size: 0.85rem;
color: var(--primary-contrast, #1a1a1a);
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.example-datepicker-cell:hover .example-datepicker-day-button {
background-color: var(--senary-contrast, #f0f0f0);
}
.example-datepicker-cell:focus-within {
outline: 2px solid var(--hot-pink, #ff007f);
outline-offset: -2px;
}
@media (forced-colors: active) {
.example-datepicker-cell:focus-within {
outline: 2px solid CanvasText;
}
}
.example-datepicker-day-button:focus {
outline: none;
}
thead {
background-image: var(
--pink-to-purple-horizontal-gradient,
linear-gradient(to right, #ff007f, #6200ee)
);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.example-datepicker-cell[aria-selected='true'] .example-datepicker-day-button {
background-color: var(--electric-violet, #6200ee);
color: var(--octonary-contrast, #ffffff);
}
.example-datepicker-nav-button[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.example-combobox-hint {
font-size: 0.75rem;
color: var(--secondary-contrast, #707070);
margin-top: 4px;
}
app.ts
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
Component,
computed,
effect,
inject,
signal,
Signal,
untracked,
viewChild,
viewChildren,
WritableSignal,
ElementRef,
} from '@angular/core';
import {
DateAdapter,
MAT_DATE_FORMATS,
MatDateFormats,
provideNativeDateAdapter,
} from '@angular/material/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {OverlayModule} from '@angular/cdk/overlay';
import {A11yModule} from '@angular/cdk/a11y';
const DAYS_PER_WEEK = 7;
interface CalendarCell<D = any> {
displayName: string;
ariaLabel: string;
date: D;
selected: boolean;
}
/** @title Combobox with Datepicker Grid. */
@Component({
selector: 'app-root:not([theme="basic-material"])',
templateUrl: 'app.html',
styleUrl: 'app.css',
providers: [provideNativeDateAdapter()],
imports: [
Grid,
GridRow,
GridCell,
GridCellWidget,
Combobox,
ComboboxPopup,
ComboboxWidget,
OverlayModule,
A11yModule,
],
})
export class App<D> {
private readonly _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
private readonly _dateFormats = inject<MatDateFormats>(MAT_DATE_FORMATS, {optional: true})!;
private readonly _dayButtons = viewChildren(GridCellWidget); // Dynamic capture of grid cell button widgets
readonly grid = viewChild(Grid);
readonly gridTable = viewChild<ElementRef<HTMLElement>>('gridTable');
readonly comboboxInput = viewChild<ElementRef<HTMLInputElement>>('comboboxInput');
readonly selection = signal('');
readonly popupExpanded = signal(false);
readonly viewMonth: WritableSignal<D> = signal(this._dateAdapter.today());
private readonly _activeDate: WritableSignal<D> = signal(this._dateAdapter.today());
// Track the target date that must receive focus post-render
readonly focusTargetDate = signal<D | null>(null);
// Helper to identify the current focus target in templates
isFocusTarget(date: D): boolean {
const target = this.focusTargetDate();
return target ? this._dateAdapter.compareDate(date, target) === 0 : false;
}
constructor() {
// Safe, post-render focus restoration loop
effect(() => {
const target = this.focusTargetDate();
if (!target) return;
// Grab dynamic dependency on day buttons list query
const buttons = this._dayButtons();
// Locate the focus button marked with our target attribute
const targetBtn = buttons.find(
(btn) => btn.element.getAttribute('data-focus-target') === 'true',
);
if (targetBtn) {
targetBtn.element.focus();
// Schedule cleanup in separate microtask to avoid circular signal write errors
Promise.resolve().then(() => {
untracked(() => this.focusTargetDate.set(null));
});
}
});
}
readonly monthYearLabel: Signal<string> = computed(() =>
this._dateAdapter
.format(this.viewMonth(), this._dateFormats.display.monthYearLabel)
.toLocaleUpperCase(),
);
readonly activeMonthAnnouncement = computed(
() =>
`Showing ${this._dateAdapter.format(this.viewMonth(), this._dateFormats.display.monthYearLabel)}`,
);
private readonly _firstWeekOffset: Signal<number> = computed(() => {
const firstOfMonth = this._dateAdapter.createDate(
this._dateAdapter.getYear(this.viewMonth()),
this._dateAdapter.getMonth(this.viewMonth()),
1,
);
return (
(DAYS_PER_WEEK +
this._dateAdapter.getDayOfWeek(firstOfMonth) -
this._dateAdapter.getFirstDayOfWeek()) %
DAYS_PER_WEEK
);
});
readonly prevMonthNumDays: Signal<number> = computed(() =>
this._dateAdapter.getNumDaysInMonth(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)),
);
readonly daysFromPrevMonth: Signal<number[]> = computed(() => {
const days: number[] = [];
for (let i = this._firstWeekOffset() - 1; i >= 0; i--) {
days.push(this.prevMonthNumDays() - i);
}
return days;
});
// Calculate the trailing empty days from the next month reactively to complete the final calendar grid week row.
readonly daysInNextMonth: Signal<number[]> = computed(() => {
const activeWeeks = this.weeks();
const lastWeekLength = activeWeeks[activeWeeks.length - 1]?.length || 0;
const trailingCount = lastWeekLength > 0 ? 7 - lastWeekLength : 0;
const days: number[] = [];
for (let i = 1; i <= trailingCount; i++) {
days.push(i);
}
return days;
});
// Shift the weekday names array reactively to align with the localized starting day of the week.
readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => {
const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();
const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow');
const longWeekdays = this._dateAdapter.getDayOfWeekNames('long');
const weekdays = longWeekdays.map((long, i) => {
return {long, narrow: narrowWeekdays[i]};
});
return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek));
});
// Reconstruct the two-dimensional week-by-week calendar grid whenever the month or selection changes.
readonly weeks = computed(() => {
this._activeDate(); // Create dependency on active date
const viewMonth = this.viewMonth();
const daysInMonth = this._dateAdapter.getNumDaysInMonth(viewMonth);
const dateNames = this._dateAdapter.getDateNames();
const weeks: CalendarCell[][] = [[]];
for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) {
if (cell == DAYS_PER_WEEK) {
weeks.push([]);
cell = 0;
}
const date = this._dateAdapter.createDate(
this._dateAdapter.getYear(viewMonth),
this._dateAdapter.getMonth(viewMonth),
i + 1,
);
const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel);
weeks[weeks.length - 1].push({
displayName: dateNames[i],
ariaLabel,
date,
selected: this._dateAdapter.compareDate(date, this._activeDate()) === 0,
});
}
return weeks;
});
nextMonth(): void {
this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1));
}
prevMonth(): void {
this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1));
}
selectDate(cell: CalendarCell<D>, event?: Event): void {
if (event) {
event.preventDefault();
event.stopPropagation();
}
const formatted = this._dateAdapter.format(cell.date, this._dateFormats.display.dateInput);
this.selection.set(formatted);
this._activeDate.set(cell.date);
// Synchronously restore focus to the trigger input element before destroying popup to avoid drop
this.comboboxInput()?.nativeElement.focus();
this.popupExpanded.set(false);
}
// Parse and reconcile dynamic input typing to calendar state
onInputInput(value: string): void {
const parsedDate = this._dateAdapter.parse(value, this._dateFormats.display.dateInput);
if (parsedDate && this._dateAdapter.isValid(parsedDate)) {
this._activeDate.set(parsedDate);
this.viewMonth.set(parsedDate);
}
}
// Handle keyboard inputs on the trigger input field.
onInputKeydown(event: KeyboardEvent) {
// Pressing Enter parses the input text and updates the selected date.
if (event.key === 'Enter') {
const value = this.selection();
const parsedDate = this._dateAdapter.parse(value, this._dateFormats.display.dateInput);
if (parsedDate && this._dateAdapter.isValid(parsedDate)) {
this._activeDate.set(parsedDate);
this.viewMonth.set(parsedDate);
this.popupExpanded.set(false);
}
// Pressing ArrowDown shifts focus into the active cell of the calendar grid.
} else if (event.key === 'ArrowDown' && this.popupExpanded()) {
setTimeout(() => {
const tableEl = this.gridTable()?.nativeElement;
if (tableEl) {
const tabbable = tableEl.querySelector('[tabindex="0"]') as HTMLElement;
(tabbable || tableEl).focus();
}
});
}
}
// Safe W3C calendar grid boundaries keys navigation checks
onGridKeydown(event: KeyboardEvent): void {
const arrowUp = event.key === 'ArrowUp';
const arrowDown = event.key === 'ArrowDown';
const arrowLeft = event.key === 'ArrowLeft';
const arrowRight = event.key === 'ArrowRight';
const pageUp = event.key === 'PageUp';
const pageDown = event.key === 'PageDown';
const homeKey = event.key === 'Home';
const endKey = event.key === 'End';
if (
!arrowUp &&
!arrowDown &&
!arrowLeft &&
!arrowRight &&
!pageUp &&
!pageDown &&
!homeKey &&
!endKey
) {
return;
}
// Extract the day number of the currently focused button cell
const targetEl = event.target as HTMLElement;
const dayAttr = targetEl.getAttribute('data-day');
if (!dayAttr) return;
const day = Number(dayAttr);
const year = this._dateAdapter.getYear(this.viewMonth());
const month = this._dateAdapter.getMonth(this.viewMonth());
const viewMonthNumDays = this._dateAdapter.getNumDaysInMonth(this.viewMonth());
// Reconstitute focused cell date Adapter entity
const currentFocusedDate = this._dateAdapter.createDate(year, month, day);
let targetDate: D | null = null;
// W3C APG Standard calendar keyboard rules
switch (event.key) {
case 'ArrowLeft':
// Day 1 boundary crossing: jump to the last day of the previous month
if (day === 1) {
targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, -1);
}
break;
case 'ArrowRight':
// Last day boundary crossing: jump to the first day of the next month
if (day === viewMonthNumDays) {
targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, 1);
}
break;
case 'ArrowUp':
// First week boundary crossing: jump back 7 days to the previous month
if (day <= 7) {
targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, -7);
}
break;
case 'ArrowDown':
// Last week boundary crossing: jump forward 7 days to the next month
if (day > viewMonthNumDays - 7) {
targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, 7);
}
break;
case 'PageUp':
// Shift back 12 months on Control-PageUp, otherwise shift back 1 month
targetDate = this._dateAdapter.addCalendarMonths(
currentFocusedDate,
event.ctrlKey ? -12 : -1,
);
break;
case 'PageDown':
// Shift forward 12 months on Control-PageDown, otherwise shift forward 1 month
targetDate = this._dateAdapter.addCalendarMonths(
currentFocusedDate,
event.ctrlKey ? 12 : 1,
);
break;
case 'Home':
// Jump to the 1st of the current month
targetDate = this._dateAdapter.createDate(year, month, 1);
break;
case 'End':
// Jump to the last day of the current month
targetDate = this._dateAdapter.createDate(year, month, viewMonthNumDays);
break;
}
if (targetDate) {
// Mute downstream event listeners inside the grid parent to prevent roving races
event.preventDefault();
event.stopImmediatePropagation();
this.navigateToDate(targetDate);
}
}
navigateToDate(targetDate: D): void {
const currentMonth = this._dateAdapter.getMonth(this.viewMonth());
const currentYear = this._dateAdapter.getYear(this.viewMonth());
const targetMonth = this._dateAdapter.getMonth(targetDate);
const targetYear = this._dateAdapter.getYear(targetDate);
const monthShift = currentMonth !== targetMonth || currentYear !== targetYear;
if (monthShift) {
// 1. Focus stable table container to stop focus drop to body (prevent overlay crash)
this.gridTable()?.nativeElement.focus();
// 2. Reset active grid state synchronously to avoid focus hijacking (Solution B)
const gridBehavior = this.grid()?._pattern.gridBehavior;
if (gridBehavior) {
gridBehavior.focusBehavior.activeCell.set(undefined);
gridBehavior.focusBehavior.activeCoords.set({row: -1, col: -1});
}
// 3. Set target state so the reactive effect knows what to grab post-render
this.focusTargetDate.set(targetDate);
// 4. Perform reactive month view transition
this.viewMonth.set(targetDate);
} else {
// Same month traversal: just set the target and the constructor effect will fire immediately
this.focusTargetDate.set(targetDate);
}
}
handleWidgetKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
this.comboboxInput()?.nativeElement.focus();
this.popupExpanded.set(false);
event.preventDefault();
event.stopPropagation();
}
}
}
app.html
<!--
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
-->
<div>
<div class="example-combobox-container no-active-outline">
<div #origin class="example-combobox-input-container">
<span class="material-symbols-outlined example-icon example-search-icon">calendar_month</span>
<!-- Synchronize typing changes reactively with onInputInput -->
<input
#comboboxInput
ngCombobox
#combobox="ngCombobox"
class="example-combobox-input"
placeholder="Pick a date..."
[(value)]="selection"
(input)="onInputInput(comboboxInput.value)"
[(expanded)]="popupExpanded"
aria-describedby="date-format-hint"
(keydown)="onInputKeydown($event)"
(click)="popupExpanded.set(true)"
/>
</div>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: false}"
[cdkConnectedOverlayOpen]="popupExpanded()"
(overlayOutsideClick)="popupExpanded.set(false)"
>
<ng-template ngComboboxPopup [combobox]="combobox" popupType="dialog">
<div class="example-popover">
<!-- Silence CDK focus yanking to protect keyboard typing with [cdkTrapFocusAutoCapture]="false" -->
<div
ngComboboxWidget
class="example-datepicker-popup"
cdkTrapFocus
[cdkTrapFocusAutoCapture]="false"
(keydown)="handleWidgetKeydown($event)"
>
<!-- Visually hidden live region for screen reader announcements -->
<div aria-live="polite" class="cdk-visually-hidden">
{{ activeMonthAnnouncement() }}
</div>
<div class="example-datepicker-header">
<button
type="button"
class="example-datepicker-nav-button"
(click)="prevMonth()"
aria-label="Previous Month"
>
<span class="material-symbols-outlined">chevron_left</span>
</button>
<div class="example-datepicker-title">{{ monthYearLabel() }}</div>
<button
type="button"
class="example-datepicker-nav-button"
(click)="nextMonth()"
aria-label="Next Month"
>
<span class="material-symbols-outlined">chevron_right</span>
</button>
</div>
<!-- Bind Arrow boundary checks to grid table -->
<table
#gridTable
tabindex="-1"
ngGrid
#grid="ngGrid"
class="example-datepicker-grid"
colWrap="continuous"
rowWrap="nowrap"
[enableSelection]="true"
selectionMode="explicit"
(keydown)="onGridKeydown($event)"
>
<thead>
<tr>
<!-- Dynamic weekday loop to prevent localization first-day-of-week shifting bug -->
@for (day of weekdays(); track day.long) {
<th
role="columnheader"
scope="col"
class="example-datepicker-cell example-datepicker-weekday"
[attr.abbr]="day.long"
>
{{ day.narrow }}
</th>
}
</tr>
</thead>
<tbody>
@for (week of weeks(); track $index) {
<tr ngGridRow>
@if ($first) {
@for (day of daysFromPrevMonth(); track $index) {
<td
class="example-datepicker-cell example-datepicker-empty"
ngGridCell
[disabled]="true"
aria-hidden="true"
[tabindex]="-1"
>
{{ day }}
</td>
}
}
@for (day of week; track $index) {
<td class="example-datepicker-cell" ngGridCell [selected]="day.selected">
<!-- Duplicate selected state inside day button to allow clear NVDA/VoiceOver announcements -->
<button
ngGridCellWidget
type="button"
class="example-datepicker-day-button"
[attr.data-day]="day.displayName"
[attr.data-focus-target]="isFocusTarget(day.date)"
[attr.aria-label]="day.ariaLabel + (day.selected ? ', Selected' : '')"
(click)="selectDate(day, $event)"
(keydown.enter)="selectDate(day, $event)"
(keydown.space)="selectDate(day, $event)"
>
{{ day.displayName }}
</button>
</td>
}
@if ($last && week.length < 7) {
@for (day of daysInNextMonth(); track $index) {
<td
class="example-datepicker-cell example-datepicker-empty"
ngGridCell
[disabled]="true"
aria-hidden="true"
[tabindex]="-1"
>
{{ day }}
</td>
}
}
</tr>
}
</tbody>
</table>
</div>
</div>
</ng-template>
</ng-template>
</div>
<div id="date-format-hint" class="example-combobox-hint">Format: MM/DD/YYYY</div>
</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, sans-serif);
--border-color: color-mix(in srgb, var(--full-contrast, #000) 20%, var(--page-background, #fff));
}
/* Input Trigger Styles */
.example-combobox-container {
position: relative;
width: 15rem;
display: flex;
flex-direction: column;
border: 1px solid var(--quinary-contrast, #e0e0e0);
border-radius: 3rem;
background-color: var(--page-background, #ffffff);
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.example-combobox-input-container {
display: flex;
position: relative;
align-items: center;
border-radius: 0.25rem;
}
.example-icon {
width: 24px;
height: 24px;
font-size: 24px;
color: var(--primary-contrast, #1a1a1a);
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
display: grid;
place-items: center;
pointer-events: none;
}
.example-search-icon {
padding: 0 0.5rem;
position: absolute;
opacity: 0.8;
}
.example-combobox-input {
border-radius: 3rem;
width: 100%;
border: none;
outline: none;
font-size: 1rem;
padding: 0.7rem 1rem 0.7rem 2.5rem;
background-color: transparent;
color: var(--primary-contrast, #1a1a1a);
}
.example-combobox-input::placeholder {
color: var(--quaternary-contrast, #888888);
}
.example-combobox-container:focus-within {
border-color: var(--hot-pink);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--hot-pink) 20%, transparent);
}
/* Overlay Bounding Popover */
.example-popover {
margin: 0;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 2rem;
background-color: var(--septenary-contrast, #f5f5f5);
overflow: hidden;
}
.example-datepicker-popup {
padding: 16px;
width: 320px;
max-height: none;
overflow: visible;
background-color: transparent;
border: none;
box-shadow: var(--mat-sys-level2-shadow);
}
.example-datepicker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 1px solid var(--mat-sys-outline-variant);
margin-bottom: 12px;
}
.example-datepicker-title {
font-weight: 600;
font-size: 0.9rem;
color: var(--mat-sys-on-surface);
}
.example-datepicker-nav-button {
background-color: transparent;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--mat-sys-on-surface);
transition: background-color 0.2s ease;
}
.example-datepicker-nav-button:hover {
background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent);
}
.example-datepicker-nav-button:focus {
outline-offset: -1px;
outline: 1px solid color-mix(in srgb, var(--hot-pink) 60%, transparent);
}
.example-datepicker-grid {
width: 100%;
border-collapse: collapse;
}
.example-datepicker-cell {
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
padding: 0;
}
.example-datepicker-weekday {
font-size: 0.75rem;
font-weight: 500;
color: var(--mat-sys-on-surface-variant);
padding-bottom: 8px;
}
.example-datepicker-empty {
color: color-mix(in srgb, var(--mat-sys-on-surface) 30%, transparent);
font-size: 0.8rem;
}
.example-datepicker-day-button {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background-color: transparent;
cursor: pointer;
font-size: 0.85rem;
color: var(--mat-sys-on-surface);
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.example-datepicker-cell:hover .example-datepicker-day-button {
background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent);
}
.example-datepicker-cell:focus-within {
outline: 2px solid var(--hot-pink);
outline-offset: -2px;
}
@media (forced-colors: active) {
.example-datepicker-cell:focus-within {
outline: 2px solid CanvasText;
}
}
.example-datepicker-day-button:focus {
outline: none;
}
.example-datepicker-cell[aria-selected='true'] .example-datepicker-day-button {
background-color: var(--electric-violet, var(--mat-sys-primary));
color: var(--octonary-contrast, #ffffff);
}
.example-combobox-hint {
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
margin-top: 4px;
}
app.ts
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
Component,
computed,
effect,
inject,
signal,
Signal,
untracked,
viewChild,
viewChildren,
WritableSignal,
ElementRef,
} from '@angular/core';
import {
DateAdapter,
MAT_DATE_FORMATS,
MatDateFormats,
provideNativeDateAdapter,
} from '@angular/material/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {OverlayModule} from '@angular/cdk/overlay';
import {A11yModule} from '@angular/cdk/a11y';
const DAYS_PER_WEEK = 7;
interface CalendarCell<D = any> {
displayName: string;
ariaLabel: string;
date: D;
selected: boolean;
}
/** @title Combobox with Datepicker Grid. */
@Component({
selector: 'app-root:not([theme="basic-retro"])',
templateUrl: 'app.html',
styleUrl: 'app.css',
providers: [provideNativeDateAdapter()],
imports: [
Grid,
GridRow,
GridCell,
GridCellWidget,
Combobox,
ComboboxPopup,
ComboboxWidget,
OverlayModule,
A11yModule,
],
})
export class App<D> {
private readonly _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
private readonly _dateFormats = inject<MatDateFormats>(MAT_DATE_FORMATS, {optional: true})!;
private readonly _dayButtons = viewChildren(GridCellWidget); // Dynamic capture of grid cell button widgets
readonly grid = viewChild(Grid);
readonly gridTable = viewChild<ElementRef<HTMLElement>>('gridTable');
readonly comboboxInput = viewChild<ElementRef<HTMLInputElement>>('comboboxInput');
readonly selection = signal('');
readonly popupExpanded = signal(false);
readonly viewMonth: WritableSignal<D> = signal(this._dateAdapter.today());
private readonly _activeDate: WritableSignal<D> = signal(this._dateAdapter.today());
// Track the target date that must receive focus post-render
readonly focusTargetDate = signal<D | null>(null);
// Helper to identify the current focus target in templates
isFocusTarget(date: D): boolean {
const target = this.focusTargetDate();
return target ? this._dateAdapter.compareDate(date, target) === 0 : false;
}
constructor() {
// Safe, post-render focus restoration loop
effect(() => {
const target = this.focusTargetDate();
if (!target) return;
// Grab dynamic dependency on day buttons list query
const buttons = this._dayButtons();
// Locate the focus button marked with our target attribute
const targetBtn = buttons.find(
(btn) => btn.element.getAttribute('data-focus-target') === 'true',
);
if (targetBtn) {
targetBtn.element.focus();
// Schedule cleanup in separate microtask to avoid circular signal write errors
Promise.resolve().then(() => {
untracked(() => this.focusTargetDate.set(null));
});
}
});
}
readonly monthYearLabel: Signal<string> = computed(() =>
this._dateAdapter
.format(this.viewMonth(), this._dateFormats.display.monthYearLabel)
.toLocaleUpperCase(),
);
readonly activeMonthAnnouncement = computed(
() =>
`Showing ${this._dateAdapter.format(this.viewMonth(), this._dateFormats.display.monthYearLabel)}`,
);
private readonly _firstWeekOffset: Signal<number> = computed(() => {
const firstOfMonth = this._dateAdapter.createDate(
this._dateAdapter.getYear(this.viewMonth()),
this._dateAdapter.getMonth(this.viewMonth()),
1,
);
return (
(DAYS_PER_WEEK +
this._dateAdapter.getDayOfWeek(firstOfMonth) -
this._dateAdapter.getFirstDayOfWeek()) %
DAYS_PER_WEEK
);
});
readonly prevMonthNumDays: Signal<number> = computed(() =>
this._dateAdapter.getNumDaysInMonth(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)),
);
readonly daysFromPrevMonth: Signal<number[]> = computed(() => {
const days: number[] = [];
for (let i = this._firstWeekOffset() - 1; i >= 0; i--) {
days.push(this.prevMonthNumDays() - i);
}
return days;
});
// Calculate the trailing empty days from the next month reactively to complete the final calendar grid week row.
readonly daysInNextMonth: Signal<number[]> = computed(() => {
const activeWeeks = this.weeks();
const lastWeekLength = activeWeeks[activeWeeks.length - 1]?.length || 0;
const trailingCount = lastWeekLength > 0 ? 7 - lastWeekLength : 0;
const days: number[] = [];
for (let i = 1; i <= trailingCount; i++) {
days.push(i);
}
return days;
});
// Shift the weekday names array reactively to align with the localized starting day of the week.
readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => {
const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();
const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow');
const longWeekdays = this._dateAdapter.getDayOfWeekNames('long');
const weekdays = longWeekdays.map((long, i) => {
return {long, narrow: narrowWeekdays[i]};
});
return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek));
});
// Reconstruct the two-dimensional week-by-week calendar grid whenever the month or selection changes.
readonly weeks = computed(() => {
this._activeDate(); // Create dependency on active date
const viewMonth = this.viewMonth();
const daysInMonth = this._dateAdapter.getNumDaysInMonth(viewMonth);
const dateNames = this._dateAdapter.getDateNames();
const weeks: CalendarCell[][] = [[]];
for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) {
if (cell == DAYS_PER_WEEK) {
weeks.push([]);
cell = 0;
}
const date = this._dateAdapter.createDate(
this._dateAdapter.getYear(viewMonth),
this._dateAdapter.getMonth(viewMonth),
i + 1,
);
const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel);
weeks[weeks.length - 1].push({
displayName: dateNames[i],
ariaLabel,
date,
selected: this._dateAdapter.compareDate(date, this._activeDate()) === 0,
});
}
return weeks;
});
nextMonth(): void {
this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1));
}
prevMonth(): void {
this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1));
}
selectDate(cell: CalendarCell<D>, event?: Event): void {
if (event) {
event.preventDefault();
event.stopPropagation();
}
const formatted = this._dateAdapter.format(cell.date, this._dateFormats.display.dateInput);
this.selection.set(formatted);
this._activeDate.set(cell.date);
// Synchronously restore focus to the trigger input element before destroying popup to avoid drop
this.comboboxInput()?.nativeElement.focus();
this.popupExpanded.set(false);
}
// Parse and reconcile dynamic input typing to calendar state
onInputInput(value: string): void {
const parsedDate = this._dateAdapter.parse(value, this._dateFormats.display.dateInput);
if (parsedDate && this._dateAdapter.isValid(parsedDate)) {
this._activeDate.set(parsedDate);
this.viewMonth.set(parsedDate);
}
}
// Handle keyboard inputs on the trigger input field.
onInputKeydown(event: KeyboardEvent) {
// Pressing Enter parses the input text and updates the selected date.
if (event.key === 'Enter') {
const value = this.selection();
const parsedDate = this._dateAdapter.parse(value, this._dateFormats.display.dateInput);
if (parsedDate && this._dateAdapter.isValid(parsedDate)) {
this._activeDate.set(parsedDate);
this.viewMonth.set(parsedDate);
this.popupExpanded.set(false);
}
// Pressing ArrowDown shifts focus into the active cell of the calendar grid.
} else if (event.key === 'ArrowDown' && this.popupExpanded()) {
setTimeout(() => {
const tableEl = this.gridTable()?.nativeElement;
if (tableEl) {
const tabbable = tableEl.querySelector('[tabindex="0"]') as HTMLElement;
(tabbable || tableEl).focus();
}
});
}
}
// Safe W3C calendar grid boundaries keys navigation checks
onGridKeydown(event: KeyboardEvent): void {
const arrowUp = event.key === 'ArrowUp';
const arrowDown = event.key === 'ArrowDown';
const arrowLeft = event.key === 'ArrowLeft';
const arrowRight = event.key === 'ArrowRight';
const pageUp = event.key === 'PageUp';
const pageDown = event.key === 'PageDown';
const homeKey = event.key === 'Home';
const endKey = event.key === 'End';
if (
!arrowUp &&
!arrowDown &&
!arrowLeft &&
!arrowRight &&
!pageUp &&
!pageDown &&
!homeKey &&
!endKey
) {
return;
}
// Extract the day number of the currently focused button cell
const targetEl = event.target as HTMLElement;
const dayAttr = targetEl.getAttribute('data-day');
if (!dayAttr) return;
const day = Number(dayAttr);
const year = this._dateAdapter.getYear(this.viewMonth());
const month = this._dateAdapter.getMonth(this.viewMonth());
const viewMonthNumDays = this._dateAdapter.getNumDaysInMonth(this.viewMonth());
// Reconstitute focused cell date Adapter entity
const currentFocusedDate = this._dateAdapter.createDate(year, month, day);
let targetDate: D | null = null;
// W3C APG Standard calendar keyboard rules
switch (event.key) {
case 'ArrowLeft':
// Day 1 boundary crossing: jump to the last day of the previous month
if (day === 1) {
targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, -1);
}
break;
case 'ArrowRight':
// Last day boundary crossing: jump to the first day of the next month
if (day === viewMonthNumDays) {
targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, 1);
}
break;
case 'ArrowUp':
// First week boundary crossing: jump back 7 days to the previous month
if (day <= 7) {
targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, -7);
}
break;
case 'ArrowDown':
// Last week boundary crossing: jump forward 7 days to the next month
if (day > viewMonthNumDays - 7) {
targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, 7);
}
break;
case 'PageUp':
// Shift back 12 months on Control-PageUp, otherwise shift back 1 month
targetDate = this._dateAdapter.addCalendarMonths(
currentFocusedDate,
event.ctrlKey ? -12 : -1,
);
break;
case 'PageDown':
// Shift forward 12 months on Control-PageDown, otherwise shift forward 1 month
targetDate = this._dateAdapter.addCalendarMonths(
currentFocusedDate,
event.ctrlKey ? 12 : 1,
);
break;
case 'Home':
// Jump to the 1st of the current month
targetDate = this._dateAdapter.createDate(year, month, 1);
break;
case 'End':
// Jump to the last day of the current month
targetDate = this._dateAdapter.createDate(year, month, viewMonthNumDays);
break;
}
if (targetDate) {
// Mute downstream event listeners inside the grid parent to prevent roving races
event.preventDefault();
event.stopImmediatePropagation();
this.navigateToDate(targetDate);
}
}
navigateToDate(targetDate: D): void {
const currentMonth = this._dateAdapter.getMonth(this.viewMonth());
const currentYear = this._dateAdapter.getYear(this.viewMonth());
const targetMonth = this._dateAdapter.getMonth(targetDate);
const targetYear = this._dateAdapter.getYear(targetDate);
const monthShift = currentMonth !== targetMonth || currentYear !== targetYear;
if (monthShift) {
// 1. Focus stable table container to stop focus drop to body (prevent overlay crash)
this.gridTable()?.nativeElement.focus();
// 2. Reset active grid state synchronously to avoid focus hijacking (Solution B)
const gridBehavior = this.grid()?._pattern.gridBehavior;
if (gridBehavior) {
gridBehavior.focusBehavior.activeCell.set(undefined);
gridBehavior.focusBehavior.activeCoords.set({row: -1, col: -1});
}
// 3. Set target state so the reactive effect knows what to grab post-render
this.focusTargetDate.set(targetDate);
// 4. Perform reactive month view transition
this.viewMonth.set(targetDate);
} else {
// Same month traversal: just set the target and the constructor effect will fire immediately
this.focusTargetDate.set(targetDate);
}
}
handleWidgetKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
this.comboboxInput()?.nativeElement.focus();
this.popupExpanded.set(false);
event.preventDefault();
event.stopPropagation();
}
}
}
app.html
<!--
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
-->
<div>
<div class="example-combobox-container no-active-outline">
<div #origin class="example-combobox-input-container">
<span class="material-symbols-outlined example-icon example-search-icon">calendar_month</span>
<!-- Synchronize typing changes reactively with onInputInput -->
<input
#comboboxInput
ngCombobox
#combobox="ngCombobox"
class="example-combobox-input"
placeholder="Pick a date..."
[(value)]="selection"
(input)="onInputInput(comboboxInput.value)"
[(expanded)]="popupExpanded"
aria-describedby="date-format-hint"
(keydown)="onInputKeydown($event)"
(click)="popupExpanded.set(true)"
/>
</div>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: false}"
[cdkConnectedOverlayOpen]="popupExpanded()"
(overlayOutsideClick)="popupExpanded.set(false)"
>
<ng-template ngComboboxPopup [combobox]="combobox" popupType="dialog">
<div class="example-popover">
<!-- Silence CDK focus yanking to protect keyboard typing with [cdkTrapFocusAutoCapture]="false" -->
<div
ngComboboxWidget
class="example-datepicker-popup"
cdkTrapFocus
[cdkTrapFocusAutoCapture]="false"
(keydown)="handleWidgetKeydown($event)"
>
<!-- Visually hidden live region for screen reader announcements -->
<div aria-live="polite" class="cdk-visually-hidden">
{{ activeMonthAnnouncement() }}
</div>
<div class="example-datepicker-header">
<button
type="button"
class="example-datepicker-nav-button"
(click)="prevMonth()"
aria-label="Previous Month"
>
<span class="material-symbols-outlined">chevron_left</span>
</button>
<div class="example-datepicker-title">{{ monthYearLabel() }}</div>
<button
type="button"
class="example-datepicker-nav-button"
(click)="nextMonth()"
aria-label="Next Month"
>
<span class="material-symbols-outlined">chevron_right</span>
</button>
</div>
<!-- Bind Arrow boundary checks to grid table -->
<table
#gridTable
tabindex="-1"
ngGrid
#grid="ngGrid"
class="example-datepicker-grid"
colWrap="continuous"
rowWrap="nowrap"
[enableSelection]="true"
selectionMode="explicit"
(keydown)="onGridKeydown($event)"
>
<thead>
<tr>
<!-- Dynamic weekday loop to prevent localization first-day-of-week shifting bug -->
@for (day of weekdays(); track day.long) {
<th
role="columnheader"
scope="col"
class="example-datepicker-cell example-datepicker-weekday"
[attr.abbr]="day.long"
>
{{ day.narrow }}
</th>
}
</tr>
</thead>
<tbody>
@for (week of weeks(); track $index) {
<tr ngGridRow>
@if ($first) {
@for (day of daysFromPrevMonth(); track $index) {
<td
class="example-datepicker-cell example-datepicker-empty"
ngGridCell
[disabled]="true"
aria-hidden="true"
[tabindex]="-1"
>
{{ day }}
</td>
}
}
@for (day of week; track $index) {
<td class="example-datepicker-cell" ngGridCell [selected]="day.selected">
<!-- Duplicate selected state inside day button to allow clear NVDA/VoiceOver announcements -->
<button
ngGridCellWidget
type="button"
class="example-datepicker-day-button"
[attr.data-day]="day.displayName"
[attr.data-focus-target]="isFocusTarget(day.date)"
[attr.aria-label]="day.ariaLabel + (day.selected ? ', Selected' : '')"
(click)="selectDate(day, $event)"
(keydown.enter)="selectDate(day, $event)"
(keydown.space)="selectDate(day, $event)"
>
{{ day.displayName }}
</button>
</td>
}
@if ($last && week.length < 7) {
@for (day of daysInNextMonth(); track $index) {
<td
class="example-datepicker-cell example-datepicker-empty"
ngGridCell
[disabled]="true"
aria-hidden="true"
[tabindex]="-1"
>
{{ day }}
</td>
}
}
</tr>
}
</tbody>
</table>
</div>
</div>
</ng-template>
</ng-template>
</div>
<div id="date-format-hint" class="example-combobox-hint">Format: MM/DD/YYYY</div>
</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 {
display: flex;
justify-content: center;
font-family: 'Press Start 2P', Courier, monospace;
font-size: 0.6rem;
--border-color: var(--tertiary-contrast, #000000);
--retro-button-color: var(--septenary-contrast, #ffffff);
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #ffffff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000000);
--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(--border-color),
0px 4px 0px 0px var(--border-color), -4px 0px 0px 0px var(--border-color),
0px -4px 0px 0px var(--border-color);
--retro-flat-shadow:
4px 0px 0px 0px var(--border-color), 0px 4px 0px 0px var(--border-color),
-4px 0px 0px 0px var(--border-color), 0px -4px 0px 0px var(--border-color);
}
/* Input Trigger Styles */
.example-combobox-container {
position: relative;
width: 16rem;
display: flex;
flex-direction: column;
border: 4px solid var(--border-color);
border-radius: 0;
background-color: var(--retro-button-color);
box-shadow: var(--retro-flat-shadow);
}
.example-combobox-input-container {
display: flex;
position: relative;
align-items: center;
}
.example-icon {
width: 24px;
height: 24px;
font-size: 24px;
color: var(--border-color);
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
display: grid;
place-items: center;
pointer-events: none;
}
.example-search-icon {
padding: 0 0.5rem;
position: absolute;
opacity: 0.8;
}
.example-combobox-input {
width: 100%;
border: none;
outline: none;
font-size: 0.6rem;
font-family: 'Press Start 2P', Courier, monospace;
word-spacing: -4px;
padding: 0.75rem 1rem 0.75rem 2.5rem;
background-color: transparent;
color: var(--primary-contrast, #000000);
}
.example-combobox-container:focus-within {
outline: 3px solid var(--vivid-pink, #ff007f);
}
/* Overlay Bounding Popover & Dividers */
.example-popover {
margin: 0;
padding: 0;
border: 4px solid var(--border-color);
background-color: var(--septenary-contrast, #ffffff);
box-shadow: 8px 8px 0px var(--border-color); /* 3D offset shadow */
}
.example-datepicker-popup {
padding: 12px;
width: 320px;
max-height: none;
overflow: visible;
background-color: transparent;
border: none;
box-shadow: none; /* Border & shadow handled by popover */
font-family: 'Press Start 2P', Courier, monospace;
}
.example-datepicker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 8px;
border-bottom: 3px double var(--border-color);
margin-bottom: 12px;
}
.example-datepicker-title {
font-weight: bold;
font-size: 0.6rem;
color: var(--border-color);
}
.example-datepicker-nav-button {
background-color: var(--retro-button-color);
border: 2px solid var(--border-color);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--border-color);
font-weight: bold;
box-shadow: 2px 2px 0px var(--border-color);
}
.example-datepicker-nav-button:active {
transform: translate(2px, 2px);
box-shadow: none;
}
.example-datepicker-nav-button:focus {
outline: 2px dashed var(--vivid-pink, #ff007f);
outline-offset: 2px;
}
.example-datepicker-grid {
width: 100%;
border-collapse: collapse;
}
.example-datepicker-cell {
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
padding: 0;
}
.example-datepicker-weekday {
font-size: 0.5rem;
font-weight: bold;
color: var(--border-color);
padding-bottom: 8px;
}
.example-datepicker-empty {
color: var(--senary-contrast, #aaaaaa);
font-size: 0.5rem;
}
.example-datepicker-day-button {
width: 32px;
height: 32px;
border: 2px solid transparent;
border-radius: 0;
background-color: transparent;
cursor: pointer;
font-size: 0.6rem;
font-weight: bold;
color: var(--border-color);
}
.example-datepicker-cell:hover .example-datepicker-day-button {
background-color: var(--senary-contrast, #dddddd);
border-color: var(--border-color);
}
/* Suppress native focus rings and force solid high contrast outlines */
.example-datepicker-cell:focus-within {
outline: 3px solid var(--border-color);
outline-offset: -3px;
}
@media (forced-colors: active) {
.example-datepicker-cell:focus-within {
outline: 3px solid CanvasText;
}
}
.example-datepicker-day-button:focus {
outline: none;
}
thead {
background-image: var(
--orange-to-pink-vertical-gradient,
linear-gradient(to bottom, #ffaa00, #ff007f)
);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.example-datepicker-cell[aria-selected='true'] .example-datepicker-day-button {
background-color: var(--vivid-pink, #ff007f);
color: var(--page-background, #ffffff);
}
.example-combobox-hint {
font-size: 0.5rem;
font-weight: bold;
font-family: 'Press Start 2P', Courier, monospace;
color: var(--border-color);
margin-top: 6px;
}
Dialog Açılır Penceresi
Dialog açılır pencereleri, combobox tetikleyicisini standart dialog yerleşimleri ve odak yakalayıcılarla (CDK'nın cdkTrapFocus'u gibi) birleştirir. Katman modal davranış veya arka plan etkileşimi gerektirdiğinde dialog açılır pencerelerini kullanın.
app.ts
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
Component,
computed,
signal,
viewChild,
untracked,
ElementRef,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
readonly listbox = viewChild<Listbox<string>>(Listbox);
readonly combobox = viewChild(Combobox);
readonly searchInput = viewChild<ElementRef<HTMLInputElement>>('searchInput');
readonly value = signal('');
readonly searchString = signal('');
readonly selectedCountries = signal<string[]>([]);
readonly popupExpanded = signal(false);
readonly options = computed(() =>
ALL_COUNTRIES.filter((country) =>
country.toLowerCase().startsWith(this.searchString().toLowerCase()),
),
);
constructor() {
afterRenderEffect(() => {
if (this.popupExpanded()) {
untracked(() => {
setTimeout(() => {
this.searchInput()?.nativeElement.focus();
});
});
}
});
afterRenderEffect(() => {
if (this.popupExpanded()) {
this.listbox()?.scrollActiveItemIntoView();
}
});
}
onCommit() {
const selected = this.selectedCountries();
if (selected.length > 0) {
this.value.set(selected[0]);
this.searchString.set('');
this.popupExpanded.set(false);
this.combobox()?.element.focus();
}
}
onSearchEscape(event: Event) {
this.popupExpanded.set(false);
this.combobox()?.element.focus();
}
}
const ALL_COUNTRIES = [
'Afghanistan',
'Albania',
'Algeria',
'Andorra',
'Angola',
'Argentina',
'Armenia',
'Australia',
'Austria',
'Azerbaijan',
'Brazil',
'Canada',
'Egypt',
'France',
'Germany',
'India',
'Japan',
'Mexico',
'United Kingdom',
'United States of America',
];
app.html
<!--
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
-->
<div
class="example-combobox-container"
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
>
<div #origin class="example-combobox-input-container">
<input
class="example-combobox-input example-dialog-input"
placeholder="Select a country..."
[value]="value()"
[readonly]="true"
[tabindex]="-1"
/>
<span class="material-symbols-outlined example-icon example-arrow-icon" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
[cdkConnectedOverlayDisableClose]="true"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popover">
<div class="example-dialog" ngComboboxWidget>
<div class="example-combobox-container">
<!-- Inner Combobox (Editable Search Trigger) -->
<div class="example-combobox-input-container">
<span
class="material-symbols-outlined example-icon example-search-icon"
aria-hidden="true"
>search</span
>
<input
ngCombobox
#innerCombobox="ngCombobox"
#searchInput
class="example-combobox-input"
placeholder="Search..."
[(ngModel)]="searchString"
[alwaysExpanded]="true"
(keydown.escape)="onSearchEscape($event)"
/>
</div>
<div aria-live="polite" class="cdk-visually-hidden">
{{ options().length === 0 ? 'No results found for ' + searchString() : '' }}
</div>
<!-- Connected Popup wrapping the options listbox -->
<ng-template ngComboboxPopup [combobox]="innerCombobox">
<div class="example-popup example-popup-no-margin">
@if (options().length === 0) {
<div class="example-no-results">No results found</div>
}
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
class="example-listbox"
focusMode="activedescendant"
tabindex="-1"
selectionMode="explicit"
[(value)]="selectedCountries"
(click)="onCommit()"
(keydown.enter)="onCommit()"
[activeDescendant]="listbox.activeDescendant()"
[class.example-empty]="options().length === 0"
>
@for (option of options(); track option) {
<div
class="example-option example-selectable example-stateful"
ngOption
[value]="option"
[label]="option"
>
<span>{{ option }}</span>
<span
aria-hidden="true"
class="material-symbols-outlined example-icon example-selected-icon"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</div>
</div>
</div>
</ng-template>
</ng-template>
</div>
app.css
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font, system-ui, sans-serif);
--border-color: color-mix(in srgb, var(--full-contrast, #000) 20%, var(--page-background, #fff));
}
.example-combobox-container {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.example-combobox-container:has(.example-dialog-input) {
width: 15rem;
border-radius: 0.25rem;
background-color: var(--page-background, #ffffff);
border: 1px solid var(--quinary-contrast, #e0e0e0);
color: var(--primary-contrast, #1a1a1a);
}
.example-combobox-input-container {
display: flex;
position: relative;
align-items: center;
border-radius: 0.25rem;
}
.example-combobox-input {
border-radius: 0.25rem;
width: 100%;
border: none;
outline: none;
font-size: 1rem;
padding: 0.7rem 1rem 0.7rem 2.5rem;
background-color: var(--septenary-contrast, #f5f5f5);
color: var(--primary-contrast, #1a1a1a);
}
.example-combobox-input::-webkit-search-cancel-button,
.example-combobox-input::-webkit-search-decoration {
-webkit-appearance: none;
appearance: none;
}
.example-combobox-input.example-dialog-input {
cursor: pointer;
text-align: left;
padding: 0 3.5rem 0 1rem;
height: 2.5rem;
background-color: transparent;
color: inherit;
font-weight: 500;
border: none;
outline: none;
}
.example-combobox-container:focus-within {
border-color: var(--hot-pink);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--hot-pink) 25%, transparent);
}
.example-icon {
width: 24px;
height: 24px;
font-size: 24px;
color: var(--primary-contrast, #1a1a1a);
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
display: grid;
place-items: center;
pointer-events: none;
}
.example-search-icon {
padding: 0 0.5rem;
position: absolute;
opacity: 0.8;
}
.example-arrow-icon {
padding: 0 0.5rem;
position: absolute;
right: 0;
opacity: 0.8;
transition: transform 0.2s ease;
}
.example-combobox-input[aria-expanded='true'] + .example-arrow-icon {
transform: rotate(180deg);
}
.example-popover {
margin: 0;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
background-color: var(--septenary-contrast, #f5f5f5);
overflow: hidden;
}
.example-popup {
width: 100%;
margin-block-start: 0;
border: none;
border-radius: 0;
background-color: transparent;
max-height: 15rem;
height: auto;
}
.example-popup.example-popup-no-margin {
margin-block-start: 0;
}
.example-listbox {
display: flex;
flex-direction: column;
overflow: auto;
max-height: 13rem;
height: auto;
box-sizing: border-box;
padding: 0.5rem;
gap: 4px;
outline: none;
}
.example-option {
cursor: pointer;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
min-height: 2.25rem;
box-sizing: border-box;
display: flex;
overflow: hidden;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
color: var(--primary-contrast, #1a1a1a);
}
.example-selected-icon {
visibility: hidden;
font-size: 0.9rem;
}
.example-option[aria-selected='true'] .example-selected-icon {
visibility: visible;
}
.example-option[aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option:hover {
background-color: color-mix(in srgb, var(--primary-contrast, #1a1a1a) 5%, transparent);
}
.example-combobox-container:not(.no-active-outline):focus-within
[data-active='true']:not(.no-active-outline),
.example-option[data-active='true'] {
outline-offset: -2px;
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
@media (forced-colors: active) {
.example-combobox-container:not(.no-active-outline):focus-within
[data-active='true']:not(.no-active-outline),
.example-option[data-active='true'] {
outline: 2px solid CanvasText;
}
}
.example-dialog {
position: relative;
padding: 0;
background-color: var(--septenary-contrast, #f5f5f5);
width: 15rem;
box-sizing: border-box;
}
.example-dialog .example-combobox-container {
border: none;
border-radius: inherit;
box-shadow: none;
background-color: transparent;
}
.example-dialog .example-combobox-input-container {
border-bottom: 1px solid var(--border-color);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.example-no-results {
padding: 1rem;
}
.example-empty {
position: absolute;
visibility: hidden;
pointer-events: none;
height: 0;
width: 0;
overflow: hidden;
}
.example-dialog-input[disabled],
.example-dialog-input[aria-disabled='true'] {
opacity: 0.6;
cursor: not-allowed;
}
app.ts
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
Component,
computed,
signal,
viewChild,
untracked,
ElementRef,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-root:not([theme="basic-material"])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
readonly listbox = viewChild<Listbox<string>>(Listbox);
readonly combobox = viewChild(Combobox);
readonly searchInput = viewChild<ElementRef<HTMLInputElement>>('searchInput');
readonly value = signal('');
readonly searchString = signal('');
readonly selectedCountries = signal<string[]>([]);
readonly popupExpanded = signal(false);
readonly options = computed(() =>
ALL_COUNTRIES.filter((country) =>
country.toLowerCase().startsWith(this.searchString().toLowerCase()),
),
);
constructor() {
afterRenderEffect(() => {
if (this.popupExpanded()) {
untracked(() => {
setTimeout(() => {
this.searchInput()?.nativeElement.focus();
});
});
}
});
afterRenderEffect(() => {
if (this.popupExpanded()) {
this.listbox()?.scrollActiveItemIntoView();
}
});
}
onCommit() {
const selected = this.selectedCountries();
if (selected.length > 0) {
this.value.set(selected[0]);
this.searchString.set('');
this.popupExpanded.set(false);
this.combobox()?.element.focus();
}
}
onSearchEscape(event: Event) {
this.popupExpanded.set(false);
this.combobox()?.element.focus();
}
}
const ALL_COUNTRIES = [
'Afghanistan',
'Albania',
'Algeria',
'Andorra',
'Angola',
'Argentina',
'Armenia',
'Australia',
'Austria',
'Azerbaijan',
'Brazil',
'Canada',
'Egypt',
'France',
'Germany',
'India',
'Japan',
'Mexico',
'United Kingdom',
'United States of America',
];
app.html
<!--
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
-->
<div
class="example-combobox-container"
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
>
<div #origin class="example-combobox-input-container">
<input
class="example-combobox-input example-dialog-input"
placeholder="Select a country..."
[value]="value()"
[readonly]="true"
[tabindex]="-1"
/>
<span class="material-symbols-outlined example-icon example-arrow-icon" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
[cdkConnectedOverlayDisableClose]="true"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popover">
<div class="example-dialog" ngComboboxWidget>
<div class="example-combobox-container">
<!-- Inner Combobox (Editable Search Trigger) -->
<div class="example-combobox-input-container">
<span
class="material-symbols-outlined example-icon example-search-icon"
aria-hidden="true"
>search</span
>
<input
ngCombobox
#innerCombobox="ngCombobox"
#searchInput
class="example-combobox-input"
placeholder="Search..."
[(ngModel)]="searchString"
[alwaysExpanded]="true"
(keydown.escape)="onSearchEscape($event)"
/>
</div>
<div aria-live="polite" class="cdk-visually-hidden">
{{ options().length === 0 ? 'No results found for ' + searchString() : '' }}
</div>
<!-- Connected Popup wrapping the options listbox -->
<ng-template ngComboboxPopup [combobox]="innerCombobox">
<div class="example-popup example-popup-no-margin">
@if (options().length === 0) {
<div class="example-no-results">No results found</div>
}
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
class="example-listbox"
focusMode="activedescendant"
tabindex="-1"
selectionMode="explicit"
[(value)]="selectedCountries"
(click)="onCommit()"
(keydown.enter)="onCommit()"
[activeDescendant]="listbox.activeDescendant()"
[class.example-empty]="options().length === 0"
>
@for (option of options(); track option) {
<div
class="example-option example-selectable example-stateful"
ngOption
[value]="option"
[label]="option"
>
<span>{{ option }}</span>
<span
aria-hidden="true"
class="material-symbols-outlined example-icon example-selected-icon"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</div>
</div>
</div>
</ng-template>
</ng-template>
</div>
app.css
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font, system-ui, sans-serif);
--border-color: color-mix(in srgb, var(--full-contrast, #000) 20%, var(--page-background, #fff));
}
.example-combobox-container {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.example-combobox-container:has(.example-dialog-input) {
width: 15rem;
border-radius: 3rem;
background-color: var(--page-background, #ffffff);
border: 1px solid var(--quinary-contrast, #e0e0e0);
color: var(--primary-contrast, #1a1a1a);
}
.example-combobox-input-container {
display: flex;
position: relative;
align-items: center;
border-radius: 0.25rem;
}
.example-combobox-input {
border-radius: 0.25rem;
width: 100%;
border: none;
outline: none;
font-size: 1rem;
padding: 0.7rem 1rem 0.7rem 2.5rem;
background-color: var(--septenary-contrast, #f5f5f5);
color: var(--primary-contrast, #1a1a1a);
}
.example-combobox-input::-webkit-search-cancel-button,
.example-combobox-input::-webkit-search-decoration {
-webkit-appearance: none;
appearance: none;
}
.example-combobox-input.example-dialog-input {
cursor: pointer;
text-align: left;
padding: 0 3.5rem 0 1.5rem;
height: 3rem;
background-color: transparent;
color: inherit;
font-weight: 500;
border: none;
outline: none;
}
.example-combobox-container:focus-within {
border-color: var(--hot-pink);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--hot-pink) 25%, transparent);
}
.example-icon {
width: 24px;
height: 24px;
font-size: 24px;
color: var(--primary-contrast, #1a1a1a);
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
display: grid;
place-items: center;
pointer-events: none;
}
.example-search-icon {
padding: 0 0.5rem;
position: absolute;
opacity: 0.8;
}
.example-arrow-icon {
padding: 0 0.5rem;
position: absolute;
right: 1rem;
opacity: 0.9;
color: var(--primary-contrast, #1a1a1a);
transition: transform 0.2s ease;
}
.example-combobox-input[aria-expanded='true'] + .example-arrow-icon {
transform: rotate(180deg);
}
.example-popover {
margin: 0;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 2rem;
background-color: var(--septenary-contrast, #f5f5f5);
overflow: hidden;
}
.example-popup {
width: 100%;
margin-block-start: 0;
border: none;
border-radius: 0;
background-color: transparent;
max-height: 15rem;
height: auto;
}
.example-popup.example-popup-no-margin {
margin-block-start: 0;
}
.example-listbox {
display: flex;
flex-direction: column;
overflow: auto;
max-height: 13rem;
height: auto;
box-sizing: border-box;
padding: 0.5rem;
gap: 4px;
outline: none;
}
.example-option {
cursor: pointer;
padding: 0 1.25rem;
border-radius: 3rem;
min-height: 3rem;
box-sizing: border-box;
display: flex;
overflow: hidden;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
color: var(--primary-contrast, #1a1a1a);
}
.example-selected-icon {
visibility: hidden;
font-size: 0.9rem;
}
.example-option[aria-selected='true'] .example-selected-icon {
visibility: visible;
}
.example-option[aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 10%, transparent);
}
.example-option:hover {
background-color: color-mix(in srgb, var(--primary-contrast, #1a1a1a) 5%, transparent);
}
.example-combobox-container:not(.no-active-outline):focus-within
[data-active='true']:not(.no-active-outline),
.example-option[data-active='true'] {
outline-offset: -2px;
outline: 2px solid var(--hot-pink);
}
@media (forced-colors: active) {
.example-combobox-container:not(.no-active-outline):focus-within
[data-active='true']:not(.no-active-outline),
.example-option[data-active='true'] {
outline: 2px solid CanvasText;
}
}
.example-dialog {
position: relative;
padding: 0;
background-color: transparent;
width: 15rem;
box-sizing: border-box;
}
.example-dialog .example-combobox-container {
border: none;
border-radius: inherit;
box-shadow: none;
background-color: transparent;
padding: 10px;
}
.example-dialog .example-combobox-input-container {
border-bottom: 1px solid var(--border-color);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.example-no-results {
padding: 1rem;
}
.example-empty {
position: absolute;
visibility: hidden;
pointer-events: none;
height: 0;
width: 0;
overflow: hidden;
}
.example-dialog-input[disabled],
.example-dialog-input[aria-disabled='true'] {
opacity: 0.6;
cursor: not-allowed;
}
app.ts
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
Component,
computed,
signal,
viewChild,
untracked,
ElementRef,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-root:not([theme="basic-retro"])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
readonly listbox = viewChild<Listbox<string>>(Listbox);
readonly combobox = viewChild(Combobox);
readonly searchInput = viewChild<ElementRef<HTMLInputElement>>('searchInput');
readonly value = signal('');
readonly searchString = signal('');
readonly selectedCountries = signal<string[]>([]);
readonly popupExpanded = signal(false);
readonly options = computed(() =>
ALL_COUNTRIES.filter((country) =>
country.toLowerCase().startsWith(this.searchString().toLowerCase()),
),
);
constructor() {
afterRenderEffect(() => {
if (this.popupExpanded()) {
untracked(() => {
setTimeout(() => {
this.searchInput()?.nativeElement.focus();
});
});
}
});
afterRenderEffect(() => {
if (this.popupExpanded()) {
this.listbox()?.scrollActiveItemIntoView();
}
});
}
onCommit() {
const selected = this.selectedCountries();
if (selected.length > 0) {
this.value.set(selected[0]);
this.searchString.set('');
this.popupExpanded.set(false);
this.combobox()?.element.focus();
}
}
onSearchEscape(event: Event) {
this.popupExpanded.set(false);
this.combobox()?.element.focus();
}
}
const ALL_COUNTRIES = [
'Afghanistan',
'Albania',
'Algeria',
'Andorra',
'Angola',
'Argentina',
'Armenia',
'Australia',
'Austria',
'Azerbaijan',
'Brazil',
'Canada',
'Egypt',
'France',
'Germany',
'India',
'Japan',
'Mexico',
'United Kingdom',
'United States of America',
];
app.html
<!--
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
-->
<div
class="example-combobox-container"
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
>
<div #origin class="example-combobox-input-container">
<input
class="example-combobox-input example-dialog-input"
placeholder="Select a country..."
[value]="value()"
[readonly]="true"
[tabindex]="-1"
/>
<span class="material-symbols-outlined example-icon example-arrow-icon" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
[cdkConnectedOverlayDisableClose]="true"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popover">
<div class="example-dialog" ngComboboxWidget>
<div class="example-combobox-container">
<!-- Inner Combobox (Editable Search Trigger) -->
<div class="example-combobox-input-container">
<span
class="material-symbols-outlined example-icon example-search-icon"
aria-hidden="true"
>search</span
>
<input
ngCombobox
#innerCombobox="ngCombobox"
#searchInput
class="example-combobox-input"
placeholder="Search..."
[(ngModel)]="searchString"
[alwaysExpanded]="true"
(keydown.escape)="onSearchEscape($event)"
/>
</div>
<div aria-live="polite" class="cdk-visually-hidden">
{{ options().length === 0 ? 'No results found for ' + searchString() : '' }}
</div>
<!-- Connected Popup wrapping the options listbox -->
<ng-template ngComboboxPopup [combobox]="innerCombobox">
<div class="example-popup example-popup-no-margin">
@if (options().length === 0) {
<div class="example-no-results">No results found</div>
}
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
class="example-listbox"
focusMode="activedescendant"
tabindex="-1"
selectionMode="explicit"
[(value)]="selectedCountries"
(click)="onCommit()"
(keydown.enter)="onCommit()"
[activeDescendant]="listbox.activeDescendant()"
[class.example-empty]="options().length === 0"
>
@for (option of options(); track option) {
<div
class="example-option example-selectable example-stateful"
ngOption
[value]="option"
[label]="option"
>
<span>{{ option }}</span>
<span
aria-hidden="true"
class="material-symbols-outlined example-icon example-selected-icon"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</div>
</div>
</div>
</ng-template>
</ng-template>
</div>
app.css
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-family: 'Press Start 2P', Courier, monospace;
font-size: 0.6rem;
--border-color: var(--tertiary-contrast, #000000);
--retro-button-color: var(--septenary-contrast, #ffffff);
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #ffffff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000000);
--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(--border-color),
0px 4px 0px 0px var(--border-color), -4px 0px 0px 0px var(--border-color),
0px -4px 0px 0px var(--border-color);
--retro-flat-shadow:
4px 0px 0px 0px var(--border-color), 0px 4px 0px 0px var(--border-color),
-4px 0px 0px 0px var(--border-color), 0px -4px 0px 0px var(--border-color);
}
.example-combobox-container {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
border: 4px solid var(--border-color);
border-radius: 0;
background-color: var(--retro-button-color);
}
.example-combobox-container:has(.example-dialog-input) {
width: 16rem;
box-shadow: var(--retro-flat-shadow);
}
.example-combobox-input-container {
display: flex;
position: relative;
align-items: center;
}
.example-combobox-input {
width: 100%;
border: none;
outline: none;
font-size: 0.6rem;
font-family: 'Press Start 2P', Courier, monospace;
word-spacing: -4px;
padding: 0.75rem 1rem 0.75rem 2.5rem;
background-color: transparent;
color: var(--primary-contrast, #000000);
}
.example-combobox-input::-webkit-search-cancel-button,
.example-combobox-input::-webkit-search-decoration {
-webkit-appearance: none;
appearance: none;
}
.example-combobox-input.example-dialog-input {
cursor: pointer;
text-align: left;
padding: 0.75rem 1rem;
}
.example-combobox-container:focus-within {
outline: 3px solid var(--vivid-pink);
}
@media (forced-colors: active) {
.example-combobox-container:focus-within {
outline: 3px solid CanvasText;
}
}
.example-icon {
width: 24px;
height: 24px;
font-size: 20px;
color: var(--primary-contrast, #000000);
display: grid;
place-items: center;
pointer-events: none;
}
.example-search-icon {
padding: 0 0.5rem;
position: absolute;
opacity: 0.8;
}
.example-arrow-icon {
padding: 0 0.5rem;
position: absolute;
right: 0;
opacity: 0.8;
}
.example-popover {
margin: 0;
padding: 0;
border: 4px solid var(--border-color);
background-color: var(--septenary-contrast, #f5f5f5);
box-shadow: 8px 8px 0px var(--border-color); /* 3D offset shadow */
}
.example-popup {
width: 100%;
background-color: transparent;
max-height: 15rem;
height: auto;
}
.example-popup.example-popup-no-margin {
margin-block-start: 0;
}
.example-listbox {
display: flex;
flex-direction: column;
overflow: auto;
max-height: 13rem;
height: auto;
box-sizing: border-box;
padding: 0.5rem;
gap: 4px;
outline: none;
}
.example-option {
cursor: pointer;
padding: 0.3rem 1rem;
display: flex;
overflow: hidden;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
color: var(--primary-contrast, #000000);
font-size: 0.6rem;
border: 2px solid transparent;
}
.example-selected-icon {
visibility: hidden;
font-size: 0.9rem;
}
.example-option[aria-selected='true'] .example-selected-icon {
visibility: visible;
}
.example-option[aria-selected='true'] {
color: var(--page-background, #ffffff);
background-color: var(--vivid-pink);
}
.example-option:hover {
background-color: color-mix(in srgb, var(--primary-contrast, #1a1a1a) 10%, transparent);
border-color: var(--border-color);
}
.example-option[data-active='true'] {
outline: 3px dashed var(--vivid-pink);
outline-offset: -3px;
}
@media (forced-colors: active) {
.example-option[data-active='true'] {
outline: 3px solid CanvasText;
}
}
.example-dialog {
position: relative;
padding: 0;
background-color: transparent;
width: 16rem;
box-sizing: border-box;
}
.example-dialog .example-combobox-input-container {
border-bottom: 3px double var(--border-color);
}
.example-no-results {
padding: 1rem;
font-weight: bold;
}
.example-empty {
position: absolute;
visibility: hidden;
pointer-events: none;
height: 0;
width: 0;
overflow: hidden;
}
.example-dialog-input[disabled],
.example-dialog-input[aria-disabled='true'] {
opacity: 0.6;
cursor: not-allowed;
}
Test Etme
Angular Aria, combobox bileşenlerini test etmek için bir ComboboxHarness sağlar.
İşte harness'ı bir bileşen testinde nasıl kullanacağınıza dair bir örnek:
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 {MyComboboxComponent} from './my-combobox'; // Bileşeniniz
describe('MyComboboxComponent', () => {
let fixture: ComponentFixture<MyComboboxComponent>;
let loader: HarnessLoader;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [MyComboboxComponent],
});
fixture = TestBed.createComponent(MyComboboxComponent);
await fixture.whenStable();
loader = TestbedHarnessEnvironment.loader(fixture);
});
it('should allow opening and closing the popup', async () => {
const combobox = await loader.getHarness(ComboboxHarness);
// Başlangıç durumunu doğrula
expect(await combobox.isOpen()).toBe(false);
// Açılır pencereyi aç
await combobox.open();
expect(await combobox.isOpen()).toBe(true);
// Açılır pencereyi kapat
await combobox.close();
expect(await combobox.isOpen()).toBe(false);
});
});
API'ler
Combobox Yönergesi
Etkileşimli bir tetikleyici eleman (metin girişi, buton veya div gibi) ile bir açılır pencere konteynerini koordine eder.
Girdiler / Model
| Property | Type | Default | Description |
|---|---|---|---|
value |
ModelSignal<string> |
'' |
Combobox'ın iki yönlü bağlanabilir metin değeri |
expanded |
ModelSignal<boolean> |
false |
Açılır pencerenin iki yönlü bağlanabilir açık/kapalı durumu |
disabled |
boolean |
false |
Combobox tetikleyici elemanını devre dışı bırakır |
softDisabled |
boolean |
true |
Elemanı klavyeyle odaklanabilir tutarken etkileşimi devre dışı bırakır |
readonly |
boolean |
false |
Combobox'ı salt okunur yapar. Elemanı klavyeyle odaklanabilir tutarken kullanıcının değeri değiştirmesini engeller |
alwaysExpanded |
boolean |
false |
Açılır pencereyi her zaman açık kalmaya zorlar |
inlineSuggestion |
string | undefined |
- | Girişin sonunda vurgulanacak satır içi bir öneri ayarlar |
tabIndex |
number | undefined |
- | Combobox elemanının tabindex'i (tabindex'e takma ad) |
Tüm klavye olayları, odak koordinasyonu ve ARIA durum nitelikleri (role="combobox", aria-autocomplete ve aria-expanded dahil) ana eleman üzerinde otomatik olarak yönetilir.
ComboboxPopup Yönergesi
Bir <ng-template>'i combobox için açılır pencere konteyneri olarak işaretler.
Girdiler
| Property | Type | Default | Description |
|---|---|---|---|
combobox |
Combobox |
(Zorunlu) | Ana Combobox yönergesine referans |
popupType |
'listbox' | 'tree' | 'grid' | 'dialog' |
'listbox' |
Açılır pencerenin yerleşim/rol profilini belirtir |
ComboboxWidget Yönergesi
Açılır pencere içeriğini (listbox veya grid gibi) ana combobox tetikleyicisiyle bağlar.
Girdiler
| Property | Type | Description |
|---|---|---|
activeDescendant |
string | undefined |
Şu anda aktif olan seçeneğin ID'si (widget'taki aktif seçenek ID'sine bağlı) |
İlgili Kalıplar ve Yönergeler
Combobox, bu belgelendirilmiş kalıplar için temel yönergedir:
- Autocomplete - Filtreleme ve öneriler kalıbı (giriş yazımını seçenekler listesiyle koordine eder)
- Select - Tek seçim açılır menü kalıbı (düzenlenemeyen buton tetikleyicilerine doğrudan uygulanır)
- Multiselect - Çoklu seçim kalıbı (çoklu etkin Listbox ile düzenlenemeyen tetikleyicilere uygulanır)
Combobox tipik olarak şunlarla birleştirilir: