MenuItem | CoUI
본문으로 건너뛰기
LogoCoUI

MenuItem

메뉴 행 하나 — Menu, DropdownMenu, ContextMenu, Menubar, Select 가 함께 놓는 단위

목차

MenuItem 은 메뉴 패널 안 상호작용 행 하나입니다. Menu, DropdownMenu, ContextMenu, Menubar, Select 가 모두 이 행을 놓기 때문에, 한 곳에서 그려서 다섯 컴포넌트가 같은 행을 갖게 합니다.

기본 사용법 (Basic Usage)#

MenuItem(
  onPressed: () {},
  label: const Text('Copy'),
  shortcut: const Text('⌘C'),
  leading: const Icon(LucideIcons.copy),
)
MenuItem(
  onPressed: () {},
  label: const Text('Copy'),
  shortcut: const Text('⌘C'),
  leading: const Icon(LucideIcons.copy),
)

Live Preview#

사용 시기 (When to Use)#

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

  • 커스텀 오버레이나 패널 안에 직접 메뉴 행을 조립할 때
  • Select 옵션 목록처럼 다른 컴포넌트가 메뉴 행을 재사용할 때
  • Menu 가 합성하는 action / checkbox / radio 행의 기반 단위가 필요할 때

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

  • Menu: 행 하나가 아니라 패널·항목 탐색을 목록으로 구성할 때 (CoreMenuItem 팩토리로 이 컴포넌트를 대신 합성해 줍니다)
  • Listing: 메뉴 행이 아니라 일반 행·열 콘텐츠 나열일 때

해부 (Anatomy)#

행은 시작부터 끝까지 다섯 좌석을 순서대로 배치합니다.

[ mark ] [ leading ] [ label / description ] [ shortcut | chevron ] [ trailing ]
  • mark — 체크박스의 체크, 라디오의 점처럼 켜짐을 나타내는 글리프. mark 가 null 이면 비어 있지만, reserveMark: true 면 칸의 폭은 유지됩니다 — 토글 그룹 안에서 일부 행만 켜져도 라벨이 어긋나지 않도록.
  • leading — 임의 아이콘/인디케이터.
  • label / description — label 은 항상 있고, description 이 있으면 두 줄이 됩니다(위쪽 정렬). description 이 있는 행은 size 의 최소 높이를 넘어 자랍니다.
  • shortcut | chevron — 키보드 단축키 힌트 또는(둘 중 하나만) 서브메뉴 셰브론(hasSubmenu: true 일 때). 서브메뉴가 있으면 shortcut 은 숨겨집니다 — 그 자리는 셰브론의 것입니다.
  • trailing — shortcut/chevron 뒤의 추가 슬롯.

좌석 사이 간격은 contentGapStyle, label 과 description 사이는 labelDescriptionGapStyle 이 각각 Gap 으로 그립니다.

Clickable + Text + Icon + Gap 으로 그려지며 Button 을 합성하지 않습니다 — 메뉴 행의 접근성 계약(메뉴 전체가 한 tab stop, roving focus, 행이 무엇인지에서 파생되는 menuitem* role, disabled 여도 focusable)이 버튼과 종류가 다르고 버튼의 chrome 으로는 표현할 수 없기 때문입니다.

축 (Axes) — 밀도와 톤#

size 와 tone 은 서로 다른 필드 집합을 결정하는 독립된 두 축입니다. size 는 높이·여백·간격·모서리·타이포·아이콘 크기를 정하고, tone 은 전경색 하나만 바꿉니다.

밀도 (size) — 32 / 36 / 48#

size 최소 높이 padding (가로 / 세로) 좌석 간격 모서리 leading 아이콘 label description shortcut
sm 32 8 / 6 8 8 16 labelLarge bodyXs bodyXs
md (기본) 36 10 / 8 10 8 18 labelLarge bodySmall bodyXs
lg 48 16 / 12 12 12 24 labelXl bodyMedium bodySmall

세 단만 있는 이유는 조사한 여섯 시스템 중 어느 것도 메뉴 행을 32 아래나 48 위로 그리지 않기 때문입니다 — xs / xl 을 선언하면 실제 범위를 넘어서는 축이 됩니다. 32 는 데스크톱 밀집 메뉴, 48 은 터치 타깃이고, 기본 36 은 컨트롤 사다리의 32 와 40 사이에 있어 이 메뉴를 여는 md 트리거 (Select / TextField, 40)보다 한 칸 낮게 앉습니다.

36 은 컨트롤 사다리의 단이 아니므로 밀도(CoreDensity)가 옮기지 않습니다. sm · lg 행은 사다리 위의 단이라 spacious 에서는 한 단 위로(40 · 56), compact 에서는 lg 만 한 단 아래로(40) 움직입니다 — compact 는 사다리의 sm(32)에서 멈춥니다.

모서리는 행 높이 × 0.25 에 가장 가까운 CoreRadius 단입니다(8 / 8 / 12). md 는 36 × 0.25 = 9 가 단이 아니어서 8 입니다. 이 값은 혼자 선 행의 것입니다. Menu (그리고 그것을 합성하는 DropdownMenu · ContextMenu · Menubar) 와 Select 패널 안의 행은 사이즈와 무관하게 6 입니다 — 부모가 자기 행 슬롯 기본값(CoreMenuStyle.defaultItemStyle · CoreSelectStyle.defaultOptionStyle)에 6 을 적습니다. 안쪽 모서리는 감싼 패널보다 둥글면 안 되는데, 판 안쪽 여백이 6 이므로 패널 모서리 12 와 동심이 되는 행의 모서리가 6 이고(12 − 6), 행이 사이즈를 따르면 lg 행(12)이 패널 안에서 바깥만큼 둥글어지기 때문입니다.

mark 와 chevron 글리프는 이 사다리를 따라가지 않고 16 으로 고정입니다 — 광학적으로 같은 대역에 머물러야 라벨 정렬이 밀도마다 흔들리지 않기 때문입니다.

톤 (tone)#

tone전경색쓰임
neutral (기본)onSurface일반 액션
criticalerror파괴적 액션 — 삭제처럼 되돌릴 수 없는 것

selected 채움은 두 tone 이 공유합니다. hover / focus / pressed 는 tone 이 다릅니다. neutral 은 전경색을 워시로 얹지만(상태 레이어 램프), critical 은 워시가 곧 에러 색 그 자체라 투명한 행 위에서 구분이 되지 않으므로 워시 대신 errorContainer 채움으로 읽힙니다. 글자색은 채움과 함께 바뀌지 않습니다 — 라벨 · 글리프는 error, 설명 · 단축키는 쉬는 상태의 흐린 색 그대로입니다. 강제 색상 모드에서는 어차피 두 tone 이 한 색으로 접히므로 색이 유일한 단서인 만큼 배치 규약(그룹 마지막, 구분선 뒤)이 색과 함께 따라다닙니다.

Props / Parameters#

W 는 Flutter의 Widget / Web의 Jaspr Component입니다. 각 슬롯은 텍스트·아이콘뿐 아니라 같은 플랫폼의 임의 콘텐츠를 받습니다.

속성타입기본값설명
label W 필수 행의 텍스트. Text chain 을 그대로 받는 composition slot — resolver 가 role( labelLarge / labelXl )을 공급합니다
size CoreMenuItemSize CoreMenuItemSize.defaultSize(md, 36px) 밀도 — 32 / 36 / 48. 한 메뉴는 모든 행에 같은 값을 넘깁니다(Menu(size:))
tone CoreMenuItemTone CoreMenuItemTone.defaultTone(neutral) 일반 또는 파괴적 액션. 전경색만 바뀝니다
description W? null label 아래 두 번째 줄. 있으면 행이 최소 높이를 넘어 자라고 두 줄이 위쪽 정렬됩니다
shortcut W? null 행 끝의 키보드 단축키 힌트. hasSubmenu 면 숨겨집니다(그 자리는 셰브론)
leading W? null 시작 슬롯의 아이콘/인디케이터
trailing W? null shortcut/chevron 뒤의 추가 슬롯
mark W? null 체크/라디오 같은 토글 글리프. 액션 행은 보통 null
reserveMark bool false mark 가 null 이어도 mark 칸의 폭을 유지 — 토글 그룹의 라벨 정렬용
hasSubmenu bool false 서브메뉴를 여는 행이면 true . 셰브론( LucideIcons.chevronRight )을 그리고 Web 에서 aria-haspopup="menu" 를 답니다
selected bool false 선택된 상태로 그릴지(활성 액션, 체크된 체크박스, 선택된 라디오) — 선택 배경/전경
enabled bool? null → onPressed != null 활성화 가능 여부. null 이면 onPressed 유무로 유도됩니다. 비활성 행도 포커스는 받습니다(비활성 키보드 탐색 관행)
onPressed void Function()? null 활성화 콜백
menuItemStyle CoreMenuItemStyle? null 모든 chrome 이 지나는 단일 슬롯 (아래 표 참고)

스타일 시스템 (Style System)#

MenuItem 의 모든 chrome / dimensional 오버라이드는 단일 CoreMenuItemStyle 슬롯으로 흐릅니다. Menu 가 이 행을 합성할 때도 같은 슬롯을 통해 override 를 전달합니다(CoreMenuStyle.itemStyle).

시맨틱 vs 스타일#

  • 시맨틱 / behaviour: 위젯 파라미터로 직접 (size, tone, selected, enabled, hasSubmenu, reserveMark)
  • chrome / dimensional: CoreMenuItemStyle 한 곳으로 (배경/전경 색, padding, 간격, radius, 아이콘/텍스트 스타일, 상태 레이어, 포커스 링)

defaultsBySize / defaultsByTone#

두 축의 기본값은 각각의 테이블이 소유합니다 — defaultsBySize 는 높이 · padding · 좌석 간격 · leading 아이콘 · label / description / shortcut 타이포를, defaultsByTone 은 foregroundColor 하나만. 구체적인 값은 위 축 (Axes) 표에 있습니다. 나머지 필드(마크 · 셰브론 글리프, 모서리, 채움, 상태 레이어 램프, 합성된 Clickable chrome)는 두 축 어느 쪽에도 속하지 않는 default* 상수입니다.

Resolve chain#

CoreMenuItemStyle.defaultsBySize[size]     // 밀도 기본값
  → CoreMenuItemStyle.defaultsByTone[tone] // 전경색 기본값
  → CoreMenuItemStyle.defaultX             // size/tone 무관 기본값
  → CoreMenuItemTheme.style                // 프로젝트 공통
  → widget.menuItemStyle                   // 인스턴스별

CoreMenuItemStyle 필드#

필드타입설명
minHeight double? Minimum row height (logical px). A description line grows past it. null → the size ladder.
padding CoreEdgeInsets? Content insets. null → the size ladder.
contentGapStyle CoreGapStyle? Gap between the mark, leading, text, shortcut / chevron and trailing seats. null → the size ladder.
labelDescriptionGapStyle CoreGapStyle? Gap between the label and its description. null → [defaultLabelDescriptionGapStyle].
borderRadius CoreBorderRadius? Corner radius. null → the size row's corner ( defaultsBySize[size].borderRadius , the row height × 0.25). A host panel states its row corner here through its nested row slot.
leadingIconStyle CoreIconStyle? Leading icon chrome (size; colour follows the foreground). null → the size ladder.
markIconStyle CoreIconStyle? Mark glyph chrome. null → [defaultMarkIconStyle].
chevronIconStyle CoreIconStyle? Submenu chevron chrome. null → [defaultChevronIconStyle].
labelStyle CoreTextStyle? Label typography (role; colour follows the foreground). null → the size ladder.
descriptionStyle CoreTextStyle? Description typography and colour. null → the size ladder.
shortcutTextStyle CoreTextStyle? Shortcut hint typography and colour. null → the size ladder.
backgroundColor CoreColor? Resting fill. null → [defaultBackgroundColor].
foregroundColor CoreColor? Resting label / glyph colour. null → the tone table.
selectedBackgroundColor CoreColor? Fill while the row is selected. null → [defaultSelectedBackgroundColor].
selectedForegroundColor CoreColor? Foreground while selected. null → [defaultSelectedForegroundColor].
disabledForegroundColor CoreColor? Foreground while disabled. null → [defaultDisabledForegroundColor].
hoverBackgroundColor CoreColor? Fill while the row is hovered or keyboard-focused. null → the row paints no fill of its own for the state and the foreground is washed over the resting fill at [stateLayer]'s weight instead. Only a tone whose active state is a fill rather than a wash states it — defaultsByTone[critical] , where the wash colour would be the error ink itself. A selected or disabled row never reads it.
pressedBackgroundColor CoreColor? Fill while the row is pressed. null → as [hoverBackgroundColor]. It takes effect only together with [hoverBackgroundColor].
activeForegroundColor CoreColor? Foreground while the row is hovered, focused or pressed — the label, the description, the shortcut and the glyphs. null → the resting foreground. Stated with [hoverBackgroundColor] and [pressedBackgroundColor], since the ink has to be chosen for the fill it sits on.
stateLayer CoreStateLayer? How strongly the foreground is washed over the row on hover / focus / press. null → [defaultStateLayer]. A tone that states an active fill takes no wash.
clickableStyle CoreClickableStyle? Nested chrome of the composed Clickable (focus ring, press feedback). null → [defaultClickableStyle]. Raw-forwarded.

사용 예#

MenuItem(
  onPressed: () {},
  label: const Text('Copy'),
  menuItemStyle: const CoreMenuItemStyle(
    borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
    foregroundColor: CoreColor.token(CoreColors.secondary),
  ),
)

위젯 슬롯 — label / leading / mark 은 위젯 그대로#

label · description · shortcut · leading · trailing · mark 은 전부 W? 슬롯이라 아이콘 · 인디케이터 · Text chain 을 그대로 받습니다(shadcn asChild 패턴). 행은 그 위젯을 좌석에 놓기만 하고, 무엇을 놓을지는 호출자가 정합니다.

MenuItem(
  onPressed: () {},
  label: const Text('Sync'),
  leading: const Icon(LucideIcons.refreshCw),
  trailing: Text('Beta').labelSmall.onSurfaceVariant,
)

labelStyle · leadingIconStyle · markIconStyle 같은 nested 슬롯은 그 자리에 놓인 Text / Icon 의 chrome 미세 조정용입니다 — 색은 행의 상태(rest / selected / disabled)에서 오고 슬롯은 크기 · 굵기 같은 나머지를 정합니다. 슬롯 안 Text chain 에 직접 색을 얹으면 그 값이 행의 상태 색을 덮습니다.

빠른 오버라이드 (Chain)#

이미 만든 MenuItem 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.

class MenuItemChainExample extends StatelessWidget {
  const MenuItemChainExample({super.key});

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: CoreLayout.layout280,
      child:
          MenuItem(
            onPressed: () {},
            label: const Text('Copy'),
            shortcut: const Text('⌘C'),
            leading: const Icon(LucideIcons.copy),
          ).withStyle(
            const CoreMenuItemStyle(
              padding: CoreEdgeInsets.symmetric(
                horizontal: CoreSpace.space16,
                vertical: CoreSpace.space8,
              ),
              contentGapStyle: CoreGapStyle(size: CoreSpace.space12),
              foregroundColor: CoreColor.token(CoreColors.primary),
            ),
          ),
    );
  }
}
class MenuItemChainExample extends StatelessComponent {
  const MenuItemChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return div(
      [
        MenuItem(
          onPressed: () {},
          label: const Text('Copy'),
          shortcut: const Text('⌘C'),
          leading: const Icon(LucideIcons.copy),
        ).withStyle(
          const CoreMenuItemStyle(
            padding: CoreEdgeInsets.symmetric(
              horizontal: CoreSpace.space16,
              vertical: CoreSpace.space8,
            ),
            contentGapStyle: CoreGapStyle(size: CoreSpace.space12),
            foregroundColor: CoreColor.token(CoreColors.primary),
          ),
        ),
      ],
      classes: 'w-${CoreLayout.scale.layout280}',
    );
  }
}

변형 (Variants)#

설명 두 줄 (lg)#

MenuItem(
  onPressed: () {},
  size: CoreMenuItemSize.lg,
  label: const Text('Archive'),
  description: const Text('Move to the archive folder'),
  leading: const Icon(LucideIcons.archive),
)

파괴적 액션 (critical)#

MenuItem(
  onPressed: () {},
  tone: CoreMenuItemTone.critical,
  label: const Text('Delete'),
  leading: const Icon(LucideIcons.trash2),
)

서브메뉴 트리거#

MenuItem(
  onPressed: () {},
  hasSubmenu: true,
  label: const Text('Export'),
)

hasSubmenu: true 면 shortcut 자리에 셰브론(LucideIcons.chevronRight)이 자동으로 그려집니다 — shortcut 을 함께 넘겨도 무시됩니다.

체크박스 / 라디오 마크#

MenuItem(
  onPressed: () {},
  label: const Text('Show grid'),
  mark: const Icon(LucideIcons.check),
  selected: true,
)

동작 스펙 (Behavior)#

인터랙션#

  • selected는 호출부의 표시 값이며 활성화가 이를 자동 토글하지 않습니다. hasSubmenu도 chevron·의미 표면만 정하고 서브메뉴의 열림·탐색은 호스트가 소유합니다.
  • 클릭/탭: enabled 가 true(또는 onPressed 가 non-null)일 때 onPressed 호출
  • 호버 / 포커스 / 눌림: 행이 스스로 상태 레이어를 그립니다 — 전경색을 stateLayer 알파(hover 8% / focus 8% / pressed 12%)로 워시합니다. 포커스가 hover 와 같은 강도인 이유는 실제 포커스 표시는 링이 담당하고 워시는 보조 신호이기 때문입니다. critical 행은 워시 대신 hoverBackgroundColor / pressedBackgroundColor 채움과 activeForegroundColor 글자로 그립니다 — 선택된 행과 비활성 행에는 적용되지 않습니다
  • 비활성화: 포커스는 받지만(비활성-focusable) 활성화는 무시되고 disabledForegroundColor 로 그려집니다

색#

  • rest: tone 표(defaultsByTone)의 전경색
  • selected: selectedForegroundColor / selectedBackgroundColor (primary / onPrimary)
  • disabled: disabledForegroundColor — 기본값은 disabledContent 슬롯이며 라벨 · 설명 · 단축키 · 글리프가 모두 이 한 필드를 읽습니다
  • active (critical 의 hover / focus / pressed): 채움은 hoverBackgroundColor · pressedBackgroundColor, 글자는 activeForegroundColor
  • 아이콘 색은 전경을 따라갑니다 — resolver 가 상태별 CoreIconStyle 을 내고 위젯이 고릅니다(leading / mark / chevron 각각 rest·selected·disabled·active)

포커스 링#

clickableStyle.focusOutlineStyle 경로로 그려집니다(합성된 Clickable 의 기본 포커스 링) — 행 자체의 모서리 반경(혼자 선 행은 8 / 8 / 12, 패널 안의 행은 6)을 따라갑니다.

사용 가이드라인 (Usage Guidelines)#

✅ Do#

한 메뉴 안에서는 같은 size 를 유지

Menu(
  size: CoreMenuItemSize.sm,
  items: [ /* 모든 행이 32px */ ],
)

Menu 는 자기 size 를 모든 행에 넘깁니다 — 행마다 다른 size 를 직접 지정하면 한 패널 안에서 높이가 들쭉날쭉해집니다.


❌ Don't#

파괴적 행을 그룹 중간에 두지 않기

// ❌ Delete 가 그룹 가운데 — 색만이 유일한 단서라 실수로 누르기 쉽다
MenuItem(tone: CoreMenuItemTone.critical, label: const Text('Delete'), onPressed: () {}),
MenuItem(label: const Text('Rename'), onPressed: () {}),

// ✅ 그룹 마지막, 구분선 뒤
Divider(),
MenuItem(tone: CoreMenuItemTone.critical, label: const Text('Delete'), onPressed: () {}),

tone 은 색만 바꾸고 배치는 바꾸지 않습니다 — 강제 색상 모드에서는 두 tone 이 같은 색으로 접힐 수 있으므로, 배치 규약(마지막에, 구분선 뒤)이 색과 함께 있어야 안전합니다.

접근성 (Accessibility)#

MenuItem 은 행 하나만 그립니다. 메뉴 전체의 roving tabindex, 화살표 탐색, 첫 글자 타이핑 점프, Home/End 는 이 행을 여러 개 합성하는 호스트 (Menu)가 소유합니다.

역할#

  • Flutter — MenuRowScope가 없으면 button, Menu 아래이면 menuItem 역할·enabled·selected를 CoUISemantics로 노출합니다. 호스트가 역할을 직접 제공하는 scope이면 행의 자체 역할을 생략하며 이름은 슬롯 콘텐츠에서 옵니다.
  • Web — 기본 role 은 menuitem. 호스트(Menu)가 행의 종류에 따라 attributes 로 role="menuitemcheckbox" + aria-checked 또는 role="menuitemradio" + aria-checked 를 통과시킵니다. hasSubmenu 이면 aria-haspopup="menu" 가 함께 붙습니다.

키보드#

키동작
Enter / Space포커스된 행 활성화
Escape 동작 취소는 이 컴포넌트가 아니라 오버레이 호스트(DropdownMenu/ContextMenu 등)의 몫

행 사이 이동(roving tabindex, 순환, Home/End, 문자 타이핑 점프)은 Menu 가 컨테이너 레벨에서 구현합니다 — MenuItem 단독으로는 다음/이전 행이라는 개념이 없습니다.

비활성 행도 포커스를 받습니다#

WAI-ARIA APG 권장대로, enabled: false 인 행도 키보드 탐색 순서에 남습니다(inert 지 invisible 이 아닙니다) — Web 은 aria-disabled="true" 를 달고 활성화만 무시합니다.

스크린 리더#

이름은 label 슬롯 콘텐츠에서 옵니다. 선택·비활성·서브메뉴 상태는 행이 전달하고, checkbox / radio 행의 체크 의미는 호스트 Menu 가 함께 실어 줍니다. 단축키 슬롯은 표시 텍스트이며 실제 단축키를 등록하지 않습니다.

포커스 관리#

행 하나는 자동 포커스·트랩·닫힘 후 복귀를 소유하지 않습니다. 포커스 링은 합성된 Clickable 이, 행 사이 탐색과 오버레이 복귀는 호스트가 관리합니다.

알려진 제약#

아이콘만 있는 label 의 이름을 자동 생성하지 않습니다. 이 행을 단독으로 여러 개 나열해도 메뉴 탐색이 생기지 않으므로 목록에는 Menu 를 쓰세요. shortcut 문구만으로 키보드 단축키가 실행되지는 않습니다.

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

항목FlutterWeb
클래스명MenuItemMenuItem
렌더 Clickable + Text + Icon + Gap Clickable + Text + Icon + Gap
역할 독립 행 button / MenuRowScope 안 menuItem menuitem (호스트가 checkbox / radio / option으로 덮을 수 있음)
상태 레이어 Clickable.onHover / onFocus / onPressedChange 로 감지 + resolver per-state 값 select CSS hover:/focus-visible:/active: pseudo + emit family
  • Menu: 정적 메뉴 패널 — CoreMenuItem 팩토리로 이 컴포넌트를 대신 합성합니다
  • DropdownMenu: 트리거로 떠오르는 오버레이 메뉴
  • ContextMenu: 우클릭/롱프레스 컨텍스트 메뉴
  • Menubar: 가로 애플리케이션 메뉴바
  • Select: 폼 값을 고르는 드롭다운 — 옵션 행에 같은 컴포넌트를 재사용합니다