개요
콤보박스와 리스트박스를 결합해서 키보드 탐색과 스크린 리더를 지원하는 단일 선택 드롭다운을 만드는 패턴입니다.
app.ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root[theme="icons-basic"], app-root:not([theme])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
readonly listbox = viewChild(Listbox);
readonly selectedValues = signal<string[]>([]);
readonly popupExpanded = signal(false);
readonly displayIcon = computed(() => {
const val = this.selectedValues()[0];
const label = this.labels.find((label) => label.value === val);
return label ? label.icon : '';
});
readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly labels = [
{value: 'Important', icon: 'label'},
{value: 'Starred', icon: 'star'},
{value: 'Work', icon: 'work'},
{value: 'Personal', icon: 'person'},
{value: 'To Do', icon: 'checklist'},
{value: 'Later', icon: 'schedule'},
{value: 'Read', icon: 'menu_book'},
{value: 'Travel', icon: 'flight'},
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
>
<span class="combobox-label">
<span class="selected-label-icon material-symbols-outlined" translate="no" aria-hidden="true">{{
displayIcon()
}}</span>
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup-container">
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
[tabindex]="-1"
focusMode="activedescendant"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
(click)="onCommit()"
(keydown.enter)="onCommit()"
(keydown.space)="onCommit()"
>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
cursor: pointer;
padding: 0 3.5rem;
height: 2.5rem;
box-sizing: border-box;
width: 14rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover {
background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select:focus,
.select:focus-within {
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 0.5rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
margin: 1px;
padding: 0 1rem;
min-height: 2.25rem;
border-radius: 0.5rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
app.ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root[theme="icons-material"], app-root:not([theme])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
readonly listbox = viewChild(Listbox);
readonly selectedValues = signal<string[]>([]);
readonly popupExpanded = signal(false);
readonly displayIcon = computed(() => {
const val = this.selectedValues()[0];
const label = this.labels.find((label) => label.value === val);
return label ? label.icon : '';
});
readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly labels = [
{value: 'Important', icon: 'label'},
{value: 'Starred', icon: 'star'},
{value: 'Work', icon: 'work'},
{value: 'Personal', icon: 'person'},
{value: 'To Do', icon: 'checklist'},
{value: 'Later', icon: 'schedule'},
{value: 'Read', icon: 'menu_book'},
{value: 'Travel', icon: 'flight'},
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
>
<span class="combobox-label">
<span class="selected-label-icon material-symbols-outlined" translate="no" aria-hidden="true">{{
displayIcon()
}}</span>
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup-container">
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
[tabindex]="-1"
focusMode="activedescendant"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
(click)="onCommit()"
(keydown.enter)="onCommit()"
(keydown.space)="onCommit()"
>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
--primary: var(--hot-pink);
--on-primary: var(--page-background);
}
.docs-light-mode {
--on-primary: #fff;
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
border-radius: 3rem;
color: var(--on-primary);
background-color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
cursor: pointer;
height: 3rem;
padding: 0 3.5rem;
box-sizing: border-box;
width: 14rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover {
background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select:focus,
.select:focus-within {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 2rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 13rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 13rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
min-height: 3rem;
border-radius: 3rem;
}
[ngOption]:hover,
[ngOption][data-active='true'] {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid var(--primary);
}
[ngOption][aria-selected='true'] {
color: var(--primary);
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
app.ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root[theme="icons-retro"], app-root:not([theme])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
readonly listbox = viewChild(Listbox);
readonly selectedValues = signal<string[]>([]);
readonly popupExpanded = signal(false);
readonly displayIcon = computed(() => {
const val = this.selectedValues()[0];
const label = this.labels.find((label) => label.value === val);
return label ? label.icon : '';
});
readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly labels = [
{value: 'Important', icon: 'label'},
{value: 'Starred', icon: 'star'},
{value: 'Work', icon: 'work'},
{value: 'Personal', icon: 'person'},
{value: 'To Do', icon: 'checklist'},
{value: 'Later', icon: 'schedule'},
{value: 'Read', icon: 'menu_book'},
{value: 'Travel', icon: 'flight'},
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
>
<span class="combobox-label">
<span class="selected-label-icon material-symbols-outlined" translate="no" aria-hidden="true">{{
displayIcon()
}}</span>
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup-container">
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
[tabindex]="-1"
focusMode="activedescendant"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
(click)="onCommit()"
(keydown.enter)="onCommit()"
(keydown.space)="onCommit()"
>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-pressed-tint: #fbfbfb;
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
:host-context(.docs-dark-mode) {
--retro-pressed-tint: #151417;
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
cursor: pointer;
padding: 0 4.5rem;
height: 2.5rem;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover,
.select:focus,
.select:focus-within {
transform: translate(1px, 1px);
}
.select:active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--retro-pressed-tint));
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select[aria-expanded='false']:focus,
.select[aria-expanded='false']:focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px dashed var(--hot-pink);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
사용법
선택 패턴은 사용자가 익숙한 옵션 중에서 값 하나를 골라야 할 때 적합합니다.
다음과 같은 경우에 이 패턴 사용을 고려하세요.
- 옵션 목록이 고정된 경우(20개 미만): 사용자가 필터링 없이 훑어보고 고를 수 있습니다.
- 옵션이 익숙한 경우: 검색하지 않고도 선택지를 알아볼 수 있습니다.
- 폼에 표준 필드가 필요한 경우: 국가, 주, 범주, 상태를 선택할 때 사용합니다.
- 설정과 구성: 환경설정이나 옵션을 위한 드롭다운 메뉴에 사용합니다.
- 옵션 레이블이 명확한 경우: 각 선택지의 이름이 구분되고 쉽게 훑어볼 수 있습니다.
다음과 같은 경우에는 이 패턴을 피하세요.
- 목록의 항목이 20개보다 많은 경우: 더 나은 필터링을 위해 자동 완성 패턴을 사용하세요.
- 사용자가 옵션을 검색해야 하는 경우: 자동 완성은 텍스트 입력과 필터링을 제공합니다.
- 다중 선택이 필요한 경우: 다중 선택 패턴을 사용하세요.
- 옵션이 매우 적은 경우(2~3개): 라디오 버튼이 모든 선택지를 더 잘 보여줍니다.
주요 기능
선택 패턴은 콤보박스와 리스트박스 디렉티브를 결합해서 다음과 같이 접근성을 갖춘 드롭다운을 제공합니다.
- 키보드 탐색: 방향키로 옵션을 탐색하고 Enter로 선택하며 Escape로 닫습니다.
- 스크린 리더 지원: 보조 기술을 위한 ARIA 어트리뷰트를 기본 제공합니다.
- 커스텀 표시: 선택된 값을 아이콘, 서식, 풍부한 콘텐츠로 표시합니다.
- 시그널 기반 반응성: Angular 시그널로 상태를 반응형으로 관리합니다.
- 스마트 위치 지정: CDK Overlay가 뷰포트 경계와 스크롤을 처리합니다.
- 양방향 텍스트 지원: 오른쪽에서 왼쪽으로 쓰는 언어(RTL)를 자동으로 처리합니다.
예제
기본 선택
사용자가 값 목록에서 선택할 수 있는 표준 드롭다운이 필요할 때가 있습니다. 콤보박스와 리스트박스를 함께 사용하면 익숙한 선택 UI와 함께 접근성을 지원할 수 있습니다.
app.ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root[theme="basic-basic"], app-root:not([theme])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
readonly listbox = viewChild(Listbox);
readonly selectedValues = signal<string[]>([]);
readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly popupExpanded = signal(false);
readonly labels = [
'Important',
'Starred',
'Work',
'Personal',
'To Do',
'Later',
'Read',
'Travel',
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
>
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup-container">
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
[tabindex]="-1"
focusMode="activedescendant"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
(click)="onCommit()"
(keydown.enter)="onCommit()"
(keydown.space)="onCommit()"
>
@for (label of labels; track label) {
<div ngOption [value]="label" [label]="label">
<span class="example-option-text">{{ label }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
cursor: pointer;
padding: 0 3rem;
height: 2.5rem;
box-sizing: border-box;
width: 14rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover {
background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.select:focus,
.select:focus-within {
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
.combobox-label {
gap: 1rem;
left: 2rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 0.5rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
margin: 1px;
padding: 0 1rem;
min-height: 2.25rem;
border-radius: 0.5rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
app.ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root[theme="basic-material"], app-root:not([theme])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
readonly listbox = viewChild(Listbox);
readonly selectedValues = signal<string[]>([]);
readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly popupExpanded = signal(false);
readonly labels = [
'Important',
'Starred',
'Work',
'Personal',
'To Do',
'Later',
'Read',
'Travel',
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
>
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup-container">
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
[tabindex]="-1"
focusMode="activedescendant"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
(click)="onCommit()"
(keydown.enter)="onCommit()"
(keydown.space)="onCommit()"
>
@for (label of labels; track label) {
<div ngOption [value]="label" [label]="label">
<span class="example-option-text">{{ label }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
--primary: var(--hot-pink);
--on-primary: var(--page-background);
}
.docs-light-mode {
--on-primary: #fff;
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
border-radius: 3rem;
color: var(--on-primary);
background-color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
cursor: pointer;
height: 3rem;
padding: 0 3rem;
box-sizing: border-box;
width: 14rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover {
background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.select:focus,
.select:focus-within {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.combobox-label {
gap: 1rem;
left: 2rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 2rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 13rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 13rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
min-height: 3rem;
border-radius: 3rem;
}
[ngOption]:hover,
[ngOption][data-active='true'] {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid var(--primary);
}
[ngOption][aria-selected='true'] {
color: var(--primary);
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
app.ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root[theme="basic-retro"], app-root:not([theme])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
readonly listbox = viewChild(Listbox);
readonly selectedValues = signal<string[]>([]);
readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly popupExpanded = signal(false);
readonly labels = [
'Important',
'Starred',
'Work',
'Personal',
'To Do',
'Later',
'Read',
'Travel',
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
>
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup-container">
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
[tabindex]="-1"
focusMode="activedescendant"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
(click)="onCommit()"
(keydown.enter)="onCommit()"
(keydown.space)="onCommit()"
>
@for (label of labels; track label) {
<div ngOption [value]="label" [label]="label">
<span class="example-option-text">{{ label }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-pressed-tint: #fbfbfb;
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
:host-context(.docs-dark-mode) {
--retro-pressed-tint: #151417;
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
cursor: pointer;
padding: 0 4.5rem;
height: 2.5rem;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover,
.select:focus,
.select:focus-within {
transform: translate(1px, 1px);
}
.select:active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--retro-pressed-tint));
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select[aria-expanded='false']:focus,
.select[aria-expanded='false']:focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px dashed var(--hot-pink);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
<input> 대신 div나 button과 같은 비대화형 호스트 엘리먼트에 ngCombobox 디렉티브를 직접 적용해서 텍스트 입력을 방지합니다.
사용자는 네이티브 select 엘리먼트와 마찬가지로 방향키와 Enter로 드롭다운을 조작합니다.
커스텀 표시를 적용한 선택
사용자가 선택지를 빠르게 알아볼 수 있도록 옵션에 아이콘이나 배지 같은 시각적 표시가 필요할 때가 많습니다. 옵션 안에 커스텀 템플릿을 사용하면 접근성을 유지하면서 풍부한 서식을 적용할 수 있습니다.
app.ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root[theme="icons-basic"], app-root:not([theme])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
readonly listbox = viewChild(Listbox);
readonly selectedValues = signal<string[]>([]);
readonly popupExpanded = signal(false);
readonly displayIcon = computed(() => {
const val = this.selectedValues()[0];
const label = this.labels.find((label) => label.value === val);
return label ? label.icon : '';
});
readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly labels = [
{value: 'Important', icon: 'label'},
{value: 'Starred', icon: 'star'},
{value: 'Work', icon: 'work'},
{value: 'Personal', icon: 'person'},
{value: 'To Do', icon: 'checklist'},
{value: 'Later', icon: 'schedule'},
{value: 'Read', icon: 'menu_book'},
{value: 'Travel', icon: 'flight'},
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
>
<span class="combobox-label">
<span class="selected-label-icon material-symbols-outlined" translate="no" aria-hidden="true">{{
displayIcon()
}}</span>
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup-container">
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
[tabindex]="-1"
focusMode="activedescendant"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
(click)="onCommit()"
(keydown.enter)="onCommit()"
(keydown.space)="onCommit()"
>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
cursor: pointer;
padding: 0 3.5rem;
height: 2.5rem;
box-sizing: border-box;
width: 14rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover {
background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select:focus,
.select:focus-within {
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 0.5rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
margin: 1px;
padding: 0 1rem;
min-height: 2.25rem;
border-radius: 0.5rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
app.ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root[theme="icons-material"], app-root:not([theme])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
readonly listbox = viewChild(Listbox);
readonly selectedValues = signal<string[]>([]);
readonly popupExpanded = signal(false);
readonly displayIcon = computed(() => {
const val = this.selectedValues()[0];
const label = this.labels.find((label) => label.value === val);
return label ? label.icon : '';
});
readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly labels = [
{value: 'Important', icon: 'label'},
{value: 'Starred', icon: 'star'},
{value: 'Work', icon: 'work'},
{value: 'Personal', icon: 'person'},
{value: 'To Do', icon: 'checklist'},
{value: 'Later', icon: 'schedule'},
{value: 'Read', icon: 'menu_book'},
{value: 'Travel', icon: 'flight'},
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
>
<span class="combobox-label">
<span class="selected-label-icon material-symbols-outlined" translate="no" aria-hidden="true">{{
displayIcon()
}}</span>
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup-container">
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
[tabindex]="-1"
focusMode="activedescendant"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
(click)="onCommit()"
(keydown.enter)="onCommit()"
(keydown.space)="onCommit()"
>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
--primary: var(--hot-pink);
--on-primary: var(--page-background);
}
.docs-light-mode {
--on-primary: #fff;
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
border-radius: 3rem;
color: var(--on-primary);
background-color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
cursor: pointer;
height: 3rem;
padding: 0 3.5rem;
box-sizing: border-box;
width: 14rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover {
background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select:focus,
.select:focus-within {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 2rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 13rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 13rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
min-height: 3rem;
border-radius: 3rem;
}
[ngOption]:hover,
[ngOption][data-active='true'] {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid var(--primary);
}
[ngOption][aria-selected='true'] {
color: var(--primary);
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
app.ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root[theme="icons-retro"], app-root:not([theme])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
readonly listbox = viewChild(Listbox);
readonly selectedValues = signal<string[]>([]);
readonly popupExpanded = signal(false);
readonly displayIcon = computed(() => {
const val = this.selectedValues()[0];
const label = this.labels.find((label) => label.value === val);
return label ? label.icon : '';
});
readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly labels = [
{value: 'Important', icon: 'label'},
{value: 'Starred', icon: 'star'},
{value: 'Work', icon: 'work'},
{value: 'Personal', icon: 'person'},
{value: 'To Do', icon: 'checklist'},
{value: 'Later', icon: 'schedule'},
{value: 'Read', icon: 'menu_book'},
{value: 'Travel', icon: 'flight'},
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
>
<span class="combobox-label">
<span class="selected-label-icon material-symbols-outlined" translate="no" aria-hidden="true">{{
displayIcon()
}}</span>
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup-container">
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
[tabindex]="-1"
focusMode="activedescendant"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
(click)="onCommit()"
(keydown.enter)="onCommit()"
(keydown.space)="onCommit()"
>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-pressed-tint: #fbfbfb;
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
:host-context(.docs-dark-mode) {
--retro-pressed-tint: #151417;
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
cursor: pointer;
padding: 0 4.5rem;
height: 2.5rem;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:hover,
.select:focus,
.select:focus-within {
transform: translate(1px, 1px);
}
.select:active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--retro-pressed-tint));
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select[aria-expanded='false']:focus,
.select[aria-expanded='false']:focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px dashed var(--hot-pink);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
각 옵션은 레이블 옆에 아이콘을 표시합니다. 선택된 값의 표시도 선택한 옵션의 아이콘과 텍스트로 바뀌어 결과를 시각적으로 명확하게 보여줍니다.
비활성화된 선택
폼의 특정 조건이 충족되지 않았을 때 선택 컨트롤을 비활성화해서 사용자 조작을 막을 수 있습니다. 비활성화 상태는 시각적인 피드백을 제공하고 키보드 조작을 막습니다.
app.ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root[theme="disabled-basic"], app-root:not([theme])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
readonly listbox = viewChild(Listbox);
readonly selectedValues = signal<string[]>([]);
readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly popupExpanded = signal(false);
readonly labels = [
'Important',
'Starred',
'Work',
'Personal',
'To Do',
'Later',
'Read',
'Travel',
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
disabled
>
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup-container">
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
[tabindex]="-1"
focusMode="activedescendant"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
(click)="onCommit()"
(keydown.enter)="onCommit()"
(keydown.space)="onCommit()"
>
@for (label of labels; track label) {
<div ngOption [value]="label" [label]="label">
<span class="example-option-text">{{ label }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
cursor: pointer;
padding: 0 3rem;
height: 2.5rem;
box-sizing: border-box;
width: 14rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:not([aria-disabled='true']):hover {
background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.select:focus,
.select:focus-within {
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
.combobox-label {
gap: 1rem;
left: 2rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 0.5rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
margin: 1px;
padding: 0 1rem;
min-height: 2.25rem;
border-radius: 0.5rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
app.ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root[theme="disabled-material"], app-root:not([theme])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
readonly listbox = viewChild(Listbox);
readonly selectedValues = signal<string[]>([]);
readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly popupExpanded = signal(false);
readonly labels = [
'Important',
'Starred',
'Work',
'Personal',
'To Do',
'Later',
'Read',
'Travel',
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
disabled
>
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup-container">
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
[tabindex]="-1"
focusMode="activedescendant"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
(click)="onCommit()"
(keydown.enter)="onCommit()"
(keydown.space)="onCommit()"
>
@for (label of labels; track label) {
<div ngOption [value]="label" [label]="label">
<span class="example-option-text">{{ label }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
--primary: var(--hot-pink);
--on-primary: var(--page-background);
}
.docs-light-mode {
--on-primary: #fff;
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
border-radius: 3rem;
color: var(--on-primary);
background-color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
cursor: pointer;
height: 3rem;
padding: 0 3rem;
box-sizing: border-box;
width: 14rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:not([aria-disabled='true']):hover {
background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.select:focus,
.select:focus-within {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.combobox-label {
gap: 1rem;
left: 2rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 2rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 13rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 13rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
min-height: 3rem;
border-radius: 3rem;
}
[ngOption]:hover,
[ngOption][data-active='true'] {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid var(--primary);
}
[ngOption][aria-selected='true'] {
color: var(--primary);
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
app.ts
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root[theme="disabled-retro"], app-root:not([theme])',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class App {
readonly listbox = viewChild(Listbox);
readonly selectedValues = signal<string[]>([]);
readonly displayValue = computed(() => this.selectedValues()[0] || 'Select a label');
readonly popupExpanded = signal(false);
readonly labels = [
'Important',
'Starred',
'Work',
'Personal',
'To Do',
'Later',
'Read',
'Travel',
];
constructor() {
afterRenderEffect(() => {
this.listbox()?.scrollActiveItemIntoView();
});
}
onCommit() {
this.popupExpanded.set(false);
}
}
app.html
<div
ngCombobox
#combobox="ngCombobox"
[(expanded)]="popupExpanded"
[preserveContent]="true"
class="select"
disabled
>
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template
[cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()"
>
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup-container">
<div
#listbox="ngListbox"
ngListbox
ngComboboxWidget
[tabindex]="-1"
focusMode="activedescendant"
selectionMode="explicit"
[(value)]="selectedValues"
[activeDescendant]="listbox.activeDescendant()"
(click)="onCommit()"
(keydown.enter)="onCommit()"
(keydown.space)="onCommit()"
>
@for (label of labels; track label) {
<div ngOption [value]="label" [label]="label">
<span class="example-option-text">{{ label }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-pressed-tint: #fbfbfb;
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
:host-context(.docs-dark-mode) {
--retro-pressed-tint: #151417;
}
.select,
[ngListbox],
[ngOption] {
outline: none;
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
cursor: pointer;
padding: 0 4.5rem;
height: 2.5rem;
box-sizing: border-box;
width: 16rem;
user-select: none;
-webkit-user-select: none;
}
.select span {
user-select: none;
-webkit-user-select: none;
}
.select:not([aria-disabled='true']):hover,
.select:not([aria-disabled='true']):focus,
.select:not([aria-disabled='true']):focus-within {
transform: translate(1px, 1px);
}
.select:not([aria-disabled='true']):active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--retro-pressed-tint));
}
.select[aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
.select[aria-expanded='false']:focus,
.select[aria-expanded='false']:focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
.select[aria-expanded='true'] .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
box-sizing: border-box;
overflow: hidden;
animation: smoothPopupOpen 150ms ease-out forwards;
transform-origin: top;
}
@keyframes smoothPopupOpen {
0% {
max-height: 0;
opacity: 0;
}
16.7% {
opacity: 1;
}
100% {
max-height: 11rem;
opacity: 1;
}
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px dashed var(--hot-pink);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
비활성화하면 선택 컨트롤은 비활성화된 모습을 표시하고 모든 사용자 조작을 차단합니다. 스크린 리더는 보조 기술 사용자에게 비활성화 상태를 안내합니다.
테스트
선택 패턴은 @angular/aria/combobox/testing의 ComboboxHarness와 @angular/aria/listbox/testing의 ListboxHarness를 함께 사용해서 테스트할 수 있습니다.
하네스로 선택 컴포넌트를 테스트하는 예제는 다음과 같습니다.
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'; // 테스트할 컴포넌트
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 () => {
// 선택 트리거 역할을 하는 콤보박스 하네스를 불러옵니다.
const select = await loader.getHarness(ComboboxHarness);
// 처음에는 닫혀 있는지 확인합니다.
expect(await select.isOpen()).toBe(false);
// 드롭다운을 엽니다.
await select.open();
expect(await select.isOpen()).toBe(true);
// 팝업에서 리스트박스 하네스를 가져옵니다.
const listbox = await select.getPopupWidget(ListboxHarness);
const options = await listbox.getOptions();
expect(options.length).toBe(3);
// 두 번째 옵션을 클릭합니다.
await options[1].click();
// 드롭다운이 닫히고 값이 변경되었는지 확인합니다.
expect(await select.isOpen()).toBe(false);
expect(await (await select.host()).text()).toContain('Option 2');
});
});
API 레퍼런스
자세한 API 문서는 다음 API 레퍼런스를 참고하세요.
위치 지정
선택 패턴은 CDK Overlay와 통합하여 팝업 위치를 지정합니다. cdkConnectedOverlay를 사용하면 뷰포트 경계와 스크롤을 자동으로 처리할 수 있습니다.