マルチセレクト
概要
読み取り専用コンボボックスと複数選択が有効なリストボックスを組み合わせて、キーボードナビゲーションとスクリーンリーダーをサポートする複数選択ドロップダウンを作成するパターンです。
app.ts
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The icon that is displayed in the combobox. */
displayIcon = computed(() => {
const values = this.listbox()?.values() || [];
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
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() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
app.html
<div ngCombobox readonly>
<div #origin 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>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@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>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.select {
display: flex;
position: relative;
align-items: center;
color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 3.5rem;
height: 2.5rem;
border: none;
}
[ngCombobox]:focus-within .select {
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;
}
[ngComboboxInput][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;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[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 {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The icon that is displayed in the combobox. */
displayIcon = computed(() => {
const values = this.listbox()?.values() || [];
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
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() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
app.html
<div ngCombobox class="material-select" readonly>
<div #origin 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>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@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>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
--primary: var(--hot-pink);
--on-primary: var(--page-background);
}
.docs-light-mode {
--on-primary: #fff;
}
.select {
display: flex;
position: relative;
align-items: center;
border-radius: 3rem;
color: var(--on-primary);
background-color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
border: none;
cursor: pointer;
height: 3rem;
padding: 0 3.5rem;
}
[ngCombobox]:focus-within .select {
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;
}
[ngComboboxInput][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;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[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 {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The icon that is displayed in the combobox. */
displayIcon = computed(() => {
const values = this.listbox()?.values() || [];
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
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() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
app.html
<div ngCombobox class="retro-select" readonly>
<div #origin 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>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@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>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
}
.select:hover,
.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:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 6rem;
height: 2.5rem;
border: none;
}
.select:has([ngComboboxInput][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;
}
[ngComboboxInput][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;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[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項目以上あり、検索が必要 - マルチセレクト機能付きのオートコンプリートパターンを使用してください
- ほとんどまたはすべてのオプションが選択される - チェックリストパターンの方が視認性が高いです
- 選択肢が独立した二者択一のオプションである - 個別のチェックボックスの方が選択肢をより明確に伝えます
機能
マルチセレクトパターンはComboboxとListboxディレクティブを組み合わせ、以下の機能を備えた完全にアクセシブルなドロップダウンを提供します:
- キーボードナビゲーション - 矢印キーでオプションを移動、Spaceキーで切り替え、Escapeキーで閉じます
- スクリーンリーダーのサポート - aria-multiselectableを含む組み込みのARIA属性
- 選択数の表示 - 複数選択時に「アイテム + 他2件」のようなコンパクトなパターンを表示します
- シグナルベースのリアクティビティ - Angularのシグナルを使用したリアクティブな状態管理
- スマートな配置 - CDK Overlayがビューポートの端やスクロールを処理します
- 永続的な選択 - 選択後も、選択されたオプションはチェックマーク付きで表示されたままになります
例
基本的な複数選択
ユーザーはオプションのリストから複数のアイテムを選択する必要があります。読み取り専用のコンボボックスと複数選択が有効なリストボックスを組み合わせることで、完全なアクセシビリティサポートを備えた使い慣れた複数選択の機能を提供します。
app.ts
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
labels = ['Important', 'Starred', 'Work', 'Personal', 'To Do', 'Later', 'Read', 'Travel'];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
app.html
<div ngCombobox readonly>
<div #origin class="select">
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@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>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.select {
display: flex;
position: relative;
align-items: center;
color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 2.5rem;
height: 2.5rem;
border: none;
}
[ngCombobox]:focus-within .select {
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
.combobox-label {
gap: 1rem;
left: 1.5rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][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;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[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 {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
labels = ['Important', 'Starred', 'Work', 'Personal', 'To Do', 'Later', 'Read', 'Travel'];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
app.html
<div ngCombobox class="material-select" readonly>
<div #origin class="select">
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@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>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
--primary: var(--hot-pink);
--on-primary: var(--page-background);
}
.docs-light-mode {
--on-primary: #fff;
}
.select {
display: flex;
position: relative;
align-items: center;
border-radius: 3rem;
color: var(--on-primary);
background-color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
[ngComboboxInput] {
opacity: 0;
border: none;
cursor: pointer;
height: 3rem;
padding: 0 2.5rem;
}
[ngCombobox]:focus-within .select {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.combobox-label {
gap: 1rem;
left: 1.5rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][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;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[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 {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
labels = ['Important', 'Starred', 'Work', 'Personal', 'To Do', 'Later', 'Read', 'Travel'];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
app.html
<div ngCombobox class="retro-select" readonly>
<div #origin class="select">
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@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>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
}
.select:hover,
.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:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 5rem;
height: 2.5rem;
border: none;
}
.select:has([ngComboboxInput][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;
}
[ngComboboxInput][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;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[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;
}
ngListboxのmulti属性は複数選択を有効にします。スペースキーを押すとオプションが切り替わり、ポップアップは追加の選択のために開いたままになります。表示には、最初に選択されたアイテムと残りの選択数が表示されます。
カスタム表示の複数選択
オプションには、ユーザーが選択肢を識別しやすくするために、アイコンや色などの視覚的なインジケーターが必要になることがよくあります。オプション内のカスタムテンプレートを使用すると、リッチなフォーマットが可能になり、表示値にはコンパクトなサマリーが表示されます。
app.ts
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The icon that is displayed in the combobox. */
displayIcon = computed(() => {
const values = this.listbox()?.values() || [];
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
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() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
app.html
<div ngCombobox readonly>
<div #origin 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>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@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>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.select {
display: flex;
position: relative;
align-items: center;
color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 3.5rem;
height: 2.5rem;
border: none;
}
[ngCombobox]:focus-within .select {
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;
}
[ngComboboxInput][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;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[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 {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The icon that is displayed in the combobox. */
displayIcon = computed(() => {
const values = this.listbox()?.values() || [];
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
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() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
app.html
<div ngCombobox class="material-select" readonly>
<div #origin 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>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@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>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
--primary: var(--hot-pink);
--on-primary: var(--page-background);
}
.docs-light-mode {
--on-primary: #fff;
}
.select {
display: flex;
position: relative;
align-items: center;
border-radius: 3rem;
color: var(--on-primary);
background-color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
border: none;
cursor: pointer;
height: 3rem;
padding: 0 3.5rem;
}
[ngCombobox]:focus-within .select {
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;
}
[ngComboboxInput][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;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[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 {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The icon that is displayed in the combobox. */
displayIcon = computed(() => {
const values = this.listbox()?.values() || [];
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
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() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
app.html
<div ngCombobox class="retro-select" readonly>
<div #origin 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>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@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>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
}
.select:hover,
.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:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 6rem;
height: 2.5rem;
border: none;
}
.select:has([ngComboboxInput][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;
}
[ngComboboxInput][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;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[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 {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select 2 labels';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} & ${values[1]}`;
});
/** The labels that are available for selection. */
labels = [
{value: 'Important', disabled: computed(() => this.isOptionDisabled('Important'))},
{value: 'Starred', disabled: computed(() => this.isOptionDisabled('Starred'))},
{value: 'Work', disabled: computed(() => this.isOptionDisabled('Work'))},
{value: 'Personal', disabled: computed(() => this.isOptionDisabled('Personal'))},
{value: 'To Do', disabled: computed(() => this.isOptionDisabled('To Do'))},
{value: 'Later', disabled: computed(() => this.isOptionDisabled('Later'))},
{value: 'Read', disabled: computed(() => this.isOptionDisabled('Read'))},
{value: 'Travel', disabled: computed(() => this.isOptionDisabled('Travel'))},
];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
isOptionDisabled(value: string) {
const values = this.listbox()?.values();
if (!values || values.length < 2) {
return false;
}
return !values.includes(value);
}
}
app.html
<div ngCombobox readonly>
<div #origin class="select">
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value" [disabled]="label.disabled()">
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.select {
display: flex;
position: relative;
align-items: center;
color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 2.5rem;
height: 2.5rem;
border: none;
}
[ngCombobox]:focus-within .select {
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
.combobox-label {
gap: 1rem;
left: 1.5rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][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;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[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;
}
[ngOption][aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
[ngOption][aria-disabled='true']:hover {
background-color: transparent;
}
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
app.ts
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select 2 labels';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} & ${values[1]}`;
});
/** The labels that are available for selection. */
labels = [
{value: 'Important', disabled: computed(() => this.isOptionDisabled('Important'))},
{value: 'Starred', disabled: computed(() => this.isOptionDisabled('Starred'))},
{value: 'Work', disabled: computed(() => this.isOptionDisabled('Work'))},
{value: 'Personal', disabled: computed(() => this.isOptionDisabled('Personal'))},
{value: 'To Do', disabled: computed(() => this.isOptionDisabled('To Do'))},
{value: 'Later', disabled: computed(() => this.isOptionDisabled('Later'))},
{value: 'Read', disabled: computed(() => this.isOptionDisabled('Read'))},
{value: 'Travel', disabled: computed(() => this.isOptionDisabled('Travel'))},
];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
isOptionDisabled(value: string) {
const values = this.listbox()?.values();
if (!values || values.length < 2) {
return false;
}
return !values.includes(value);
}
}
app.html
<div ngCombobox class="material-select" readonly>
<div #origin class="select">
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value" [disabled]="label.disabled()">
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
--primary: var(--hot-pink);
--on-primary: var(--page-background);
}
.docs-light-mode {
--on-primary: #fff;
}
.select {
display: flex;
position: relative;
align-items: center;
border-radius: 3rem;
color: var(--on-primary);
background-color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
[ngComboboxInput] {
opacity: 0;
border: none;
cursor: pointer;
height: 3rem;
padding: 0 2.5rem;
}
[ngCombobox]:focus-within .select {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.combobox-label {
gap: 1rem;
left: 1.5rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][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;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[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;
}
[ngOption][aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
[ngOption][aria-disabled='true']:hover {
background-color: transparent;
}
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
app.ts
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select 2 labels';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} & ${values[1]}`;
});
/** The labels that are available for selection. */
labels = [
{value: 'Important', disabled: computed(() => this.isOptionDisabled('Important'))},
{value: 'Starred', disabled: computed(() => this.isOptionDisabled('Starred'))},
{value: 'Work', disabled: computed(() => this.isOptionDisabled('Work'))},
{value: 'Personal', disabled: computed(() => this.isOptionDisabled('Personal'))},
{value: 'To Do', disabled: computed(() => this.isOptionDisabled('To Do'))},
{value: 'Later', disabled: computed(() => this.isOptionDisabled('Later'))},
{value: 'Read', disabled: computed(() => this.isOptionDisabled('Read'))},
{value: 'Travel', disabled: computed(() => this.isOptionDisabled('Travel'))},
];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
isOptionDisabled(value: string) {
const values = this.listbox()?.values();
if (!values || values.length < 2) {
return false;
}
return !values.includes(value);
}
}
app.html
<div ngCombobox class="retro-select" readonly>
<div #origin class="select">
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value" [disabled]="label.disabled()">
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
}
.select:hover,
.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:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 5rem;
height: 2.5rem;
border: none;
}
.select:has([ngComboboxInput][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;
}
[ngComboboxInput][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;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[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;
}
[ngOption][aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
[ngOption][aria-disabled='true']:hover {
background-color: transparent;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
この例では、選択を3つのアイテムに制限しています。制限に達すると、選択されていないオプションは無効になり、追加の選択ができなくなります。メッセージでユーザーに制約を通知します。
API
マルチセレクトパターンは、AngularのAriaライブラリから以下のディレクティブを使用します。詳細なAPIドキュメントについては、リンク先のガイドを参照してください。
Comboboxディレクティブ
マルチセレクトパターンでは、ngComboboxとreadonly属性を使用して、キーボードナビゲーションを維持しながらテキスト入力を防ぎます。
入力
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
readonly |
boolean |
false |
trueに設定するとドロップダウンの動作になります |
disabled |
boolean |
false |
マルチセレクト全体を無効化します |
利用可能なすべての入力とシグナルの詳細については、Combobox APIドキュメントを参照してください。
Listboxディレクティブ
マルチセレクトパターンでは、複数選択のためにngListboxとmulti属性を、各選択可能な項目のためにngOptionを使用します。
入力
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
multi |
boolean |
false |
trueに設定すると複数選択が可能になります |
モデル
| プロパティ | 型 | 説明 |
|---|---|---|
values |
any[] |
選択された値の双方向バインディング可能な配列 |
multiがtrueの場合、ユーザーはスペースキーを使用して選択を切り替えることで、複数のオプションを選択できます。ポップアップは選択後も開いたままで、追加の選択が可能です。
リストボックスの設定、選択モード、オプションのプロパティに関する完全な詳細については、Listbox APIドキュメントを参照してください。
ポジショニング
マルチセレクトパターンは、スマートなポジショニングのためにCDK Overlayと統合されています。cdkConnectedOverlayを使用すると、ビューポートの端やスクロールを自動的に処理できます。