Menubar | CoUI
LogoCoUI

Menubar

데스크탑 앱 스타일의 상단 가로 메뉴 바 컴포넌트

Menubar#

데스크탑 애플리케이션 스타일의 상단 가로 메뉴 바 컴포넌트입니다. 각 상위 메뉴는 클릭 시 드롭다운 패널을 열고, 한 번 열린 뒤에는 다른 메뉴에 마우스를 올리는 것만으로 전환되며, 키보드 단축키 힌트와 구분선을 지원합니다.

Menubar는 데이터 기반 컴포넌트입니다. CoreMenubarMenu 목록을 넘기면 각 메뉴가 가로 바에 라벨로 표시되고, entriesCoreMenubarEntry.action / CoreMenubarEntry.divider가 드롭다운 패널로 렌더링됩니다. Flutter와 Web이 동일한 파라미터 이름·동작을 갖습니다.

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • IDE, 문서 편집기, 데스크탑 앱처럼 파일/편집/보기 스타일의 메뉴 바가 필요할 때
  • 많은 기능을 계층적 메뉴 구조로 정리해야 할 때
  • 키보드 단축키와 함께 메뉴 항목을 표시할 때

대신 다른 컴포넌트를 사용하세요:

  • Navigation: 페이지 간 이동을 위한 네비게이션 메뉴에
  • DropdownMenu: 단일 버튼의 드롭다운 액션 목록에
  • Tabs: 동일 화면 내 콘텐츠 섹션 전환에

기본 사용법 (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'),
      ],
    ),
  ],
)

Props / Parameters#

속성타입기본값설명
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#

속성타입기본값설명
labelString필수가로 바에 표시되는 메뉴 라벨
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 필드#

필드타입설명
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
barBackgroundColorsurface
barBorderColoroutline (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)와 동일하게, 첫 클릭이 바를 "무장"시키고 그 뒤로는 포인터를 따라간다
  • 항목 선택: 활성 엔트리 클릭 시 onSelect 호출 후 닫힘
  • 외부 클릭: 메뉴 외부 클릭 시 닫힘
  • Escape: 열린 드롭다운 닫힘

닫힌 상태에서의 호버는 아무 일도 하지 않는다. 바 위를 지나가기만 해도 메뉴가 열리면 포인터가 바를 가로지를 때마다 메뉴가 튀어나오기 때문이다 (호버만으로 여는 NavigationMenu 와 의도적으로 다른 지점). 호버가 메뉴를 닫지도 않는다 — 포인터는 아래 패널에 도달하려면 반드시 트리거를 벗어나므로, 닫힘은 항목 선택 / 외부 클릭 / Escape 가 담당한다.

호버 전환은 마우스 포인터에서만 동작한다. Flutter 는 MouseRegion 기반이라 구조적으로 터치를 보고하지 않고, Web 은 (hover: hover) 미디어 쿼리로 게이트한다 — 터치 브라우저가 탭에서 mouseenter 를 에뮬레이트해 "전환 후 즉시 닫힘" 이 되는 것을 막는다. 같은 capability, 각 플랫폼의 idiom.

상태 전환#

  • closedopen: 메뉴 바 항목 클릭 (호버는 열지 않음)
  • openopen (다른 메뉴): 다른 메뉴 바 항목 클릭 또는 호버
  • openclosed: 항목 선택, 외부 클릭, 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는 데스크탑 환경에 최적화되어 있다. 모바일에서는 NavigationBarDrawer가 적합하다.

접근성 (Accessibility)#

시맨틱 역할#

  • 가로 바: role="menubar" + aria-orientation="horizontal" + 로컬라이제이션 menubarLabel 에서 온 aria-label (Flutter 는 같은 값을 Semantics(label: ...) 로)
  • 상위 메뉴 트리거: role="menuitem" + aria-haspopup + aria-expanded
  • 드롭다운 패널: role="menu"
  • 드롭다운 엔트리: role="menuitem" (비활성 시 aria-disabled)
  • 구분선: role="separator"

키보드 인터랙션#

동작
Escape열린 드롭다운 닫기

스크린 리더#

  • Flutter: Semantics로 menubar / button / expanded 상태 전달
  • Web: role="menubar" / role="menu" / role="menuitem" / role="separator" 자동 적용

크로스 플랫폼 차이점 (Platform Differences)#

Menubar는 Flutter와 Web이 동일한 menus / border / variant / menubarStyle API와 동작을 갖습니다. 드롭다운 패널은 Flutter에서는 OverlayPortal, Web에서는 루트 OverlayHost의 menu 레이어로 portal되어 양쪽 모두 ancestor overflow에 잘리지 않습니다.

  • Menu: 사이드바 형태의 일반 메뉴
  • ContextMenu: 우클릭으로 표시되는 상황별 메뉴
  • Navigation: 페이지 이동을 위한 네비게이션 메뉴