Menu | CoUI
LogoCoUI

Menu

정적 메뉴 패널 컴포넌트

Menu#

Menu 는 테두리가 있는 패널 안에 풍부한 메뉴 항목 목록을 표시하는 정적(인라인) 메뉴 컴포넌트입니다. 항목은 CoreMenuItem 의 이름 있는 팩토리 생성자로 만듭니다 — 헤딩 라벨, 액션, 체크박스, 라디오, 구분선, 간격, 그리고 인라인 중첩 서브메뉴까지 지원합니다.

Live Preview#

사용 시기 (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#

속성타입기본값설명
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).
itemPaddingCoreEdgeInsets?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.actionsubmenu 를 지정하면 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)와 그 오버레이가 소유하므로, 아래는 두 층을 나눠 적습니다.

역할#

  • FlutterariaLabelSemantics 컨테이너 라벨로 노출됩니다. menu role 은 없습니다.
  • Webrole="menu"(세로) / role="menubar"(가로), 항목별 role="menuitem" / menuitemcheckbox / menuitemradio, aria-orientation·aria-disabled· aria-current·aria-checked.

키보드#

FlutterWeb
Enter / Space포커스된 항목 활성화포커스된 항목 활성화
Tab항목마다 개별 tab stop항목마다 개별 tab stop
ArrowUp/ArrowDown 로 항목 이동 메뉴 의미론 없음 (아래) 없음
Home/End없음없음
첫 글자 타이핑으로 점프없음없음

WAI-ARIA 메뉴 탐색이 구현돼 있지 않습니다. 항목 이동은 Tab 으로 합니다. Flutter 에서 화살표가 포커스를 옮기는 것은 프레임워크의 방향성 순회이지 메뉴 탐색이 아닙니다(순환·Home/End·타이핑 점프 없음).

EscapeMenu 자신이 아니라 호스트가 제공합니다:

호스트FlutterWeb
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)#

항목FlutterWeb
패널 Container + Column/Row <div> flex 패널
항목 CoreMenuItem 팩토리 (양쪽 동일 API) CoreMenuItem 팩토리 (양쪽 동일 API)
구분선Separator<hr> (border 기반)
서브메뉴인라인 중첩 Menu인라인 중첩 Menu
아이콘IconIcon
ARIA Flutter Semantics role / aria-* 속성
  • DropdownMenu: 트리거로 떠오르는 오버레이 메뉴
  • ContextMenu: 우클릭/롱프레스 컨텍스트 메뉴
  • Menubar: 가로 애플리케이션 메뉴바
  • Popover: 임의의 콘텐츠를 표시하는 팝업