Menu#
Menu 는 테두리가 있는 패널 안에 풍부한 메뉴 항목 목록을 표시하는
정적(인라인) 메뉴 컴포넌트입니다. 항목은 CoreMenuItem 의 이름 있는
팩토리 생성자로 만듭니다 — 헤딩 라벨, 액션, 체크박스, 라디오, 구분선,
간격, 그리고 인라인 중첩 서브메뉴까지 지원합니다.
Live Preview#
class MenuDefaultExample extends StatelessComponent {
const MenuDefaultExample({super.key});
@override
Component build(BuildContext context) {
return Menu(
ariaLabel: 'Main menu',
items: [
const CoreMenuItem.label('Navigation'),
CoreMenuItem.action('Home', onPressed: () {}, active: true),
CoreMenuItem.action('Profile', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.checkbox('Show grid', onChanged: (_) {}, checked: true),
CoreMenuItem.action('Settings', disabled: true),
],
);
}
}
class MenuDefaultExample extends StatelessWidget {
const MenuDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return Menu(
ariaLabel: 'Main menu',
items: [
const CoreMenuItem.label('Navigation'),
CoreMenuItem.action('Home', onPressed: () {}, active: true),
CoreMenuItem.action('Profile', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.checkbox('Show grid', onChanged: (_) {}, checked: true),
CoreMenuItem.action('Settings', disabled: true),
],
);
}
}
class MenuChainExample extends StatelessComponent {
const MenuChainExample({super.key});
@override
Component build(BuildContext context) {
return Menu(
ariaLabel: 'Main menu',
items: [
const CoreMenuItem.label('Navigation'),
CoreMenuItem.action('Home', onPressed: () {}, active: true),
CoreMenuItem.action('Profile', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.checkbox('Show grid', onChanged: (_) {}, checked: true),
CoreMenuItem.action('Settings', disabled: true),
],
).withStyle(
const CoreMenuStyle(
itemPadding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space20,
vertical: CoreSpace.space12,
),
itemContentGapStyle: CoreGapStyle(size: CoreSpace.space12),
dividerStyle: CoreDividerStyle(
color: CoreColor.token(CoreColors.primary),
thickness: CoreStrokeWidth.stroke2,
),
markIconStyle: CoreIconStyle(
color: CoreColor.token(CoreColors.tertiary),
),
),
);
}
}
class MenuChainExample extends StatelessWidget {
const MenuChainExample({super.key});
@override
Widget build(BuildContext context) {
return Menu(
ariaLabel: 'Main menu',
items: [
const CoreMenuItem.label('Navigation'),
CoreMenuItem.action('Home', onPressed: () {}, active: true),
CoreMenuItem.action('Profile', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.checkbox('Show grid', onChanged: (_) {}, checked: true),
CoreMenuItem.action('Settings', disabled: true),
],
).withStyle(
const CoreMenuStyle(
itemPadding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space20,
vertical: CoreSpace.space12,
),
itemContentGapStyle: CoreGapStyle(size: CoreSpace.space12),
dividerStyle: CoreDividerStyle(
color: CoreColor.token(CoreColors.primary),
thickness: CoreStrokeWidth.stroke2,
),
markIconStyle: CoreIconStyle(
color: CoreColor.token(CoreColors.tertiary),
),
),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 액션이나 네비게이션 항목 목록을 패널로 직접 표시할 때
- leading/trailing 아이콘, 체크박스, 라디오를 포함한 메뉴가 필요할 때
- 인라인으로 펼쳐지는 계층적 서브메뉴 구조가 필요할 때
대신 다른 컴포넌트를 사용하세요:
DropdownMenu: 트리거 클릭으로 떠오르는 오버레이 메뉴일 때ContextMenu: 우클릭/롱프레스로 여는 컨텍스트 메뉴일 때Menubar: 가로 애플리케이션 메뉴바일 때Select: 폼에서 값을 선택하는 드롭다운일 때
기본 사용법 (Basic Usage)#
Menu(
ariaLabel: 'Main menu',
items: [
const CoreMenuItem.label('Navigation'),
CoreMenuItem.action('Home', onPressed: () {}, active: true),
CoreMenuItem.action('Profile', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.checkbox('Show grid', onChanged: (_) {}, checked: true),
CoreMenuItem.action('Settings', disabled: true),
],
)
Menu(
ariaLabel: 'Main menu',
items: [
const CoreMenuItem.label('Navigation'),
CoreMenuItem.action('Home', onPressed: () {}, active: true),
CoreMenuItem.action('Profile', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.checkbox('Show grid', onChanged: (_) {}, checked: true),
CoreMenuItem.action('Settings', disabled: true),
],
)
Props / Parameters#
Menu#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
items |
List<CoreMenuItem<W>> |
필수 | 패널에 렌더링할 메뉴 항목 |
vertical |
bool |
true |
false 면 항목을 가로로 배치 |
ariaLabel |
String? |
null |
메뉴 컨테이너의 접근성 이름 |
menuStyle |
CoreMenuStyle? |
null |
인스턴스별 chrome 스타일 |
CoreMenuItem 팩토리#
| 팩토리 | 설명 |
|---|---|
CoreMenuItem.label(text, {leading, trailing}) | 비대화 섹션 헤딩 |
CoreMenuItem.action(text, {leading, trailing, onPressed, href, submenu, active, disabled})
|
대화형 액션 항목 (submenu 지정 시 인라인 서브메뉴 트리거) |
CoreMenuItem.checkbox(text, {trailing, onChanged, checked, disabled}) |
체크박스 토글 항목 |
CoreMenuItem.radio(text, {trailing, onChanged, selected, disabled}) |
라디오 단일 선택 항목 |
CoreMenuItem.divider() | 시각적 구분선 |
CoreMenuItem.gap(size) | 고정 크기 간격 |
스타일 시스템 (Style System)#
Menu 의 모든 chrome / dimensional 오버라이드는 단일 CoreMenuStyle
슬롯으로 흐릅니다.
시맨틱 vs 스타일#
- 시맨틱 / behaviour: 위젯 파라미터로 직접 (
vertical,ariaLabel) -
chrome / dimensional:
CoreMenuStyle한 곳으로 (background / border / radius / padding / itemPadding / itemSpacing / itemContentGapStyle / activeColor / hoverColor / dividerColor / leadingColumnWidth / submenuIndent)
Resolve chain#
CoreMenuStyle.defaultX (디자인 시스템 기본값)
→ CoreMenuTheme.style // 프로젝트 공통
→ widget.menuStyle // 인스턴스별
CoreMenuStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
popupStyle |
CorePopupStyle? |
Nested panel-box chrome style — forwarded straight to the composed
Popup(popupStyle: …)
, which resolves the box background / border / radius / padding / shadow / min-width and the inter-row spacing. Only the menu-divergent fields are pre-set in [defaultPopupStyle]; the rest fall back to [CorePopupStyle]'s own defaults inside the popup resolver (raw-forward). Overlaid by the per-instance value.
|
submenuStyle |
CoreMenuStyle? |
Nested style for an inline nested submenu — the submenu is itself a
Menu
(an indented child), so this slot carries a full [CoreMenuStyle] that the resolver raw-forwards to the child's
menuStyle
(the child self-resolves, exactly like
dividerStyle
forwards to
Separator
/
chevronIconStyle
to
Icon
). Defaults to [defaultSubmenuStyle] (a flush, box-less panel) so inline submenus read as an indented group; a designer can override this slot to give nested submenus their own box / item chrome. Overlaid by the per-instance value on top of the default.
|
itemButtonStyle |
CoreButtonStyle? |
Nested style for an interactive entry's composed
Button(variant: menu)
.
null
falls back to [defaultItemButtonStyle]; resolvers raw-forward
defaultItemButtonStyle.merge(this)
to each row Button (no resolver-side assembly).
|
itemPadding | CoreEdgeInsets? | Per-item padding. |
itemContentGapStyle |
CoreGapStyle? |
Nested gap style between an item's leading-icon column and its label — the inline gap inside a single row, distinct from the panel's inter-row spacing ([CorePopupStyle.spacing]) which spaces adjacent rows. Forwarded to
Gap(gapStyle: …)
.
|
labelTextStyle |
CoreTextStyle? |
Nested text style for non-interactive label / header entries (
CoreMenuItemKind.label
) — overlaid on the [defaultLabelTextStyle] (
labelSmall
role + bold weight + variant colour) and forwarded to the entry's
Text
/
<span>
as a paint-ready style. Carries the heading weight / colour so both platforms drive the caption typography from this single slot.
|
shortcutTextStyle |
CoreTextStyle? |
Nested text style for the keyboard-shortcut hint — overlaid on [defaultShortcutTextStyle] (
labelMedium
role +
onSurfaceVariant
).
|
dividerStyle |
CoreDividerStyle? |
Nested divider style for the divider entry — forwarded straight to
Divider(dividerStyle: …)
, which resolves the colour / thickness (rule 2.2 child-component nested slot). Overlaid on [defaultDividerStyle].
|
leadingColumnWidth |
double? |
Leading-icon column width (logical px). |
submenuIndent |
double? |
Per-level submenu indentation (logical px). |
chevronIconStyle |
CoreIconStyle? |
Nested icon style for the submenu chevron-right indicator — forwarded straight to
Icon(iconStyle: …)
. Sized to the leading-icon column by default so the marker fits the reserved footprint.
|
markIconStyle |
CoreIconStyle? |
Nested icon style for the toggle mark (checkbox checkmark / radio dot) leading indicator — forwarded straight to
Icon(iconStyle: …)
. Sized to the leading-icon column by default so the mark fits the reserved footprint.
|
사용 예#
Menu(
items: items,
menuStyle: const CoreMenuStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
activeColor: CoreColor.token(CoreColors.secondary),
),
)
빠른 오버라이드 (Chain)#
이미 만든 Menu 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class MenuChainExample extends StatelessWidget {
const MenuChainExample({super.key});
@override
Widget build(BuildContext context) {
return Menu(
ariaLabel: 'Main menu',
items: [
const CoreMenuItem.label('Navigation'),
CoreMenuItem.action('Home', onPressed: () {}, active: true),
CoreMenuItem.action('Profile', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.checkbox('Show grid', onChanged: (_) {}, checked: true),
CoreMenuItem.action('Settings', disabled: true),
],
).withStyle(
const CoreMenuStyle(
itemPadding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space20,
vertical: CoreSpace.space12,
),
itemContentGapStyle: CoreGapStyle(size: CoreSpace.space12),
dividerStyle: CoreDividerStyle(
color: CoreColor.token(CoreColors.primary),
thickness: CoreStrokeWidth.stroke2,
),
markIconStyle: CoreIconStyle(
color: CoreColor.token(CoreColors.tertiary),
),
),
);
}
}
class MenuChainExample extends StatelessComponent {
const MenuChainExample({super.key});
@override
Component build(BuildContext context) {
return Menu(
ariaLabel: 'Main menu',
items: [
const CoreMenuItem.label('Navigation'),
CoreMenuItem.action('Home', onPressed: () {}, active: true),
CoreMenuItem.action('Profile', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.checkbox('Show grid', onChanged: (_) {}, checked: true),
CoreMenuItem.action('Settings', disabled: true),
],
).withStyle(
const CoreMenuStyle(
itemPadding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space20,
vertical: CoreSpace.space12,
),
itemContentGapStyle: CoreGapStyle(size: CoreSpace.space12),
dividerStyle: CoreDividerStyle(
color: CoreColor.token(CoreColors.primary),
thickness: CoreStrokeWidth.stroke2,
),
markIconStyle: CoreIconStyle(
color: CoreColor.token(CoreColors.tertiary),
),
),
);
}
}
변형 (Variants)#
서브메뉴 (인라인 중첩)#
Menu(
items: [
CoreMenuItem.action('File', onPressed: () {}),
CoreMenuItem.action(
'Export',
submenu: [
CoreMenuItem.action('PDF', onPressed: () {}),
CoreMenuItem.action('CSV', onPressed: () {}),
],
),
],
)
서브메뉴는 부모 항목 아래에 한 단계 들여쓰기되어 인라인으로 펼쳐집니다.
가로 메뉴#
Menu(
vertical: false,
items: [
CoreMenuItem.action('Home', onPressed: () {}),
CoreMenuItem.action('Docs', onPressed: () {}),
],
)
체크 / 라디오 항목#
Menu(
items: [
CoreMenuItem.checkbox('Auto save', onChanged: (_) {}, checked: true),
CoreMenuItem.radio('Compact', onChanged: (_) {}, selected: true),
CoreMenuItem.radio('Comfortable', onChanged: (_) {}),
],
)
동작 스펙 (Behavior)#
항목 활성화#
- action: 클릭/탭으로
onPressed호출.disabled면 무시 + 0.5 불투명도 - checkbox: 활성화 시
onChanged(!checked)호출, 체크 시 leading 체크 아이콘 표시 - radio: 활성화 시
onChanged(true)호출, 선택 시 leading dot 아이콘 표시
Hover#
- 대화형 항목에 포인터를 올리면
hoverColor배경이 표시됩니다.
서브메뉴#
-
CoreMenuItem.action에submenu를 지정하면 trailing 위치에 chevron 아이콘이 자동 추가되고, 항목 아래에 중첩Menu가 인라인으로 렌더됩니다.
사용 가이드라인 (Usage Guidelines)#
✅ Do#
독립적으로 쓸 때는 ariaLabel 지정
Menu(
ariaLabel: 'Main menu',
items: [
CoreMenuItem.action('Home', onPressed: () {}),
CoreMenuItem.action('Profile', onPressed: () {}),
],
)
Web 은 role="menu" 만으로는 이름 붙은 조상 트리거가 없으면 스크린 리더가 메뉴의 목적을 알 수 없습니다. ariaLabel 은 Flutter Semantics 컨테이너 라벨로도 동일하게 노출됩니다.
❌ Don't#
커스텀 오버레이 안에서 onActivate 연결을 빠뜨리지 않기
// ❌ onActivate 미연결 — 액션 클릭 후에도 패널이 열린 채로 남음
Popover(
content: Menu(items: actionItems),
)
// ✅ onActivate 로 닫기 로직 연결
Popover(
content: Menu(
items: actionItems,
onActivate: closePopover,
),
)
Menu 자신은 열림/닫힘 상태를 관리하지 않습니다 — action 항목이 활성화되면 onActivate 가 호출될 뿐이므로, 직접 만든 오버레이 안에 Menu 를 넣었다면 그 콜백으로 닫기 로직을 연결해야 합니다(DropdownMenu/ContextMenu/Menubar 는 이미 이 배선을 대신 해줍니다).
접근성 (Accessibility)#
Menu 는 항목 목록을 그립니다. 열고 닫기·포커스·Escape 는 이것을 합성하는
호스트(DropdownMenu·ContextMenu·Menubar·NavigationMenu)와 그 오버레이가
소유하므로, 아래는 두 층을 나눠 적습니다.
역할#
-
Flutter —
ariaLabel이Semantics컨테이너 라벨로 노출됩니다. menu role 은 없습니다. -
Web —
role="menu"(세로) /role="menubar"(가로), 항목별role="menuitem"/menuitemcheckbox/menuitemradio,aria-orientation·aria-disabled·aria-current·aria-checked.
키보드#
| 키 | Flutter | Web |
|---|---|---|
Enter / Space | 포커스된 항목 활성화 | 포커스된 항목 활성화 |
Tab | 항목마다 개별 tab stop | 항목마다 개별 tab stop |
ArrowUp/ArrowDown 로 항목 이동 |
메뉴 의미론 없음 (아래) | 없음 |
Home/End | 없음 | 없음 |
| 첫 글자 타이핑으로 점프 | 없음 | 없음 |
WAI-ARIA 메뉴 탐색이 구현돼 있지 않습니다. 항목 이동은 Tab 으로 합니다.
Flutter 에서 화살표가 포커스를 옮기는 것은 프레임워크의 방향성 순회이지 메뉴 탐색이
아닙니다(순환·Home/End·타이핑 점프 없음).
Escape 는 Menu 자신이 아니라 호스트가 제공합니다:
| 호스트 | Flutter | Web |
|---|---|---|
DropdownMenu · ContextMenu · NavigationMenu |
닫힘 | 닫힘 |
Menubar | 없음 | 닫힘 |
Flutter Menubar 는 non-modal 로 열려 오버레이의 Escape 배선을 받지 않습니다.
포커스 관리#
-
진입 —
Menu자신은 포커스를 옮기지 않습니다. Flutter 의 modal 호스트 (DropdownMenu·ContextMenu·NavigationMenu)는 오버레이가 열릴 때 패널 스코프로 포커스를 옮깁니다(첫 항목이 아니라 패널입니다). -
트랩 — Flutter modal 호스트에서는
Tab이 패널 안에 갇히고, 배경이 스크린 리더에서 가려집니다. Web 은 갇히지 않아 마지막 항목 다음Tab이 패널 밖으로 나갑니다. - 이탈 — 닫을 때 트리거로 포커스를 되돌리는 코드가 양 플랫폼 어디에도 없습니다.
알려진 제약#
- 항목 수만큼 tab stop 이 생깁니다.
- 메뉴가 닫힌 뒤 포커스가 어디로 가는지 보장되지 않습니다.
- 포커스 트랩과 배경 격리가 Flutter modal 호스트에만 있어, 같은 메뉴라도 플랫폼과 호스트에 따라 키보드 경험이 다릅니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 패널 | Container + Column/Row |
<div> flex 패널 |
| 항목 | CoreMenuItem 팩토리 (양쪽 동일 API) |
CoreMenuItem 팩토리 (양쪽 동일 API) |
| 구분선 | Separator | <hr> (border 기반) |
| 서브메뉴 | 인라인 중첩 Menu | 인라인 중첩 Menu |
| 아이콘 | Icon | Icon |
| ARIA | Flutter Semantics |
role / aria-* 속성 |
관련 컴포넌트 (Related Components)#
- DropdownMenu: 트리거로 떠오르는 오버레이 메뉴
- ContextMenu: 우클릭/롱프레스 컨텍스트 메뉴
- Menubar: 가로 애플리케이션 메뉴바
- Popover: 임의의 콘텐츠를 표시하는 팝업