DropdownMenu | CoUI

DropdownMenu

트리거 클릭 시 하단에 옵션 목록을 표시하는 드롭다운 메뉴 컴포넌트

DropdownMenu#

버튼이나 다른 트리거 요소를 클릭하면 옵션 목록을 표시하는 드롭다운 메뉴 컴포넌트입니다. 다양한 배치 위치와 오프셋을 지원합니다.

Live Preview#

Web
Flutter
Loading Flutter...
DropdownMenu(
  trigger: Button(variant: CoreButtonVariant.outline, child: text('Menu')),
  items: [
    DropdownMenuItem(label: 'Profile'),
    DropdownMenuItem(label: 'Settings'),
    DropdownMenuItem(label: 'Logout'),
  ],
)
DropdownMenu(
  trigger: Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: Text('Menu')),
  items: [
    DropdownMenuItem(label: 'Profile'),
    DropdownMenuItem(label: 'Settings'),
    DropdownMenuItem(label: 'Logout'),
  ],
)

사용 시기 (When to Use)#

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

  • "더보기" 버튼(...)에 편집/공유/삭제 등 액션 목록을 표시할 때
  • 특정 버튼 클릭에 반응하는 컨텍스트 액션 메뉴가 필요할 때
  • 배치 위치를 세밀하게 제어해야 할 때

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

  • Select: 값을 선택하는 폼 입력에 (시각적으로 폼 컨트롤처럼 보여야 할 때)
  • ContextMenu: 우클릭으로 표시되는 상황별 메뉴에
  • Menu: 네비게이션 사이드바 구성에

기본 사용법 (Basic Usage)#

// 기본 드롭다운 메뉴
DropdownMenu(
  trigger: Button(
    onPressed: null,
    child: Row(
      children: [Text('옵션'), Icon(Icons.arrow_drop_down)],
    ),
  ),
  onSelect: handleDropdownSelect,
  items: [
    DropdownMenuItem(value: 'option1', label: '옵션 1'),
    DropdownMenuItem(value: 'option2', label: '옵션 2'),
    DropdownMenuDivider(),
    DropdownMenuItem(value: 'option3', label: '옵션 3'),
  ],
)

// 배치 위치 및 오프셋 설정
DropdownMenu(
  trigger: IconButton(
    onPressed: null,
    icon: Icon(Icons.more_vert),
  ),
  onSelect: handleDropdownSelect,
  placement: DropdownPlacement.bottomEnd,
  offset: 8.0,
  items: [
    DropdownMenuItem(value: 'edit', label: '편집', icon: Icon(Icons.edit)),
    DropdownMenuItem(value: 'share', label: '공유', icon: Icon(Icons.share)),
    DropdownMenuDivider(),
    DropdownMenuItem(
      value: 'delete',
      label: '삭제',
      icon: Icon(Icons.delete),
      isDestructive: true,
    ),
  ],
)
DropdownMenu(
  trigger: Button(
    onPressed: null,
    child: Row(
      children: [Text('옵션'), Icon(Icons.chevronDown)],
    ),
  ),
  onSelect: handleDropdownSelect,
  items: [
    DropdownMenuItem(value: 'option1', label: '옵션 1'),
    DropdownMenuItem(value: 'option2', label: '옵션 2'),
    DropdownMenuDivider(),
    DropdownMenuItem(value: 'option3', label: '옵션 3'),
  ],
)

Props / Parameters#

속성타입기본값설명
triggerWidget필수메뉴를 열 트리거 위젯
items List<DropdownMenuEntry> 필수 메뉴 아이템 목록
onSelect void Function(String value)? null 아이템 선택 핸들러
placement DropdownPlacement bottomStart 메뉴 표시 위치
offset double 4.0 트리거와 메뉴 사이 간격(px)
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: 위젯 파라미터로 직접 (autofocus, enabled, placement, offset, items …)
  • chrome / dimensional / 슬롯 스타일: CoreDropdownMenuStyle 한 곳으로 (popoverStyle / triggerStyle / itemStyle / headerTextStyle + flat separatorColor / separatorThickness / separatorIndent)
  • 변형(variant) 교체: asChild — 트리거를 직접 위젯으로 주입 (trigger: Button.outline(...))

Resolve chain#

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

각 nested 슬롯 스타일 (popoverStyle / triggerStyle / itemStyle / headerTextStyle) 은 자기 컴포넌트의 자체 resolve chain 으로 다시 한 번 머지됩니다.

슬롯 매핑 (CoreDropdownMenuStyle 7 필드)#

필드타입 (Flutter)적용 영역
popoverStyle CorePopoverStyle<Color, List<BoxShadow>>? 팝오버 패널 chrome (border / radius / padding / shadow / blur / open animation)
triggerStyle CoreButtonStyle<Color>? 위젯이 자체 트리거를 렌더링할 때
itemStyle CoreButtonStyle<Color>? 각 메뉴 아이템 (MenuButton / MenubarAction) chrome
headerTextStyle CoreTextStyle<Color>? MenuLabel / 헤더 라벨 텍스트
separatorColor Color? MenuDivider / MenubarDivider 색상
separatorThickness double? divider 두께 (logical px)
separatorIndentdouble?divider 좌우 indent

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

옛 chrome 필드새 위치
surfaceBlur (Flutter) 위젯 파라미터로 유지 (현재 backward-compat); 권장은 CoreComponentTheme.dropdownMenu.style.popoverStyle 사용
surfaceOpacity (Flutter)위젯 파라미터로 유지 (현재 backward-compat)
MenuPopupTheme.borderColor dropdownMenuStyle.popoverStyle.panelBorderColor
MenuPopupTheme.borderRadius dropdownMenuStyle.popoverStyle.panelBorderRadius (logical px → BorderRadius)
MenuPopupTheme.fillColor dropdownMenuStyle.popoverStyle.panelBackgroundColor
MenuPopupTheme.padding dropdownMenuStyle.popoverStyle.panelPadding
MenuTheme.itemPadding dropdownMenuStyle.itemStyle.paddingH (logical px)

사용 예 (Flutter)#

DropdownMenu(
  dropdownMenuStyle: CoreDropdownMenuStyle(
    popoverStyle: CorePopoverStyle(
      panelBorderRadius: 12,
      panelPadding: 8,
    ),
    itemStyle: CoreButtonStyle(
      paddingH: 12,
    ),
    separatorColor: Color(0xFFE5E7EB),
    separatorThickness: 1,
  ),
  children: [
    MenuButton(child: Text('Edit')),
    MenuDivider(),
    MenuButton(child: Text('Delete')),
  ],
)

사용 예 (Web)#

DropdownMenu(
  trigger: Button.outline(child: Component.text('Menu')),
  dropdownMenuStyle: CoreDropdownMenuStyle<String, String>(
    popoverStyle: CorePopoverStyle<String, String>(
      panelBorderRadius: 12,
      panelPadding: 8,
    ),
  ),
  items: [
    DropdownMenuItem(label: 'Item 1', onSelect: () {}),
    DropdownMenuItem(label: 'Item 2', onSelect: () {}),
  ],
)
속성타입기본값설명
valueString필수선택 시 onSelect에 전달되는 값
labelString필수메뉴 아이템 텍스트
iconWidget?null아이템 아이콘
isDestructive bool false 파괴적 액션 스타일 적용
enabledbooltrue활성화 여부

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

변형 (Variants)#

배치 위치 (Placement)#

설명
bottomStart트리거 하단 왼쪽 정렬 (기본)
bottomEnd트리거 하단 오른쪽 정렬
topStart트리거 상단 왼쪽 정렬
topEnd트리거 상단 오른쪽 정렬
DropdownMenu(
  trigger: Button(onPressed: null, child: Text('더보기')),
  onSelect: handleDropdownSelect,
  placement: DropdownPlacement.topEnd,
  items: [
    DropdownMenuItem(value: 'action1', label: '액션 1'),
  ],
)

동작 스펙 (Behavior)#

인터랙션#

  • 트리거 클릭: 메뉴 열기/닫기 토글
  • 항목 선택: onSelect(value) 호출 후 메뉴 닫힘
  • 외부 클릭: 메뉴 외부 클릭 시 닫힘
  • Escape: 메뉴 닫힘

상태 전환#

  • closedopen: 트리거 클릭
  • openclosed: 항목 선택, 외부 클릭, Escape

애니메이션#

  • 열림: 페이드 인 + 스케일 업 150ms
  • 닫힘: 페이드 아웃 100ms

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

✅ Do#

"더보기" 버튼에 관련 액션 그룹 표시

CouiDropdownMenu(
  trigger: IconButton(
    onPressed: null,
    icon: const Icon(Icons.more_vert),
    tooltip: '더보기',
  ),
  placement: DropdownPlacement.bottomEnd,
  onSelect: handleMoreActionsSelect,
  items: [
    DropdownMenuItem(value: 'edit', label: '편집', icon: const Icon(Icons.edit)),
    DropdownMenuItem(value: 'duplicate', label: '복제', icon: const Icon(Icons.copy)),
    const DropdownMenuDivider(),
    DropdownMenuItem(
      value: 'delete',
      label: '삭제',
      icon: const Icon(Icons.delete),
      isDestructive: true,
    ),
  ],
)

"더보기" 아이콘 버튼에 추가 액션을 숨겨두면 UI를 깔끔하게 유지하면서 기능을 제공한다.


❌ Don't#

Select 대신 DropdownMenu를 폼 입력으로 사용

// ❌ 값 선택 폼에 DropdownMenu 사용
CouiDropdownMenu(
  trigger: Text('국가 선택'),
  onSelect: handleCountryChanged,
  items: countries.map((c) => DropdownMenuItem(value: c.code, label: c.name)).toList(),
)

폼에서 값을 선택하는 경우 Select 컴포넌트가 레이블, 유효성 검사, 접근성을 더 잘 지원한다.

✅ Do#

뷰포트 경계에 따라 placement 자동 조정 고려

// 화면 하단 근처의 버튼은 top placement 사용
CouiDropdownMenu(
  trigger: bottomButton,
  placement: DropdownPlacement.topEnd, // 위로 열림
  items: actionItems,
  onSelect: handleSelect,
)

화면 경계에서 메뉴가 잘리지 않도록 위치를 적절히 선택해야 한다.


❌ Don't#

메뉴 항목을 10개 이상 넣지 않기

// ❌ 너무 많은 항목
CouiDropdownMenu(
  trigger: moreButton,
  items: List.generate(15, (i) => DropdownMenuItem(
    value: 'action_$i',
    label: '액션 ${i + 1}',
  )),
  onSelect: handleSelect,
)

항목이 너무 많으면 메뉴가 화면을 넘치거나 사용자가 원하는 항목을 찾기 어렵다.

✅ Do#

파괴적인 액션에는 시각적 경고를 표시하세요.

CouiDropdownMenu(
  items: [
    DropdownMenuItem(label: '편집', onTap: handleEdit),
    DropdownMenuItem(label: '복제', onTap: handleDuplicate),
    DropdownMenuDivider(),
    DropdownMenuItem(
      label: '삭제',
      onTap: handleDelete,
      destructive: true,  // 빨간색으로 강조
    ),
  ],
)

삭제처럼 되돌릴 수 없는 액션은 색상이나 아이콘으로 위험도를 명확히 표시해야 사용자가 신중하게 선택할 수 있습니다.


❌ Don't#

드롭다운 메뉴에 단 하나의 항목만 넣지 마세요.

// ❌ 항목 하나짜리 드롭다운
CouiDropdownMenu(
  items: [
    DropdownMenuItem(label: '삭제', onTap: handleDelete),
  ],
)

항목이 하나뿐이라면 드롭다운 대신 직접 Button을 사용하는 것이 더 간결하고 접근성도 좋습니다.

접근성 (Accessibility)#

키보드 인터랙션#

동작
Enter / Space트리거 클릭으로 메뉴 열기
Arrow Down메뉴 열기 + 첫 항목 포커스
Arrow Up/Down메뉴 항목 간 이동
Enter포커스된 항목 선택
Escape메뉴 닫기, 트리거로 포커스 복귀
Tab메뉴 닫기

스크린 리더#

  • Flutter: Semantics(button: true, expanded: isOpen) 트리거에 적용
  • Web: aria-haspopup="menu" + aria-expanded 트리거에, role="menu" + role="menuitem" 메뉴에 자동 적용

터치 타겟#

  • 트리거 버튼 최소 크기: 44x44dp
  • 각 메뉴 항목 최소 높이: 44dp

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

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

항목FlutterWeb
클래스명CouiDropdownMenuDropdownMenu
위치 계산 OverlayEntry + RenderBox CSS position: absolute + Floating UI
닫기 감지 TapRegion document.addEventListener('click')
뷰포트 조정수동 위치 조정자동 flip 지원
  • Menu: 사이드바/네비게이션 메뉴 구성에 사용
  • ContextMenu: 우클릭으로 표시되는 상황별 메뉴
  • Select: 폼에서 값을 선택하는 드롭다운

조합 예제#

// 카드 헤더의 더보기 메뉴
CouiCard(
  child: Column(
    children: [
      Row(
        children: [
          Text('항목 제목'),
          const Spacer(),
          CouiDropdownMenu(
            trigger: const Icon(Icons.more_horiz),
            placement: DropdownPlacement.bottomEnd,
            onSelect: (value) => handleCardAction(value, item),
            items: [
              DropdownMenuItem(value: 'edit', label: '편집'),
              DropdownMenuItem(value: 'duplicate', label: '복제'),
              const DropdownMenuDivider(),
              DropdownMenuItem(
                value: 'delete',
                label: '삭제',
                isDestructive: true,
              ),
            ],
          ),
        ],
      ),
      Text('카드 내용'),
    ],
  ),
)