Spec-Zone.ru › Angular

Выбор

Обзор

Шаблон, объединяющий комбобокс со списком для создания раскрывающихся списков с выбором одного значения, поддержкой клавиатурной навигации и экранных дикторов.

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 обозначает шаблон наложения и требует ссылку на родительский комбобокс:

Свойство Тип Описание
combobox Combobox Обязательная ссылка на родительский элемент Combobox

Директива 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

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API