docs: aria combobox examples

This commit is contained in:
Wagner Maciel
2025-11-18 16:11:16 -05:00
committed by Jessica Janiuk
parent 695f4df912
commit 114c83ed8b
10 changed files with 1543 additions and 37 deletions
@@ -1,9 +1,172 @@
h1 {
background: #1e1e1e;
color: #e0e0e0;
padding: 2rem;
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
font-size: 2rem;
text-align: center;
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
--border-color: color-mix(in srgb, var(--full-contrast) 20%, var(--page-background));
}
[ngCombobox] {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
}
[ngCombobox]:has([readonly='true']) {
width: 15rem;
}
.combobox-input-container {
display: flex;
position: relative;
align-items: center;
border-radius: 0.25rem;
}
[ngComboboxInput] {
border-radius: 0.25rem;
}
[ngComboboxInput][readonly='true'] {
cursor: pointer;
padding: 0.7rem 1rem;
}
[ngCombobox]:focus-within [ngComboboxInput] {
outline: 1.5px solid var(--vivid-pink);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--vivid-pink) 25%, transparent);
}
.icon {
width: 24px;
height: 24px;
font-size: 20px;
display: grid;
place-items: center;
pointer-events: none;
}
.search-icon {
padding: 0 0.5rem;
position: absolute;
opacity: 0.8;
}
.arrow-icon {
padding: 0 0.5rem;
position: absolute;
right: 0;
opacity: 0.8;
transition: transform 0.2s ease;
}
[ngComboboxInput][aria-expanded='true'] + .arrow-icon {
transform: rotate(180deg);
}
[ngComboboxInput] {
width: 100%;
border: none;
outline: none;
font-size: 1rem;
padding: 0.7rem 1rem 0.7rem 2.5rem;
background-color: var(--mat-sys-surface);
}
.popover {
margin: 0;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
background-color: var(--mat-sys-surface);
}
[ngListbox] {
gap: 2px;
max-height: 200px;
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 var(--vivid-pink);
}
[ngOption][aria-selected='true'] {
color: var(--vivid-pink);
background-color: color-mix(in srgb, var(--vivid-pink) 5%, transparent);
}
[ngOption]:not([aria-selected='true']) .check-icon {
display: none;
}
.option-label {
flex: 1;
}
.check-icon {
font-size: 0.9rem;
}
.dialog {
position: absolute;
left: auto;
right: auto;
top: auto;
bottom: auto;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
}
.dialog .combobox-input-container {
border-radius: 0;
}
.dialog [ngCombobox],
.dialog .combobox-input-container {
border: none;
}
.dialog [ngComboboxInput] {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.dialog [ngCombobox]:focus-within [ngComboboxInput] {
outline: none;
box-shadow: none;
}
.dialog .combobox-input-container {
border-bottom: 1px solid var(--border-color);
}
.dialog::backdrop {
opacity: 0;
}
.no-results {
padding: 1rem;
}
@@ -1 +1,37 @@
<h1>PLACEHOLDER</h1>
<div ngCombobox #combobox="ngCombobox" [readonly]="true">
<div class="combobox-input-container">
<input ngComboboxInput placeholder="Select a country..." [value]="value()" />
<span class="material-symbols-outlined icon arrow-icon">arrow_drop_down</span>
</div>
<ng-template ngComboboxPopupContainer>
<dialog ngComboboxDialog class="dialog">
<div ngCombobox #combobox="ngCombobox" filterMode="manual" [alwaysExpanded]="true">
<div class="combobox-input-container">
<span class="material-symbols-outlined icon search-icon">search</span>
<input
ngComboboxInput
class="combobox-input"
placeholder="Search..."
[(value)]="searchString"
/>
</div>
<ng-template ngComboboxPopupContainer>
@if (options().length === 0) {
<div class="no-results">No results found</div>
}
<div ngListbox [(values)]="selectedCountries">
@for (option of options(); track option) {
<div ngOption [value]="option" [label]="option">
<span class="option-label">{{option}}</span>
<span class="material-symbols-outlined icon check-icon">check</span>
</div>
}
</div>
</ng-template>
</div>
</dialog>
</ng-template>
</div>
@@ -1,8 +1,274 @@
import {Component} from '@angular/core';
import {
Combobox,
ComboboxDialog,
ComboboxInput,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {afterRenderEffect, Component, computed, signal, untracked, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
ComboboxDialog,
Combobox,
ComboboxInput,
ComboboxPopupContainer,
Listbox,
Option,
FormsModule,
],
})
export class App {}
export class App {
dialog = viewChild(ComboboxDialog);
listbox = viewChild<Listbox<string>>(Listbox);
combobox = viewChild<Combobox<string>>(Combobox);
value = signal('');
searchString = signal('');
options = computed(() =>
ALL_COUNTRIES.filter((country) =>
country.toLowerCase().startsWith(this.searchString().toLowerCase()),
),
);
selectedCountries = signal<string[]>([]);
constructor() {
afterRenderEffect(() => {
if (this.dialog() && this.combobox()?.expanded()) {
untracked(() => this.listbox()?.gotoFirst());
this.positionDialog();
}
});
afterRenderEffect(() => {
if (this.selectedCountries().length > 0) {
untracked(() => this.dialog()?.close());
this.value.set(this.selectedCountries()[0]);
this.searchString.set('');
}
});
afterRenderEffect(() => this.listbox()?.scrollActiveItemIntoView());
}
// TODO(wagnermaciel): Switch to using the CDK for positioning.
positionDialog() {
const dialog = this.dialog()!;
const combobox = this.combobox()!;
const comboboxRect = combobox.inputElement()?.getBoundingClientRect();
const scrollY = window.scrollY;
if (comboboxRect) {
dialog.element.style.width = `${comboboxRect.width}px`;
dialog.element.style.top = `${comboboxRect.bottom + scrollY + 4}px`;
dialog.element.style.left = `${comboboxRect.left - 1}px`;
}
}
}
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',
'Turkey',
'Turkmenistan',
'Tuvalu',
'Uganda',
'Ukraine',
'United Arab Emirates',
'United Kingdom',
'United States of America',
'Uruguay',
'Uzbekistan',
'Vanuatu',
'Venezuela',
'Vietnam',
'Yemen',
'Zambia',
'Zimbabwe',
];
@@ -0,0 +1,161 @@
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
--border-color: color-mix(in srgb, var(--full-contrast) 20%, var(--page-background));
}
[ngCombobox] {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: 1rem;
}
[ngCombobox]:has([readonly='true']) {
width: 15rem;
}
.combobox-input-container {
display: flex;
position: relative;
align-items: center;
border-radius: 1rem;
}
[ngComboboxInput] {
border-radius: 1rem;
}
[ngComboboxInput][readonly='true'] {
cursor: pointer;
padding: 0.7rem 1rem;
}
[ngCombobox]:focus-within [ngComboboxInput] {
outline: 1.5px solid var(--vivid-pink);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--vivid-pink) 25%, transparent);
}
.icon {
width: 24px;
height: 24px;
font-size: 20px;
display: grid;
place-items: center;
pointer-events: none;
}
.search-icon {
padding: 0 0.5rem;
position: absolute;
opacity: 0.8;
}
.arrow-icon {
padding: 0 0.5rem;
position: absolute;
right: 0;
opacity: 0.8;
transition: transform 0.2s ease;
}
[ngComboboxInput][aria-expanded='true'] + .arrow-icon {
transform: rotate(180deg);
}
[ngComboboxInput] {
width: 100%;
border: none;
outline: none;
font-size: 1rem;
padding: 0.7rem 1rem 0.7rem 2.5rem;
background-color: var(--mat-sys-surface);
}
[ngListbox] {
gap: 2px;
max-height: 10rem;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
margin: 1px;
padding: 1rem;
min-height: 1rem;
border-radius: 1rem;
}
[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);
}
[ngOption]:not([aria-selected='true']) .check-icon {
display: none;
}
.option-label {
flex: 1;
}
.check-icon {
font-size: 0.9rem;
}
.dialog {
padding: none;
position: absolute;
left: auto;
right: auto;
top: auto;
bottom: auto;
border: 1px solid var(--border-color);
border-radius: 1rem;
}
.dialog .combobox-input-container {
border-radius: 0;
}
.dialog [ngCombobox],
.dialog .combobox-input-container {
border: none;
}
.dialog [ngComboboxInput] {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.dialog [ngCombobox]:focus-within [ngComboboxInput] {
outline: none;
box-shadow: none;
}
.dialog::backdrop {
opacity: 0;
}
.no-results {
padding: 1rem;
}
@@ -0,0 +1,37 @@
<div ngCombobox #combobox="ngCombobox" [readonly]="true" class="material-combobox">
<div class="combobox-input-container">
<input ngComboboxInput placeholder="Select a country..." [value]="value()" />
<span class="material-symbols-outlined icon arrow-icon">arrow_drop_down</span>
</div>
<ng-template ngComboboxPopupContainer>
<dialog ngComboboxDialog class="dialog">
<div ngCombobox #combobox="ngCombobox" filterMode="manual" [alwaysExpanded]="true">
<div class="combobox-input-container">
<span class="material-symbols-outlined icon search-icon">search</span>
<input
ngComboboxInput
class="combobox-input"
placeholder="Search..."
[(value)]="searchString"
/>
</div>
<ng-template ngComboboxPopupContainer>
@if (options().length === 0) {
<div class="no-results">No results found</div>
}
<div ngListbox [(values)]="selectedCountries">
@for (option of options(); track option) {
<div ngOption [value]="option" [label]="option">
<span class="option-label">{{option}}</span>
<span class="material-symbols-outlined icon check-icon">check</span>
</div>
}
</div>
</ng-template>
</div>
</dialog>
</ng-template>
</div>
@@ -0,0 +1,274 @@
import {
Combobox,
ComboboxDialog,
ComboboxInput,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {afterRenderEffect, Component, computed, signal, untracked, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
ComboboxDialog,
Combobox,
ComboboxInput,
ComboboxPopupContainer,
Listbox,
Option,
FormsModule,
],
})
export class App {
dialog = viewChild(ComboboxDialog);
listbox = viewChild<Listbox<string>>(Listbox);
combobox = viewChild<Combobox<string>>(Combobox);
value = signal('');
searchString = signal('');
options = computed(() =>
ALL_COUNTRIES.filter((country) =>
country.toLowerCase().startsWith(this.searchString().toLowerCase()),
),
);
selectedCountries = signal<string[]>([]);
constructor() {
afterRenderEffect(() => {
if (this.dialog() && this.combobox()?.expanded()) {
untracked(() => this.listbox()?.gotoFirst());
this.positionDialog();
}
});
afterRenderEffect(() => {
if (this.selectedCountries().length > 0) {
untracked(() => this.dialog()?.close());
this.value.set(this.selectedCountries()[0]);
this.searchString.set('');
}
});
afterRenderEffect(() => this.listbox()?.scrollActiveItemIntoView());
}
// TODO(wagnermaciel): Switch to using the CDK for positioning.
positionDialog() {
const dialog = this.dialog()!;
const combobox = this.combobox()!;
const comboboxRect = combobox.inputElement()?.getBoundingClientRect();
const scrollY = window.scrollY;
if (comboboxRect) {
dialog.element.style.width = `${comboboxRect.width}px`;
dialog.element.style.top = `${comboboxRect.bottom + scrollY + 4}px`;
dialog.element.style.left = `${comboboxRect.left - 1}px`;
}
}
}
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',
'Turkey',
'Turkmenistan',
'Tuvalu',
'Uganda',
'Ukraine',
'United Arab Emirates',
'United Kingdom',
'United States of America',
'Uruguay',
'Uzbekistan',
'Vanuatu',
'Venezuela',
'Vietnam',
'Yemen',
'Zambia',
'Zimbabwe',
];
@@ -0,0 +1,183 @@
@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(--gray-700),
0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700),
0px -4px 0px 0px var(--gray-700);
--retro-flat-shadow:
4px 0px 0px 0px var(--gray-700), 0px 4px 0px 0px var(--gray-700),
-4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700);
--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(--gray-700),
0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700),
0px -4px 0px 0px var(--gray-700), 0px 0px 0px 0px var(--gray-700);
}
[ngComboboxInput] {
width: 15rem;
font-size: 0.6rem;
border-radius: 0;
font-family: 'Press Start 2P';
word-spacing: -5px;
padding: 0.75rem 0.5rem 0.75rem 2.5rem;
color: #000;
border: none;
}
[ngComboboxInput]::placeholder {
color: #000;
opacity: 0.7;
}
[ngCombobox]:has([readonly='true']) {
width: 15rem;
box-shadow: var(--retro-flat-shadow);
background-color: var(--retro-button-color);
}
.combobox-input-container {
display: flex;
position: relative;
align-items: center;
}
[ngComboboxInput][readonly='true'] {
cursor: pointer;
padding: 0.7rem 1rem;
}
[ngCombobox]:focus-within [ngComboboxInput] {
outline: 1.5px solid var(--vivid-pink);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--vivid-pink) 25%, transparent);
}
.icon {
width: 24px;
height: 24px;
font-size: 20px;
display: grid;
place-items: center;
pointer-events: none;
}
.search-icon {
padding: 0 0.5rem;
position: absolute;
opacity: 0.8;
}
.arrow-icon {
padding: 0 0.5rem;
position: absolute;
right: 0;
opacity: 0.8;
transition: transform 0.2s ease;
}
[ngComboboxInput][aria-expanded='true'] + .arrow-icon {
transform: rotate(180deg);
}
[ngListbox] {
display: flex;
flex-direction: column;
overflow: auto;
max-height: 10rem;
padding: 0.5rem;
gap: 4px;
font-size: 0.9rem;
}
[ngOption] {
cursor: pointer;
padding: 0.3rem 1rem;
display: flex;
overflow: hidden;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
font-size: 0.6rem;
}
.checkbox-blank-icon,
[ngOption][aria-selected='true'] .checkbox-filled-icon {
display: flex;
align-items: center;
}
.checkbox-filled-icon,
[ngOption][aria-selected='true'] .checkbox-blank-icon {
display: none;
}
.checkbox-blank-icon {
opacity: 0.6;
}
.selected-icon {
visibility: hidden;
}
[ngOption][aria-selected='true'] .selected-icon {
visibility: visible;
}
[ngOption][aria-selected='true'] {
color: var(--vivid-pink);
background-color: color-mix(in srgb, var(--vivid-pink) 10%, transparent);
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent);
}
[ngCombobox]:focus-within [data-active='true'] {
outline: 2px solid color-mix(in srgb, var(--vivid-pink) 80%, transparent);
}
.dialog {
margin-top: 8px;
position: absolute;
left: auto;
right: auto;
top: auto;
bottom: auto;
padding: 0;
border: none;
box-shadow: var(--retro-flat-shadow);
}
.dialog .combobox-input-container {
border-radius: 0;
}
.dialog [ngCombobox],
.dialog .combobox-input-container {
border: none;
}
.dialog [ngComboboxInput] {
border-bottom: 4px solid #000;
}
.dialog [ngCombobox]:focus-within [ngComboboxInput] {
outline: none;
box-shadow: none;
}
.dialog::backdrop {
opacity: 0;
}
@@ -0,0 +1,33 @@
<div ngCombobox #combobox="ngCombobox" [readonly]="true" class="retro-combobox">
<div class="combobox-input-container">
<input ngComboboxInput placeholder="Select a country..." [value]="value()" />
<span class="material-symbols-outlined icon arrow-icon">arrow_drop_down</span>
</div>
<ng-template ngComboboxPopupContainer>
<dialog ngComboboxDialog class="dialog">
<div ngCombobox #combobox="ngCombobox" filterMode="manual" [alwaysExpanded]="true">
<div class="combobox-input-container">
<span class="material-symbols-outlined icon search-icon">search</span>
<input
ngComboboxInput
class="combobox-input"
placeholder="Search..."
[(value)]="searchString"
/>
</div>
<ng-template ngComboboxPopupContainer>
<div ngListbox [(values)]="selectedCountries">
@for (option of options(); track option) {
<div ngOption [value]="option" [label]="option">
<span>{{option}}</span>
<span class="material-symbols-outlined icon selected-icon">check</span>
</div>
}
</div>
</ng-template>
</div>
</dialog>
</ng-template>
</div>
@@ -0,0 +1,274 @@
import {
Combobox,
ComboboxDialog,
ComboboxInput,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {afterRenderEffect, Component, computed, signal, untracked, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
ComboboxDialog,
Combobox,
ComboboxInput,
ComboboxPopupContainer,
Listbox,
Option,
FormsModule,
],
})
export class App {
dialog = viewChild(ComboboxDialog);
listbox = viewChild<Listbox<string>>(Listbox);
combobox = viewChild<Combobox<string>>(Combobox);
value = signal('');
searchString = signal('');
options = computed(() =>
ALL_COUNTRIES.filter((country) =>
country.toLowerCase().startsWith(this.searchString().toLowerCase()),
),
);
selectedCountries = signal<string[]>([]);
constructor() {
afterRenderEffect(() => {
if (this.dialog() && this.combobox()?.expanded()) {
untracked(() => this.listbox()?.gotoFirst());
this.positionDialog();
}
});
afterRenderEffect(() => {
if (this.selectedCountries().length > 0) {
untracked(() => this.dialog()?.close());
this.value.set(this.selectedCountries()[0]);
this.searchString.set('');
}
});
afterRenderEffect(() => this.listbox()?.scrollActiveItemIntoView());
}
// TODO(wagnermaciel): Switch to using the CDK for positioning.
positionDialog() {
const dialog = this.dialog()!;
const combobox = this.combobox()!;
const comboboxRect = combobox.inputElement()?.getBoundingClientRect();
const scrollY = window.scrollY;
if (comboboxRect) {
dialog.element.style.width = `${comboboxRect.width}px`;
dialog.element.style.top = `${comboboxRect.bottom + scrollY + 4}px`;
dialog.element.style.left = `${comboboxRect.left - 1}px`;
}
}
}
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',
'Turkey',
'Turkmenistan',
'Tuvalu',
'Uganda',
'Ukraine',
'United Arab Emirates',
'United Kingdom',
'United States of America',
'Uruguay',
'Uzbekistan',
'Vanuatu',
'Venezuela',
'Vietnam',
'Yemen',
'Zambia',
'Zimbabwe',
];
+105 -26
View File
@@ -5,11 +5,31 @@
A directive that coordinates a text input with a popup, providing the primitive directive for autocomplete, select, and multiselect patterns.
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/combobox/src/basic/app/app.ts">
<docs-code header="app.ts" path="adev/src/content/examples/aria/combobox/src/basic/app/app.ts"/>
<docs-code header="app.html" path="adev/src/content/examples/aria/combobox/src/basic/app/app.html"/>
<docs-code header="app.css" path="adev/src/content/examples/aria/combobox/src/basic/app/app.css"/>
</docs-code-multifile>
<docs-tab-group>
<docs-tab label="Basic">
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.ts">
<docs-code header="app.component.ts" path="adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.ts"/>
<docs-code header="app.component.html" path="adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.html"/>
<docs-code header="app.component.css" path="adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.css"/>
</docs-code-multifile>
</docs-tab>
<docs-tab label="Material">
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/autocomplete/src/basic/material/app/app.component.ts">
<docs-code header="app.component.ts" path="adev/src/content/examples/aria/autocomplete/src/basic/material/app/app.component.ts"/>
<docs-code header="app.component.html" path="adev/src/content/examples/aria/autocomplete/src/basic/material/app/app.component.html"/>
<docs-code header="app.component.css" path="adev/src/content/examples/aria/autocomplete/src/basic/material/app/app.component.css"/>
</docs-code-multifile>
</docs-tab>
<docs-tab label="Retro">
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/autocomplete/src/basic/retro/app/app.component.ts">
<docs-code header="app.component.ts" path="adev/src/content/examples/aria/autocomplete/src/basic/retro/app/app.component.ts"/>
<docs-code header="app.component.html" path="adev/src/content/examples/aria/autocomplete/src/basic/retro/app/app.component.html"/>
<docs-code header="app.component.css" path="adev/src/content/examples/aria/autocomplete/src/basic/retro/app/app.component.css"/>
</docs-code-multifile>
</docs-tab>
</docs-tab-group>
## Usage
@@ -41,30 +61,68 @@ Angular's combobox provides a fully accessible input-popup coordination system w
## Examples
### Manual filter mode
### Autocomplete
Applications need control over filtering and selection behavior. Manual mode provides full control where users explicitly select options and the application manages filtering logic.
An accessible input field that filters and suggests options as users type, helping them find and select values from a list.
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/combobox/src/basic/app/app.ts">
<docs-code header="app.ts" path="adev/src/content/examples/aria/combobox/src/basic/app/app.ts" visibleLines="[18,26,30,40]"/>
<docs-code header="app.html" path="adev/src/content/examples/aria/combobox/src/basic/app/app.html" visibleLines="[1,6,10,15]"/>
</docs-code-multifile>
<docs-tab-group>
<docs-tab label="Basic">
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.ts">
<docs-code header="app.component.ts" path="adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.ts"/>
<docs-code header="app.component.html" path="adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.html"/>
<docs-code header="app.component.css" path="adev/src/content/examples/aria/autocomplete/src/basic/app/app.component.css"/>
</docs-code-multifile>
</docs-tab>
<docs-tab label="Material">
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/autocomplete/src/basic/material/app/app.component.ts">
<docs-code header="app.component.ts" path="adev/src/content/examples/aria/autocomplete/src/basic/material/app/app.component.ts"/>
<docs-code header="app.component.html" path="adev/src/content/examples/aria/autocomplete/src/basic/material/app/app.component.html"/>
<docs-code header="app.component.css" path="adev/src/content/examples/aria/autocomplete/src/basic/material/app/app.component.css"/>
</docs-code-multifile>
</docs-tab>
<docs-tab label="Retro">
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/autocomplete/src/basic/retro/app/app.component.ts">
<docs-code header="app.component.ts" path="adev/src/content/examples/aria/autocomplete/src/basic/retro/app/app.component.ts"/>
<docs-code header="app.component.html" path="adev/src/content/examples/aria/autocomplete/src/basic/retro/app/app.component.html"/>
<docs-code header="app.component.css" path="adev/src/content/examples/aria/autocomplete/src/basic/retro/app/app.component.css"/>
</docs-code-multifile>
</docs-tab>
</docs-tab-group>
The `filterMode="manual"` setting gives complete control over filtering and selection. The input updates a signal that filters the options list. Users navigate with arrow keys and select with Enter or click. This mode provides the most flexibility for custom filtering logic. See the [Autocomplete guide](guide/aria/autocomplete) for complete filtering patterns and examples.
### Auto-select mode
Forms sometimes benefit from automatically selecting the first matching option as users type. Auto-select mode updates the input value to match the first option, providing faster interaction.
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/combobox/src/auto-select/app/app.ts">
<docs-code header="app.ts" path="adev/src/content/examples/aria/combobox/src/auto-select/app/app.ts" visibleLines="[18,26,30,40]"/>
<docs-code header="app.html" path="adev/src/content/examples/aria/combobox/src/auto-select/app/app.html" visibleLines="[1,6]"/>
</docs-code-multifile>
The `filterMode="auto-select"` setting automatically updates the input value to the first matching option. The `firstMatch` input provides coordination between filtering logic and selection. This mode works well when the first match is typically what users want. See the [Autocomplete guide](guide/aria/autocomplete#auto-select-mode) for detailed examples.
### Readonly mode
A pattern that combines a readonly combobox with listbox to create single-selection dropdowns with keyboard navigation and screen reader support.
<docs-tab-group>
<docs-tab label="Basic">
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/select/src/icons/app/app.ts">
<docs-code header="app.ts" path="adev/src/content/examples/aria/select/src/icons/app/app.ts"/>
<docs-code header="app.html" path="adev/src/content/examples/aria/select/src/icons/app/app.html"/>
<docs-code header="app.css" path="adev/src/content/examples/aria/select/src/icons/app/app.css"/>
</docs-code-multifile>
</docs-tab>
<docs-tab label="Material">
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/select/src/icons/material/app/app.ts">
<docs-code header="app.ts" path="adev/src/content/examples/aria/select/src/icons/material/app/app.ts"/>
<docs-code header="app.html" path="adev/src/content/examples/aria/select/src/icons/material/app/app.html"/>
<docs-code header="app.css" path="adev/src/content/examples/aria/select/src/icons/material/app/app.css"/>
</docs-code-multifile>
</docs-tab>
<docs-tab label="Retro">
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/select/src/icons/retro/app/app.ts">
<docs-code header="app.ts" path="adev/src/content/examples/aria/select/src/icons/retro/app/app.ts"/>
<docs-code header="app.html" path="adev/src/content/examples/aria/select/src/icons/retro/app/app.html"/>
<docs-code header="app.css" path="adev/src/content/examples/aria/select/src/icons/retro/app/app.css"/>
</docs-code-multifile>
</docs-tab>
</docs-tab-group>
The `readonly` attribute prevents typing in the input field. The popup opens on click or arrow keys. Users navigate options with keyboard and select with Enter or click.
This configuration provides the foundation for the [Select](guide/aria/select) and [Multiselect](guide/aria/multiselect) patterns. See those guides for complete dropdown implementations with triggers and overlay positioning.
@@ -73,10 +131,31 @@ This configuration provides the foundation for the [Select](guide/aria/select) a
Popups sometimes need modal behavior with a backdrop and focus trap. The combobox dialog directive provides this pattern for specialized use cases.
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/combobox/src/dialog/app/app.ts">
<docs-code header="app.ts" path="adev/src/content/examples/aria/combobox/src/dialog/app/app.ts" visibleLines="[18,26]"/>
<docs-code header="app.html" path="adev/src/content/examples/aria/combobox/src/dialog/app/app.html" visibleLines="[1,5]"/>
</docs-code-multifile>
<docs-tab-group>
<docs-tab label="Basic">
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/combobox/src/dialog/app/app.ts">
<docs-code header="app.ts" path="adev/src/content/examples/aria/combobox/src/dialog/app/app.ts"/>
<docs-code header="app.html" path="adev/src/content/examples/aria/combobox/src/dialog/app/app.html"/>
<docs-code header="app.css" path="adev/src/content/examples/aria/combobox/src/dialog/app/app.css"/>
</docs-code-multifile>
</docs-tab>
<docs-tab label="Material">
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/combobox/src/dialog/material/app/app.ts">
<docs-code header="app.ts" path="adev/src/content/examples/aria/combobox/src/dialog/material/app/app.ts"/>
<docs-code header="app.html" path="adev/src/content/examples/aria/combobox/src/dialog/material/app/app.html"/>
<docs-code header="app.css" path="adev/src/content/examples/aria/combobox/src/dialog/material/app/app.css"/>
</docs-code-multifile>
</docs-tab>
<docs-tab label="Retro">
<docs-code-multifile preview hideCode path="adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.ts">
<docs-code header="app.ts" path="adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.ts"/>
<docs-code header="app.html" path="adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.html"/>
<docs-code header="app.css" path="adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.css"/>
</docs-code-multifile>
</docs-tab>
</docs-tab-group>
The `ngComboboxDialog` directive creates a modal popup using the native dialog element. This provides backdrop behavior and focus trapping. Use dialog popups when the selection interface requires modal interaction or when the popup content is complex enough to warrant full-screen focus.