Выбор
Обзор
Шаблон, объединяющий комбобокс со списком для создания раскрывающихся списков с выбором одного значения, поддержкой клавиатурной навигации и экранных дикторов.
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);
}
}
<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>
@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;
}
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);
}
}
<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>
@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;
}
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);
}
}
<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>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
cursor: pointer;
padding: 0 4.5rem;
height: 2.5rem;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover,
.select:focus,
.select:focus-within {
transform: translate(1px, 1px);
}
.select:active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--gray-50));
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select[aria-expanded='false']:focus,
.select[aria-expanded='false']:focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px dashed var(--hot-pink);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
Использование
Шаблон выбора лучше всего подходит, когда пользователям нужно выбрать одно значение из знакомого набора вариантов.
Рассмотрите возможность использования этого шаблона, если:
- Список вариантов фиксирован (менее 20 элементов) — пользователи могут просмотреть его и сделать выбор без фильтрации
- Варианты знакомы — пользователи узнают нужный вариант без поиска
- В формах нужны стандартные поля — выбор страны, штата, категории или статуса
- Настройки и конфигурация — раскрывающиеся меню для параметров или вариантов
- Понятные подписи вариантов — у каждого варианта есть отличающееся и легко просматриваемое название
Не используйте этот шаблон, если:
- В списке больше 20 элементов — используйте шаблон автозаполнения для более эффективной фильтрации
- Пользователям нужно искать варианты — автозаполнение обеспечивает ввод текста и фильтрацию
- Нужно выбрать несколько вариантов — вместо этого используйте шаблон множественного выбора
- Вариантов очень мало (2–3) — переключатели позволяют лучше видеть все варианты
Возможности
Шаблон выбора объединяет директивы Combobox и Listbox, чтобы создать полностью доступный раскрывающийся список со следующими возможностями:
- Клавиатурная навигация — переход между вариантами клавишами со стрелками, выбор клавишей Enter, закрытие клавишей Escape
- Поддержка экранных дикторов — встроенные атрибуты ARIA для вспомогательных технологий
- Настраиваемое отображение — показ выбранных значений с иконками, форматированием или расширенным содержимым
- Реактивность на основе сигналов — реактивное управление состоянием с помощью сигналов Angular
- Умное позиционирование — CDK Overlay учитывает границы области просмотра и прокрутку
- Поддержка двунаправленного текста — автоматическая поддержка языков с письмом справа налево (RTL)
Примеры
Простой список выбора
Пользователям нужен стандартный раскрывающийся список для выбора значения. Комбобокс в сочетании со списком обеспечивает привычный интерфейс выбора с полной поддержкой специальных возможностей.
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);
}
}
<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>
@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;
}
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);
}
}
<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>
@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;
}
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);
}
}
<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>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
cursor: pointer;
padding: 0 4.5rem;
height: 2.5rem;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover,
.select:focus,
.select:focus-within {
transform: translate(1px, 1px);
}
.select:active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--gray-50));
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select[aria-expanded='false']:focus,
.select[aria-expanded='false']:focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px dashed var(--hot-pink);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
Ввод текста отключается, если применить директиву ngCombobox непосредственно к неинтерактивному элементу-контейнеру (например, к div или button) вместо <input>. Пользователи взаимодействуют с раскрывающимся списком с помощью клавиш со стрелками и Enter, как и с обычным элементом выбора.
Список выбора с настраиваемым отображением
Чтобы помочь пользователям быстро распознавать варианты, часто нужны визуальные индикаторы, например иконки или значки. Пользовательские шаблоны внутри вариантов позволяют использовать расширенное форматирование, сохраняя доступность.
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);
}
}
<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>
@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;
}
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);
}
}
<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>
@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;
}
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);
}
}
<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>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
cursor: pointer;
padding: 0 4.5rem;
height: 2.5rem;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover,
.select:focus,
.select:focus-within {
transform: translate(1px, 1px);
}
.select:active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--gray-50));
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select[aria-expanded='false']:focus,
.select[aria-expanded='false']:focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px dashed var(--hot-pink);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
У каждого варианта рядом с подписью отображается иконка. Выбранное значение обновляется и показывает иконку и текст выбранного варианта, обеспечивая понятную визуальную обратную связь.
Отключённый список выбора
Списки выбора можно отключать, чтобы запретить взаимодействие с ними, пока не выполнены определённые условия формы. Отключённое состояние отображается визуально и блокирует взаимодействие с клавиатуры.
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);
}
}
<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>
@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;
}
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);
}
}
<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>
@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;
}
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);
}
}
<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>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
cursor: pointer;
padding: 0 4.5rem;
height: 2.5rem;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select: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(--gray-50));
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select[aria-expanded='false']:focus,
.select[aria-expanded='false']:focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px dashed var(--hot-pink);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
В отключённом состоянии у списка отображается соответствующий визуальный стиль, и любое взаимодействие пользователя блокируется. Экранные дикторы сообщают пользователям вспомогательных технологий, что элемент отключён.
Тестирование
Шаблон выбора можно протестировать с помощью ComboboxHarness и ListboxHarness из @angular/aria/combobox/testing и @angular/aria/listbox/testing. Ниже показан пример использования тестовых стендов для проверки компонента выбора:
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');
});
}); API
В шаблоне выбора используются следующие директивы из библиотеки Aria для Angular. Полную документацию по API см. в руководствах по ссылкам.
Директивы Combobox
В шаблоне выбора директива ngCombobox применяется непосредственно к неинтерактивному элементу-контейнеру (например, к div или button), чтобы отключить ввод текста, сохранив клавиатурную навигацию.
Входные параметры
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
disabled | boolean | false | Отключает весь список выбора |
expanded | ModelSignal<boolean> | false | Состояние раскрытия списка выбора |
Полное описание всех доступных входных параметров и сигналов см. в документации по API Combobox.
Директивы Popup
Структурная директива ngComboboxPopup обозначает шаблон наложения и требует ссылку на родительский комбобокс:
Директива ComboboxWidget
Директива ngComboboxWidget связывает список с триггером комбобокса для отслеживания фокуса активного потомка.
| Свойство | Тип | Описание |
|---|---|---|
activeDescendant | string | undefined | Идентификатор текущего активного варианта (привязанный к listbox.activeDescendant()) для обновления атрибута aria-activedescendant у триггера |
Директивы Listbox
В шаблоне выбора директива ngListbox используется для списка раскрывающегося меню, а ngOption — для каждого выбираемого элемента.
Входные параметры
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
selectionMode |
'follow' | 'explicit'
| 'explicit' | Установите значение 'explicit', чтобы варианты переключались явно щелчком мыши или клавишей Enter, а не вслед за активным фокусом |
focusMode |
'roving' | 'activedescendant'
| 'roving' | Стратегия фокусировки, используемая списком. Установите значение 'activedescendant', чтобы фокус браузера оставался на триггере комбобокса. |
tabIndex | number | 0 | Значение tabindex списка. Установите значение -1, чтобы предотвратить переход клавиатурного фокуса в контейнер всплывающего окна в режиме активного потомка. |
Модель
| Свойство | Тип | Описание |
|---|---|---|
value | ModelSignal<any[]> | Двусторонне связываемый массив выбранных значений (для списка выбора содержит одно значение) |
Позиционирование
Шаблон выбора интегрируется с CDK Overlay для умного позиционирования. Используйте cdkConnectedOverlay, чтобы автоматически учитывать границы области просмотра и прокрутку.
Super-powered by Google ©2010–2025.
Code licensed under an MIT-style License. Documentation licensed under CC BY 4.0.
https://angular.dev/guide/aria/select