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

DropdownMenu

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

목차

버튼 등 트리거 요소를 클릭하면 액션 목록을 떠 있는 패널로 표시하는 드롭다운 메뉴 컴포넌트입니다. 액션 행과 구분선, 키보드 단축키 힌트를 지원합니다.

DropdownMenu는 레거시 Flutter DropdownMenu와 Web DropdownMenu를 통일한 컴포넌트입니다. Flutter와 Web이 동일한 파라미터 이름·동작·시각을 갖습니다 — 패널은 양쪽 모두 루트 오버레이로 portal되어 ancestor overflow에 잘리지 않습니다.

기본 사용법 (Basic Usage)#

DropdownMenu(
  trigger: Button(
    variant: CoreButtonVariant.outline,
    onPressed: () {},
    child: const Text('Open Menu'),
  ),
  items: [
    CoreMenuItem.action('Profile', onPressed: () {}),
    CoreMenuItem.action('Settings', onPressed: () {}),
    const CoreMenuItem.divider(),
    CoreMenuItem.action('Sign out', onPressed: () {}),
  ],
)
DropdownMenu(
  trigger: Button(
    variant: CoreButtonVariant.outline,
    onPressed: () {},
    child: Text('Open Menu'),
  ),
  items: [
    CoreMenuItem.action('Profile', onPressed: () {}),
    CoreMenuItem.action('Settings', onPressed: () {}),
    const CoreMenuItem.divider(),
    CoreMenuItem.action('Sign out', onPressed: () {}),
  ],
)

Live Preview#

사용 시기 (When to Use)#

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

  • "더보기" 버튼에 편집 / 공유 / 삭제 등 액션 목록을 표시할 때
  • 특정 버튼 클릭에 반응하는 컨텍스트 액션 메뉴가 필요할 때

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

  • Select: 액션 실행 대신 값을 선택하는 폼 입력에
  • ContextMenu: 트리거 앵커 대신 우클릭 위치에서 표시하는 메뉴에
  • Menubar: 가로 바의 여러 메뉴 트리거를 전환해야 할 때

Props / Parameters#

W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. 메뉴의 CoreMenuItem도 같은 플랫폼 콘텐츠를 받습니다.

속성타입기본값설명
triggerW필수메뉴를 여는 트리거 요소
items List<CoreMenuItem<W>> 필수 드롭다운 패널의 행 — Menu / Menubar / ContextMenu 와 공유하는 통합 [CoreMenuItem] 모델
dropdownMenuStyle CoreDropdownMenuStyle? null 모든 chrome 이 지나는 단일 슬롯 (아래 표 참고)
controller PopoverController? null 외부에서 열기/닫기를 명령형으로 제어 (Popover 와 동일한 통합 컨트롤러)

CoreDropdownMenuStyle 필드#

필드타입설명
menuStyle CoreMenuStyle? Nested style for the composed Menu — the whole menu chrome (panel box via [CoreMenuStyle.popupStyle], the per-row MenuItem chrome via [CoreMenuStyle.itemStyle], the divider, the heading typography …). Raw-forwarded to the inner Menu 's menuStyle (the Menu resolver merges it over CoreMenuTheme.style ); null leaves the inner Menu at its design-system defaults.
menuOffset double? Per-instance gap between the trigger and the menu panel along the placement axis — null falls back to [defaultMenuOffset]. A single placement-axis scalar (not a multi-side inset), so it stays a flat double field (mirrors CoreMenubarStyle.menuOffset ).

CoreMenuItem (드롭다운에서 쓰는 종류)#

드롭다운은 통합 CoreMenuItem 모델을 받는다. 주로 쓰는 두 종류:

생성자속성타입기본값설명
.action label (positional) String 필수 액션 행 텍스트
.action shortcut String? null 라벨 뒤에 표시되는 단축키 힌트
.action disabled bool false true 시 muted 처리 + onPressed 미호출
.action onPressed void Function()? null 활성 액션 선택 시 호출
.divider———그룹 사이 구분선

스타일 시스템 (Style System)#

DropdownMenu의 드롭다운 패널은 떠 있는 popup 표면이므로 CorePopupStyle chrome 슬롯을 공유합니다. 패널의 보더 / 반경 / 배경 / 그림자 / 패딩 / 최소 너비가 모두 popupStyle 한 곳으로 흐릅니다.

Resolve chain#

CorePopupStyle.defaultX                          // 디자인 시스템 기본값
  → CorePopupTheme.style                          // 프로젝트 공통
  → widget.popupStyle                             // 인스턴스별

엔트리 행 chrome (패딩 / hover 배경 / 구분선 색)은 CoreDropdownMenuStyle의 defaultEntryPadding / defaultEntryBorderRadius / defaultEntryHoverColor / defaultDividerColor / defaultEntryDuration 디자인 시스템 기본값으로 고정되어 앱 전체에서 일관성을 유지합니다.

DropdownMenu(
  popupStyle: const CorePopupStyle(
    borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
  ),
  trigger: const Text('Menu'),
  items: const [CoreMenuItem.action('Item')],
)

빠른 오버라이드 (Chain)#

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

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

  @override
  Widget build(BuildContext context) {
    return DropdownMenu(
      trigger: Button(
        variant: CoreButtonVariant.outline,
        onPressed: () {},
        child: const Text('Open Menu'),
      ),
      items: [
        CoreMenuItem.action('Profile', onPressed: () {}),
        CoreMenuItem.action('Settings', onPressed: () {}),
        const CoreMenuItem.divider(),
        CoreMenuItem.action('Sign out', onPressed: () {}),
      ],
    ).withStyle(
      const CoreDropdownMenuStyle(
        menuOffset: CoreSpace.space16,
        menuStyle: CoreMenuStyle(
          itemStyle: CoreMenuItemStyle(
            padding: CoreEdgeInsets.symmetric(
              horizontal: CoreSpace.space16,
              vertical: CoreSpace.space8,
            ),
          ),
          labelTextStyle: CoreTextStyle.token(
            CoreTextStyles.labelLarge,
            color: CoreColor.token(CoreColors.primary),
          ),
        ),
      ),
    );
  }
}
class DropdownMenuChainExample extends StatelessComponent {
  const DropdownMenuChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return DropdownMenu(
      trigger: Button(
        variant: CoreButtonVariant.outline,
        onPressed: () {},
        child: Text('Open Menu'),
      ),
      items: [
        CoreMenuItem.action('Profile', onPressed: () {}),
        CoreMenuItem.action('Settings', onPressed: () {}),
        const CoreMenuItem.divider(),
        CoreMenuItem.action('Sign out', onPressed: () {}),
      ],
    ).withStyle(
      const CoreDropdownMenuStyle(
        menuOffset: CoreSpace.space16,
        menuStyle: CoreMenuStyle(
          itemStyle: CoreMenuItemStyle(
            padding: CoreEdgeInsets.symmetric(
              horizontal: CoreSpace.space16,
              vertical: CoreSpace.space8,
            ),
          ),
          labelTextStyle: CoreTextStyle.token(
            CoreTextStyles.labelLarge,
            color: CoreColor.token(CoreColors.primary),
          ),
        ),
      ),
    );
  }
}

변형 (Variants)#

단축키 힌트 / 비활성 엔트리#

DropdownMenu(
  trigger: const Text('Edit'),
  items: const [
    CoreMenuItem.action('Undo', shortcut: 'Ctrl+Z'),
    CoreMenuItem.action('Redo', disabled: true),
  ],
)

동작 스펙 (Behavior)#

인터랙션#

  • 트리거 클릭: 메뉴 열기 / 닫기 토글
  • 항목 선택: 서브메뉴 없는 action이 활성화되면 Menu.onActivate 로 먼저 닫기를 요청한 뒤 항목의 onPressed 호출. checkbox / radio는 변경 콜백만 호출하고 메뉴를 유지
  • 외부 클릭: 메뉴 외부 클릭 시 닫힘
  • Escape: 열린 메뉴 닫힘
  • 긴 목록: 항목이 뷰포트가 패널에 남기는 높이보다 길면, 패널이 남는 높이를 모두 차지하고 위쪽 여백에 붙어 열립니다(트리거를 덮을 수 있음). 행은 패널 안에서 스크롤되어 마지막 행까지 닿습니다. 남는 높이에 들어가는 메뉴는 그대로입니다
  • 열린 채로 items 변경: 열려 있는 동안 items 가 바뀌면 열린 패널이 새 항목으로 갱신됩니다. 다시 열 필요가 없습니다
  • 메뉴는 값 선택 폼이 아니라 액션 목록입니다. 값·검증이 필요한 입력은 Select, 포인터 위치에서 여는 메뉴는 ContextMenu 입니다.

애니메이션#

  • 엔트리 hover 배경: 150ms 색상 전환 (CoreDuration.ms150)

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

✅ Do#

관련 액션을 구분선으로 그룹화

DropdownMenu(
  trigger: const Text('More'),
  items: const [
    CoreMenuItem.action('Edit'),
    CoreMenuItem.action('Duplicate'),
    CoreMenuItem.divider(),
    CoreMenuItem.action('Delete'),
  ],
)

구분선으로 관련 액션을 묶으면 메뉴 탐색이 더 직관적이다.


❌ Don't#

폼 값 선택에 DropdownMenu 사용

// ❌ 값 선택 폼
DropdownMenu(
  trigger: const Text('Country'),
  items: const [CoreMenuItem.action('Korea')],
)

폼에서 값을 선택할 때는 Select가 레이블·유효성 검사·접근성을 더 잘 지원한다.

접근성 (Accessibility)#

역할#

  • 트리거 — Web: 래퍼 <div> 에 role="button" + aria-haspopup="true" + aria-expanded(열림 상태 반영). Flutter: Semantics(button: true, expanded: …) 로 같은 두 신호를 냅니다. aria-controls 는 양쪽 다 없습니다.
  • 패널 — Web: role=menu + aria-orientation=vertical. Flutter: 합성된 Menu의 CoUISemantics.menu 역할입니다.
  • 엔트리 — Web은 menuitem / menuitemcheckbox / menuitemradio와 aria-checked · aria-disabled를 사용합니다. Flutter는 MenuRowScope 안 MenuItem의 menuItem 역할·enabled·selected를 사용하며 토글은 selected로 전달합니다.
  • 구분선 — 양쪽 모두 의미 있는 경계로 처리합니다. Web role="separator" + aria-label, Flutter Semantics(container: true, label: …), 라벨은 dividerSeparatorLabel(ko: "구분선").

키보드#

키FlutterWeb
Escape모달 메뉴 닫기positioner가 메뉴 닫기
Tab / Shift+Tab 항목별 포커스 합성된 Menu의 한 roving tab stop
Enter / Space항목 활성화항목 활성화
ArrowUp / ArrowDown메뉴 전용 탐색 없음Menu의 행 순환 탐색
Home / End · 문자 입력 메뉴 전용 탐색 없음 첫/마지막 행·label typeahead

Web 탐색은 DropdownMenu가 새로 구현하지 않고 합성된 Menu 에서 상속합니다. label / divider / gap은 탐색 대상이 아니고 비활성 action / checkbox / radio 행은 도달 가능하되 활성화하지 않습니다.

트리거를 여는 경로가 플랫폼마다 다릅니다. Web 은 래퍼 <div> 의 click 을 듣기 때문에 안쪽 트리거가 포커스 가능한 컨트롤(예: Button)이면 키보드 활성화에서도 DOM 클릭이 버블링되어 메뉴가 열립니다. Flutter 는 TriggerTapDetector(Listener 기반, 포인터 전용)라 키보드로 트리거를 활성화해도 메뉴가 열리지 않습니다 — 그 경우 트리거 자신의 onPressed 만 실행됩니다. 대안은 controller (PopoverController) 로 호출부에서 여는 것입니다.

스크린 리더#

  • 트리거의 이름은 caller 가 넘긴 trigger 위젯에서 옵니다. 컴포넌트는 이름을 만들지 않으므로 아이콘만 넘기면 이름 없는 버튼이 됩니다.
  • 패널에는 접근 가능한 이름이 없습니다. Menu 는 ariaLabel 슬롯을 갖지만 DropdownMenu 가 그것을 노출하지 않고 내부에서 Menu 를 생성하므로 호출부에서 붙일 수 없습니다.
  • 각 항목 이름은 CoreMenuItem.label 입니다. Web 은 비활성 항목을 aria-disabled 로, Flutter 는 Semantics(enabled: false) 로 함께 알립니다.
  • 배경 차단은 양쪽 다 있습니다. Flutter 는 모달 배리어의 BlockSemantics(엔진 기본 modal: true), Web 은 modal: true 마운트가 거는 inert 입니다.
  • 닫기 동작도 양쪽 다 있습니다. 배경이 막혀 스크림에 닿을 수 없는 스크린 리더 사용자가 "바깥을 눌러 닫기" 를 대신할 수단입니다. Flutter 는 패널 범위의 Semantics(onDismiss:) 가 지역화된 dismissLabel(ko: "닫기")을 이름으로 가져 스크린 리더의 닫기 제스처와 그 노드의 활성화가 메뉴를 닫고, Web 은 패널 곁의 시각적으로 숨긴 role="button" 노드(aria-label 이 같은 dismissLabel)를 활성화하면 닫힙니다. 터치 스크린 리더에는 Escape 가 없어 이 노드가 유일한 닫기 수단입니다. Web 노드는 tabindex="-1" 이라 탭 순서에 들지 않고, 키보드에는 Escape 가 있습니다. 이 이름은 닫기 동작의 이름이지 메뉴의 이름이 아니므로 위의 "패널에 이름이 없다" 는 제약은 그대로입니다.
  • 열림/닫힘 상태 통보(live region)는 없습니다 — 트리거의 aria-expanded / Semantics(expanded:) 만 상태를 노출합니다.

포커스 관리#

  • 진입 — Flutter: 모달 오버레이가 패널 스코프로 포커스를 요청합니다. Web: 열릴 때 첫 행으로 포커스를 옮기지는 않습니다. 패널에 들어간 뒤에는 Menu 키 탐색이 focusRovingItem 으로 포커스를 옮깁니다.
  • 이탈 — 양쪽 모두 트리거로 포커스를 되돌리는 코드가 없습니다. Flutter 는 패널 스코프를 dispose 할 뿐입니다.
  • 트랩 — Flutter: 패널의 FocusScope 가 closedLoop 기본값을 가져 포커스가 들어간 뒤에는 Tab 이 패널 안에서 순환합니다. Web: 배경이 inert 라 배경으로 넘어가지 않지만 패널 안에서 순환하지도 않습니다 — 마지막 항목에서 Tab 은 브라우저 UI 로 나갔다가 돌아옵니다.

알려진 제약#

  • Flutter에는 메뉴 전용 화살표·Home/End·typeahead·roving 탐색이 없습니다. Web Menu의 탐색과 같다고 기대하지 마세요.
  • Flutter 는 키보드로 트리거를 열 수 없습니다 (위 키보드 참고). controller 로 우회하세요.
  • 패널에 이름을 붙일 수 없습니다 — Menu.ariaLabel 이 DropdownMenu 표면에 노출되지 않습니다.
  • 토글 의미 표현이 다릅니다. Flutter는 menuItem의 selected 플래그, Web은 menuitemcheckbox / radio의 aria-checked를 사용합니다.
  • Web 은 닫을 때 포커스를 트리거로 되돌리지 않습니다.

전역으로 적용되는 축(동작 줄이기·고대비·색 강제 모드·최소 터치 타겟)은 전역 접근성 축에 있습니다.

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

DropdownMenu는 Flutter와 Web이 동일한 trigger / items / dropdownMenuStyle API를 공유합니다. 드롭다운 패널은 Flutter에서는 PopoverOverlayHandler, Web에서는 루트 OverlayHost의 menu 레이어로 portal되어 양쪽 모두 ancestor overflow에 잘리지 않습니다.

항목FlutterWeb
항목 렌더합성된 MenuItem합성된 MenuItem
메뉴 탐색항목별 포커스Menu의 roving · 화살표 · Home / End · typeahead
패널 배치모달 오버레이 engineOverlayPositioner와 modal menu portal
스크린 리더의 닫기 모달 패널의 dismiss 접근성 동작 패널 곁의 숨긴 role="button" 노드 (탭 순서 밖)
긴 메뉴 엔진이 패널 최대 높이를 레이아웃 제약으로 제한 (여백·시스템 바·키보드 제외) 위치 엔진이 패널을 측정한 뒤 상자에 max-height 를 걸고 상자 안의 목록을 overflow-y: auto 로 스크롤 (뷰포트 여백 viewportPadding 만 제외)
  • Menu: 사이드바 형태의 일반 메뉴
  • ContextMenu: 우클릭 상황별 메뉴
  • Popup: 플로팅 패널 컨테이너