Multiselect
Genel Bakış
Çoklu seçim kalıbı, klavye navigasyonu ve ekran okuyucu desteğine sahip son derece erişilebilir çoklu seçimli açılır menüler oluşturmak için salt okunur bir combobox tetikleyicisini çoklu seçimli bir listbox açılır penceresiyle birleştirir.
app.ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild, effect} from '@angular/core';
@Component({
selector: 'app-root:not([theme="icons-basic"])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
/** The combobox listbox popup. */
readonly listbox = viewChild(Listbox);
/** The options available in the listbox. */
readonly selectedValues = signal<string[]>([]);
/** The icon that is displayed in the combobox. */
readonly displayIcon = computed(() => {
const values = this.selectedValues();
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
readonly displayValue = computed(() => {
const values = this.selectedValues();
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
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'},
];
/** Whether the popup is expanded. */
readonly popupExpanded = signal(false);
constructor() {
// Scrolls to the active item when the active option changes.
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
}
app.html
<div
#combobox="ngCombobox"
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
[multi]="true"
ngComboboxWidget
focusMode="activedescendant"
[tabindex]="-1"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
>
@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 {
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);
/* Modernized additions for div-trigger Select: */
height: 2.5rem;
padding: 0 3.5rem;
cursor: pointer;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
outline: 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;
}
}
.example-popup-container.closing {
animation: smoothPopupClose 150ms ease-in forwards;
}
@keyframes smoothPopupClose {
0% {
max-height: 11rem;
opacity: 1;
}
100% {
max-height: 0;
opacity: 0;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
outline: none;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
margin: 1px;
padding: 0 1rem;
min-height: 2.25rem;
border-radius: 0.5rem;
outline: none;
}
[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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild, effect} from '@angular/core';
@Component({
selector: 'app-root:not([theme="icons-material"])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
/** The combobox listbox popup. */
readonly listbox = viewChild(Listbox);
/** The options available in the listbox. */
readonly selectedValues = signal<string[]>([]);
/** The icon that is displayed in the combobox. */
readonly displayIcon = computed(() => {
const values = this.selectedValues();
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
readonly displayValue = computed(() => {
const values = this.selectedValues();
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
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'},
];
/** Whether the popup is expanded. */
readonly popupExpanded = signal(false);
constructor() {
// Scrolls to the active item when the active option changes.
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
}
app.html
<div
#combobox="ngCombobox"
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
[multi]="true"
ngComboboxWidget
focusMode="activedescendant"
[tabindex]="-1"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
>
@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 {
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);
/* Modernized additions for div-trigger Select: */
height: 3rem;
padding: 0 3.5rem;
cursor: pointer;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
outline: 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;
}
}
.example-popup-container.closing {
animation: smoothPopupClose 150ms ease-in forwards;
}
@keyframes smoothPopupClose {
0% {
max-height: 13rem;
opacity: 1;
}
100% {
max-height: 0;
opacity: 0;
}
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
outline: none;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
min-height: 3rem;
border-radius: 3rem;
outline: none;
}
[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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild, effect} from '@angular/core';
@Component({
selector: 'app-root:not([theme="icons-retro"])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
/** The combobox listbox popup. */
readonly listbox = viewChild(Listbox);
/** The options available in the listbox. */
readonly selectedValues = signal<string[]>([]);
/** The icon that is displayed in the combobox. */
readonly displayIcon = computed(() => {
const values = this.selectedValues();
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
readonly displayValue = computed(() => {
const values = this.selectedValues();
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
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'},
];
/** Whether the popup is expanded. */
readonly popupExpanded = signal(false);
constructor() {
// Scrolls to the active item when the active option changes.
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
}
app.html
<div
#combobox="ngCombobox"
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
[multi]="true"
ngComboboxWidget
focusMode="activedescendant"
[tabindex]="-1"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
/* Modernized additions for div-trigger Select: */
height: 2.5rem;
padding: 0 4.5rem;
cursor: pointer;
box-sizing: border-box;
width: 22rem;
user-select: none;
-webkit-user-select: none;
outline: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover,
.select:focus,
.select:focus-within {
transform: translate(1px, 1px);
}
.select:active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--gray-50));
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select[aria-expanded='false']:focus,
.select[aria-expanded='false']:focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
.example-popup-container.closing {
animation: smoothPopupClose 150ms ease-in forwards;
}
@keyframes smoothPopupClose {
0% {
max-height: 11rem;
opacity: 1;
}
100% {
max-height: 0;
opacity: 0;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
outline: none;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
outline: none;
}
[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;
}
Kullanım
Çoklu seçim kalıbı, kullanıcıların bilinen bir seçenek kümesinden birden fazla ilişkili öğe seçmesi gerektiğinde en iyi çalışır.
Şu durumlarda bu kalıbı kullanmayı düşünün:
- Kullanıcıların birden fazla seçime ihtiyacı var - Birden fazla seçimin geçerli olduğu etiketler, kategoriler, filtreler veya etiketler
- Seçenek listesi sabit (20'den az öğe) - Kullanıcılar arama yapmadan seçenekleri tarayabilir
- İçerik filtreleme - Aynı anda birden fazla kriter aktif olabilir
- Özellik atama - Birden fazla değerin anlamlı olduğu etiketler, izinler veya özellikler
- İlişkili seçimler - Mantıksal olarak birlikte çalışan seçenekler (birden fazla takım üyesi seçmek gibi)
Şu durumlarda bu kalıptan kaçının:
- Yalnızca tek seçim gerekli - Daha basit tek seçimli açılır menüler için Select kalıbını kullanın
- Liste 20'den fazla öğe ve arama gerekli - Çoklu seçim yeteneğine sahip Autocomplete kalıbını kullanın
- Çoğu veya tüm seçenekler seçilecek - Bir kontrol listesi kalıbı daha iyi görünürlük sağlar
- Seçimler bağımsız ikili seçenekler - Bireysel onay kutuları seçimleri daha açık iletir
Özellikler
Çoklu seçim kalıbı, tam erişilebilir bir açılır menü sağlamak için Combobox ve Listbox yönergelerini birleştirir:
- Klavye Navigasyonu - Ok tuşlarıyla seçenekler arasında gezinin, Boşluk ile değiştirin, Escape ile kapatın
- Ekran Okuyucu Desteği - aria-multiselectable dahil yerleşik ARIA nitelikleri
- Seçim Sayısı Görünümü - Birden fazla seçim için kompakt "Öğe + 2 daha" kalıbı gösterir
- Sinyal Tabanlı Reaktivite - Angular sinyalleri kullanan reaktif durum yönetimi
- Akıllı Konumlandırma - CDK Overlay görünüm alanı kenarlarını ve kaydırmayı yönetir
- Kalıcı Seçim - Seçili seçenekler, seçimden sonra onay işaretleriyle görünür kalır
Örnekler
Temel çoklu seçim
Kullanıcıların bir seçenek listesinden birden fazla öğe seçmesi gerekir. Salt okunur combobox, çoklu etkin listbox ile eşleştirildiğinde, tam erişilebilirlik desteği ile tanıdık çoklu seçim işlevi sağlar.
app.ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild, effect} from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
/** The combobox listbox popup. */
readonly listbox = viewChild(Listbox);
/** The options available in the listbox. */
readonly selectedValues = signal<string[]>([]);
/** The string that is displayed in the combobox. */
readonly displayValue = computed(() => {
const values = this.selectedValues();
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
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'},
];
/** Whether the popup is expanded. */
readonly popupExpanded = signal(false);
constructor() {
// Scrolls to the active item when the active option changes.
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
}
app.html
<div
#combobox="ngCombobox"
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
[multi]="true"
ngComboboxWidget
focusMode="activedescendant"
[tabindex]="-1"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
>
@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 {
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);
/* Modernized additions for div-trigger Select: */
height: 2.5rem;
padding: 0 2.5rem;
cursor: pointer;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
outline: 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: 1.5rem;
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;
}
}
.example-popup-container.closing {
animation: smoothPopupClose 150ms ease-in forwards;
}
@keyframes smoothPopupClose {
0% {
max-height: 11rem;
opacity: 1;
}
100% {
max-height: 0;
opacity: 0;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
outline: none;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
margin: 1px;
padding: 0 1rem;
min-height: 2.25rem;
border-radius: 0.5rem;
outline: none;
}
[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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild, effect} from '@angular/core';
@Component({
selector: 'app-root:not([theme="basic-material"])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
/** The combobox listbox popup. */
readonly listbox = viewChild(Listbox);
/** The options available in the listbox. */
readonly selectedValues = signal<string[]>([]);
/** The string that is displayed in the combobox. */
readonly displayValue = computed(() => {
const values = this.selectedValues();
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
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'},
];
/** Whether the popup is expanded. */
readonly popupExpanded = signal(false);
constructor() {
// Scrolls to the active item when the active option changes.
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
}
app.html
<div
#combobox="ngCombobox"
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
[multi]="true"
ngComboboxWidget
focusMode="activedescendant"
[tabindex]="-1"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
>
@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 {
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);
/* Modernized additions for div-trigger Select: */
height: 3rem;
padding: 0 2.5rem;
cursor: pointer;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
outline: 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: 1.5rem;
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;
}
}
.example-popup-container.closing {
animation: smoothPopupClose 150ms ease-in forwards;
}
@keyframes smoothPopupClose {
0% {
max-height: 13rem;
opacity: 1;
}
100% {
max-height: 0;
opacity: 0;
}
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
outline: none;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
min-height: 3rem;
border-radius: 3rem;
outline: none;
}
[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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild, effect} from '@angular/core';
@Component({
selector: 'app-root:not([theme="basic-retro"])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
/** The combobox listbox popup. */
readonly listbox = viewChild(Listbox);
/** The options available in the listbox. */
readonly selectedValues = signal<string[]>([]);
/** The string that is displayed in the combobox. */
readonly displayValue = computed(() => {
const values = this.selectedValues();
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
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'},
];
/** Whether the popup is expanded. */
readonly popupExpanded = signal(false);
constructor() {
// Scrolls to the active item when the active option changes.
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
}
app.html
<div
#combobox="ngCombobox"
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
[multi]="true"
ngComboboxWidget
focusMode="activedescendant"
[tabindex]="-1"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
/* Modernized additions for div-trigger Select: */
height: 2.5rem;
padding: 0 4.5rem;
cursor: pointer;
box-sizing: border-box;
width: 22rem;
user-select: none;
-webkit-user-select: none;
outline: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover,
.select:focus,
.select:focus-within {
transform: translate(1px, 1px);
}
.select:active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--gray-50));
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select[aria-expanded='false']:focus,
.select[aria-expanded='false']:focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
.example-popup-container.closing {
animation: smoothPopupClose 150ms ease-in forwards;
}
@keyframes smoothPopupClose {
0% {
max-height: 11rem;
opacity: 1;
}
100% {
max-height: 0;
opacity: 0;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
outline: none;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
outline: none;
}
[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;
}
ngListbox üzerindeki multi niteliği çoklu seçimi etkinleştirir. Seçenekleri değiştirmek için Boşluk'a basın ve açılır pencere ek seçimler için açık kalır. Gösterim, ilk seçili öğeyi ardından kalan seçimlerin sayısını gösterir.
Özel görünümlü çoklu seçim
Seçeneklerin genellikle kullanıcıların seçimleri tanımasına yardımcı olacak simgeler veya renkler gibi görsel göstergelere ihtiyacı vardır. Seçenekler içindeki özel şablonlar, görünüm değeri kompakt bir özet gösterirken zengin biçimlendirme sağlar.
app.ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild, effect} from '@angular/core';
@Component({
selector: 'app-root:not([theme="icons-basic"])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
/** The combobox listbox popup. */
readonly listbox = viewChild(Listbox);
/** The options available in the listbox. */
readonly selectedValues = signal<string[]>([]);
/** The icon that is displayed in the combobox. */
readonly displayIcon = computed(() => {
const values = this.selectedValues();
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
readonly displayValue = computed(() => {
const values = this.selectedValues();
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
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'},
];
/** Whether the popup is expanded. */
readonly popupExpanded = signal(false);
constructor() {
// Scrolls to the active item when the active option changes.
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
}
app.html
<div
#combobox="ngCombobox"
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
[multi]="true"
ngComboboxWidget
focusMode="activedescendant"
[tabindex]="-1"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
>
@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 {
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);
/* Modernized additions for div-trigger Select: */
height: 2.5rem;
padding: 0 3.5rem;
cursor: pointer;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
outline: 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;
}
}
.example-popup-container.closing {
animation: smoothPopupClose 150ms ease-in forwards;
}
@keyframes smoothPopupClose {
0% {
max-height: 11rem;
opacity: 1;
}
100% {
max-height: 0;
opacity: 0;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
outline: none;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
margin: 1px;
padding: 0 1rem;
min-height: 2.25rem;
border-radius: 0.5rem;
outline: none;
}
[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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild, effect} from '@angular/core';
@Component({
selector: 'app-root:not([theme="icons-material"])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
/** The combobox listbox popup. */
readonly listbox = viewChild(Listbox);
/** The options available in the listbox. */
readonly selectedValues = signal<string[]>([]);
/** The icon that is displayed in the combobox. */
readonly displayIcon = computed(() => {
const values = this.selectedValues();
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
readonly displayValue = computed(() => {
const values = this.selectedValues();
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
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'},
];
/** Whether the popup is expanded. */
readonly popupExpanded = signal(false);
constructor() {
// Scrolls to the active item when the active option changes.
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
}
app.html
<div
#combobox="ngCombobox"
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
[multi]="true"
ngComboboxWidget
focusMode="activedescendant"
[tabindex]="-1"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
>
@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 {
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);
/* Modernized additions for div-trigger Select: */
height: 3rem;
padding: 0 3.5rem;
cursor: pointer;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
outline: 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;
}
}
.example-popup-container.closing {
animation: smoothPopupClose 150ms ease-in forwards;
}
@keyframes smoothPopupClose {
0% {
max-height: 13rem;
opacity: 1;
}
100% {
max-height: 0;
opacity: 0;
}
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
outline: none;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
min-height: 3rem;
border-radius: 3rem;
outline: none;
}
[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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild, effect} from '@angular/core';
@Component({
selector: 'app-root:not([theme="icons-retro"])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
/** The combobox listbox popup. */
readonly listbox = viewChild(Listbox);
/** The options available in the listbox. */
readonly selectedValues = signal<string[]>([]);
/** The icon that is displayed in the combobox. */
readonly displayIcon = computed(() => {
const values = this.selectedValues();
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
readonly displayValue = computed(() => {
const values = this.selectedValues();
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
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'},
];
/** Whether the popup is expanded. */
readonly popupExpanded = signal(false);
constructor() {
// Scrolls to the active item when the active option changes.
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
}
app.html
<div
#combobox="ngCombobox"
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
[multi]="true"
ngComboboxWidget
focusMode="activedescendant"
[tabindex]="-1"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
/* Modernized additions for div-trigger Select: */
height: 2.5rem;
padding: 0 4.5rem;
cursor: pointer;
box-sizing: border-box;
width: 22rem;
user-select: none;
-webkit-user-select: none;
outline: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover,
.select:focus,
.select:focus-within {
transform: translate(1px, 1px);
}
.select:active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--gray-50));
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select[aria-expanded='false']:focus,
.select[aria-expanded='false']:focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
.example-popup-container.closing {
animation: smoothPopupClose 150ms ease-in forwards;
}
@keyframes smoothPopupClose {
0% {
max-height: 11rem;
opacity: 1;
}
100% {
max-height: 0;
opacity: 0;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
outline: none;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
outline: none;
}
[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;
}
Her seçenek etiketinin yanında bir simge gösterir. Görünüm değeri, seçilen seçeneğin simgesini ve metnini, ardından ek seçimlerin sayısını göstermek üzere güncellenir. Seçili seçenekler net görsel geri bildirim için bir onay işareti gösterir.
Kontrollü seçim
Formlar bazen seçim sayısını sınırlamak veya kullanıcı seçimlerini doğrulamak gerektirir. Seçim üzerinde programatik kontrol, erişilebilirliği korurken bu kısıtlamaları sağlar.
app.ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild, effect} from '@angular/core';
@Component({
selector: 'app-root:not([theme="limited-basic"])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
/** The combobox listbox popup. */
readonly listbox = viewChild(Listbox);
/** The options available in the listbox. */
readonly selectedValues = signal<string[]>([]);
/** The string that is displayed in the combobox. */
readonly displayValue = computed(() => {
const values = this.selectedValues();
if (values.length === 0) {
return 'Select 2 labels';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} & ${values[1]}`;
});
/** The labels that are available for selection. */
readonly labels = [
{value: 'Important', disabled: computed(() => this.isOptionDisabled('Important'))},
{value: 'Starred', disabled: computed(() => this.isOptionDisabled('Starred'))},
{value: 'Work', disabled: computed(() => this.isOptionDisabled('Work'))},
{value: 'Personal', disabled: computed(() => this.isOptionDisabled('Personal'))},
{value: 'To Do', disabled: computed(() => this.isOptionDisabled('To Do'))},
{value: 'Later', disabled: computed(() => this.isOptionDisabled('Later'))},
{value: 'Read', disabled: computed(() => this.isOptionDisabled('Read'))},
{value: 'Travel', disabled: computed(() => this.isOptionDisabled('Travel'))},
];
/** Whether the popup is expanded. */
readonly popupExpanded = signal(false);
constructor() {
// Scrolls to the active item when the active option changes.
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
isOptionDisabled(value: string) {
const values = this.selectedValues();
if (!values || values.length < 2) {
return false;
}
return !values.includes(value);
}
}
app.html
<div
#combobox="ngCombobox"
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
[multi]="true"
ngComboboxWidget
focusMode="activedescendant"
[tabindex]="-1"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value" [disabled]="label.disabled()">
<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 {
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);
/* Modernized additions for div-trigger Select: */
height: 2.5rem;
padding: 0 2.5rem;
cursor: pointer;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
outline: 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: 1.5rem;
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;
}
}
.example-popup-container.closing {
animation: smoothPopupClose 150ms ease-in forwards;
}
@keyframes smoothPopupClose {
0% {
max-height: 11rem;
opacity: 1;
}
100% {
max-height: 0;
opacity: 0;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
outline: none;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
margin: 1px;
padding: 0 1rem;
min-height: 2.25rem;
border-radius: 0.5rem;
outline: none;
}
[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;
}
[ngOption][aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
[ngOption][aria-disabled='true']:hover {
background-color: transparent;
}
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
app.ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild, effect} from '@angular/core';
@Component({
selector: 'app-root:not([theme="limited-material"])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
/** The combobox listbox popup. */
readonly listbox = viewChild(Listbox);
/** The options available in the listbox. */
readonly selectedValues = signal<string[]>([]);
/** The string that is displayed in the combobox. */
readonly displayValue = computed(() => {
const values = this.selectedValues();
if (values.length === 0) {
return 'Select 2 labels';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} & ${values[1]}`;
});
/** The labels that are available for selection. */
readonly labels = [
{value: 'Important', disabled: computed(() => this.isOptionDisabled('Important'))},
{value: 'Starred', disabled: computed(() => this.isOptionDisabled('Starred'))},
{value: 'Work', disabled: computed(() => this.isOptionDisabled('Work'))},
{value: 'Personal', disabled: computed(() => this.isOptionDisabled('Personal'))},
{value: 'To Do', disabled: computed(() => this.isOptionDisabled('To Do'))},
{value: 'Later', disabled: computed(() => this.isOptionDisabled('Later'))},
{value: 'Read', disabled: computed(() => this.isOptionDisabled('Read'))},
{value: 'Travel', disabled: computed(() => this.isOptionDisabled('Travel'))},
];
/** Whether the popup is expanded. */
readonly popupExpanded = signal(false);
constructor() {
// Scrolls to the active item when the active option changes.
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
isOptionDisabled(value: string) {
const values = this.selectedValues();
if (!values || values.length < 2) {
return false;
}
return !values.includes(value);
}
}
app.html
<div
#combobox="ngCombobox"
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
[multi]="true"
ngComboboxWidget
focusMode="activedescendant"
[tabindex]="-1"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value" [disabled]="label.disabled()">
<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 {
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);
/* Modernized additions for div-trigger Select: */
height: 3rem;
padding: 0 2.5rem;
cursor: pointer;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
outline: 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: 1.5rem;
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;
}
}
.example-popup-container.closing {
animation: smoothPopupClose 150ms ease-in forwards;
}
@keyframes smoothPopupClose {
0% {
max-height: 13rem;
opacity: 1;
}
100% {
max-height: 0;
opacity: 0;
}
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
outline: none;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
min-height: 3rem;
border-radius: 3rem;
outline: none;
}
[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;
}
[ngOption][aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
[ngOption][aria-disabled='true']:hover {
background-color: transparent;
}
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
app.ts
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild, effect} from '@angular/core';
@Component({
selector: 'app-root:not([theme="limited-retro"])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
/** The combobox listbox popup. */
readonly listbox = viewChild(Listbox);
/** The options available in the listbox. */
readonly selectedValues = signal<string[]>([]);
/** The string that is displayed in the combobox. */
readonly displayValue = computed(() => {
const values = this.selectedValues();
if (values.length === 0) {
return 'Select 2 labels';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} & ${values[1]}`;
});
/** The labels that are available for selection. */
readonly labels = [
{value: 'Important', disabled: computed(() => this.isOptionDisabled('Important'))},
{value: 'Starred', disabled: computed(() => this.isOptionDisabled('Starred'))},
{value: 'Work', disabled: computed(() => this.isOptionDisabled('Work'))},
{value: 'Personal', disabled: computed(() => this.isOptionDisabled('Personal'))},
{value: 'To Do', disabled: computed(() => this.isOptionDisabled('To Do'))},
{value: 'Later', disabled: computed(() => this.isOptionDisabled('Later'))},
{value: 'Read', disabled: computed(() => this.isOptionDisabled('Read'))},
{value: 'Travel', disabled: computed(() => this.isOptionDisabled('Travel'))},
];
/** Whether the popup is expanded. */
readonly popupExpanded = signal(false);
constructor() {
// Scrolls to the active item when the active option changes.
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
isOptionDisabled(value: string) {
const values = this.selectedValues();
if (!values || values.length < 2) {
return false;
}
return !values.includes(value);
}
}
app.html
<div
#combobox="ngCombobox"
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
[multi]="true"
ngComboboxWidget
focusMode="activedescendant"
[tabindex]="-1"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value" [disabled]="label.disabled()">
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
/* Modernized additions for div-trigger Select: */
height: 2.5rem;
padding: 0 4.5rem;
cursor: pointer;
box-sizing: border-box;
width: 22rem;
user-select: none;
-webkit-user-select: none;
outline: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover,
.select:focus,
.select:focus-within {
transform: translate(1px, 1px);
}
.select:active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--gray-50));
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select[aria-expanded='false']:focus,
.select[aria-expanded='false']:focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
.example-popup-container.closing {
animation: smoothPopupClose 150ms ease-in forwards;
}
@keyframes smoothPopupClose {
0% {
max-height: 11rem;
opacity: 1;
}
100% {
max-height: 0;
opacity: 0;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
outline: none;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
outline: none;
}
[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;
}
[ngOption][aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
[ngOption][aria-disabled='true']:hover {
background-color: transparent;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
Bu örnek seçimleri iki öğeyle sınırlar. Sınıra ulaşıldığında, daha fazla seçimi engellemek için seçilmemiş seçenekler devre dışı bırakılır ve combobox gösterimi seçimleri yansıtacak şekilde güncellenir.
Test etme
Çoklu seçim kalıbı, @angular/aria/combobox/testing ve @angular/aria/listbox/testing paketlerindeki ComboboxHarness ve ListboxHarness kombinasyonu kullanılarak test edilebilir.
Harness'leri kullanarak bir çoklu seçim bileşenini nasıl test edeceğinize dair bir örnek aşağıdadır:
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 {MyMultiselectComponent} from './my-multiselect'; // Bileşeniniz
describe('MyMultiselectComponent', () => {
let fixture: ComponentFixture<MyMultiselectComponent>;
let loader: HarnessLoader;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [MyMultiselectComponent],
});
fixture = TestBed.createComponent(MyMultiselectComponent);
await fixture.whenStable();
loader = TestbedHarnessEnvironment.loader(fixture);
});
it('should allow selecting multiple options', async () => {
const select = await loader.getHarness(ComboboxHarness);
// Açılır menüyü aç
await select.open();
// Açılır pencereden listbox harness'ini al
const listbox = await select.getPopupWidget(ListboxHarness);
expect(await listbox.isMulti()).toBe(true);
const options = await listbox.getOptions();
// Birinci ve ikinci seçenekleri seç
await options[0].click();
await options[1].click();
// Her iki seçeneğin de seçili olduğunu doğrula
expect(await options[0].isSelected()).toBe(true);
expect(await options[1].isSelected()).toBe(true);
// Açılır menüyü kapat
await select.close();
// Değerin güncellendiğini doğrula (ör. virgülle ayrılmış liste veya sayı)
expect(await (await select.host()).text()).toContain('Option 1, Option 2');
});
});
API'ler
Çoklu seçim kalıbı, Angular'ın Aria kütüphanesindeki aşağıdaki yönergeleri kullanır. Bağlantılı rehberlerdeki tam API dokümantasyonuna bakın.
Combobox yönergeleri
Çoklu seçim kalıbı, select benzeri bir çoklu seçim açılır menüsü oluşturmak için ngCombobox yönergesini doğrudan tetikleyici öğe (div veya button gibi) üzerinde kullanır.
Girişler
| Property | Type | Default | Description |
|---|---|---|---|
disabled |
boolean |
false |
Tüm çoklu seçimi devre dışı bırakır |
Mevcut tüm girişler ve sinyaller hakkında eksiksiz bilgi için Combobox API dokümantasyonuna bakın.
Açılır pencere yönergeleri
Yapısal ngComboboxPopup yönergesi, katman şablonunu işaretler ve ana combobox'a bir referans gerektirir:
ComboboxWidget yönergesi
ngComboboxWidget yönergesi, aktif soyundan (active-descendant) odak takibini desteklemek için listbox ile combobox tetikleyicisi arasında köprü kurar.
| Property | Type | Description |
|---|---|---|
activeDescendant |
string | undefined |
Tetikleyicideki aria-activedescendant niteliğini güncellemek için o anda aktif olan seçeneğin kimliği (listbox.activeDescendant() ile bağlanır) |
Listbox yönergeleri
Çoklu seçim kalıbı, çoklu seçim için multi niteliği ile ngListbox ve her seçilebilir öğe için ngOption kullanır.
Girişler
| Property | Type | Default | Description |
|---|---|---|---|
multi |
boolean |
false |
Çoklu seçimi etkinleştirmek için true olarak ayarlayın |
selectionMode |
'follow' | 'explicit' |
'follow' |
Seçeneklerin aktif odağı takip etmek yerine tıklama/Boşluk ile açıkça değiştirilmesi için 'explicit' olarak ayarlayın |
focusMode |
'roving' | 'activedescendant' |
'roving' |
Listbox tarafından kullanılan odak stratejisi. Tarayıcı odağının combobox tetikleyicisinde kalması için 'activedescendant' ayarlayın. |
tabIndex |
number |
0 |
Listbox'ın tabindex'i. Aktif soyundan modunda klavye odağının açılır pencere kapsayıcısına girmesini önlemek için -1 olarak ayarlayın. |
Model
| Property | Type | Description |
|---|---|---|
value |
ModelSignal<any[]> |
Seçili değerlerin iki yönlü bağlanabilir dizisi |
multi true olduğunda, kullanıcılar seçimi değiştirmek için Boşluk kullanarak birden fazla seçenek seçebilir. Açılır pencere seçimden sonra açık kalır ve ek seçimlere izin verir.
Listbox yapılandırması, seçim modları ve seçenek özellikleri hakkında eksiksiz bilgi için Listbox API dokümantasyonuna bakın.
Konumlandırma
Çoklu seçim kalıbı, akıllı konumlandırma için CDK Overlay ile entegre olur. Görünüm alanı kenarlarını ve kaydırmayı otomatik olarak yönetmek için cdkConnectedOverlay kullanın.