DropdownMenu#
버튼이나 다른 트리거 요소를 클릭하면 옵션 목록을 표시하는 드롭다운 메뉴 컴포넌트입니다. 다양한 배치 위치와 오프셋을 지원합니다.
Live Preview#
DropdownMenu(
trigger: Button(variant: CoreButtonVariant.outline, child: text('Menu')),
items: [
DropdownMenuItem(label: 'Profile'),
DropdownMenuItem(label: 'Settings'),
DropdownMenuItem(label: 'Logout'),
],
)
DropdownMenu(
trigger: Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: Text('Menu')),
items: [
DropdownMenuItem(label: 'Profile'),
DropdownMenuItem(label: 'Settings'),
DropdownMenuItem(label: 'Logout'),
],
)
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- "더보기" 버튼(...)에 편집/공유/삭제 등 액션 목록을 표시할 때
- 특정 버튼 클릭에 반응하는 컨텍스트 액션 메뉴가 필요할 때
- 배치 위치를 세밀하게 제어해야 할 때
대신 다른 컴포넌트를 사용하세요:
Select: 값을 선택하는 폼 입력에 (시각적으로 폼 컨트롤처럼 보여야 할 때)ContextMenu: 우클릭으로 표시되는 상황별 메뉴에Menu: 네비게이션 사이드바 구성에
기본 사용법 (Basic Usage)#
// 기본 드롭다운 메뉴
DropdownMenu(
trigger: Button(
onPressed: null,
child: Row(
children: [Text('옵션'), Icon(Icons.arrow_drop_down)],
),
),
onSelect: handleDropdownSelect,
items: [
DropdownMenuItem(value: 'option1', label: '옵션 1'),
DropdownMenuItem(value: 'option2', label: '옵션 2'),
DropdownMenuDivider(),
DropdownMenuItem(value: 'option3', label: '옵션 3'),
],
)
// 배치 위치 및 오프셋 설정
DropdownMenu(
trigger: IconButton(
onPressed: null,
icon: Icon(Icons.more_vert),
),
onSelect: handleDropdownSelect,
placement: DropdownPlacement.bottomEnd,
offset: 8.0,
items: [
DropdownMenuItem(value: 'edit', label: '편집', icon: Icon(Icons.edit)),
DropdownMenuItem(value: 'share', label: '공유', icon: Icon(Icons.share)),
DropdownMenuDivider(),
DropdownMenuItem(
value: 'delete',
label: '삭제',
icon: Icon(Icons.delete),
isDestructive: true,
),
],
)
DropdownMenu(
trigger: Button(
onPressed: null,
child: Row(
children: [Text('옵션'), Icon(Icons.chevronDown)],
),
),
onSelect: handleDropdownSelect,
items: [
DropdownMenuItem(value: 'option1', label: '옵션 1'),
DropdownMenuItem(value: 'option2', label: '옵션 2'),
DropdownMenuDivider(),
DropdownMenuItem(value: 'option3', label: '옵션 3'),
],
)
Props / Parameters#
DropdownMenu#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
trigger | Widget | 필수 | 메뉴를 열 트리거 위젯 |
items |
List<DropdownMenuEntry> |
필수 | 메뉴 아이템 목록 |
onSelect |
void Function(String value)? |
null |
아이템 선택 핸들러 |
placement |
DropdownPlacement |
bottomStart |
메뉴 표시 위치 |
offset |
double |
4.0 |
트리거와 메뉴 사이 간격(px) |
dropdownMenuStyle |
CoreDropdownMenuStyle<Color, List<BoxShadow>>?
(Flutter) /
CoreDropdownMenuStyle<String, String>?
(Web)
|
null |
인스턴스 스타일 (Style 시스템 참조) |
스타일 시스템 (Style System)#
DropdownMenu / Menu / Menubar / ContextMenu 의 모든 chrome / dimensional / nested-slot 오버라이드는 공통
CoreDropdownMenuStyle<Clr, Shadow> 단일 슬롯으로 흐릅니다 (Epic #1302 원칙 6/7/8). 4 종류 위젯이지만 chrome 은 모두 동일 Style 클래스로 받습니다.
시맨틱 vs 스타일#
-
시맨틱 / behaviour: 위젯 파라미터로 직접 (
autofocus,enabled,placement,offset,items…) -
chrome / dimensional / 슬롯 스타일:
CoreDropdownMenuStyle한 곳으로 (popoverStyle/triggerStyle/itemStyle/headerTextStyle+ flatseparatorColor/separatorThickness/separatorIndent) -
변형(variant) 교체: asChild — 트리거를 직접 위젯으로 주입 (
trigger: Button.outline(...))
Resolve chain#
design system default for dropdown menu
→ CoreDropdownMenuTheme.style // 프로젝트 공통
→ parent component slot override
→ widget.dropdownMenuStyle // 인스턴스별
각 nested 슬롯 스타일 (popoverStyle / triggerStyle / itemStyle / headerTextStyle) 은 자기 컴포넌트의 자체 resolve chain 으로 다시 한 번 머지됩니다.
슬롯 매핑 (CoreDropdownMenuStyle 7 필드)#
| 필드 | 타입 (Flutter) | 적용 영역 |
|---|---|---|
popoverStyle |
CorePopoverStyle<Color, List<BoxShadow>>? |
팝오버 패널 chrome (border / radius / padding / shadow / blur / open animation) |
triggerStyle |
CoreButtonStyle<Color>? |
위젯이 자체 트리거를 렌더링할 때 |
itemStyle |
CoreButtonStyle<Color>? |
각 메뉴 아이템 (MenuButton / MenubarAction) chrome |
headerTextStyle |
CoreTextStyle<Color>? |
MenuLabel / 헤더 라벨 텍스트 |
separatorColor |
Color? |
MenuDivider / MenubarDivider 색상 |
separatorThickness |
double? |
divider 두께 (logical px) |
separatorIndent | double? | divider 좌우 indent |
Migration — 옛 평면 필드 → 새 위치 매핑#
| 옛 chrome 필드 | 새 위치 |
|---|---|
surfaceBlur (Flutter) |
위젯 파라미터로 유지 (현재 backward-compat); 권장은
CoreComponentTheme.dropdownMenu.style.popoverStyle
사용
|
surfaceOpacity (Flutter) | 위젯 파라미터로 유지 (현재 backward-compat) |
MenuPopupTheme.borderColor |
dropdownMenuStyle.popoverStyle.panelBorderColor |
MenuPopupTheme.borderRadius |
dropdownMenuStyle.popoverStyle.panelBorderRadius (logical px → BorderRadius) |
MenuPopupTheme.fillColor |
dropdownMenuStyle.popoverStyle.panelBackgroundColor |
MenuPopupTheme.padding |
dropdownMenuStyle.popoverStyle.panelPadding |
MenuTheme.itemPadding |
dropdownMenuStyle.itemStyle.paddingH (logical px) |
사용 예 (Flutter)#
DropdownMenu(
dropdownMenuStyle: CoreDropdownMenuStyle(
popoverStyle: CorePopoverStyle(
panelBorderRadius: 12,
panelPadding: 8,
),
itemStyle: CoreButtonStyle(
paddingH: 12,
),
separatorColor: Color(0xFFE5E7EB),
separatorThickness: 1,
),
children: [
MenuButton(child: Text('Edit')),
MenuDivider(),
MenuButton(child: Text('Delete')),
],
)
사용 예 (Web)#
DropdownMenu(
trigger: Button.outline(child: Component.text('Menu')),
dropdownMenuStyle: CoreDropdownMenuStyle<String, String>(
popoverStyle: CorePopoverStyle<String, String>(
panelBorderRadius: 12,
panelPadding: 8,
),
),
items: [
DropdownMenuItem(label: 'Item 1', onSelect: () {}),
DropdownMenuItem(label: 'Item 2', onSelect: () {}),
],
)
DropdownMenuItem#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
value | String | 필수 | 선택 시 onSelect에 전달되는 값 |
label | String | 필수 | 메뉴 아이템 텍스트 |
icon | Widget? | null | 아이템 아이콘 |
isDestructive |
bool |
false |
파괴적 액션 스타일 적용 |
enabled | bool | true | 활성화 여부 |
DropdownMenuDivider#
메뉴 아이템 사이의 구분선입니다. 별도의 속성이 없습니다.
변형 (Variants)#
배치 위치 (Placement)#
| 값 | 설명 |
|---|---|
bottomStart | 트리거 하단 왼쪽 정렬 (기본) |
bottomEnd | 트리거 하단 오른쪽 정렬 |
topStart | 트리거 상단 왼쪽 정렬 |
topEnd | 트리거 상단 오른쪽 정렬 |
DropdownMenu(
trigger: Button(onPressed: null, child: Text('더보기')),
onSelect: handleDropdownSelect,
placement: DropdownPlacement.topEnd,
items: [
DropdownMenuItem(value: 'action1', label: '액션 1'),
],
)
동작 스펙 (Behavior)#
인터랙션#
- 트리거 클릭: 메뉴 열기/닫기 토글
- 항목 선택:
onSelect(value)호출 후 메뉴 닫힘 - 외부 클릭: 메뉴 외부 클릭 시 닫힘
- Escape: 메뉴 닫힘
상태 전환#
closed→open: 트리거 클릭open→closed: 항목 선택, 외부 클릭, Escape
애니메이션#
- 열림: 페이드 인 + 스케일 업 150ms
- 닫힘: 페이드 아웃 100ms
사용 가이드라인 (Usage Guidelines)#
✅ Do#
"더보기" 버튼에 관련 액션 그룹 표시
CouiDropdownMenu(
trigger: IconButton(
onPressed: null,
icon: const Icon(Icons.more_vert),
tooltip: '더보기',
),
placement: DropdownPlacement.bottomEnd,
onSelect: handleMoreActionsSelect,
items: [
DropdownMenuItem(value: 'edit', label: '편집', icon: const Icon(Icons.edit)),
DropdownMenuItem(value: 'duplicate', label: '복제', icon: const Icon(Icons.copy)),
const DropdownMenuDivider(),
DropdownMenuItem(
value: 'delete',
label: '삭제',
icon: const Icon(Icons.delete),
isDestructive: true,
),
],
)
"더보기" 아이콘 버튼에 추가 액션을 숨겨두면 UI를 깔끔하게 유지하면서 기능을 제공한다.
❌ Don't#
Select 대신 DropdownMenu를 폼 입력으로 사용
// ❌ 값 선택 폼에 DropdownMenu 사용
CouiDropdownMenu(
trigger: Text('국가 선택'),
onSelect: handleCountryChanged,
items: countries.map((c) => DropdownMenuItem(value: c.code, label: c.name)).toList(),
)
폼에서 값을 선택하는 경우 Select 컴포넌트가 레이블, 유효성 검사, 접근성을 더 잘 지원한다.
✅ Do#
뷰포트 경계에 따라 placement 자동 조정 고려
// 화면 하단 근처의 버튼은 top placement 사용
CouiDropdownMenu(
trigger: bottomButton,
placement: DropdownPlacement.topEnd, // 위로 열림
items: actionItems,
onSelect: handleSelect,
)
화면 경계에서 메뉴가 잘리지 않도록 위치를 적절히 선택해야 한다.
❌ Don't#
메뉴 항목을 10개 이상 넣지 않기
// ❌ 너무 많은 항목
CouiDropdownMenu(
trigger: moreButton,
items: List.generate(15, (i) => DropdownMenuItem(
value: 'action_$i',
label: '액션 ${i + 1}',
)),
onSelect: handleSelect,
)
항목이 너무 많으면 메뉴가 화면을 넘치거나 사용자가 원하는 항목을 찾기 어렵다.
✅ Do#
파괴적인 액션에는 시각적 경고를 표시하세요.
CouiDropdownMenu(
items: [
DropdownMenuItem(label: '편집', onTap: handleEdit),
DropdownMenuItem(label: '복제', onTap: handleDuplicate),
DropdownMenuDivider(),
DropdownMenuItem(
label: '삭제',
onTap: handleDelete,
destructive: true, // 빨간색으로 강조
),
],
)
삭제처럼 되돌릴 수 없는 액션은 색상이나 아이콘으로 위험도를 명확히 표시해야 사용자가 신중하게 선택할 수 있습니다.
❌ Don't#
드롭다운 메뉴에 단 하나의 항목만 넣지 마세요.
// ❌ 항목 하나짜리 드롭다운
CouiDropdownMenu(
items: [
DropdownMenuItem(label: '삭제', onTap: handleDelete),
],
)
항목이 하나뿐이라면 드롭다운 대신 직접 Button을 사용하는 것이 더 간결하고 접근성도 좋습니다.
접근성 (Accessibility)#
키보드 인터랙션#
| 키 | 동작 |
|---|---|
Enter / Space | 트리거 클릭으로 메뉴 열기 |
Arrow Down | 메뉴 열기 + 첫 항목 포커스 |
Arrow Up/Down | 메뉴 항목 간 이동 |
Enter | 포커스된 항목 선택 |
Escape | 메뉴 닫기, 트리거로 포커스 복귀 |
Tab | 메뉴 닫기 |
스크린 리더#
- Flutter:
Semantics(button: true, expanded: isOpen)트리거에 적용 -
Web:
aria-haspopup="menu"+aria-expanded트리거에,role="menu"+role="menuitem"메뉴에 자동 적용
터치 타겟#
- 트리거 버튼 최소 크기: 44x44dp
- 각 메뉴 항목 최소 높이: 44dp
크로스 플랫폼 차이점 (Platform Differences)#
v3.0부터 기본 API (
enabled,onChanged등)가 통일되었습니다. 아래는 플랫폼 고유 차이점만 나열합니다.
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | CouiDropdownMenu | DropdownMenu |
| 위치 계산 | OverlayEntry + RenderBox |
CSS position: absolute + Floating UI |
| 닫기 감지 | TapRegion |
document.addEventListener('click') |
| 뷰포트 조정 | 수동 위치 조정 | 자동 flip 지원 |
관련 컴포넌트 (Related Components)#
- Menu: 사이드바/네비게이션 메뉴 구성에 사용
- ContextMenu: 우클릭으로 표시되는 상황별 메뉴
- Select: 폼에서 값을 선택하는 드롭다운
조합 예제#
// 카드 헤더의 더보기 메뉴
CouiCard(
child: Column(
children: [
Row(
children: [
Text('항목 제목'),
const Spacer(),
CouiDropdownMenu(
trigger: const Icon(Icons.more_horiz),
placement: DropdownPlacement.bottomEnd,
onSelect: (value) => handleCardAction(value, item),
items: [
DropdownMenuItem(value: 'edit', label: '편집'),
DropdownMenuItem(value: 'duplicate', label: '복제'),
const DropdownMenuDivider(),
DropdownMenuItem(
value: 'delete',
label: '삭제',
isDestructive: true,
),
],
),
],
),
Text('카드 내용'),
],
),
)