리스트박스 패턴
리스트박스 API 레퍼런스
사용자가 선택할 수 있는 옵션 목록을 표시하는 디렉티브입니다.
키보드 탐색, 단일/다중 선택, 스크린 리더를 지원합니다.
import { Listbox , Option } from '@angular/aria/listbox' ;
import { Component } from '@angular/core' ;
@ Component ({
selector: 'app-root' ,
templateUrl: './app.html' ,
styleUrl: './app.css' ,
imports: [ Listbox , Option ],
})
export class App {
/** The options available in the listbox. */
options = [
'Option 1' ,
'Option 2' ,
'Option 3' ,
'Option 4' ,
'Option 5' ,
'Option 6' ,
'Option 7' ,
'Option 8' ,
];
}
< div class = "listbox-container" >
< div ngListbox [value] = "['Option 1']" >
@for ( option of options; track option) {
< div ngOption [value] = "option" >
< span class = "example-option-text" >{{ option }}</ span >
< span
class = "example-option-check material-symbols-outlined"
translate = "no"
aria-hidden = "true"
>check</ span
>
</ div >
}
</ div >
</ div >
@import url ( 'https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined' );
:host {
display : flex ;
justify-content : center ;
font-family : var ( --inter-font );
}
.listbox-container {
width : 200 px ;
height : 11 rem ;
padding : 0.5 rem ;
border-radius : 0.5 rem ;
background-color : var ( --septenary-contrast );
font-size : 0.9 rem ;
}
[ ngListbox ] {
gap : 2 px ;
height : 100 % ;
display : flex ;
overflow : auto ;
flex-direction : column ;
}
[ ngOption ] {
display : flex ;
cursor : pointer ;
align-items : center ;
margin : 1 px ;
padding : 0 1 rem ;
min-height : 2.25 rem ;
border-radius : 0.5 rem ;
}
[ ngOption ] :hover {
background-color : color-mix ( in srgb , var ( --primary-contrast ) 5 % , transparent );
}
[ ngOption ][ data-active = 'true' ] {
outline-offset : -2 px ;
outline : 2 px 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.9 rem ;
}
.example-option-text {
flex : 1 ;
}
import { Listbox , Option } from '@angular/aria/listbox' ;
import { Component } from '@angular/core' ;
@ Component ({
selector: 'app-root' ,
templateUrl: './app.html' ,
styleUrl: './app.css' ,
imports: [ Listbox , Option ],
})
export class App {
/** The options available in the listbox. */
options = [
'Option 1' ,
'Option 2' ,
'Option 3' ,
'Option 4' ,
'Option 5' ,
'Option 6' ,
'Option 7' ,
'Option 8' ,
];
}
< div class = "material-listbox" >
< div ngListbox >
@for ( option of options; track option) {
< div ngOption [value] = "option" >
< span class = "example-option-text" >{{ option }}</ span >
< span
class = "example-option-check material-symbols-outlined"
translate = "no"
aria-hidden = "true"
>check</ span
>
</ div >
}
</ div >
</ div >
@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 ;
}
.material-listbox {
width : 200 px ;
height : 13 rem ;
padding : 0.5 rem ;
border-radius : 2 rem ;
background-color : var ( --septenary-contrast );
font-size : 0.9 rem ;
}
[ ngListbox ] {
gap : 2 px ;
padding : 2 px ;
height : 100 % ;
display : flex ;
overflow : auto ;
flex-direction : column ;
}
[ ngOption ] {
display : flex ;
cursor : pointer ;
align-items : center ;
padding : 0 1 rem ;
min-height : 3 rem ;
border-radius : 3 rem ;
}
[ ngOption ] :hover ,
[ ngOption ][ data-active = 'true' ] {
background-color : color-mix ( in srgb , var ( --primary-contrast ) 5 % , transparent );
}
[ ngOption ][ data-active = 'true' ] {
outline-offset : -2 px ;
outline : 2 px 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.9 rem ;
}
.example-option-text {
flex : 1 ;
}
import { Listbox , Option } from '@angular/aria/listbox' ;
import { Component } from '@angular/core' ;
@ Component ({
selector: 'app-root' ,
templateUrl: './app.html' ,
styleUrl: './app.css' ,
imports: [ Listbox , Option ],
})
export class App {
/** The options available in the listbox. */
options = [
'Option 1' ,
'Option 2' ,
'Option 3' ,
'Option 4' ,
'Option 5' ,
'Option 6' ,
'Option 7' ,
'Option 8' ,
];
}
< div class = "retro-listbox" >
< div ngListbox >
@for ( option of options; track option) {
< div ngOption [value] = "option" >
< span class = "example-option-text" >{{ option }}</ span >
< span
class = "example-option-check material-symbols-outlined"
translate = "no"
aria-hidden = "true"
>check</ span
>
</ div >
}
</ div >
</ div >
@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.8 rem ;
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-flat-shadow :
4 px 0 px 0 px 0 px var ( --tertiary-contrast ), 0 px 4 px 0 px 0 px var ( --tertiary-contrast ),
-4 px 0 px 0 px 0 px var ( --tertiary-contrast ), 0 px -4 px 0 px 0 px var ( --tertiary-contrast );
}
.retro-listbox {
width : 200 px ;
height : 11 rem ;
padding : 0.5 rem ;
box-shadow : var ( --retro-flat-shadow );
background-color : var ( --septenary-contrast );
}
[ ngListbox ] {
gap : 2 px ;
height : 100 % ;
display : flex ;
overflow : auto ;
flex-direction : column ;
}
[ ngOption ] {
display : flex ;
cursor : pointer ;
align-items : center ;
padding : 0 1 rem ;
font-size : 0.6 rem ;
min-height : 2.25 rem ;
}
[ ngOption ] :hover {
background-color : color-mix ( in srgb , var ( --primary-contrast ) 5 % , transparent );
}
[ ngOption ][ data-active = 'true' ] {
outline-offset : -2 px ;
outline : 2 px dashed var ( --hot-pink );
}
[ 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-icon ,
.example-option-check {
font-size : 0.9 rem ;
}
.example-option-text {
flex : 1 ;
}
리스트박스는 선택 , 다중 선택 , 자동 완성 패턴에서 사용하는 기본 디렉티브입니다.
대부분의 드롭다운 요구사항에는 이 패턴의 가이드를 참고해서 구현하세요.
다음과 같은 경우에는 리스트박스를 직접 사용하는 것을 고려하세요.
커스텀 선택 컴포넌트 개발 : 특정 동작이 필요한 전용 인터페이스를 만들 때
항상 표시되는 선택 목록 : 선택할 항목을 드롭다운이 아닌 페이지에 직접 표시할 때
커스텀 통합 패턴 : 고유한 팝업이나 레이아웃 요구사항에 맞춰 통합할 때
다음과 같은 경우에는 리스트박스를 피하세요.
탐색 메뉴가 필요한 경우 : 작업과 명령에는 메뉴 디렉티브를 사용하세요.
Angular의 리스트박스는 다음과 같이 접근성을 갖춘 목록 구현을 제공합니다.
키보드 탐색 : 방향키로 옵션을 탐색하고 Enter나 Space로 선택합니다.
스크린 리더 지원 : role="listbox"를 비롯한 ARIA 어트리뷰트를 기본 제공합니다.
단일/다중 선택 : multi 어트리뷰트로 선택 모드를 제어합니다.
수평/수직 배치 : orientation 어트리뷰트로 배치 방향을 지정합니다.
문자 입력 검색 : 문자를 입력하면 일치하는 옵션으로 이동합니다.
시그널 기반 반응성 : Angular 시그널로 상태를 반응형으로 관리합니다.
애플리케이션에서는 선택 목록을 드롭다운 안에 숨기지 않고 페이지에 직접 표시해야 할 때가 있습니다.
리스트박스를 독립적으로 사용하면 이러한 목록 인터페이스에 키보드 탐색과 선택 기능을 제공할 수 있습니다.
import { Listbox , Option } from '@angular/aria/listbox' ;
import { Component } from '@angular/core' ;
@ Component ({
selector: 'app-root' ,
templateUrl: './app.html' ,
styleUrl: './app.css' ,
imports: [ Listbox , Option ],
})
export class App {
/** The options available in the listbox. */
options = [
'Option 1' ,
'Option 2' ,
'Option 3' ,
'Option 4' ,
'Option 5' ,
'Option 6' ,
'Option 7' ,
'Option 8' ,
];
}
< div class = "listbox-container" >
< div ngListbox [value] = "['Option 1']" >
@for ( option of options; track option) {
< div ngOption [value] = "option" >
< span class = "example-option-text" >{{ option }}</ span >
< span
class = "example-option-check material-symbols-outlined"
translate = "no"
aria-hidden = "true"
>check</ span
>
</ div >
}
</ div >
</ div >
value 모델 시그널은 선택된 항목에 대한 양방향 바인딩을 제공합니다.
selectionMode="explicit"이면 사용자가 Space나 Enter를 눌러 옵션을 선택합니다.
리스트박스, 콤보박스, 오버레이 위치 지정을 결합한 드롭다운 패턴은 선택 패턴을 참고하세요.
툴바와 비슷한 인터페이스나 탭 형태의 선택 UI처럼 목록을 수평으로 배치하는 것이 더 적합한 경우도 있습니다.
orientation 어트리뷰트는 배치 방향과 키보드 탐색 방향을 함께 변경합니다.
import { Listbox , Option } from '@angular/aria/listbox' ;
import { Component } from '@angular/core' ;
@ Component ({
selector: 'app-root' ,
templateUrl: './app.html' ,
styleUrl: './app.css' ,
imports: [ Listbox , Option ],
})
export class App {
/** The options available in the listbox. */
amenities = [ 'Washer / Dryer' , 'Ramp access' , 'Garden' , 'Cats OK' , 'Dogs OK' , 'Smoke-free' ];
}
< div ngListbox aria-label = "Amenities" orientation = "horizontal" selectionMode = "explicit" multi >
@for ( amenity of amenities; track amenity) {
< div ngOption [value] = "amenity" [label] = "amenity" >
< span class = "option-label" >{{ amenity }}</ span >
</ div >
}
</ div >
@import url ( 'https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined' );
:host {
font-size : 0.8 rem ;
font-family : var ( --inter-font );
}
[ ngListbox ] {
gap : 0.5 rem ;
display : flex ;
flex-wrap : wrap ;
}
[ ngOption ] {
cursor : pointer ;
border-radius : 1 rem ;
padding : 0.3 rem 1 rem ;
color : var ( --hot-pink );
border : 1 px solid var ( --hot-pink );
background-color : color-mix ( in srgb , var ( --hot-pink ) 5 % , transparent );
}
[ ngOption ] :focus {
outline : 2 px solid var ( --hot-pink );
outline-offset : 2 px ;
}
[ ngOption ] :hover {
background-color : color-mix ( in srgb , var ( --hot-pink ) 15 % , transparent );
}
[ ngOption ][ aria-selected = 'true' ] {
color : var ( --page-background );
background-color : var ( --hot-pink );
}
import { Listbox , Option } from '@angular/aria/listbox' ;
import { Component } from '@angular/core' ;
@ Component ({
selector: 'app-root' ,
templateUrl: './app.html' ,
styleUrl: './app.css' ,
imports: [ Listbox , Option ],
})
export class App {
/** The options available in the listbox. */
amenities = [ 'Washer / Dryer' , 'Ramp access' , 'Garden' , 'Cats OK' , 'Dogs OK' , 'Smoke-free' ];
}
< div
ngListbox
class = "material-listbox"
aria-label = "Amenities"
orientation = "horizontal"
selectionMode = "explicit"
multi
>
@for ( amenity of amenities; track amenity) {
< div ngOption [value] = "amenity" [label] = "amenity" >
< span class = "check-icon material-symbols-outlined" translate = "no" aria-hidden = "true"
>check</ span
>
< span class = "option-label" >{{ amenity }}</ span >
</ div >
}
</ div >
@import url ( 'https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined' );
:host {
font-size : 0.8 rem ;
font-family : var ( --inter-font );
}
[ ngListbox ] {
gap : 0.5 rem ;
display : flex ;
flex-wrap : wrap ;
}
[ ngOption ] {
display : flex ;
cursor : pointer ;
align-items : center ;
border-radius : 0.3 rem ;
padding : 0.3 rem 0.5 rem ;
color : var ( --hot-pink );
border : 1 px solid var ( --hot-pink );
background-color : color-mix ( in srgb , var ( --hot-pink ) 5 % , transparent );
}
[ ngOption ] :focus {
outline : 2 px solid var ( --hot-pink );
outline-offset : 2 px ;
}
[ ngOption ] :hover {
background-color : color-mix ( in srgb , var ( --hot-pink ) 15 % , transparent );
}
[ ngOption ][ aria-selected = 'true' ] {
color : var ( --page-background );
background-color : var ( --hot-pink );
}
.check-icon {
width : 0 ;
font-size : 1.25 rem ;
overflow : hidden ;
transition :
width 0.2 s ease-in-out ,
padding-right 0.2 s ease-in-out ;
}
[ ngOption ][ aria-selected = 'true' ] .check-icon {
width : 1.5 rem ;
padding-right : 0.2 rem ;
}
import { Listbox , Option } from '@angular/aria/listbox' ;
import { Component } from '@angular/core' ;
@ Component ({
selector: 'app-root' ,
templateUrl: './app.html' ,
styleUrl: './app.css' ,
imports: [ Listbox , Option ],
})
export class App {
/** The options available in the listbox. */
amenities = [ 'Washer / Dryer' , 'Ramp access' , 'Garden' , 'Cats OK' , 'Dogs OK' , 'Smoke-free' ];
}
< div
ngListbox
class = "retro-listbox"
aria-label = "Amenities"
orientation = "horizontal"
selectionMode = "explicit"
multi
>
@for ( amenity of amenities; track amenity) {
< div ngOption [value] = "amenity" [label] = "amenity" >
< span class = "check-icon material-symbols-outlined" translate = "no" aria-hidden = "true"
>check</ span
>
< span class = "option-label" >{{ amenity }}</ span >
</ div >
}
</ div >
@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.8 rem ;
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-flat-shadow :
4 px 0 px 0 px 0 px var ( --tertiary-contrast ), 0 px 4 px 0 px 0 px var ( --tertiary-contrast ),
-4 px 0 px 0 px 0 px var ( --tertiary-contrast ), 0 px -4 px 0 px 0 px var ( --tertiary-contrast );
--retro-flat-shadow-small :
2 px 0 px 0 px 0 px var ( --tertiary-contrast ), 0 px 2 px 0 px 0 px var ( --tertiary-contrast ),
-2 px 0 px 0 px 0 px var ( --tertiary-contrast ), 0 px -2 px 0 px 0 px var ( --tertiary-contrast );
}
.retro-listbox {
gap : 0.5 rem ;
display : flex ;
flex-wrap : wrap ;
padding : 0.5 rem ;
box-shadow : var ( --retro-flat-shadow );
background-color : var ( --septenary-contrast );
}
[ ngOption ] {
display : flex ;
cursor : pointer ;
align-items : center ;
padding : 0.3 rem 0.5 rem ;
font-size : 0.6 rem ;
box-shadow : var ( --retro-flat-shadow-small );
}
[ ngOption ] :hover {
background-color : color-mix ( in srgb , var ( --primary-contrast ) 5 % , transparent );
}
[ ngOption ][ data-active = 'true' ] {
outline-offset : -2 px ;
outline : 2 px dashed var ( --hot-pink );
}
[ ngOption ][ aria-selected = 'true' ] {
color : var ( --hot-pink );
background-color : color-mix ( in srgb , var ( --hot-pink ) 5 % , transparent );
}
.check-icon {
width : 0 ;
font-size : 0.9 rem ;
overflow : hidden ;
transition : width 0.2 s ease-in-out ;
}
[ ngOption ][ aria-selected = 'true' ] .check-icon {
width : 1.2 rem ;
}
orientation="horizontal"이면 위/아래 방향키 대신 왼쪽/오른쪽 방향키로 옵션 사이를 이동합니다.
리스트박스는 탐색 방향을 반대로 전환해서 오른쪽에서 왼쪽으로 쓰는 언어(RTL)를 자동으로 처리합니다.
리스트박스는 항목이 선택되는 시점을 제어하는 두 가지 선택 모드를 지원합니다.
'follow' 모드는 포커스가 있는 항목을 자동으로 선택해서 선택이 자주 바뀌는 경우 더 빠르게 조작할 수 있습니다.
'explicit' 모드는 Space나 Enter로 선택을 확정해야 하므로 탐색 도중 실수로 선택이 바뀌는 일을 방지합니다.
드롭다운 패턴의 단일 선택에는 대체로 'follow' 모드를 사용합니다.
import { Component } from '@angular/core' ;
import { Listbox , Option } from '@angular/aria/listbox' ;
@ Component ({
selector: 'app-root' ,
templateUrl: './app.html' ,
styleUrl: './app.css' ,
imports: [ Listbox , Option ],
})
export class App {
amenities = [ 'Washer / Dryer' , 'Ramp access' , 'Garden' , 'Cats OK' , 'Dogs OK' , 'Smoke-free' ];
}
< div
ngListbox
aria-label = "Amenities_explicit"
orientation = "horizontal"
selectionMode = "explicit"
multi
>
@for ( amenity of amenities; track amenity) {
< div ngOption [value] = "amenity" [label] = "amenity" >
< span class = "option-label" >{{ amenity }}</ span >
</ div >
}
</ div >
import { Component } from '@angular/core' ;
import { Listbox , Option } from '@angular/aria/listbox' ;
@ Component ({
selector: 'app-root' ,
templateUrl: './app.html' ,
styleUrl: './app.css' ,
imports: [ Listbox , Option ],
})
export class App {
amenities = [ 'Washer / Dryer' , 'Ramp access' , 'Garden' , 'Cats OK' , 'Dogs OK' , 'Smoke-free' ];
}
< div
ngListbox
aria-label = "Amenities_explicit"
orientation = "horizontal"
selectionMode = "follow"
multi
>
@for ( amenity of amenities; track amenity) {
< div ngOption [value] = "amenity" [label] = "amenity" >
< span class = "option-label" >{{ amenity }}</ span >
</ div >
}
</ div >
TIP: 드롭다운 패턴의 단일 선택에는 대체로 'follow' 모드를 사용합니다.
Angular Aria는 리스트박스 컴포넌트를 테스트할 수 있는 컴포넌트 하네스를 제공합니다.
컴포넌트 테스트에서 하네스를 사용하는 예제는 다음과 같습니다.
import { ComponentFixture , TestBed } from '@angular/core/testing' ;
import { HarnessLoader } from '@angular/cdk/testing' ;
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed' ;
import {ListboxHarness} from '@angular/aria/listbox/testing' ;
import {MyListboxComponent} from './my-listbox' ; // 테스트할 컴포넌트
describe ( 'MyListboxComponent' , () => {
let fixture : ComponentFixture < MyListboxComponent >;
let loader : HarnessLoader ;
beforeEach ( async () => {
TestBed . configureTestingModule ({
imports: [MyListboxComponent],
});
fixture = TestBed . createComponent (MyListboxComponent);
await fixture. whenStable ();
loader = TestbedHarnessEnvironment . loader (fixture);
});
it ( 'should allow selecting options' , async () => {
const listbox = await loader. getHarness (ListboxHarness);
// 리스트박스 프로퍼티를 확인합니다.
expect ( await listbox. isMulti ()). toBe ( true );
// 모든 옵션을 가져옵니다.
const options = await listbox. getOptions ();
expect (options. length ). toBe ( 2 );
// 옵션을 클릭합니다.
await options[ 0 ]. click ();
// 옵션이 선택되었는지 확인합니다.
expect ( await options[ 0 ]. isSelected ()). toBe ( true );
// 텍스트로 옵션을 필터링합니다.
const bananaOption = await listbox. getOptions ({text: 'Banana' });
expect (bananaOption. length ). toBe ( 1 );
});
});
자세한 API 문서는 다음 API 레퍼런스를 참고하세요.
리스트박스는 다음 문서에서 설명하는 드롭다운 패턴에 사용됩니다:
단일 선택 - 읽기 전용 콤보박스와 리스트박스를 사용하는 단일 선택 드롭다운 패턴
다중 선택 - 읽기 전용 콤보박스와 multi가 활성화된 리스트박스를 사용하는 다중 선택 드롭다운 패턴
자동 완성 - 콤보박스와 리스트박스를 사용하는 필터링 가능한 드롭다운 패턴
트리거, 팝업, 오버레이 위치 지정이 포함된 완전한 드롭다운 패턴을 구현하려면 리스트박스만 사용하기보다 해당 패턴 가이드를 참고하세요.