Vue d'ensemble
Un pattern qui combine une combobox et une listbox pour créer des listes déroulantes à sélection unique, avec navigation au clavier et prise en charge des lecteurs d'écran.
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-pressed-tint: #fbfbfb;
--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);
}
:host-context(.docs-dark-mode) {
--retro-pressed-tint: #151417;
}
.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(--retro-pressed-tint));
}
.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;
}
Utilisation
Le pattern select convient le mieux lorsque les utilisateurs doivent choisir une seule valeur parmi un ensemble d'options familier.
Envisagez ce pattern lorsque :
- La liste d'options est fixe (moins de 20 éléments) - Les utilisateurs peuvent parcourir et choisir sans filtrer
- Les options sont familières - Les utilisateurs reconnaissent les choix sans avoir besoin de chercher
- Les formulaires ont besoin de champs standard - Sélection d'un pays, d'une région, d'une catégorie ou d'un statut
- Paramètres et configuration - Menus déroulants pour des préférences ou des options
- Libellés d'options clairs - Chaque choix porte un nom distinct et facile à parcourir
Évitez ce pattern lorsque :
- La liste compte plus de 20 éléments - Utilisez le pattern Autocomplete pour un meilleur filtrage
- Les utilisateurs doivent rechercher parmi les options - Autocomplete fournit une saisie de texte et un filtrage
- Une sélection multiple est nécessaire - Utilisez plutôt le pattern Multiselect
- Il existe très peu d'options (2-3) - Des boutons radio offrent une meilleure visibilité de tous les choix
Fonctionnalités
Le pattern select combine les directives Combobox et Listbox pour fournir une liste déroulante entièrement accessible avec :
- Navigation au clavier - Parcourez les options avec les touches fléchées, sélectionnez avec Entrée, fermez avec Échap
- Prise en charge des lecteurs d'écran - Attributs ARIA intégrés pour les technologies d'assistance
- Affichage personnalisé - Affichez les valeurs sélectionnées avec des icônes, une mise en forme ou du contenu riche
- Réactivité à base de signals - Gestion d'état réactive avec les signals Angular
- Positionnement intelligent - CDK Overlay gère les bords du viewport et le défilement
- Prise en charge du texte bidirectionnel - Gère automatiquement les langues de droite à gauche (RTL)
Exemples
Select de base
Les utilisateurs ont besoin d'une liste déroulante standard pour choisir parmi une liste de valeurs. Une combobox associée à une listbox offre l'expérience de select habituelle avec une prise en charge complète de l'accessibilité.
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="basic-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 displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly popupExpanded = signal(false);
readonly labels = [
'Important',
'Starred',
'Work',
'Personal',
'To Do',
'Later',
'Read',
'Travel',
];
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-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) {
<div ngOption [value]="label" [label]="label">
<span class="example-option-text">{{ label }}</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 3rem;
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;
}
.select:focus,
.select:focus-within {
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
.combobox-label {
gap: 1rem;
left: 2rem;
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);
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.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="basic-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 displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly popupExpanded = signal(false);
readonly labels = [
'Important',
'Starred',
'Work',
'Personal',
'To Do',
'Later',
'Read',
'Travel',
];
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-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) {
<div ngOption [value]="label" [label]="label">
<span class="example-option-text">{{ label }}</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 3rem;
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;
}
.select:focus,
.select:focus-within {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.combobox-label {
gap: 1rem;
left: 2rem;
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);
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.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="basic-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 displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly popupExpanded = signal(false);
readonly labels = [
'Important',
'Starred',
'Work',
'Personal',
'To Do',
'Later',
'Read',
'Travel',
];
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-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) {
<div ngOption [value]="label" [label]="label">
<span class="example-option-text">{{ label }}</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-pressed-tint: #fbfbfb;
--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);
}
:host-context(.docs-dark-mode) {
--retro-pressed-tint: #151417;
}
.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(--retro-pressed-tint));
}
.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;
}
La saisie de texte est empêchée en appliquant la directive ngCombobox directement sur un host element non interactif (comme un div ou un button) plutôt que sur un <input>. Les utilisateurs interagissent avec la liste déroulante à l'aide des touches fléchées et d'Entrée, exactement comme avec un élément select natif.
Select avec affichage personnalisé
Les options ont souvent besoin d'indicateurs visuels comme des icônes ou des badges pour aider les utilisateurs à identifier rapidement les choix. Des templates personnalisés dans les options permettent une mise en forme riche tout en préservant l'accessibilité.
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-pressed-tint: #fbfbfb;
--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);
}
:host-context(.docs-dark-mode) {
--retro-pressed-tint: #151417;
}
.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(--retro-pressed-tint));
}
.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;
}
Chaque option affiche une icône à côté du libellé. La valeur sélectionnée se met à jour pour montrer l'icône et le texte de l'option choisie, ce qui fournit un retour visuel clair.
Select désactivé
Un select peut être désactivé pour empêcher toute interaction de l'utilisateur lorsque certaines conditions du formulaire ne sont pas remplies. L'état désactivé fournit un retour visuel et empêche l'interaction au clavier.
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="disabled-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 displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly popupExpanded = signal(false);
readonly labels = [
'Important',
'Starred',
'Work',
'Personal',
'To Do',
'Later',
'Read',
'Travel',
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
disabled
>
<span class="combobox-label">
<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) {
<div ngOption [value]="label" [label]="label">
<span class="example-option-text">{{ label }}</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 3rem;
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:not([aria-disabled='true']):hover {
background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.select:focus,
.select:focus-within {
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
.combobox-label {
gap: 1rem;
left: 2rem;
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);
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.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="disabled-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 displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly popupExpanded = signal(false);
readonly labels = [
'Important',
'Starred',
'Work',
'Personal',
'To Do',
'Later',
'Read',
'Travel',
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
disabled
>
<span class="combobox-label">
<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) {
<div ngOption [value]="label" [label]="label">
<span class="example-option-text">{{ label }}</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 3rem;
box-sizing: border-box;
width: 14rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:not([aria-disabled='true']):hover {
background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.select:focus,
.select:focus-within {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.combobox-label {
gap: 1rem;
left: 2rem;
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);
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.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="disabled-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 displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly popupExpanded = signal(false);
readonly labels = [
'Important',
'Starred',
'Work',
'Personal',
'To Do',
'Later',
'Read',
'Travel',
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
disabled
>
<span class="combobox-label">
<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) {
<div ngOption [value]="label" [label]="label">
<span class="example-option-text">{{ label }}</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-pressed-tint: #fbfbfb;
--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);
}
:host-context(.docs-dark-mode) {
--retro-pressed-tint: #151417;
}
.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:not([aria-disabled='true']):hover,
.select:not([aria-disabled='true']):focus,
.select:not([aria-disabled='true']):focus-within {
transform: translate(1px, 1px);
}
.select:not([aria-disabled='true']):active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--retro-pressed-tint));
}
.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;
}
Lorsqu'il est désactivé, le select affiche un état visuel désactivé et bloque toute interaction de l'utilisateur. Les lecteurs d'écran annoncent l'état désactivé aux utilisateurs de technologies d'assistance.
Tests
Le pattern select peut être testé en combinant ComboboxHarness et ListboxHarness, issus de @angular/aria/combobox/testing et @angular/aria/listbox/testing.
Voici un exemple d'utilisation des harnais pour tester un composant select :
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {HarnessLoader} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {ComboboxHarness} from '@angular/aria/combobox/testing';
import {ListboxHarness} from '@angular/aria/listbox/testing';
import {MySelectComponent} from './my-select'; // Your component
describe('MySelectComponent', () => {
let fixture: ComponentFixture<MySelectComponent>;
let loader: HarnessLoader;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [MySelectComponent],
});
fixture = TestBed.createComponent(MySelectComponent);
await fixture.whenStable();
loader = TestbedHarnessEnvironment.loader(fixture);
});
it('should allow selecting an option', async () => {
// Load the combobox harness (which acts as the select trigger)
const select = await loader.getHarness(ComboboxHarness);
// Verify it is closed initially
expect(await select.isOpen()).toBe(false);
// Open the dropdown
await select.open();
expect(await select.isOpen()).toBe(true);
// Get the listbox harness from the popup
const listbox = await select.getPopupWidget(ListboxHarness);
const options = await listbox.getOptions();
expect(options.length).toBe(3);
// Click the second option
await options[1].click();
// Verify the dropdown closed and the value updated
expect(await select.isOpen()).toBe(false);
expect(await (await select.host()).text()).toContain('Option 2');
});
});
Référence de l'API
Pour une documentation détaillée de l'API, consultez les références d'API suivantes :
Positionnement
Le pattern select s'intègre à CDK Overlay pour un positionnement intelligent. Utilisez cdkConnectedOverlay pour gérer automatiquement les bords du viewport et le défilement.