آنلاین

Select

Overview

patternای که combobox را با listbox ترکیب می‌کند تا dropdownهای تک‌انتخابی همراه با keyboard navigation و screen reader support بسازد.

ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';

@Component({
  selector: 'app-root[theme="icons-basic"], app-root:not([theme])',
  templateUrl: './app.html',
  styleUrl: './app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
  readonly listbox = viewChild(Listbox);

  readonly selectedValues = signal<string[]>([]);
  readonly popupExpanded = signal(false);

  readonly displayIcon = computed(() => {
    const val = this.selectedValues()[0];
    const label = this.labels.find((label) => label.value === val);
    return label ? label.icon : '';
  });

  readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');

  readonly labels = [
    {value: 'Important', icon: 'label'},
    {value: 'Starred', icon: 'star'},
    {value: 'Work', icon: 'work'},
    {value: 'Personal', icon: 'person'},
    {value: 'To Do', icon: 'checklist'},
    {value: 'Later', icon: 'schedule'},
    {value: 'Read', icon: 'menu_book'},
    {value: 'Travel', icon: 'flight'},
  ];

  constructor() {
    afterRenderEffect(() => {
      this.listbox()?.scrollActiveItemIntoView();
    });
  }

  onCommit() {
    this.popupExpanded.set(false);
  }
}
html
<div
  ngCombobox
  #combobox="ngCombobox"
  [(expanded)]="popupExpanded"
  [preserveContent]="true"
  class="select"
>
  <span class="combobox-label">
    <span class="selected-label-icon material-symbols-outlined" translate="no" aria-hidden="true">{{
      displayIcon()
    }}</span>
    <span class="selected-label-text">{{ displayValue() }}</span>
  </span>
  <span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
    >arrow_drop_down</span
  >
</div>

<ng-template
  [cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
  [cdkConnectedOverlayOpen]="popupExpanded()"
>
  <ng-template ngComboboxPopup [combobox]="combobox">
    <div class="example-popup-container">
      <div
        #listbox="ngListbox"
        ngListbox
        ngComboboxWidget
        [tabindex]="-1"
        focusMode="activedescendant"
        selectionMode="explicit"
        [(value)]="selectedValues"
        [activeDescendant]="listbox.activeDescendant()"
        (click)="onCommit()"
        (keydown.enter)="onCommit()"
        (keydown.space)="onCommit()"
      >
        @for (label of labels; track label.value) {
          <div ngOption [value]="label.value" [label]="label.value">
            <span
              class="example-option-icon material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >{{ label.icon }}</span
            >
            <span class="example-option-text">{{ label.value }}</span>
            <span
              class="example-option-check material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >check</span
            >
          </div>
        }
      </div>
    </div>
  </ng-template>
</ng-template>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

.select,
[ngListbox],
[ngOption] {
  outline: none;
}

.select {
  display: flex;
  position: relative;
  align-items: center;
  color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
  border-radius: 0.5rem;
  border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
  cursor: pointer;
  padding: 0 3.5rem;
  height: 2.5rem;
  box-sizing: border-box;
  width: 14rem;
  user-select: none;
  -webkit-user-select: none;
}

.select span {
  user-select: none;
  -webkit-user-select: none;
}

.select:hover {
  background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}

.select[aria-disabled='true'] {
  opacity: 0.6;
  cursor: default;
}

.selected-label-icon {
  font-size: 1.25rem;
}

.select:focus,
.select:focus-within {
  outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}

.combobox-label {
  gap: 1rem;
  left: 1rem;
  display: flex;
  position: absolute;
  align-items: center;
  pointer-events: none;
}

.example-arrow {
  right: 1rem;
  position: absolute;
  pointer-events: none;
  transition: transform 150ms ease-in-out;
}

.select[aria-expanded='true'] .example-arrow {
  transform: rotate(180deg);
}

.example-popup-container {
  width: 100%;
  padding: 0.5rem;
  margin-top: 8px;
  border-radius: 0.5rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  max-height: 11rem;
  box-sizing: border-box;
  overflow: hidden;
  animation: smoothPopupOpen 150ms ease-out forwards;
  transform-origin: top;
}

@keyframes smoothPopupOpen {
  0% {
    max-height: 0;
    opacity: 0;
  }
  16.7% {
    opacity: 1;
  }
  100% {
    max-height: 11rem;
    opacity: 1;
  }
}

[ngListbox] {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
}

[ngOption] {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 2.25rem;
  border-radius: 0.5rem;
}

[ngOption]:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

[ngOption][data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}

[ngOption][aria-selected='true'] {
  color: var(--hot-pink);
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}

.example-option-icon {
  font-size: 1.25rem;
  padding-right: 1rem;
}

[ngOption]:not([aria-selected='true']) .example-option-check {
  display: none;
}

.example-option-icon,
.example-option-check {
  font-size: 0.9rem;
}

.example-option-text {
  flex: 1;
}
ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';

@Component({
  selector: 'app-root[theme="icons-material"], app-root:not([theme])',
  templateUrl: './app.html',
  styleUrl: './app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
  readonly listbox = viewChild(Listbox);

  readonly selectedValues = signal<string[]>([]);
  readonly popupExpanded = signal(false);

  readonly displayIcon = computed(() => {
    const val = this.selectedValues()[0];
    const label = this.labels.find((label) => label.value === val);
    return label ? label.icon : '';
  });

  readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');

  readonly labels = [
    {value: 'Important', icon: 'label'},
    {value: 'Starred', icon: 'star'},
    {value: 'Work', icon: 'work'},
    {value: 'Personal', icon: 'person'},
    {value: 'To Do', icon: 'checklist'},
    {value: 'Later', icon: 'schedule'},
    {value: 'Read', icon: 'menu_book'},
    {value: 'Travel', icon: 'flight'},
  ];

  constructor() {
    afterRenderEffect(() => {
      this.listbox()?.scrollActiveItemIntoView();
    });
  }

  onCommit() {
    this.popupExpanded.set(false);
  }
}
html
<div
  ngCombobox
  #combobox="ngCombobox"
  [(expanded)]="popupExpanded"
  [preserveContent]="true"
  class="select"
>
  <span class="combobox-label">
    <span class="selected-label-icon material-symbols-outlined" translate="no" aria-hidden="true">{{
      displayIcon()
    }}</span>
    <span class="selected-label-text">{{ displayValue() }}</span>
  </span>
  <span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
    >arrow_drop_down</span
  >
</div>

<ng-template
  [cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
  [cdkConnectedOverlayOpen]="popupExpanded()"
>
  <ng-template ngComboboxPopup [combobox]="combobox">
    <div class="example-popup-container">
      <div
        #listbox="ngListbox"
        ngListbox
        ngComboboxWidget
        [tabindex]="-1"
        focusMode="activedescendant"
        selectionMode="explicit"
        [(value)]="selectedValues"
        [activeDescendant]="listbox.activeDescendant()"
        (click)="onCommit()"
        (keydown.enter)="onCommit()"
        (keydown.space)="onCommit()"
      >
        @for (label of labels; track label.value) {
          <div ngOption [value]="label.value" [label]="label.value">
            <span
              class="example-option-icon material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >{{ label.icon }}</span
            >
            <span class="example-option-text">{{ label.value }}</span>
            <span
              class="example-option-check material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >check</span
            >
          </div>
        }
      </div>
    </div>
  </ng-template>
</ng-template>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
  --primary: var(--hot-pink);
  --on-primary: var(--page-background);
}

.docs-light-mode {
  --on-primary: #fff;
}

.select,
[ngListbox],
[ngOption] {
  outline: none;
}

.select {
  display: flex;
  position: relative;
  align-items: center;
  border-radius: 3rem;
  color: var(--on-primary);
  background-color: var(--primary);
  border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
  cursor: pointer;
  height: 3rem;
  padding: 0 3.5rem;
  box-sizing: border-box;
  width: 14rem;
  user-select: none;
  -webkit-user-select: none;
}

.select span {
  user-select: none;
  -webkit-user-select: none;
}

.select:hover {
  background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}

.select[aria-disabled='true'] {
  opacity: 0.6;
  cursor: default;
}

.selected-label-icon {
  font-size: 1.25rem;
}

.select:focus,
.select:focus-within {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}

.combobox-label {
  gap: 1rem;
  left: 1rem;
  display: flex;
  position: absolute;
  align-items: center;
  pointer-events: none;
}

.example-arrow {
  right: 1rem;
  position: absolute;
  pointer-events: none;
  transition: transform 150ms ease-in-out;
}

.select[aria-expanded='true'] .example-arrow {
  transform: rotate(180deg);
}

.example-popup-container {
  width: 100%;
  padding: 0.5rem;
  margin-top: 8px;
  border-radius: 2rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  max-height: 13rem;
  box-sizing: border-box;
  overflow: hidden;
  animation: smoothPopupOpen 150ms ease-out forwards;
  transform-origin: top;
}

@keyframes smoothPopupOpen {
  0% {
    max-height: 0;
    opacity: 0;
  }
  16.7% {
    opacity: 1;
  }
  100% {
    max-height: 13rem;
    opacity: 1;
  }
}

[ngListbox] {
  gap: 2px;
  padding: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
}

[ngOption] {
  display: flex;
  cursor: pointer;
  align-items: center;
  padding: 0 1rem;
  min-height: 3rem;
  border-radius: 3rem;
}

[ngOption]:hover,
[ngOption][data-active='true'] {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

[ngOption][data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid var(--primary);
}

[ngOption][aria-selected='true'] {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}

.example-option-icon {
  font-size: 1.25rem;
  padding-right: 1rem;
}

[ngOption]:not([aria-selected='true']) .example-option-check {
  display: none;
}

.example-option-icon,
.example-option-check {
  font-size: 0.9rem;
}

.example-option-text {
  flex: 1;
}
ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';

@Component({
  selector: 'app-root[theme="icons-retro"], app-root:not([theme])',
  templateUrl: './app.html',
  styleUrl: './app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
  readonly listbox = viewChild(Listbox);

  readonly selectedValues = signal<string[]>([]);
  readonly popupExpanded = signal(false);

  readonly displayIcon = computed(() => {
    const val = this.selectedValues()[0];
    const label = this.labels.find((label) => label.value === val);
    return label ? label.icon : '';
  });

  readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');

  readonly labels = [
    {value: 'Important', icon: 'label'},
    {value: 'Starred', icon: 'star'},
    {value: 'Work', icon: 'work'},
    {value: 'Personal', icon: 'person'},
    {value: 'To Do', icon: 'checklist'},
    {value: 'Later', icon: 'schedule'},
    {value: 'Read', icon: 'menu_book'},
    {value: 'Travel', icon: 'flight'},
  ];

  constructor() {
    afterRenderEffect(() => {
      this.listbox()?.scrollActiveItemIntoView();
    });
  }

  onCommit() {
    this.popupExpanded.set(false);
  }
}
html
<div
  ngCombobox
  #combobox="ngCombobox"
  [(expanded)]="popupExpanded"
  [preserveContent]="true"
  class="select"
>
  <span class="combobox-label">
    <span class="selected-label-icon material-symbols-outlined" translate="no" aria-hidden="true">{{
      displayIcon()
    }}</span>
    <span class="selected-label-text">{{ displayValue() }}</span>
  </span>
  <span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
    >arrow_drop_down</span
  >
</div>

<ng-template
  [cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
  [cdkConnectedOverlayOpen]="popupExpanded()"
>
  <ng-template ngComboboxPopup [combobox]="combobox">
    <div class="example-popup-container">
      <div
        #listbox="ngListbox"
        ngListbox
        ngComboboxWidget
        [tabindex]="-1"
        focusMode="activedescendant"
        selectionMode="explicit"
        [(value)]="selectedValues"
        [activeDescendant]="listbox.activeDescendant()"
        (click)="onCommit()"
        (keydown.enter)="onCommit()"
        (keydown.space)="onCommit()"
      >
        @for (label of labels; track label.value) {
          <div ngOption [value]="label.value" [label]="label.value">
            <span
              class="example-option-icon material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >{{ label.icon }}</span
            >
            <span class="example-option-text">{{ label.value }}</span>
            <span
              class="example-option-check material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >check</span
            >
          </div>
        }
      </div>
    </div>
  </ng-template>
</ng-template>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

:host {
  display: flex;
  justify-content: center;
  font-size: 0.8rem;

  font-family: 'Press Start 2P';
  --retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
  --retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
  --retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
  --retro-elevated-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast);
  --retro-flat-shadow:
    4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
    -4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
  --retro-clickable-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
  --retro-pressed-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-dark),
    inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}

.select,
[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;
}

Usage

pattern مربوط به select زمانی بهترین انتخاب است که کاربران باید یک value را از مجموعه‌ای آشنا از optionها انتخاب کنند.

استفاده از این pattern را در این موارد در نظر بگیرید:

  • Option list ثابت است \(کمتر از 20 item\) - کاربران می‌توانند بدون filtering، scan و choose کنند.
  • Optionها آشنا هستند - کاربران choiceها را بدون نیاز به search تشخیص می‌دهند.
  • Formها به fieldهای استاندارد نیاز دارند - انتخاب country، state، category یا status.
  • Settingها و configuration - dropdown menuها برای preferenceها یا optionها.
  • Option labelهای روشن - هر choice نامی distinct و قابل scan دارد.

از این pattern پرهیز کنید وقتی:

  • list بیش از 20 item دارد - برای filtering بهتر از pattern مربوط به Autocomplete استفاده کنید.
  • کاربران باید optionها را search کنند - Autocomplete text input و filtering فراهم می‌کند.
  • Multiple selection لازم است - به جای آن از pattern مربوط به Multiselect استفاده کنید.
  • optionهای خیلی کمی وجود دارد \(2-3\) - radio buttonها visibility بهتری از همه choiceها فراهم می‌کنند.

قابلیت‌ها

pattern مربوط به select، directiveهای Combobox و Listbox را ترکیب می‌کند تا یک dropdown کاملاً accessible فراهم کند با:

  • Keyboard Navigation - با arrow keyها بین optionها navigate کنید، با Enter انتخاب کنید و با Escape ببندید.
  • Screen Reader Support - attributeهای ARIA داخلی برای assistive technologyها.
  • Custom Display - valueهای selected را با icon، formatting یا rich content نمایش دهید.
  • Signal-Based Reactivity - مدیریت state reactive با Angular signals.
  • Smart Positioning - CDK Overlay، edgeهای viewport و scrolling را مدیریت می‌کند.
  • Bidirectional Text Support - مدیریت خودکار زبان‌های راست‌به‌چپ \(RTL\).

مثال‌ها

Basic select

کاربران به یک dropdown استاندارد نیاز دارند تا از listای از valueها انتخاب کنند. یک combobox همراه با listbox تجربه familiar مربوط به select را با accessibility کامل فراهم می‌کند.

ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';

@Component({
  selector: 'app-root[theme="basic-basic"], app-root:not([theme])',
  templateUrl: './app.html',
  styleUrl: './app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
  readonly listbox = viewChild(Listbox);

  readonly selectedValues = signal<string[]>([]);
  readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
  readonly popupExpanded = signal(false);

  readonly labels = [
    'Important',
    'Starred',
    'Work',
    'Personal',
    'To Do',
    'Later',
    'Read',
    'Travel',
  ];

  constructor() {
    afterRenderEffect(() => {
      this.listbox()?.scrollActiveItemIntoView();
    });
  }

  onCommit() {
    this.popupExpanded.set(false);
  }
}
html
<div
  ngCombobox
  #combobox="ngCombobox"
  [(expanded)]="popupExpanded"
  [preserveContent]="true"
  class="select"
>
  <span class="combobox-label">
    <span class="selected-label-text">{{ displayValue() }}</span>
  </span>
  <span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
    >arrow_drop_down</span
  >
</div>

<ng-template
  [cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
  [cdkConnectedOverlayOpen]="popupExpanded()"
>
  <ng-template ngComboboxPopup [combobox]="combobox">
    <div class="example-popup-container">
      <div
        #listbox="ngListbox"
        ngListbox
        ngComboboxWidget
        [tabindex]="-1"
        focusMode="activedescendant"
        selectionMode="explicit"
        [(value)]="selectedValues"
        [activeDescendant]="listbox.activeDescendant()"
        (click)="onCommit()"
        (keydown.enter)="onCommit()"
        (keydown.space)="onCommit()"
      >
        @for (label of labels; track label) {
          <div ngOption [value]="label" [label]="label">
            <span class="example-option-text">{{ label }}</span>
            <span
              class="example-option-check material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >check</span
            >
          </div>
        }
      </div>
    </div>
  </ng-template>
</ng-template>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

.select,
[ngListbox],
[ngOption] {
  outline: none;
}

.select {
  display: flex;
  position: relative;
  align-items: center;
  color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
  border-radius: 0.5rem;
  border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
  cursor: pointer;
  padding: 0 3rem;
  height: 2.5rem;
  box-sizing: border-box;
  width: 14rem;
  user-select: none;
  -webkit-user-select: none;
}

.select span {
  user-select: none;
  -webkit-user-select: none;
}

.select:hover {
  background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}

.select[aria-disabled='true'] {
  opacity: 0.6;
  cursor: default;
}

.select:focus,
.select:focus-within {
  outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}

.combobox-label {
  gap: 1rem;
  left: 2rem;
  display: flex;
  position: absolute;
  align-items: center;
  pointer-events: none;
}

.example-arrow {
  right: 1rem;
  position: absolute;
  pointer-events: none;
  transition: transform 150ms ease-in-out;
}

.select[aria-expanded='true'] .example-arrow {
  transform: rotate(180deg);
}

.example-popup-container {
  width: 100%;
  padding: 0.5rem;
  margin-top: 8px;
  border-radius: 0.5rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  max-height: 11rem;
  box-sizing: border-box;
  overflow: hidden;
  animation: smoothPopupOpen 150ms ease-out forwards;
  transform-origin: top;
}

@keyframes smoothPopupOpen {
  0% {
    max-height: 0;
    opacity: 0;
  }
  16.7% {
    opacity: 1;
  }
  100% {
    max-height: 11rem;
    opacity: 1;
  }
}

[ngListbox] {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
}

[ngOption] {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 2.25rem;
  border-radius: 0.5rem;
}

[ngOption]:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

[ngOption][data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}

[ngOption][aria-selected='true'] {
  color: var(--hot-pink);
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}

[ngOption]:not([aria-selected='true']) .example-option-check {
  display: none;
}

.example-option-check {
  font-size: 0.9rem;
}

.example-option-text {
  flex: 1;
}
ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';

@Component({
  selector: 'app-root[theme="basic-material"], app-root:not([theme])',
  templateUrl: './app.html',
  styleUrl: './app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
  readonly listbox = viewChild(Listbox);

  readonly selectedValues = signal<string[]>([]);
  readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
  readonly popupExpanded = signal(false);

  readonly labels = [
    'Important',
    'Starred',
    'Work',
    'Personal',
    'To Do',
    'Later',
    'Read',
    'Travel',
  ];

  constructor() {
    afterRenderEffect(() => {
      this.listbox()?.scrollActiveItemIntoView();
    });
  }

  onCommit() {
    this.popupExpanded.set(false);
  }
}
html
<div
  ngCombobox
  #combobox="ngCombobox"
  [(expanded)]="popupExpanded"
  [preserveContent]="true"
  class="select"
>
  <span class="combobox-label">
    <span class="selected-label-text">{{ displayValue() }}</span>
  </span>
  <span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
    >arrow_drop_down</span
  >
</div>

<ng-template
  [cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
  [cdkConnectedOverlayOpen]="popupExpanded()"
>
  <ng-template ngComboboxPopup [combobox]="combobox">
    <div class="example-popup-container">
      <div
        #listbox="ngListbox"
        ngListbox
        ngComboboxWidget
        [tabindex]="-1"
        focusMode="activedescendant"
        selectionMode="explicit"
        [(value)]="selectedValues"
        [activeDescendant]="listbox.activeDescendant()"
        (click)="onCommit()"
        (keydown.enter)="onCommit()"
        (keydown.space)="onCommit()"
      >
        @for (label of labels; track label) {
          <div ngOption [value]="label" [label]="label">
            <span class="example-option-text">{{ label }}</span>
            <span
              class="example-option-check material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >check</span
            >
          </div>
        }
      </div>
    </div>
  </ng-template>
</ng-template>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
  --primary: var(--hot-pink);
  --on-primary: var(--page-background);
}

.docs-light-mode {
  --on-primary: #fff;
}

.select,
[ngListbox],
[ngOption] {
  outline: none;
}

.select {
  display: flex;
  position: relative;
  align-items: center;
  border-radius: 3rem;
  color: var(--on-primary);
  background-color: var(--primary);
  border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
  cursor: pointer;
  height: 3rem;
  padding: 0 3rem;
  box-sizing: border-box;
  width: 14rem;
  user-select: none;
  -webkit-user-select: none;
}

.select span {
  user-select: none;
  -webkit-user-select: none;
}

.select:hover {
  background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}

.select[aria-disabled='true'] {
  opacity: 0.6;
  cursor: default;
}

.select:focus,
.select:focus-within {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}

.combobox-label {
  gap: 1rem;
  left: 2rem;
  display: flex;
  position: absolute;
  align-items: center;
  pointer-events: none;
}

.example-arrow {
  right: 1rem;
  position: absolute;
  pointer-events: none;
  transition: transform 150ms ease-in-out;
}

.select[aria-expanded='true'] .example-arrow {
  transform: rotate(180deg);
}

.example-popup-container {
  width: 100%;
  padding: 0.5rem;
  margin-top: 8px;
  border-radius: 2rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  max-height: 13rem;
  box-sizing: border-box;
  overflow: hidden;
  animation: smoothPopupOpen 150ms ease-out forwards;
  transform-origin: top;
}

@keyframes smoothPopupOpen {
  0% {
    max-height: 0;
    opacity: 0;
  }
  16.7% {
    opacity: 1;
  }
  100% {
    max-height: 13rem;
    opacity: 1;
  }
}

[ngListbox] {
  gap: 2px;
  padding: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
}

[ngOption] {
  display: flex;
  cursor: pointer;
  align-items: center;
  padding: 0 1rem;
  min-height: 3rem;
  border-radius: 3rem;
}

[ngOption]:hover,
[ngOption][data-active='true'] {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

[ngOption][data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid var(--primary);
}

[ngOption][aria-selected='true'] {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}

[ngOption]:not([aria-selected='true']) .example-option-check {
  display: none;
}

.example-option-check {
  font-size: 0.9rem;
}

.example-option-text {
  flex: 1;
}
ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';

@Component({
  selector: 'app-root[theme="basic-retro"], app-root:not([theme])',
  templateUrl: './app.html',
  styleUrl: './app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
  readonly listbox = viewChild(Listbox);

  readonly selectedValues = signal<string[]>([]);
  readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
  readonly popupExpanded = signal(false);

  readonly labels = [
    'Important',
    'Starred',
    'Work',
    'Personal',
    'To Do',
    'Later',
    'Read',
    'Travel',
  ];

  constructor() {
    afterRenderEffect(() => {
      this.listbox()?.scrollActiveItemIntoView();
    });
  }

  onCommit() {
    this.popupExpanded.set(false);
  }
}
html
<div
  ngCombobox
  #combobox="ngCombobox"
  [(expanded)]="popupExpanded"
  [preserveContent]="true"
  class="select"
>
  <span class="combobox-label">
    <span class="selected-label-text">{{ displayValue() }}</span>
  </span>
  <span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
    >arrow_drop_down</span
  >
</div>

<ng-template
  [cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
  [cdkConnectedOverlayOpen]="popupExpanded()"
>
  <ng-template ngComboboxPopup [combobox]="combobox">
    <div class="example-popup-container">
      <div
        #listbox="ngListbox"
        ngListbox
        ngComboboxWidget
        [tabindex]="-1"
        focusMode="activedescendant"
        selectionMode="explicit"
        [(value)]="selectedValues"
        [activeDescendant]="listbox.activeDescendant()"
        (click)="onCommit()"
        (keydown.enter)="onCommit()"
        (keydown.space)="onCommit()"
      >
        @for (label of labels; track label) {
          <div ngOption [value]="label" [label]="label">
            <span class="example-option-text">{{ label }}</span>
            <span
              class="example-option-check material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >check</span
            >
          </div>
        }
      </div>
    </div>
  </ng-template>
</ng-template>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

:host {
  display: flex;
  justify-content: center;
  font-size: 0.8rem;

  font-family: 'Press Start 2P';
  --retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
  --retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
  --retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
  --retro-elevated-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast);
  --retro-flat-shadow:
    4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
    -4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
  --retro-clickable-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
  --retro-pressed-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-dark),
    inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}

.select,
[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;
}

Text input با اعمال مستقیم directive مربوط به ngCombobox روی یک host element غیرتعاملی، مثل div یا button، به جای <input> جلوگیری می‌شود. کاربران درست مثل یک native select element، با arrow keyها و Enter با dropdown تعامل می‌کنند.

Select با display سفارشی

Optionها اغلب به indicatorهای visual مثل icon یا badge نیاز دارند تا کاربران choiceها را سریع تشخیص دهند. templateهای سفارشی داخل optionها rich formatting را ممکن می‌کنند و در عین حال accessibility را حفظ می‌کنند.

ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';

@Component({
  selector: 'app-root[theme="icons-basic"], app-root:not([theme])',
  templateUrl: './app.html',
  styleUrl: './app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
  readonly listbox = viewChild(Listbox);

  readonly selectedValues = signal<string[]>([]);
  readonly popupExpanded = signal(false);

  readonly displayIcon = computed(() => {
    const val = this.selectedValues()[0];
    const label = this.labels.find((label) => label.value === val);
    return label ? label.icon : '';
  });

  readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');

  readonly labels = [
    {value: 'Important', icon: 'label'},
    {value: 'Starred', icon: 'star'},
    {value: 'Work', icon: 'work'},
    {value: 'Personal', icon: 'person'},
    {value: 'To Do', icon: 'checklist'},
    {value: 'Later', icon: 'schedule'},
    {value: 'Read', icon: 'menu_book'},
    {value: 'Travel', icon: 'flight'},
  ];

  constructor() {
    afterRenderEffect(() => {
      this.listbox()?.scrollActiveItemIntoView();
    });
  }

  onCommit() {
    this.popupExpanded.set(false);
  }
}
html
<div
  ngCombobox
  #combobox="ngCombobox"
  [(expanded)]="popupExpanded"
  [preserveContent]="true"
  class="select"
>
  <span class="combobox-label">
    <span class="selected-label-icon material-symbols-outlined" translate="no" aria-hidden="true">{{
      displayIcon()
    }}</span>
    <span class="selected-label-text">{{ displayValue() }}</span>
  </span>
  <span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
    >arrow_drop_down</span
  >
</div>

<ng-template
  [cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
  [cdkConnectedOverlayOpen]="popupExpanded()"
>
  <ng-template ngComboboxPopup [combobox]="combobox">
    <div class="example-popup-container">
      <div
        #listbox="ngListbox"
        ngListbox
        ngComboboxWidget
        [tabindex]="-1"
        focusMode="activedescendant"
        selectionMode="explicit"
        [(value)]="selectedValues"
        [activeDescendant]="listbox.activeDescendant()"
        (click)="onCommit()"
        (keydown.enter)="onCommit()"
        (keydown.space)="onCommit()"
      >
        @for (label of labels; track label.value) {
          <div ngOption [value]="label.value" [label]="label.value">
            <span
              class="example-option-icon material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >{{ label.icon }}</span
            >
            <span class="example-option-text">{{ label.value }}</span>
            <span
              class="example-option-check material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >check</span
            >
          </div>
        }
      </div>
    </div>
  </ng-template>
</ng-template>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

.select,
[ngListbox],
[ngOption] {
  outline: none;
}

.select {
  display: flex;
  position: relative;
  align-items: center;
  color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
  border-radius: 0.5rem;
  border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
  cursor: pointer;
  padding: 0 3.5rem;
  height: 2.5rem;
  box-sizing: border-box;
  width: 14rem;
  user-select: none;
  -webkit-user-select: none;
}

.select span {
  user-select: none;
  -webkit-user-select: none;
}

.select:hover {
  background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}

.select[aria-disabled='true'] {
  opacity: 0.6;
  cursor: default;
}

.selected-label-icon {
  font-size: 1.25rem;
}

.select:focus,
.select:focus-within {
  outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}

.combobox-label {
  gap: 1rem;
  left: 1rem;
  display: flex;
  position: absolute;
  align-items: center;
  pointer-events: none;
}

.example-arrow {
  right: 1rem;
  position: absolute;
  pointer-events: none;
  transition: transform 150ms ease-in-out;
}

.select[aria-expanded='true'] .example-arrow {
  transform: rotate(180deg);
}

.example-popup-container {
  width: 100%;
  padding: 0.5rem;
  margin-top: 8px;
  border-radius: 0.5rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  max-height: 11rem;
  box-sizing: border-box;
  overflow: hidden;
  animation: smoothPopupOpen 150ms ease-out forwards;
  transform-origin: top;
}

@keyframes smoothPopupOpen {
  0% {
    max-height: 0;
    opacity: 0;
  }
  16.7% {
    opacity: 1;
  }
  100% {
    max-height: 11rem;
    opacity: 1;
  }
}

[ngListbox] {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
}

[ngOption] {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 2.25rem;
  border-radius: 0.5rem;
}

[ngOption]:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

[ngOption][data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}

[ngOption][aria-selected='true'] {
  color: var(--hot-pink);
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}

.example-option-icon {
  font-size: 1.25rem;
  padding-right: 1rem;
}

[ngOption]:not([aria-selected='true']) .example-option-check {
  display: none;
}

.example-option-icon,
.example-option-check {
  font-size: 0.9rem;
}

.example-option-text {
  flex: 1;
}
ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';

@Component({
  selector: 'app-root[theme="icons-material"], app-root:not([theme])',
  templateUrl: './app.html',
  styleUrl: './app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
  readonly listbox = viewChild(Listbox);

  readonly selectedValues = signal<string[]>([]);
  readonly popupExpanded = signal(false);

  readonly displayIcon = computed(() => {
    const val = this.selectedValues()[0];
    const label = this.labels.find((label) => label.value === val);
    return label ? label.icon : '';
  });

  readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');

  readonly labels = [
    {value: 'Important', icon: 'label'},
    {value: 'Starred', icon: 'star'},
    {value: 'Work', icon: 'work'},
    {value: 'Personal', icon: 'person'},
    {value: 'To Do', icon: 'checklist'},
    {value: 'Later', icon: 'schedule'},
    {value: 'Read', icon: 'menu_book'},
    {value: 'Travel', icon: 'flight'},
  ];

  constructor() {
    afterRenderEffect(() => {
      this.listbox()?.scrollActiveItemIntoView();
    });
  }

  onCommit() {
    this.popupExpanded.set(false);
  }
}
html
<div
  ngCombobox
  #combobox="ngCombobox"
  [(expanded)]="popupExpanded"
  [preserveContent]="true"
  class="select"
>
  <span class="combobox-label">
    <span class="selected-label-icon material-symbols-outlined" translate="no" aria-hidden="true">{{
      displayIcon()
    }}</span>
    <span class="selected-label-text">{{ displayValue() }}</span>
  </span>
  <span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
    >arrow_drop_down</span
  >
</div>

<ng-template
  [cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
  [cdkConnectedOverlayOpen]="popupExpanded()"
>
  <ng-template ngComboboxPopup [combobox]="combobox">
    <div class="example-popup-container">
      <div
        #listbox="ngListbox"
        ngListbox
        ngComboboxWidget
        [tabindex]="-1"
        focusMode="activedescendant"
        selectionMode="explicit"
        [(value)]="selectedValues"
        [activeDescendant]="listbox.activeDescendant()"
        (click)="onCommit()"
        (keydown.enter)="onCommit()"
        (keydown.space)="onCommit()"
      >
        @for (label of labels; track label.value) {
          <div ngOption [value]="label.value" [label]="label.value">
            <span
              class="example-option-icon material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >{{ label.icon }}</span
            >
            <span class="example-option-text">{{ label.value }}</span>
            <span
              class="example-option-check material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >check</span
            >
          </div>
        }
      </div>
    </div>
  </ng-template>
</ng-template>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
  --primary: var(--hot-pink);
  --on-primary: var(--page-background);
}

.docs-light-mode {
  --on-primary: #fff;
}

.select,
[ngListbox],
[ngOption] {
  outline: none;
}

.select {
  display: flex;
  position: relative;
  align-items: center;
  border-radius: 3rem;
  color: var(--on-primary);
  background-color: var(--primary);
  border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
  cursor: pointer;
  height: 3rem;
  padding: 0 3.5rem;
  box-sizing: border-box;
  width: 14rem;
  user-select: none;
  -webkit-user-select: none;
}

.select span {
  user-select: none;
  -webkit-user-select: none;
}

.select:hover {
  background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}

.select[aria-disabled='true'] {
  opacity: 0.6;
  cursor: default;
}

.selected-label-icon {
  font-size: 1.25rem;
}

.select:focus,
.select:focus-within {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}

.combobox-label {
  gap: 1rem;
  left: 1rem;
  display: flex;
  position: absolute;
  align-items: center;
  pointer-events: none;
}

.example-arrow {
  right: 1rem;
  position: absolute;
  pointer-events: none;
  transition: transform 150ms ease-in-out;
}

.select[aria-expanded='true'] .example-arrow {
  transform: rotate(180deg);
}

.example-popup-container {
  width: 100%;
  padding: 0.5rem;
  margin-top: 8px;
  border-radius: 2rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  max-height: 13rem;
  box-sizing: border-box;
  overflow: hidden;
  animation: smoothPopupOpen 150ms ease-out forwards;
  transform-origin: top;
}

@keyframes smoothPopupOpen {
  0% {
    max-height: 0;
    opacity: 0;
  }
  16.7% {
    opacity: 1;
  }
  100% {
    max-height: 13rem;
    opacity: 1;
  }
}

[ngListbox] {
  gap: 2px;
  padding: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
}

[ngOption] {
  display: flex;
  cursor: pointer;
  align-items: center;
  padding: 0 1rem;
  min-height: 3rem;
  border-radius: 3rem;
}

[ngOption]:hover,
[ngOption][data-active='true'] {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

[ngOption][data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid var(--primary);
}

[ngOption][aria-selected='true'] {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}

.example-option-icon {
  font-size: 1.25rem;
  padding-right: 1rem;
}

[ngOption]:not([aria-selected='true']) .example-option-check {
  display: none;
}

.example-option-icon,
.example-option-check {
  font-size: 0.9rem;
}

.example-option-text {
  flex: 1;
}
ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';

@Component({
  selector: 'app-root[theme="icons-retro"], app-root:not([theme])',
  templateUrl: './app.html',
  styleUrl: './app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
  readonly listbox = viewChild(Listbox);

  readonly selectedValues = signal<string[]>([]);
  readonly popupExpanded = signal(false);

  readonly displayIcon = computed(() => {
    const val = this.selectedValues()[0];
    const label = this.labels.find((label) => label.value === val);
    return label ? label.icon : '';
  });

  readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');

  readonly labels = [
    {value: 'Important', icon: 'label'},
    {value: 'Starred', icon: 'star'},
    {value: 'Work', icon: 'work'},
    {value: 'Personal', icon: 'person'},
    {value: 'To Do', icon: 'checklist'},
    {value: 'Later', icon: 'schedule'},
    {value: 'Read', icon: 'menu_book'},
    {value: 'Travel', icon: 'flight'},
  ];

  constructor() {
    afterRenderEffect(() => {
      this.listbox()?.scrollActiveItemIntoView();
    });
  }

  onCommit() {
    this.popupExpanded.set(false);
  }
}
html
<div
  ngCombobox
  #combobox="ngCombobox"
  [(expanded)]="popupExpanded"
  [preserveContent]="true"
  class="select"
>
  <span class="combobox-label">
    <span class="selected-label-icon material-symbols-outlined" translate="no" aria-hidden="true">{{
      displayIcon()
    }}</span>
    <span class="selected-label-text">{{ displayValue() }}</span>
  </span>
  <span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
    >arrow_drop_down</span
  >
</div>

<ng-template
  [cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
  [cdkConnectedOverlayOpen]="popupExpanded()"
>
  <ng-template ngComboboxPopup [combobox]="combobox">
    <div class="example-popup-container">
      <div
        #listbox="ngListbox"
        ngListbox
        ngComboboxWidget
        [tabindex]="-1"
        focusMode="activedescendant"
        selectionMode="explicit"
        [(value)]="selectedValues"
        [activeDescendant]="listbox.activeDescendant()"
        (click)="onCommit()"
        (keydown.enter)="onCommit()"
        (keydown.space)="onCommit()"
      >
        @for (label of labels; track label.value) {
          <div ngOption [value]="label.value" [label]="label.value">
            <span
              class="example-option-icon material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >{{ label.icon }}</span
            >
            <span class="example-option-text">{{ label.value }}</span>
            <span
              class="example-option-check material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >check</span
            >
          </div>
        }
      </div>
    </div>
  </ng-template>
</ng-template>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

:host {
  display: flex;
  justify-content: center;
  font-size: 0.8rem;

  font-family: 'Press Start 2P';
  --retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
  --retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
  --retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
  --retro-elevated-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast);
  --retro-flat-shadow:
    4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
    -4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
  --retro-clickable-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
  --retro-pressed-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-dark),
    inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}

.select,
[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;
}

هر option یک icon کنار label نمایش می‌دهد. selected value update می‌شود تا icon و text مربوط به option انتخاب‌شده را نشان دهد و visual feedback روشنی فراهم کند.

Select disabled

Selectها می‌توانند disabled شوند تا وقتی شرط‌های مشخص form برقرار نیستند، از user interaction جلوگیری شود. disabled state، visual feedback فراهم می‌کند و keyboard interaction را متوقف می‌کند.

ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';

@Component({
  selector: 'app-root[theme="disabled-basic"], app-root:not([theme])',
  templateUrl: './app.html',
  styleUrl: './app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
  readonly listbox = viewChild(Listbox);

  readonly selectedValues = signal<string[]>([]);
  readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
  readonly popupExpanded = signal(false);

  readonly labels = [
    'Important',
    'Starred',
    'Work',
    'Personal',
    'To Do',
    'Later',
    'Read',
    'Travel',
  ];

  constructor() {
    afterRenderEffect(() => {
      this.listbox()?.scrollActiveItemIntoView();
    });
  }

  onCommit() {
    this.popupExpanded.set(false);
  }
}
html
<div
  ngCombobox
  #combobox="ngCombobox"
  [(expanded)]="popupExpanded"
  [preserveContent]="true"
  class="select"
  disabled
>
  <span class="combobox-label">
    <span class="selected-label-text">{{ displayValue() }}</span>
  </span>
  <span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
    >arrow_drop_down</span
  >
</div>

<ng-template
  [cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
  [cdkConnectedOverlayOpen]="popupExpanded()"
>
  <ng-template ngComboboxPopup [combobox]="combobox">
    <div class="example-popup-container">
      <div
        #listbox="ngListbox"
        ngListbox
        ngComboboxWidget
        [tabindex]="-1"
        focusMode="activedescendant"
        selectionMode="explicit"
        [(value)]="selectedValues"
        [activeDescendant]="listbox.activeDescendant()"
        (click)="onCommit()"
        (keydown.enter)="onCommit()"
        (keydown.space)="onCommit()"
      >
        @for (label of labels; track label) {
          <div ngOption [value]="label" [label]="label">
            <span class="example-option-text">{{ label }}</span>
            <span
              class="example-option-check material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >check</span
            >
          </div>
        }
      </div>
    </div>
  </ng-template>
</ng-template>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

.select,
[ngListbox],
[ngOption] {
  outline: none;
}

.select {
  display: flex;
  position: relative;
  align-items: center;
  color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
  border-radius: 0.5rem;
  border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
  cursor: pointer;
  padding: 0 3rem;
  height: 2.5rem;
  box-sizing: border-box;
  width: 14rem;
  user-select: none;
  -webkit-user-select: none;
}

.select span {
  user-select: none;
  -webkit-user-select: none;
}

.select:not([aria-disabled='true']):hover {
  background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}

.select[aria-disabled='true'] {
  opacity: 0.6;
  cursor: default;
}

.select:focus,
.select:focus-within {
  outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}

.combobox-label {
  gap: 1rem;
  left: 2rem;
  display: flex;
  position: absolute;
  align-items: center;
  pointer-events: none;
}

.example-arrow {
  right: 1rem;
  position: absolute;
  pointer-events: none;
  transition: transform 150ms ease-in-out;
}

.select[aria-expanded='true'] .example-arrow {
  transform: rotate(180deg);
}

.example-popup-container {
  width: 100%;
  padding: 0.5rem;
  margin-top: 8px;
  border-radius: 0.5rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  max-height: 11rem;
  box-sizing: border-box;
  overflow: hidden;
  animation: smoothPopupOpen 150ms ease-out forwards;
  transform-origin: top;
}

@keyframes smoothPopupOpen {
  0% {
    max-height: 0;
    opacity: 0;
  }
  16.7% {
    opacity: 1;
  }
  100% {
    max-height: 11rem;
    opacity: 1;
  }
}

[ngListbox] {
  gap: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
}

[ngOption] {
  display: flex;
  cursor: pointer;
  align-items: center;
  margin: 1px;
  padding: 0 1rem;
  min-height: 2.25rem;
  border-radius: 0.5rem;
}

[ngOption]:hover {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

[ngOption][data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}

[ngOption][aria-selected='true'] {
  color: var(--hot-pink);
  background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}

[ngOption]:not([aria-selected='true']) .example-option-check {
  display: none;
}

.example-option-check {
  font-size: 0.9rem;
}

.example-option-text {
  flex: 1;
}
ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';

@Component({
  selector: 'app-root[theme="disabled-material"], app-root:not([theme])',
  templateUrl: './app.html',
  styleUrl: './app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
  readonly listbox = viewChild(Listbox);

  readonly selectedValues = signal<string[]>([]);
  readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
  readonly popupExpanded = signal(false);

  readonly labels = [
    'Important',
    'Starred',
    'Work',
    'Personal',
    'To Do',
    'Later',
    'Read',
    'Travel',
  ];

  constructor() {
    afterRenderEffect(() => {
      this.listbox()?.scrollActiveItemIntoView();
    });
  }

  onCommit() {
    this.popupExpanded.set(false);
  }
}
html
<div
  ngCombobox
  #combobox="ngCombobox"
  [(expanded)]="popupExpanded"
  [preserveContent]="true"
  class="select"
  disabled
>
  <span class="combobox-label">
    <span class="selected-label-text">{{ displayValue() }}</span>
  </span>
  <span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
    >arrow_drop_down</span
  >
</div>

<ng-template
  [cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
  [cdkConnectedOverlayOpen]="popupExpanded()"
>
  <ng-template ngComboboxPopup [combobox]="combobox">
    <div class="example-popup-container">
      <div
        #listbox="ngListbox"
        ngListbox
        ngComboboxWidget
        [tabindex]="-1"
        focusMode="activedescendant"
        selectionMode="explicit"
        [(value)]="selectedValues"
        [activeDescendant]="listbox.activeDescendant()"
        (click)="onCommit()"
        (keydown.enter)="onCommit()"
        (keydown.space)="onCommit()"
      >
        @for (label of labels; track label) {
          <div ngOption [value]="label" [label]="label">
            <span class="example-option-text">{{ label }}</span>
            <span
              class="example-option-check material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >check</span
            >
          </div>
        }
      </div>
    </div>
  </ng-template>
</ng-template>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
  --primary: var(--hot-pink);
  --on-primary: var(--page-background);
}

.docs-light-mode {
  --on-primary: #fff;
}

.select,
[ngListbox],
[ngOption] {
  outline: none;
}

.select {
  display: flex;
  position: relative;
  align-items: center;
  border-radius: 3rem;
  color: var(--on-primary);
  background-color: var(--primary);
  border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
  cursor: pointer;
  height: 3rem;
  padding: 0 3rem;
  box-sizing: border-box;
  width: 14rem;
  user-select: none;
  -webkit-user-select: none;
}

.select span {
  user-select: none;
  -webkit-user-select: none;
}

.select:not([aria-disabled='true']):hover {
  background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}

.select[aria-disabled='true'] {
  opacity: 0.6;
  cursor: default;
}

.select:focus,
.select:focus-within {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}

.combobox-label {
  gap: 1rem;
  left: 2rem;
  display: flex;
  position: absolute;
  align-items: center;
  pointer-events: none;
}

.example-arrow {
  right: 1rem;
  position: absolute;
  pointer-events: none;
  transition: transform 150ms ease-in-out;
}

.select[aria-expanded='true'] .example-arrow {
  transform: rotate(180deg);
}

.example-popup-container {
  width: 100%;
  padding: 0.5rem;
  margin-top: 8px;
  border-radius: 2rem;
  background-color: var(--septenary-contrast);
  font-size: 0.9rem;
  max-height: 13rem;
  box-sizing: border-box;
  overflow: hidden;
  animation: smoothPopupOpen 150ms ease-out forwards;
  transform-origin: top;
}

@keyframes smoothPopupOpen {
  0% {
    max-height: 0;
    opacity: 0;
  }
  16.7% {
    opacity: 1;
  }
  100% {
    max-height: 13rem;
    opacity: 1;
  }
}

[ngListbox] {
  gap: 2px;
  padding: 2px;
  height: 100%;
  display: flex;
  overflow: auto;
  flex-direction: column;
}

[ngOption] {
  display: flex;
  cursor: pointer;
  align-items: center;
  padding: 0 1rem;
  min-height: 3rem;
  border-radius: 3rem;
}

[ngOption]:hover,
[ngOption][data-active='true'] {
  background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}

[ngOption][data-active='true'] {
  outline-offset: -2px;
  outline: 2px solid var(--primary);
}

[ngOption][aria-selected='true'] {
  color: var(--primary);
  background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}

[ngOption]:not([aria-selected='true']) .example-option-check {
  display: none;
}

.example-option-check {
  font-size: 0.9rem;
}

.example-option-text {
  flex: 1;
}
ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';

@Component({
  selector: 'app-root[theme="disabled-retro"], app-root:not([theme])',
  templateUrl: './app.html',
  styleUrl: './app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
  readonly listbox = viewChild(Listbox);

  readonly selectedValues = signal<string[]>([]);
  readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
  readonly popupExpanded = signal(false);

  readonly labels = [
    'Important',
    'Starred',
    'Work',
    'Personal',
    'To Do',
    'Later',
    'Read',
    'Travel',
  ];

  constructor() {
    afterRenderEffect(() => {
      this.listbox()?.scrollActiveItemIntoView();
    });
  }

  onCommit() {
    this.popupExpanded.set(false);
  }
}
html
<div
  ngCombobox
  #combobox="ngCombobox"
  [(expanded)]="popupExpanded"
  [preserveContent]="true"
  class="select"
  disabled
>
  <span class="combobox-label">
    <span class="selected-label-text">{{ displayValue() }}</span>
  </span>
  <span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
    >arrow_drop_down</span
  >
</div>

<ng-template
  [cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
  [cdkConnectedOverlayOpen]="popupExpanded()"
>
  <ng-template ngComboboxPopup [combobox]="combobox">
    <div class="example-popup-container">
      <div
        #listbox="ngListbox"
        ngListbox
        ngComboboxWidget
        [tabindex]="-1"
        focusMode="activedescendant"
        selectionMode="explicit"
        [(value)]="selectedValues"
        [activeDescendant]="listbox.activeDescendant()"
        (click)="onCommit()"
        (keydown.enter)="onCommit()"
        (keydown.space)="onCommit()"
      >
        @for (label of labels; track label) {
          <div ngOption [value]="label" [label]="label">
            <span class="example-option-text">{{ label }}</span>
            <span
              class="example-option-check material-symbols-outlined"
              translate="no"
              aria-hidden="true"
              >check</span
            >
          </div>
        }
      </div>
    </div>
  </ng-template>
</ng-template>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

:host {
  display: flex;
  justify-content: center;
  font-size: 0.8rem;

  font-family: 'Press Start 2P';
  --retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
  --retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
  --retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
  --retro-elevated-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast);
  --retro-flat-shadow:
    4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
    -4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
  --retro-clickable-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
  --retro-pressed-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-dark),
    inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}

.select,
[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;
}

وقتی disabled باشد، select یک visual state مربوط به disabled نمایش می‌دهد و همه user interactionها را block می‌کند. Screen readerها disabled state را برای کاربران assistive technology announce می‌کنند.

Testing

pattern مربوط به select را می‌توان با ترکیبی از ComboboxHarness و ListboxHarness از @angular/aria/combobox/testing و @angular/aria/listbox/testing test کرد. این نمونه نشان می‌دهد چطور از harnessها برای test کردن یک کامپوننت select استفاده کنید:

ts
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 reference

برای مستندات دقیق API، referenceهای API زیر را بررسی کنید:

Positioning

pattern مربوط به select با CDK Overlay برای smart positioning integrate می‌شود. از cdkConnectedOverlay استفاده کنید تا edgeهای viewport و scrolling به صورت خودکار مدیریت شوند.