Menubar
데스크탑 앱 스타일의 상단 가로 메뉴 바 컴포넌트
목차
데스크탑 애플리케이션 스타일의 상단 가로 메뉴 바 컴포넌트입니다. 각 상위 메뉴는 클릭 시 드롭다운 패널을 열고, 한 번 열린 뒤에는 다른 메뉴에 마우스를 올리는 것만으로 전환되며, 키보드 단축키 힌트와 구분선을 지원합니다.
Menubar는 데이터 기반 컴포넌트입니다. CoreMenubarMenu 목록을 넘기면 각
메뉴가 가로 바에 라벨로 표시되고, entries의 CoreMenubarEntry.action
/
CoreMenubarEntry.divider가 드롭다운 패널로 렌더링됩니다. Flutter와 Web이
동일한 파라미터 이름·동작을 갖습니다.
기본 사용법 (Basic Usage)#
Menubar(
menus: const [
CoreMenubarMenu(
label: 'File',
entries: [
CoreMenubarEntry.action(label: 'New', shortcut: 'Ctrl+N'),
CoreMenubarEntry.action(label: 'Open', shortcut: 'Ctrl+O'),
CoreMenubarEntry.divider(),
CoreMenubarEntry.action(label: 'Exit'),
],
),
CoreMenubarMenu(
label: 'Edit',
entries: [
CoreMenubarEntry.action(label: 'Cut', shortcut: 'Ctrl+X'),
CoreMenubarEntry.action(label: 'Copy', shortcut: 'Ctrl+C'),
CoreMenubarEntry.action(label: 'Paste', shortcut: 'Ctrl+V'),
],
),
],
)
Menubar(
menus: const [
CoreMenubarMenu(
label: 'File',
entries: [
CoreMenubarEntry.action(label: 'New', shortcut: 'Ctrl+N'),
CoreMenubarEntry.action(label: 'Open', shortcut: 'Ctrl+O'),
CoreMenubarEntry.divider(),
CoreMenubarEntry.action(label: 'Exit'),
],
),
CoreMenubarMenu(
label: 'Edit',
entries: [
CoreMenubarEntry.action(label: 'Cut', shortcut: 'Ctrl+X'),
CoreMenubarEntry.action(label: 'Copy', shortcut: 'Ctrl+C'),
CoreMenubarEntry.action(label: 'Paste', shortcut: 'Ctrl+V'),
],
),
],
)
Live Preview#
코드 보기
class MenubarDefaultExample extends StatelessComponent {
const MenubarDefaultExample({super.key});
@override
Component build(BuildContext context) {
return Menubar(
menus: const [
CoreMenubarMenu(
label: 'File',
entries: [
CoreMenubarEntry.action(label: 'New', shortcut: 'Ctrl+N'),
CoreMenubarEntry.action(label: 'Open', shortcut: 'Ctrl+O'),
CoreMenubarEntry.divider(),
CoreMenubarEntry.action(label: 'Exit'),
],
),
CoreMenubarMenu(
label: 'Edit',
entries: [
CoreMenubarEntry.action(label: 'Cut', shortcut: 'Ctrl+X'),
CoreMenubarEntry.action(label: 'Copy', shortcut: 'Ctrl+C'),
CoreMenubarEntry.action(label: 'Paste', shortcut: 'Ctrl+V'),
],
),
CoreMenubarMenu(
label: 'View',
entries: [
CoreMenubarEntry.action(label: 'Zoom In'),
CoreMenubarEntry.action(label: 'Zoom Out'),
CoreMenubarEntry.divider(),
CoreMenubarEntry.action(label: 'Full Screen'),
],
),
],
);
}
}
class MenubarDefaultExample extends StatelessWidget {
const MenubarDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return Menubar(
menus: const [
CoreMenubarMenu(
label: 'File',
entries: [
CoreMenubarEntry.action(label: 'New', shortcut: 'Ctrl+N'),
CoreMenubarEntry.action(label: 'Open', shortcut: 'Ctrl+O'),
CoreMenubarEntry.divider(),
CoreMenubarEntry.action(label: 'Exit'),
],
),
CoreMenubarMenu(
label: 'Edit',
entries: [
CoreMenubarEntry.action(label: 'Cut', shortcut: 'Ctrl+X'),
CoreMenubarEntry.action(label: 'Copy', shortcut: 'Ctrl+C'),
CoreMenubarEntry.action(label: 'Paste', shortcut: 'Ctrl+V'),
],
),
CoreMenubarMenu(
label: 'View',
entries: [
CoreMenubarEntry.action(label: 'Zoom In'),
CoreMenubarEntry.action(label: 'Zoom Out'),
CoreMenubarEntry.divider(),
CoreMenubarEntry.action(label: 'Full Screen'),
],
),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- IDE, 문서 편집기, 데스크탑 앱처럼 파일/편집/보기 스타일의 메뉴 바가 필요할 때
- 많은 기능을 계층적 메뉴 구조로 정리해야 할 때
- 키보드 단축키와 함께 메뉴 항목을 표시할 때
대신 다른 컴포넌트를 사용하세요:
- NavigationMenu: 앱 액션 메뉴 대신 상위 사이트 링크와 드롭다운 콘텐츠를 구성할 때
- DropdownMenu: 여러 상위 메뉴 대신 단일 버튼의 액션 목록이 필요할 때
- Tabs: 오버레이 메뉴 대신 같은 화면의 패널 하나를 교체할 때
이런 곳에는 쓰지 마세요:
- 트리거 바의 intrinsic 폭보다 좁은 컨테이너 — 좁으면 플랫폼마다 다르게 깨집니다 (아래 "크로스 플랫폼 차이점" 참조).
Props / Parameters#
Menubar#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
menus |
List<CoreMenubarMenu> |
필수 | 가로 바에 표시되는 상위 메뉴 목록 |
border |
bool |
true |
가로 바에 외곽선 컨테이너를 그릴지 여부 |
activeIndex |
int? |
null |
열린 메뉴 인덱스 (controlled 모드). null → 내부 상태로 추적 |
onActiveIndexChanged |
void Function(int?)? |
null |
열린 메뉴가 바뀔 때 호출 (닫힘은 null) |
variant |
CoreMenubarVariant |
standard |
시각 변형 |
menubarStyle |
CoreMenubarStyle? |
null |
인스턴스별 chrome / 치수 스타일 |
CoreMenubarMenu#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
label | String | 필수 | 가로 바에 표시되는 메뉴 라벨 |
entries |
List<CoreMenubarEntry> |
const [] |
메뉴를 열면 표시되는 드롭다운 엔트리 |
CoreMenubarEntry#
| 생성자 | 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|---|
.action |
label |
String |
필수 | 액션 행 텍스트 |
.action |
shortcut |
String? |
null |
라벨 뒤에 표시되는 단축키 힌트 |
.action |
enabled |
bool |
true |
비활성 시 muted 처리 + onSelect 미호출 |
.action |
onSelect |
CoreVoidCallback? |
null |
활성 액션 선택 시 호출 |
.divider | — | — | — | 그룹 사이 구분선 |
스타일 시스템 (Style System)#
Menubar의 모든 chrome / 치수 오버라이드는 단일 CoreMenubarStyle 슬롯으로
흐릅니다. 가로 바 chrome, 트리거 버튼 chrome, 드롭다운 패널 박스 chrome을
한 클래스가 받습니다. 드롭다운 안의 행 chrome(엔트리 버튼 · 구분선 ·
단축키 힌트 타이포그래피)은 합성된 Menu가 소유하므로 CoreMenuStyle /
CoreMenuTheme로 조정합니다.
시맨틱 vs 스타일#
-
시맨틱 / behaviour: 위젯 파라미터로 직접 (
border,variant,menus) - chrome / dimensional:
menubarStyle: CoreMenubarStyle?한 곳으로
Resolve chain#
CoreMenubarStyle.defaultX // 디자인 시스템 기본값
→ CoreMenubarTheme.style // 프로젝트 공통
→ CoreMenubarTheme.variantStyles[variant] // variant별 공통
→ widget.menubarStyle // 인스턴스별
CoreMenubarStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
surfaceStyle |
CoreSurfaceStyleId? |
Material override for this surface; null inherits the surrounding material. Deliberately no default: null inherits the active theme material axis; a fixed default would override that axis before the resolver sees it. |
barPadding |
CoreEdgeInsets? |
Internal padding of the horizontal bar container. |
barBorderRadius |
CoreBorderRadius? |
Corner radius of the horizontal bar container. |
barBorderWidth |
double? |
Border width of the horizontal bar container (logical px). |
barBackgroundColor |
CoreColor? |
Background colour of the horizontal bar. |
barBorderColor |
CoreColor? |
Border colour of the horizontal bar. |
triggerButtonStyle |
CoreButtonStyle? |
Nested style for the top-level trigger
Button(variant: menubar)
— the single source for the trigger's button chrome (padding + highlight radius). Resolvers raw-forward
defaultTriggerButtonStyle.merge(this)
to the composed
Button
(no resolver-side assembly).
null
falls back to [defaultTriggerButtonStyle].
|
menuPanelStyle |
CorePopupStyle? |
Nested panel-box chrome for the dropdown menu panel — the dropdown is a
Popup
(composed by the panel
Menu
), so the resolver raw-forwards this (merged over [defaultMenuPanelStyle]) into the composed
Menu
's panel slot. Overlaid by the per-instance value.
|
menuOffset |
double? |
Vertical gap between the bar and a dropdown panel (logical px). |
labelTextStyle |
CoreTextStyle? |
Per-instance override for the top-level trigger label typography — overlaid on the
labelLarge
role / [defaultLabelTextStyle]. Text colour is carried via [CoreTextStyle.color] inside this slot.
|
CoreMenubarStyle 변형별 기본값 (CoreMenubarVariantStyle)#
| 필드 | standard |
|---|---|
barBackgroundColor | surface |
barBorderColor | outline (fallback: outlineVariant) |
사용 예 (Flutter / Web 동일)#
Menubar(
menubarStyle: const CoreMenubarStyle(
// 패널 박스는 Popup 합성 — chrome 은 menuPanelStyle 단일 슬롯으로.
menuPanelStyle: CorePopupStyle(
backgroundColor: CoreColor.token(CoreColors.surface),
minWidth: CoreSpace.space160,
),
// 트리거는 Button 합성 — padding / 반경은 triggerButtonStyle 로 흐른다.
triggerButtonStyle: CoreButtonStyle(
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space16,
vertical: CoreSpace.space6,
),
),
),
menus: const [
CoreMenubarMenu(
label: 'File',
entries: [CoreMenubarEntry.action(label: 'New')],
),
],
)
빠른 오버라이드 (Chain)#
이미 만든 Menubar 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class MenubarChainExample extends StatelessWidget {
const MenubarChainExample({super.key});
@override
Widget build(BuildContext context) {
return Menubar(
menus: const [
CoreMenubarMenu(
label: 'File',
entries: [
CoreMenubarEntry.action(label: 'New', shortcut: 'Ctrl+N'),
CoreMenubarEntry.action(label: 'Open', shortcut: 'Ctrl+O'),
CoreMenubarEntry.divider(),
CoreMenubarEntry.action(label: 'Exit'),
],
),
CoreMenubarMenu(
label: 'Edit',
entries: [
CoreMenubarEntry.action(label: 'Cut', shortcut: 'Ctrl+X'),
CoreMenubarEntry.action(label: 'Copy', shortcut: 'Ctrl+C'),
CoreMenubarEntry.action(label: 'Paste', shortcut: 'Ctrl+V'),
],
),
CoreMenubarMenu(
label: 'View',
entries: [
CoreMenubarEntry.action(label: 'Zoom In'),
CoreMenubarEntry.action(label: 'Zoom Out'),
CoreMenubarEntry.divider(),
CoreMenubarEntry.action(label: 'Full Screen'),
],
),
],
).withStyle(
const CoreMenubarStyle(
barPadding: CoreEdgeInsets.all(CoreSpace.space8),
barBorderRadius: CoreBorderRadius.all(CoreRadius.radius16),
barBorderWidth: CoreStrokeWidth.stroke2,
barBackgroundColor: CoreColor.token(CoreColors.surfaceContainerHigh),
barBorderColor: CoreColor.token(CoreColors.primary),
),
);
}
}
class MenubarChainExample extends StatelessComponent {
const MenubarChainExample({super.key});
@override
Component build(BuildContext context) {
return Menubar(
menus: const [
CoreMenubarMenu(
label: 'File',
entries: [
CoreMenubarEntry.action(label: 'New', shortcut: 'Ctrl+N'),
CoreMenubarEntry.action(label: 'Open', shortcut: 'Ctrl+O'),
CoreMenubarEntry.divider(),
CoreMenubarEntry.action(label: 'Exit'),
],
),
CoreMenubarMenu(
label: 'Edit',
entries: [
CoreMenubarEntry.action(label: 'Cut', shortcut: 'Ctrl+X'),
CoreMenubarEntry.action(label: 'Copy', shortcut: 'Ctrl+C'),
CoreMenubarEntry.action(label: 'Paste', shortcut: 'Ctrl+V'),
],
),
CoreMenubarMenu(
label: 'View',
entries: [
CoreMenubarEntry.action(label: 'Zoom In'),
CoreMenubarEntry.action(label: 'Zoom Out'),
CoreMenubarEntry.divider(),
CoreMenubarEntry.action(label: 'Full Screen'),
],
),
],
).withStyle(
const CoreMenubarStyle(
barPadding: CoreEdgeInsets.all(CoreSpace.space8),
barBorderRadius: CoreBorderRadius.all(CoreRadius.radius16),
barBorderWidth: CoreStrokeWidth.stroke2,
barBackgroundColor: CoreColor.token(CoreColors.surfaceContainerHigh),
barBorderColor: CoreColor.token(CoreColors.primary),
),
);
}
}
변형 (Variants)#
단축키 포함#
키보드 단축키를 함께 표시합니다.
Menubar(
menus: const [
CoreMenubarMenu(
label: 'File',
entries: [
CoreMenubarEntry.action(label: 'New', shortcut: 'Ctrl+N'),
CoreMenubarEntry.action(label: 'Open', shortcut: 'Ctrl+O'),
CoreMenubarEntry.action(label: 'Save', shortcut: 'Ctrl+S'),
],
),
],
)
비활성 엔트리#
enabled: false 엔트리는 muted 처리되며 선택해도 onSelect가 호출되지 않습니다.
Menubar(
menus: const [
CoreMenubarMenu(
label: 'File',
entries: [
CoreMenubarEntry.action(label: 'New'),
CoreMenubarEntry.action(label: 'Exit', enabled: false),
],
),
],
)
동작 스펙 (Behavior)#
인터랙션#
- 클릭: 메뉴 바 항목 클릭 시 드롭다운 열기. 같은 항목 재클릭 시 닫힘
- 호버 전환: 메뉴가 이미 열려 있을 때 다른 메뉴 바 항목에 마우스를 올리면 클릭 없이 그 메뉴로 전환. 데스크톱 메뉴바(macOS / Windows)와 동일하게, 첫 클릭이 바를 "무장"시키고 그 뒤로는 포인터를 따라간다
- 항목 선택: 합성된 Menu의 onActivate로 먼저 닫기를 요청하고 활성 엔트리의 onSelect 호출
- 외부 클릭: 메뉴 외부 클릭 시 닫힘
- Escape: Web의 열린 드롭다운 닫힘. Flutter의 현재 non-modal 메뉴 경로는 Escape 닫힘을 제공하지 않음
- 긴 목록: 드롭다운 항목이 뷰포트가 패널에 남기는 높이보다 길면, 패널이 남는 높이를 모두 차지하고 위쪽 여백에 붙어 열리며 행이 패널 안에서 스크롤됩니다
-
열린 채로
menus변경: 열려 있는 동안menus가 바뀌면 열린 드롭다운이 새 항목으로 갱신됩니다
닫힌 상태에서의 호버는 아무 일도 하지 않는다. 바 위를 지나가기만 해도 메뉴가 열리면 포인터가 바를 가로지를 때마다 메뉴가 튀어나오기 때문이다 (호버만으로 여는
NavigationMenu 와 의도적으로 다른 지점). 호버가 메뉴를 닫지도 않는다 — 포인터는 아래 패널에 도달하려면 반드시 트리거를 벗어나므로, 닫힘은 항목 선택 / 외부 클릭 / Escape 가 담당한다.
호버 전환은 마우스 포인터에서만 동작한다. Flutter 는 MouseRegion 기반이라 구조적으로 터치를 보고하지 않고, Web 은
(hover: hover) 미디어 쿼리로 게이트한다 — 터치 브라우저가 탭에서 mouseenter 를 에뮬레이트해 "전환 후 즉시 닫힘" 이 되는 것을 막는다. 같은 capability, 각 플랫폼의 idiom.
상태 전환#
- activeIndex가 non-null이면 호출부 값이 열린 메뉴를 정하고 변경은 onActiveIndexChanged로 요청합니다. null이면 내부 인덱스를 사용합니다. 메뉴 인덱스는 0-based이며 그룹 비활성 플래그는 없습니다.
closed→open: 메뉴 바 항목 클릭 (호버는 열지 않음)open→open (다른 메뉴): 다른 메뉴 바 항목 클릭 또는 호버open→closed: 항목 선택, 외부 클릭, Escape
애니메이션#
-
트리거 hover / open 배경: 합성된
Button(variant: .menubar)의 색상 전환 (CoreButtonStyle.defaultAnimationDuration=CoreDuration.fast, 150ms) —menubarStyle.triggerButtonStyle.animationDuration로 조정 -
드롭다운 패널 열림 / 닫힘: 패널
Popup의 모션 (menubarStyle.menuPanelStyle.openAnimationDuration/closeAnimationDuration)
사용 가이드라인 (Usage Guidelines)#
✅ Do#
메뉴 항목을 논리적 그룹으로 구분선으로 분리
CoreMenubarMenu(
label: 'File',
entries: [
CoreMenubarEntry.action(label: 'New', shortcut: 'Ctrl+N'),
CoreMenubarEntry.action(label: 'Open', shortcut: 'Ctrl+O'),
CoreMenubarEntry.divider(),
CoreMenubarEntry.action(label: 'Save', shortcut: 'Ctrl+S'),
CoreMenubarEntry.divider(),
CoreMenubarEntry.action(label: 'Exit'),
],
)
구분선으로 관련 항목을 그룹화하면 메뉴 탐색이 더 쉽고 직관적이다.
❌ Don't#
하나의 메뉴에 너무 많은 항목 사용 (10개 초과)
// ❌ 너무 많은 항목
CoreMenubarMenu(
label: 'Tools',
entries: List.generate(
15,
(i) => CoreMenubarEntry.action(label: 'Tool ${i + 1}'),
),
)
항목이 너무 많으면 탐색이 어려워진다. 뷰포트보다 긴 메뉴는 패널 안에서 스크롤되어 마지막 행까지 닿지만, 항목을 줄이거나 메뉴를 나누는 편이 낫다.
✅ Do#
자주 사용하는 항목에 키보드 단축키 표시
CoreMenubarEntry.action(label: 'Save', shortcut: 'Ctrl+S')
CoreMenubarEntry.action(label: 'Undo', shortcut: 'Ctrl+Z')
CoreMenubarEntry.action(label: 'Copy', shortcut: 'Ctrl+C')
단축키 표시가 파워 유저의 생산성을 높이고 메뉴를 통해 단축키를 학습하게 한다.
❌ Don't#
모바일 앱의 주요 네비게이션으로 Menubar 사용
// ❌ 모바일에 데스크탑 스타일 Menubar 강요
Menubar(
menus: const [
CoreMenubarMenu(label: 'Home'),
CoreMenubarMenu(label: 'Search'),
CoreMenubarMenu(label: 'Profile'),
],
)
Menubar는 데스크탑 환경에 최적화되어 있다. 모바일에서는 NavigationBar나
Drawer가 적합하다.
트리거 바의 intrinsic 폭보다 좁은 컨테이너에 두기
// ❌ 메뉴 라벨을 다 담지 못하는 폭
SizedBox(
width: 120,
child: Menubar(
menus: const [
CoreMenubarMenu(label: 'File'),
CoreMenubarMenu(label: 'Edit'),
CoreMenubarMenu(label: 'View'),
],
),
)
Menubar 는 트리거를 줄이거나 감싸거나 스크롤하지 않는다. 좁아지면 Flutter 는
오버플로 경고를 그리고, Web 은 아무 신호 없이 테두리 밖으로 흘러넘친다. 이
갈림은 의도적으로 해소하지 않은 상태이므로, 모든 트리거가 들어가는 폭을
호출자가 보장한다.
접근성 (Accessibility)#
역할#
| 요소 | Flutter | Web |
|---|---|---|
| 가로 바 | Semantics(container: true, label: ...) — 로컬라이제이션 menubarLabel("메뉴 막대") |
role="menubar"
+
aria-orientation="horizontal"
+ 같은 값의
aria-label
|
| 상위 메뉴 트리거 | Semantics(expanded:) 로 감싼 Button (버튼 시맨틱은 Clickable 이 제공) |
<button role="menuitem" aria-haspopup="true" aria-expanded=…>
(열려 있으면
aria-selected="true"
)
|
| 드롭다운 패널 | 합성된 Menu의 menu 역할. 별도 패널 라벨은 없음 | role=menu · aria-orientation=vertical (라벨 없음) |
| 드롭다운 엔트리 | Menu 가 각 행의 시맨틱을 제공 |
Menu 가 menuitem 역할·비활성 상태·roving tabindex 를 제공 |
| 구분선 |
Semantics(label: ...)
—
dividerSeparatorLabel
("구분선"). Flutter 에 separator role 이 없어 이름으로 대신합니다
|
role="separator" |
Web 루트의 role / aria-orientation / aria-label 은 호출자 attributes
를 펼친 뒤에 기록되므로 호출자가 덮을 수 없습니다.
키보드#
| 키 | Flutter | Web |
|---|---|---|
Tab |
트리거마다 개별 tab stop | 바 전체가 tab stop 하나(마지막으로 머문 트리거) |
ArrowLeft / ArrowRight |
프레임워크 방향성 포커스 이동 | 바에서: 이전/다음 트리거(끝에서 순환), 열린 메뉴는 포커스된 트리거를 따라감 · 패널 안에서: 옆 메뉴를 열고 첫 항목으로 |
Home / End | 없음 | 첫 / 마지막 트리거 |
ArrowDown / ArrowUp |
없음 | 트리거에서 메뉴를 열고 첫 / 마지막 항목으로 포커스 · 패널 안에서는 항목 사이 이동(Menu) |
Enter / Space | 메뉴 열기/닫기 | 메뉴 열기/닫기 |
Escape | 비모달 경로에는 배선 없음 | 열린 드롭다운 닫고 트리거로 포커스 복귀 |
Tab (패널 안) | — | 드롭다운을 닫고 트리거로 포커스 복귀 |
Web 은 WAI-ARIA menubar 패턴을 따릅니다. 패널은 문서 끝의 오버레이 레이어로
portal 되므로, 패널 안의 Tab 은 네이티브 순서로 흘려보내지 않고 바로 되돌립니다.
브라우저 검증 — 이 컴포넌트의 Web 키보드 이동은 야간 파리티 스윕의 키보드 프로브(
tool/parity/keyboard_probe.dart)가 하이드레이션된 문서 사이트에서 실제로 키를 눌러, 포커스가 기대한 항목으로 옮겨 갔는지 확인합니다. 핸들러가 달렸는지가 아니라 포커스가 어디에 있는지를 봅니다.
스크린 리더#
접근 가능한 이름의 출처:
- 바 — 로컬라이제이션
menubarLabel(양 플랫폼 동일 문자열). - 트리거 · 드롭다운 행 —
CoreMenubarMenu.label/CoreMenubarEntry.label텍스트. - 구분선 — Web 은
role="separator"로, Flutter 는dividerSeparatorLabel이름으로 읽힙니다.
메뉴가 열리고 닫히는 것을 알리는 live region 은 없습니다. 상태는 트리거의 expanded / aria-expanded
로만 드러납니다.
Flutter 드롭다운 패널은 menu 역할과 menuItem 행 의미를 제공합니다. 패널 자체의 이름은 따로 제공하지 않습니다.
양쪽 드롭다운은 비모달이라 모달 패널에 붙는 닫기 접근성 동작(Flutter dismiss, Web 의 숨긴 닫기 노드)도 없습니다.
포커스 관리#
-
진입 — Flutter 는
modal: false라 전용 패널 포커스를 요청하지 않습니다. Web 은 트리거에서ArrowDown/ArrowUp으로 열면 첫/마지막 행으로 이동합니다. -
이탈 — Web 은
Escape와 패널 안의Tab에서 트리거로 복귀합니다. Flutter 는 명시적 복귀 경로가 없습니다. -
트랩 — 없음. Web 의 패널
Tab은 포털의 문서 순서로 흘려보내는 대신 패널을 닫고 트리거로 돌립니다. -
포커스 표시 — Flutter 트리거는
disableFocusOutline: true라 포커스 링을 그리지 않습니다..menubarvariant 의focusedBackground만 남는데useFocusedAsHover: true로 hover 와 같은 색이라 키보드 포커스와 마우스 호버를 구분할 수 없습니다. Web 트리거는Button기본값이라focus-visible:outline-*링이 그대로 남습니다.
알려진 제약#
- Flutter 는 roving 탐색이 아닙니다. 트리거와 행마다 tab stop 이 생기며, Web 의 메뉴 의미론을 갖춘 순환 탐색과 다릅니다.
-
Flutter 에서는
Escape로 닫을 수 없습니다. 대안: 패널 바깥을 탭하거나,activeIndex/onActiveIndexChanged로 제어 모드를 써서 호출부가 직접 닫으세요. -
트리거와 패널이 접근성 트리에서 이어지지 않습니다. Web 패널은 오버레이 레이어로 포탈되는데
aria-controls가 없어role="menu"가role="menubar"의 자식으로 놓이지 않습니다. 대안 없음 — 패널의 attributes 진입점이 노출되지 않습니다.
크로스 플랫폼 차이점 (Platform Differences)#
menus / border / variant / menubarStyle을 공유하지만 키보드 탐색은 아래처럼 다릅니다. 드롭다운 패널은 Flutter에서는
PopoverOverlayHandler, Web에서는 루트 OverlayHost의 menu 레이어로 portal되어
양쪽 모두 ancestor overflow에 잘리지 않습니다.
| 항목 | Flutter | Web |
|---|---|---|
| 트리거 탐색 | 버튼별 Tab | Left / Right · Home / End의 roving |
| 패널 진입 | 버튼 활성화 후 오버레이 | Down 첫 행 / Up 마지막 행 |
| 패널 탐색 | 항목별 포커스 | Menu의 roving · typeahead; Left / Right로 이웃 메뉴 전환 |
| 패널 Tab | 일반 포커스 이동 | 패널 닫기 요청 후 트리거 복귀 |
| 긴 메뉴 | 엔진이 패널 최대 높이를 레이아웃 제약으로 제한 (여백·시스템 바·키보드 제외) |
위치 엔진이 패널을 측정한 뒤 상자에
max-height
를 걸고 상자 안의 목록을
overflow-y: auto
로 스크롤 (뷰포트 여백
viewportPadding
만 제외)
|
좁은 컨테이너 (의도된 미해소) — 컨테이너가 트리거 바의 intrinsic 폭보다
좁으면 두 플랫폼이 다르게 깨집니다. Flutter 는 RenderFlex overflowed 경고와
오버플로 스트라이프를 그리고, Web 은 아무 신호 없이 테두리 밖으로
흘러넘칩니다. 가로 스크롤·clip 으로 맞추지 않고 이 상태를 유지하기로
했으므로, 트리거가 모두 들어가는 폭에서만 사용하세요.
관련 컴포넌트 (Related Components)#
- Menu: 사이드바 형태의 일반 메뉴
- ContextMenu: 우클릭으로 표시되는 상황별 메뉴
- Navigation: 페이지 이동을 위한 네비게이션 메뉴