Popup | CoUI
LogoCoUI

Popup

메뉴·드롭다운·툴팁 등 플로팅 콘텐츠를 담는 chrome 스타일 컨테이너

Popup#

메뉴·드롭다운·툴팁처럼 떠 있는 콘텐츠를 담는 chrome 스타일 컨테이너입니다. 보더, 그림자, 둥근 모서리, 배경을 가진 패널 안에 자식을 배치합니다.

Popup은 레거시 Flutter MenuPopup과 Web Popup을 통일한 컴포넌트입니다. Flutter와 Web이 동일한 파라미터 이름·동작·시각을 갖습니다.

Live Preview#

사용 시기 (When to Use)#

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

  • 메뉴 / 드롭다운 / 툴팁의 플로팅 패널 chrome 이 필요할 때
  • 떠 있는 콘텐츠에 일관된 보더 / 그림자 / 배경이 필요할 때

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

  • Popover: 트리거에 anchor 된 위치 계산이 필요한 팝오버에
  • DropdownMenu: 액션 목록을 가진 완전한 드롭다운 메뉴에
  • Card: 인라인(비-플로팅) 콘텐츠 카드에

기본 사용법 (Basic Usage)#

Popup(
  children: const [
    Text('Profile'),
    Text('Settings'),
    Text('Sign out'),
  ],
)
Popup(
  children: [
    div([text('Profile')]),
    div([text('Settings')]),
    div([text('Sign out')]),
  ],
)

빠른 오버라이드 (Chain)#

이미 만든 Popup 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius24처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius24 == CoreRadius.radius24) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

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

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        Popup(
          children: [
            Padding(
              padding: const EdgeInsets.symmetric(
                horizontal: CoreSpace.space8,
                vertical: CoreSpace.space4,
              ),
              child: Text('Profile').bodyMedium,
            ),
            Padding(
              padding: const EdgeInsets.symmetric(
                horizontal: CoreSpace.space8,
                vertical: CoreSpace.space4,
              ),
              child: Text('Settings').bodyMedium,
            ),
            Padding(
              padding: const EdgeInsets.symmetric(
                horizontal: CoreSpace.space8,
                vertical: CoreSpace.space4,
              ),
              child: Text('Sign out').bodyMedium,
            ),
          ],
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
        Popup(
          children: [
            Padding(
              padding: const EdgeInsets.symmetric(
                horizontal: CoreSpace.space8,
                vertical: CoreSpace.space4,
              ),
              child: Text('Profile').bodyMedium,
            ),
            Padding(
              padding: const EdgeInsets.symmetric(
                horizontal: CoreSpace.space8,
                vertical: CoreSpace.space4,
              ),
              child: Text('Settings').bodyMedium,
            ),
            Padding(
              padding: const EdgeInsets.symmetric(
                horizontal: CoreSpace.space8,
                vertical: CoreSpace.space4,
              ),
              child: Text('Sign out').bodyMedium,
            ),
          ],
        ).withStyle(
          const CorePopupStyle(
            backgroundColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            padding: CoreEdgeInsets.symmetric(
              horizontal: CoreSpace.space24,
              vertical: CoreSpace.space8,
            ),
          ),
        ),
      ],
    );
  }
}
class PopupChainExample extends StatelessComponent {
  const PopupChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return div(
      [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        Popup(
          children: [
            div(
              [Text('Profile').bodyMedium],
              classes: 'px-${CoreSpace.scale.space8} py-${CoreSpace.scale.space4}',
            ),
            div(
              [Text('Settings').bodyMedium],
              classes: 'px-${CoreSpace.scale.space8} py-${CoreSpace.scale.space4}',
            ),
            div(
              [Text('Sign out').bodyMedium],
              classes: 'px-${CoreSpace.scale.space8} py-${CoreSpace.scale.space4}',
            ),
          ],
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
        Popup(
          children: [
            div(
              [Text('Profile').bodyMedium],
              classes: 'px-${CoreSpace.scale.space8} py-${CoreSpace.scale.space4}',
            ),
            div(
              [Text('Settings').bodyMedium],
              classes: 'px-${CoreSpace.scale.space8} py-${CoreSpace.scale.space4}',
            ),
            div(
              [Text('Sign out').bodyMedium],
              classes: 'px-${CoreSpace.scale.space8} py-${CoreSpace.scale.space4}',
            ),
          ],
        ).withStyle(
          const CorePopupStyle(
            backgroundColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            padding: CoreEdgeInsets.symmetric(
              horizontal: CoreSpace.space24,
              vertical: CoreSpace.space8,
            ),
          ),
        ),
      ],
      classes: 'flex flex-col items-start',
    );
  }
}

Props / Parameters#

속성타입기본값설명
childrenList<W>필수패널 내부에 배치되는 콘텐츠
openbooltrue패널 표시 여부
direction CoreAxis vertical 자식 배치 축
popupStyle CorePopupStyle? null 인스턴스별 패널 chrome 스타일

스타일 시스템 (Style System)#

Popup의 모든 chrome 오버라이드는 단일 CorePopupStyle 슬롯으로 흐릅니다 .

Resolve chain#

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

CorePopupStyle 필드#

필드타입설명
padding CoreEdgeInsets? Internal padding of the popup panel.
borderRadius CoreBorderRadius? Corner radius of the popup panel.
borderWidth double? Border width of the popup panel (logical px).
backgroundColor CoreColor? Background colour of the popup panel.
borderColor CoreColor? Border colour of the popup panel.
boxShadow List<CoreShadowLayer>? Elevation shadow of the popup panel.
shadowBaseColor CoreColor? Base colour the [boxShadow] layers tint against.
minWidth double? Minimum width of the popup panel (logical px).
spacing double? Uniform spacing (logical px) between adjacent children laid out along the panel's axis. Flows into Column / Row.spacing (Flutter) and the flex gap (Web). null → [defaultSpacing] (0, flush).
contentColor CoreColor? Ambient text colour for the panel content. The panel wraps its content in a DefaultTextStyle whose colour is this, inheriting the ambient typography role and overriding only the colour.
openAnimationDuration Duration? Open (enter) animation duration of the popup panel. null → [defaultOpenAnimationDuration].
closeAnimationDuration Duration? Close (exit) animation duration of the popup panel. null → [defaultCloseAnimationDuration].
Popup(
  popupStyle: const CorePopupStyle(
    backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
    borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
  ),
  children: const [Text('Item')],
)

동작 스펙 (Behavior)#

Popup은 chrome 컨테이너입니다 — 위치 계산이나 외부 클릭 닫기 같은 오버레이 동작은 포함하지 않습니다. openfalse면 아무것도 렌더링하지 않습니다. 위치 계산이 필요하면 Popover를 사용하세요.

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

✅ Do#

위치 계산이 필요하면 Popover로 감싸서 사용

Popover(
  trigger: triggerWidget,
  child: Popup(
    children: const [Text('Profile'), Text('Settings')],
  ),
)

Popup은 보더 · 그림자 · 배경 같은 패널 chrome 만 그리고 anchor 위치 계산은 하지 않습니다 — 트리거에 붙는 위치가 필요하면 Popover로 감싸야 합니다.


❌ Don't#

intrinsic 크기를 측정할 수 없는 자식을 직접 넣지 않기

// ❌ AspectRatio + LayoutBuilder 조합은 IntrinsicWidth 측정 시 throw
Popup(
  children: [
    AspectRatio(
      aspectRatio: 1,
      child: LayoutBuilder(builder: (_, _) => content),
    ),
  ],
)

PopupIntrinsicWidth로 콘텐츠 크기에 맞춰 패널을 shrink-wrap 합니다(minWidth가 하한). LayoutBuilder처럼 intrinsic 크기를 계산할 수 없는 위젯을 자식으로 넣으면 레이아웃이 throw 됩니다 — 고정 크기(SizedBox.square 등)로 감싸야 합니다.

접근성 (Accessibility)#

Popup 은 떠 있는 패널의 chrome 만 그리는 인프라 컴포넌트입니다. 아래는 Popup 자신이 하는 일이며, 키보드·포커스는 대부분 이것을 감싸는 오버레이 (Popover 등)가 소유합니다.

역할#

  • Web — 루트에 role="menu". 호출자가 attributes 로 덮을 수 있습니다.
  • Flutter — role 없음(컨테이너 Semantics 만).

Web 의 라이브러리 컴포넌트 중 이 Popup 을 합성하는 것은 없습니다 — tooltip·dropdown-menu·context-menu·menubar·popover 는 chrome 해석만 공유하고 role 은 각자 자기 요소에 직접 붙입니다. 따라서 위 role="menu" 기본값은 Popup직접 쓰는 호출자에게만 적용됩니다. 메뉴가 아닌 패널을 만든다면 attributes 로 역할을 바꾸세요.

키보드#

Popup 자신에는 키 핸들러가 없습니다.

스크린 리더#

Popup 은 자체 라벨을 내보내지 않습니다. 리더가 읽는 것은 Web 의 역할(menu)과 호출자가 안에 넣은 콘텐츠뿐입니다. 패널이 열리고 닫힌 사실을 알리는 live region 은 없습니다 — 필요하면 이것을 감싸는 오버레이가 트리거에 확장 상태를 알립니다.

Flutter 는 역할도 라벨도 없어 이름 없는 컨테이너로 노출됩니다.

포커스 관리#

  • 진입Popup 자신은 포커스를 옮기지 않습니다. modal Popover 로 감쌌을 때만 오버레이가 패널 scope 에 포커스를 줍니다(Flutter).
  • 이탈트리거로 되돌리는 코드 경로가 양 플랫폼 어디에도 없습니다.
  • 트랩Popup 자신에는 없습니다. Flutter 에서 modal 오버레이로 감싸면 오버레이의 FocusScopeTab 을 패널 안에 가둡니다.

알려진 제약#

  • 패널을 닫은 뒤 포커스가 어디로 가는지 컴포넌트가 보장하지 않습니다.
  • Flutter 는 패널 역할을 알리지 않습니다.

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

Popup은 Flutter와 Web이 동일한 children / open / direction / popupStyle API와 동작을 갖습니다. Flutter는 DecoratedBox + ClipRRect, Web은 inline CSS로 동일한 보더 / 그림자 / 둥근 모서리를 렌더링합니다.