DropdownMenu
트리거 클릭 시 하단에 액션 목록을 표시하는 드롭다운 메뉴 컴포넌트
목차
버튼 등 트리거 요소를 클릭하면 액션 목록을 떠 있는 패널로 표시하는 드롭다운 메뉴 컴포넌트입니다. 액션 행과 구분선, 키보드 단축키 힌트를 지원합니다.
DropdownMenu는 레거시 Flutter DropdownMenu와 Web DropdownMenu를 통일한
컴포넌트입니다. Flutter와 Web이 동일한 파라미터 이름·동작·시각을 갖습니다 —
패널은 양쪽 모두 루트 오버레이로 portal되어 ancestor overflow에 잘리지
않습니다.
기본 사용법 (Basic Usage)#
DropdownMenu(
trigger: Button(
variant: CoreButtonVariant.outline,
onPressed: () {},
child: const Text('Open Menu'),
),
items: [
CoreMenuItem.action('Profile', onPressed: () {}),
CoreMenuItem.action('Settings', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Sign out', onPressed: () {}),
],
)
DropdownMenu(
trigger: Button(
variant: CoreButtonVariant.outline,
onPressed: () {},
child: Text('Open Menu'),
),
items: [
CoreMenuItem.action('Profile', onPressed: () {}),
CoreMenuItem.action('Settings', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Sign out', onPressed: () {}),
],
)
Live Preview#
코드 보기
class DropdownMenuDefaultExample extends StatelessComponent {
const DropdownMenuDefaultExample({super.key});
@override
Component build(BuildContext context) {
return DropdownMenu(
trigger: Button(
variant: CoreButtonVariant.outline,
onPressed: () {},
child: Text('Open Menu'),
),
items: [
CoreMenuItem.action('Profile', onPressed: () {}),
CoreMenuItem.action('Settings', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Sign out', onPressed: () {}),
],
);
}
}
class DropdownMenuDefaultExample extends StatelessWidget {
const DropdownMenuDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return DropdownMenu(
trigger: Button(
variant: CoreButtonVariant.outline,
onPressed: () {},
child: const Text('Open Menu'),
),
items: [
CoreMenuItem.action('Profile', onPressed: () {}),
CoreMenuItem.action('Settings', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Sign out', onPressed: () {}),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- "더보기" 버튼에 편집 / 공유 / 삭제 등 액션 목록을 표시할 때
- 특정 버튼 클릭에 반응하는 컨텍스트 액션 메뉴가 필요할 때
대신 다른 컴포넌트를 사용하세요:
- Select: 액션 실행 대신 값을 선택하는 폼 입력에
- ContextMenu: 트리거 앵커 대신 우클릭 위치에서 표시하는 메뉴에
- Menubar: 가로 바의 여러 메뉴 트리거를 전환해야 할 때
Props / Parameters#
W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. 메뉴의 CoreMenuItem도 같은 플랫폼 콘텐츠를 받습니다.
DropdownMenu#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
trigger | W | 필수 | 메뉴를 여는 트리거 요소 |
items |
List<CoreMenuItem<W>> |
필수 |
드롭다운 패널의 행 —
Menu
/
Menubar
/
ContextMenu
와 공유하는 통합 [CoreMenuItem] 모델
|
dropdownMenuStyle |
CoreDropdownMenuStyle? |
null |
모든 chrome 이 지나는 단일 슬롯 (아래 표 참고) |
controller |
PopoverController? |
null |
외부에서 열기/닫기를 명령형으로 제어 (Popover 와 동일한 통합 컨트롤러) |
CoreDropdownMenuStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
menuStyle |
CoreMenuStyle? |
Nested style for the composed
Menu
— the whole menu chrome (panel box via [CoreMenuStyle.popupStyle], the per-row
MenuItem
chrome via [CoreMenuStyle.itemStyle], the divider, the heading typography …). Raw-forwarded to the inner
Menu
's
menuStyle
(the
Menu
resolver merges it over
CoreMenuTheme.style
);
null
leaves the inner
Menu
at its design-system defaults.
|
menuOffset |
double? |
Per-instance gap between the trigger and the menu panel along the placement axis —
null
falls back to [defaultMenuOffset]. A single placement-axis scalar (not a multi-side inset), so it stays a flat
double
field (mirrors
CoreMenubarStyle.menuOffset
).
|
CoreMenuItem (드롭다운에서 쓰는 종류)#
드롭다운은 통합 CoreMenuItem 모델을 받는다. 주로 쓰는 두 종류:
| 생성자 | 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|---|
.action |
label (positional) |
String |
필수 | 액션 행 텍스트 |
.action |
shortcut |
String? |
null |
라벨 뒤에 표시되는 단축키 힌트 |
.action |
disabled |
bool |
false |
true 시 muted 처리 + onPressed 미호출 |
.action |
onPressed |
void Function()? |
null |
활성 액션 선택 시 호출 |
.divider | — | — | — | 그룹 사이 구분선 |
스타일 시스템 (Style System)#
DropdownMenu의 드롭다운 패널은 떠 있는 popup 표면이므로 CorePopupStyle
chrome 슬롯을 공유합니다. 패널의 보더 / 반경 / 배경 /
그림자 / 패딩 / 최소 너비가 모두 popupStyle 한 곳으로 흐릅니다.
Resolve chain#
CorePopupStyle.defaultX // 디자인 시스템 기본값
→ CorePopupTheme.style // 프로젝트 공통
→ widget.popupStyle // 인스턴스별
엔트리 행 chrome (패딩 / hover 배경 / 구분선 색)은 CoreDropdownMenuStyle의
defaultEntryPadding / defaultEntryBorderRadius
/ defaultEntryHoverColor /
defaultDividerColor / defaultEntryDuration 디자인 시스템 기본값으로
고정되어 앱 전체에서 일관성을 유지합니다.
DropdownMenu(
popupStyle: const CorePopupStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
),
trigger: const Text('Menu'),
items: const [CoreMenuItem.action('Item')],
)
빠른 오버라이드 (Chain)#
이미 만든 DropdownMenu 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class DropdownMenuChainExample extends StatelessWidget {
const DropdownMenuChainExample({super.key});
@override
Widget build(BuildContext context) {
return DropdownMenu(
trigger: Button(
variant: CoreButtonVariant.outline,
onPressed: () {},
child: const Text('Open Menu'),
),
items: [
CoreMenuItem.action('Profile', onPressed: () {}),
CoreMenuItem.action('Settings', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Sign out', onPressed: () {}),
],
).withStyle(
const CoreDropdownMenuStyle(
menuOffset: CoreSpace.space16,
menuStyle: CoreMenuStyle(
itemStyle: CoreMenuItemStyle(
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space16,
vertical: CoreSpace.space8,
),
),
labelTextStyle: CoreTextStyle.token(
CoreTextStyles.labelLarge,
color: CoreColor.token(CoreColors.primary),
),
),
),
);
}
}
class DropdownMenuChainExample extends StatelessComponent {
const DropdownMenuChainExample({super.key});
@override
Component build(BuildContext context) {
return DropdownMenu(
trigger: Button(
variant: CoreButtonVariant.outline,
onPressed: () {},
child: Text('Open Menu'),
),
items: [
CoreMenuItem.action('Profile', onPressed: () {}),
CoreMenuItem.action('Settings', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Sign out', onPressed: () {}),
],
).withStyle(
const CoreDropdownMenuStyle(
menuOffset: CoreSpace.space16,
menuStyle: CoreMenuStyle(
itemStyle: CoreMenuItemStyle(
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space16,
vertical: CoreSpace.space8,
),
),
labelTextStyle: CoreTextStyle.token(
CoreTextStyles.labelLarge,
color: CoreColor.token(CoreColors.primary),
),
),
),
);
}
}
변형 (Variants)#
단축키 힌트 / 비활성 엔트리#
DropdownMenu(
trigger: const Text('Edit'),
items: const [
CoreMenuItem.action('Undo', shortcut: 'Ctrl+Z'),
CoreMenuItem.action('Redo', disabled: true),
],
)
동작 스펙 (Behavior)#
인터랙션#
- 트리거 클릭: 메뉴 열기 / 닫기 토글
-
항목 선택: 서브메뉴 없는 action이 활성화되면
Menu.onActivate로 먼저 닫기를 요청한 뒤 항목의onPressed호출. checkbox / radio는 변경 콜백만 호출하고 메뉴를 유지 - 외부 클릭: 메뉴 외부 클릭 시 닫힘
- Escape: 열린 메뉴 닫힘
- 긴 목록: 항목이 뷰포트가 패널에 남기는 높이보다 길면, 패널이 남는 높이를 모두 차지하고 위쪽 여백에 붙어 열립니다(트리거를 덮을 수 있음). 행은 패널 안에서 스크롤되어 마지막 행까지 닿습니다. 남는 높이에 들어가는 메뉴는 그대로입니다
-
열린 채로
items변경: 열려 있는 동안items가 바뀌면 열린 패널이 새 항목으로 갱신됩니다. 다시 열 필요가 없습니다 - 메뉴는 값 선택 폼이 아니라 액션 목록입니다. 값·검증이 필요한 입력은 Select, 포인터 위치에서 여는 메뉴는 ContextMenu 입니다.
애니메이션#
- 엔트리 hover 배경: 150ms 색상 전환 (
CoreDuration.ms150)
사용 가이드라인 (Usage Guidelines)#
✅ Do#
관련 액션을 구분선으로 그룹화
DropdownMenu(
trigger: const Text('More'),
items: const [
CoreMenuItem.action('Edit'),
CoreMenuItem.action('Duplicate'),
CoreMenuItem.divider(),
CoreMenuItem.action('Delete'),
],
)
구분선으로 관련 액션을 묶으면 메뉴 탐색이 더 직관적이다.
❌ Don't#
폼 값 선택에 DropdownMenu 사용
// ❌ 값 선택 폼
DropdownMenu(
trigger: const Text('Country'),
items: const [CoreMenuItem.action('Korea')],
)
폼에서 값을 선택할 때는 Select가 레이블·유효성 검사·접근성을 더 잘 지원한다.
접근성 (Accessibility)#
역할#
-
트리거 — Web: 래퍼
<div>에role="button"+aria-haspopup="true"+aria-expanded(열림 상태 반영). Flutter:Semantics(button: true, expanded: …)로 같은 두 신호를 냅니다.aria-controls는 양쪽 다 없습니다. - 패널 — Web: role=menu + aria-orientation=vertical. Flutter: 합성된 Menu의 CoUISemantics.menu 역할입니다.
- 엔트리 — Web은 menuitem / menuitemcheckbox / menuitemradio와 aria-checked · aria-disabled를 사용합니다. Flutter는 MenuRowScope 안 MenuItem의 menuItem 역할·enabled·selected를 사용하며 토글은 selected로 전달합니다.
-
구분선 — 양쪽 모두 의미 있는 경계로 처리합니다. Web
role="separator"+aria-label, FlutterSemantics(container: true, label: …), 라벨은dividerSeparatorLabel(ko: "구분선").
키보드#
| 키 | Flutter | Web |
|---|---|---|
Escape | 모달 메뉴 닫기 | positioner가 메뉴 닫기 |
Tab / Shift+Tab |
항목별 포커스 | 합성된 Menu의 한 roving tab stop |
Enter / Space | 항목 활성화 | 항목 활성화 |
ArrowUp / ArrowDown | 메뉴 전용 탐색 없음 | Menu의 행 순환 탐색 |
Home / End · 문자 입력 |
메뉴 전용 탐색 없음 | 첫/마지막 행·label typeahead |
Web 탐색은 DropdownMenu가 새로 구현하지 않고 합성된 Menu 에서 상속합니다. label / divider / gap은 탐색 대상이 아니고 비활성 action / checkbox / radio 행은 도달 가능하되 활성화하지 않습니다.
트리거를 여는 경로가 플랫폼마다 다릅니다. Web 은 래퍼 <div> 의 click 을 듣기 때문에 안쪽 트리거가 포커스 가능한 컨트롤(예:
Button)이면 키보드 활성화에서도 DOM 클릭이 버블링되어 메뉴가 열립니다. Flutter 는 TriggerTapDetector(Listener
기반, 포인터 전용)라 키보드로 트리거를 활성화해도 메뉴가 열리지 않습니다 — 그 경우 트리거 자신의 onPressed 만 실행됩니다. 대안은
controller (PopoverController) 로 호출부에서 여는 것입니다.
스크린 리더#
-
트리거의 이름은 caller 가 넘긴
trigger위젯에서 옵니다. 컴포넌트는 이름을 만들지 않으므로 아이콘만 넘기면 이름 없는 버튼이 됩니다. -
패널에는 접근 가능한 이름이 없습니다.
Menu는ariaLabel슬롯을 갖지만DropdownMenu가 그것을 노출하지 않고 내부에서Menu를 생성하므로 호출부에서 붙일 수 없습니다. -
각 항목 이름은
CoreMenuItem.label입니다. Web 은 비활성 항목을aria-disabled로, Flutter 는Semantics(enabled: false)로 함께 알립니다. -
배경 차단은 양쪽 다 있습니다. Flutter 는 모달 배리어의
BlockSemantics(엔진 기본modal: true), Web 은modal: true마운트가 거는inert입니다. -
닫기 동작도 양쪽 다 있습니다. 배경이 막혀 스크림에 닿을 수 없는 스크린 리더 사용자가 "바깥을 눌러 닫기" 를 대신할 수단입니다. Flutter 는 패널 범위의
Semantics(onDismiss:)가 지역화된dismissLabel(ko: "닫기")을 이름으로 가져 스크린 리더의 닫기 제스처와 그 노드의 활성화가 메뉴를 닫고, Web 은 패널 곁의 시각적으로 숨긴role="button"노드(aria-label이 같은dismissLabel)를 활성화하면 닫힙니다. 터치 스크린 리더에는Escape가 없어 이 노드가 유일한 닫기 수단입니다. Web 노드는tabindex="-1"이라 탭 순서에 들지 않고, 키보드에는Escape가 있습니다. 이 이름은 닫기 동작의 이름이지 메뉴의 이름이 아니므로 위의 "패널에 이름이 없다" 는 제약은 그대로입니다. -
열림/닫힘 상태 통보(live region)는 없습니다 — 트리거의
aria-expanded/Semantics(expanded:)만 상태를 노출합니다.
포커스 관리#
-
진입 — Flutter: 모달 오버레이가 패널 스코프로 포커스를 요청합니다. Web: 열릴 때 첫 행으로 포커스를 옮기지는 않습니다. 패널에 들어간 뒤에는 Menu 키 탐색이
focusRovingItem으로 포커스를 옮깁니다. - 이탈 — 양쪽 모두 트리거로 포커스를 되돌리는 코드가 없습니다. Flutter 는 패널 스코프를 dispose 할 뿐입니다.
-
트랩 — Flutter: 패널의
FocusScope가closedLoop기본값을 가져 포커스가 들어간 뒤에는Tab이 패널 안에서 순환합니다. Web: 배경이inert라 배경으로 넘어가지 않지만 패널 안에서 순환하지도 않습니다 — 마지막 항목에서Tab은 브라우저 UI 로 나갔다가 돌아옵니다.
알려진 제약#
- Flutter에는 메뉴 전용 화살표·Home/End·typeahead·roving 탐색이 없습니다. Web Menu의 탐색과 같다고 기대하지 마세요.
- Flutter 는 키보드로 트리거를 열 수 없습니다 (위 키보드 참고).
controller로 우회하세요. -
패널에 이름을 붙일 수 없습니다 —
Menu.ariaLabel이DropdownMenu표면에 노출되지 않습니다. - 토글 의미 표현이 다릅니다. Flutter는 menuItem의 selected 플래그, Web은 menuitemcheckbox / radio의 aria-checked를 사용합니다.
- Web 은 닫을 때 포커스를 트리거로 되돌리지 않습니다.
전역으로 적용되는 축(동작 줄이기·고대비·색 강제 모드·최소 터치 타겟)은 전역 접근성 축에 있습니다.
크로스 플랫폼 차이점 (Platform Differences)#
DropdownMenu는 Flutter와 Web이 동일한 trigger / items /
dropdownMenuStyle API를 공유합니다. 드롭다운 패널은 Flutter에서는
PopoverOverlayHandler, Web에서는 루트 OverlayHost의 menu 레이어로 portal되어
양쪽 모두 ancestor overflow에 잘리지 않습니다.
| 항목 | Flutter | Web |
|---|---|---|
| 항목 렌더 | 합성된 MenuItem | 합성된 MenuItem |
| 메뉴 탐색 | 항목별 포커스 | Menu의 roving · 화살표 · Home / End · typeahead |
| 패널 배치 | 모달 오버레이 engine | OverlayPositioner와 modal menu portal |
| 스크린 리더의 닫기 | 모달 패널의 dismiss 접근성 동작 |
패널 곁의 숨긴 role="button" 노드 (탭 순서 밖) |
| 긴 메뉴 | 엔진이 패널 최대 높이를 레이아웃 제약으로 제한 (여백·시스템 바·키보드 제외) |
위치 엔진이 패널을 측정한 뒤 상자에
max-height
를 걸고 상자 안의 목록을
overflow-y: auto
로 스크롤 (뷰포트 여백
viewportPadding
만 제외)
|
관련 컴포넌트 (Related Components)#
- Menu: 사이드바 형태의 일반 메뉴
- ContextMenu: 우클릭 상황별 메뉴
- Popup: 플로팅 패널 컨테이너