ContextMenu | CoUI

ContextMenu

우클릭 또는 길게 누르기로 표시되는 컨텍스트 메뉴 컴포넌트

ContextMenu#

우클릭(데스크탑) 또는 길게 누르기(모바일)로 표시되는 컨텍스트 메뉴 컴포넌트입니다. 선택 가능한 액션 목록을 제공합니다.

Live Preview#

Flutter
Loading Flutter...
ContextMenu(
  triggerChild: text('Right-click me'),
  items: [
    ContextMenuItem(label: 'Cut'),
    ContextMenuItem(label: 'Copy'),
    ContextMenuItem(label: 'Paste'),
  ],
)
ContextMenu(
  triggerChild: Text('Right-click me'),
  items: [
    ContextMenuItem(label: 'Cut'),
    ContextMenuItem(label: 'Copy'),
    ContextMenuItem(label: 'Paste'),
  ],
)

사용 시기 (When to Use)#

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

  • 파일, 항목, 이미지에 우클릭 시 편집/공유/삭제 등 상황별 액션을 제공할 때
  • 데스크탑 앱 스타일의 오른쪽 클릭 메뉴가 필요할 때
  • 모바일에서 길게 누르기로 항목 액션을 표시할 때

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

  • DropdownMenu: 특정 버튼 클릭으로 열리는 메뉴에
  • Menu: 사이드바나 네비게이션 메뉴 구성에

기본 사용법 (Basic Usage)#

// 기본 컨텍스트 메뉴
ContextMenu(
  onSelect: handleContextMenuSelect,
  items: [
    ContextMenuItem(value: 'copy', label: '복사', icon: Icon(Icons.copy)),
    ContextMenuItem(value: 'cut', label: '잘라내기', icon: Icon(Icons.cut)),
    ContextMenuDivider(),
    ContextMenuItem(value: 'paste', label: '붙여넣기', icon: Icon(Icons.paste)),
    ContextMenuDivider(),
    ContextMenuItem(
      value: 'delete',
      label: '삭제',
      icon: Icon(Icons.delete),
      isDestructive: true,
    ),
  ],
  child: Container(
    padding: EdgeInsets.all(kDefaultPadding),
    color: Colors.grey[100],
    child: Text('우클릭하거나 길게 누르세요'),
  ),
)
ContextMenu(
  onSelect: handleContextMenuSelect,
  items: [
    ContextMenuItem(value: 'copy', label: '복사', icon: Icon(Icons.copy)),
    ContextMenuItem(value: 'cut', label: '잘라내기', icon: Icon(Icons.cut)),
    ContextMenuDivider(),
    ContextMenuItem(value: 'paste', label: '붙여넣기', icon: Icon(Icons.paste)),
    ContextMenuDivider(),
    ContextMenuItem(
      value: 'delete',
      label: '삭제',
      isDestructive: true,
    ),
  ],
  child: Container(
    child: Text('우클릭하세요'),
  ),
)

Props / Parameters#

ContextMenu#

속성타입기본값설명
childWidget필수컨텍스트 메뉴를 붙일 대상 위젯
items List<ContextMenuEntry> 필수 메뉴 아이템 목록
onSelect void Function(String value)? null 아이템 선택 핸들러
dropdownMenuStyle CoreDropdownMenuStyle<Color, List<BoxShadow>>? (Flutter) / CoreDropdownMenuStyle<String, String>? (Web) null 인스턴스 스타일 (Style 시스템 참조)

스타일 시스템 (Style System)#

DropdownMenu / Menu / Menubar / ContextMenu 의 모든 chrome / dimensional / nested-slot 오버라이드는 공통 CoreDropdownMenuStyle<Clr, Shadow> 단일 슬롯으로 흐릅니다 (Epic #1302 원칙 6/7/8). 4 종류 위젯이지만 chrome 은 모두 동일 Style 클래스로 받습니다.

시맨틱 vs 스타일#

  • 시맨틱 / behaviour: 위젯 파라미터로 직접 (enabled, direction, behavior …)
  • chrome / dimensional / 슬롯 스타일: CoreDropdownMenuStyle 한 곳으로 (popoverStyle / triggerStyle / itemStyle / headerTextStyle + flat separatorColor / separatorThickness / separatorIndent)
  • 변형 교체: asChild — 트리거 자식 (child) 을 위젯으로 직접 주입

Resolve chain#

design system default for context menu
  → CoreDropdownMenuTheme.style                  // 프로젝트 공통
  → parent component slot override
  → widget.dropdownMenuStyle                     // 인스턴스별

슬롯 매핑 (CoreDropdownMenuStyle 7 필드)#

필드타입 (Flutter)적용 영역
popoverStyle CorePopoverStyle<Color, List<BoxShadow>>? 우클릭 / long-press 로 열린 컨텍스트 팝업 패널 chrome
triggerStyle CoreButtonStyle<Color>? (사용 안 함 — child 직접 주입)
itemStyle CoreButtonStyle<Color>? 각 컨텍스트 메뉴 항목 chrome
headerTextStyle CoreTextStyle<Color>? 그룹 헤더 라벨
separatorColorColor?divider 색
separatorThicknessdouble?divider 두께
separatorIndentdouble?divider 좌우 indent

Migration — 옛 평면 필드 → 새 위치 매핑#

옛 chrome 필드새 위치
ContextMenuTheme.surfaceBlur (Flutter) 위젯 파라미터로 유지 (현재 backward-compat)
ContextMenuTheme.surfaceOpacity (Flutter) 위젯 파라미터로 유지 (현재 backward-compat)
ContextMenuTheme.barrierColor별도 시스템 (overlay barrier — Style 슬롯 영역 밖)
MenuPopupTheme.borderColor dropdownMenuStyle.popoverStyle.panelBorderColor
MenuPopupTheme.borderRadius dropdownMenuStyle.popoverStyle.panelBorderRadius
MenuPopupTheme.fillColor dropdownMenuStyle.popoverStyle.panelBackgroundColor
MenuPopupTheme.padding dropdownMenuStyle.popoverStyle.panelPadding

사용 예 (Flutter)#

ContextMenu(
  dropdownMenuStyle: CoreDropdownMenuStyle(
    popoverStyle: CorePopoverStyle(
      panelBorderRadius: 8,
      panelPadding: 4,
    ),
  ),
  items: [
    MenuButton(child: Text('Copy')),
    MenuButton(child: Text('Paste')),
    MenuDivider(),
    MenuButton(child: Text('Delete')),
  ],
  child: Container(),
)

사용 예 (Web)#

ContextMenu(
  triggerChild: div([Component.text('Right-click me')]),
  dropdownMenuStyle: CoreDropdownMenuStyle<String, String>(
    popoverStyle: CorePopoverStyle<String, String>(
      panelBorderRadius: 8,
    ),
  ),
  items: [
    ContextMenuItem(label: 'Copy', onSelect: () {}),
    ContextMenuItem(label: 'Paste', onSelect: () {}),
  ],
)

ContextMenuItem#

속성타입기본값설명
valueString필수선택 시 onSelect에 전달되는 값
labelString필수메뉴 아이템 텍스트
iconWidget?null아이템 아이콘
isDestructive bool false 파괴적 액션 스타일 적용 (빨간색)
enabledbooltrue활성화 여부

ContextMenuDivider#

메뉴 아이템 사이의 구분선입니다. 별도의 속성이 없습니다.

변형 (Variants)#

아이콘 포함#

아이콘과 함께 메뉴 아이템을 표시합니다.

ContextMenu(
  onSelect: handleContextMenuSelect,
  items: [
    ContextMenuItem(
      value: 'edit',
      label: '편집',
      icon: Icon(Icons.edit),
    ),
    ContextMenuItem(
      value: 'share',
      label: '공유',
      icon: Icon(Icons.share),
    ),
  ],
  child: FileCard(name: '문서.pdf'),
)

파괴적 액션 포함#

삭제 등 위험한 액션은 빨간색으로 강조합니다.

ContextMenu(
  onSelect: handleContextMenuSelect,
  items: [
    ContextMenuItem(value: 'rename', label: '이름 변경'),
    ContextMenuDivider(),
    ContextMenuItem(
      value: 'delete',
      label: '삭제',
      isDestructive: true,
    ),
  ],
  child: FileItem(name: '파일명'),
)

동작 스펙 (Behavior)#

인터랙션#

  • 우클릭 (데스크탑): 마우스 우클릭 시 커서 위치에 메뉴 표시
  • 길게 누르기 (모바일): 500ms 이상 터치 유지 시 메뉴 표시
  • 항목 선택: 클릭/탭 시 onSelect(value) 호출 후 메뉴 닫힘
  • 외부 클릭: 메뉴 외부 클릭 또는 탭 시 메뉴 닫힘

상태 전환#

  • hiddenvisible: 우클릭/길게 누르기 시
  • visiblehidden: 항목 선택 또는 외부 클릭 시

애니메이션#

  • 표시: 페이드 인 + 스케일 아웃 150ms
  • 닫힘: 페이드 아웃 100ms

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

✅ Do#

파괴적 액션은 구분선으로 분리하고 isDestructive 적용

CouiContextMenu(
  onSelect: handleContextMenuSelect,
  items: [
    ContextMenuItem(value: 'copy', label: '복사'),
    ContextMenuItem(value: 'rename', label: '이름 변경'),
    ContextMenuDivider(), // 일반 액션과 구분
    ContextMenuItem(
      value: 'delete',
      label: '삭제',
      isDestructive: true, // 빨간색으로 강조
    ),
  ],
  child: FileCard(name: '파일.pdf'),
)

파괴적 액션을 시각적으로 분리하면 사용자가 실수로 삭제를 선택하는 것을 방지한다.


❌ Don't#

너무 많은 메뉴 항목 사용 (7개 초과)

// ❌ 너무 많은 항목
CouiContextMenu(
  items: List.generate(12, (i) => ContextMenuItem(
    value: 'action_$i',
    label: '액션 ${i + 1}',
  )),
  onSelect: handleContextMenuSelect,
  child: targetWidget,
)

항목이 많으면 메뉴가 너무 길어져 스크롤이 필요하고 사용성이 떨어진다.

✅ Do#

현재 상황에 관련된 액션만 표시

CouiContextMenu(
  onSelect: handleContextMenuSelect,
  items: [
    if (canEdit) ContextMenuItem(value: 'edit', label: '편집'),
    if (canShare) ContextMenuItem(value: 'share', label: '공유'),
    if (canDelete) ContextMenuItem(
      value: 'delete',
      label: '삭제',
      isDestructive: true,
    ),
  ],
  child: DocumentCard(document: document),
)

권한에 따라 동적으로 표시되는 항목은 사용자에게 혼란 없이 관련 액션만 제공한다.


❌ Don't#

컨텍스트 메뉴에만 접근 가능한 핵심 기능 배치

// ❌ 중요한 기능이 우클릭에만 있음
CouiContextMenu(
  items: [
    ContextMenuItem(value: 'save', label: '저장'), // 저장이 여기에만 있으면?
  ],
  onSelect: handleContextMenuSelect,
  child: Editor(),
)

컨텍스트 메뉴를 모르는 사용자나 키보드 사용자가 핵심 기능에 접근하지 못한다.

✅ Do#

현재 컨텍스트에 관련된 항목만 표시하세요.

CouiContextMenu(
  items: [
    ContextMenuItem(label: '복사', onTap: handleCopy),
    ContextMenuItem(label: '잘라내기', onTap: handleCut),
    ContextMenuItem(label: '붙여넣기', onTap: handlePaste, enabled: canPaste),
    ContextMenuDivider(),
    ContextMenuItem(label: '삭제', onTap: handleDelete, destructive: true),
  ],
  child: EditableContent(),
)

컨텍스트와 관련 없는 항목을 포함하면 사용자가 원하는 액션을 찾기 어려워집니다. 상황에 맞는 항목만 노출하세요.


❌ Don't#

너무 많은 항목을 Context Menu에 넣지 마세요.

// ❌ 20개 이상의 항목을 Context Menu에 나열
CouiContextMenu(
  items: allTwentyPlusActions,  // 너무 많은 항목
  child: content,
)

항목이 너무 많으면 스크롤이 필요하고 원하는 항목을 찾기 어렵습니다. 7개 이하로 제한하고 그룹화하세요.

접근성 (Accessibility)#

키보드 인터랙션#

동작
Shift + F10포커스된 요소의 컨텍스트 메뉴 열기
Arrow Up/Down메뉴 항목 간 이동
Enter포커스된 항목 선택
Escape메뉴 닫기

스크린 리더#

  • Flutter: Semantics(customSemanticsActions: ...) 으로 메뉴 항목을 시맨틱 액션으로 추가
  • Web: role="menu" + 각 항목 role="menuitem" 자동 적용

터치 타겟#

  • 각 메뉴 항목 최소 높이: 44dp

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

v3.0부터 기본 API (enabled, onChanged 등)가 통일되었습니다. 아래는 플랫폼 고유 차이점만 나열합니다.

항목FlutterWeb
클래스명CouiContextMenuContextMenu
트리거 GestureDetector (우클릭 + 길게 누르기) contextmenu 이벤트
위치 계산 OverlayEntry + 포인터 위치 position: fixed + 마우스 좌표
기본 메뉴 차단해당 없음event.preventDefault() 자동
  • Menu: 네비게이션이나 사이드바에 사용하는 일반 메뉴
  • DropdownMenu: 버튼 클릭으로 열리는 드롭다운 메뉴

조합 예제#

// 파일 탐색기에서 파일 우클릭 메뉴
CouiContextMenu(
  onSelect: (value) {
    switch (value) {
      case 'open':
        handleFileOpen(file);
      case 'copy':
        handleFileCopy(file);
      case 'rename':
        handleFileRename(file);
      case 'delete':
        handleFileDelete(file);
    }
  },
  items: [
    ContextMenuItem(value: 'open', label: '열기', icon: const Icon(Icons.open_in_new)),
    ContextMenuItem(value: 'copy', label: '복사', icon: const Icon(Icons.copy)),
    ContextMenuItem(value: 'rename', label: '이름 변경', icon: const Icon(Icons.edit)),
    const ContextMenuDivider(),
    ContextMenuItem(
      value: 'delete',
      label: '삭제',
      icon: const Icon(Icons.delete),
      isDestructive: true,
    ),
  ],
  child: FileTile(file: file),
)