Popover | CoUI
LogoCoUI

Popover

팝오버 컴포넌트

Popover#

트리거 요소에 앵커된 플로팅 패널을 표시합니다. 12-direction 배치, viewport 충돌 시 자동 flip/shift, 외부 클릭/ESC 닫기, 모달 backdrop, 명시적 controller 제어, 트리거 follow를 지원합니다.

Live Preview#

기본 사용법 (Basic Usage)#

final controller = PopoverController();

Popover(
  controller: controller,
  openTrigger: CorePopoverTrigger.manual,
  trigger: Button(
    onPressed: controller.toggle,
    variant: CoreButtonVariant.outline,
    child: Text('Open Popover').labelLarge,
  ),
  content: Text('This is the popover content.').bodyMedium,
)
final controller = PopoverController();

Popover(
  controller: controller,
  openTrigger: CorePopoverTrigger.manual,
  trigger: Button(
    onPressed: controller.toggle,
    variant: CoreButtonVariant.outline,
    child: Text('Open Popover').labelLarge,
  ),
  content: Text('This is the popover content.').bodyMedium,
)

명시적 controller 제어#

trigger 외부에서 open/close를 직접 제어해야 할 때 PopoverController를 주입합니다. 색상 피커의 swatch toggle, form field의 dropdown 등이 대표 예시.

final controller = PopoverController();

Popover(
  controller: controller,
  trigger: Button(
    onPressed: controller.toggle,
    child: const Text('Toggle'),
  ),
  content: const Text('Driven by controller.toggle()'),
)

controller.show(), controller.close(), controller.toggle(), controller.isOpen, controller.addListener() 모두 양 플랫폼 동일.

Props / Parameters#

속성타입기본값설명
trigger Widget / Component 필수 팝오버를 열 트리거 요소
content Widget / Component 필수 팝오버 콘텐츠
placement CorePopoverPlacement bottom 12-direction 배치
controller PopoverController? null 외부 imperative 제어
openTrigger CorePopoverTrigger click click / hover / focus / manual
modal bool false 배경 backdrop으로 다른 입력 차단
dismissOnOutsideTap bool true 외부 클릭 시 닫기
dismissOnEscape bool true ESC 키로 닫기
collision Set<CorePopoverCollision>? null (flip+shift) viewport 충돌 정책
widthConstraint CorePopoverConstraint? null (flexible) width 사이징 정책
heightConstraint CorePopoverConstraint? null (flexible) height 사이징 정책
popoverStyle CorePopoverStyle? null panel chrome / nested triggerStyle 묶음
onClose VoidCallback? null 닫힘 콜백

스타일 시스템 — popoverStyle#

Popover 의 panel chrome / 슬롯 미세 조정은 단일 popoverStyle (CorePopoverStyle) 으로 흐릅니다. 동작 정책 (placement, openTrigger, modal, dismiss*, collision, *Constraint) 은 위젯 파라미터.

Popover(
  placement: CorePopoverPlacement.bottomStart,
  trigger: Button(child: Text('Open')),
  content: ...,
  popoverStyle: CorePopoverStyle(
    panelBackgroundColor: cs.surface,
    panelBorderColor: cs.outline,
    panelBorderRadius: CoreBorderRadius.all(12),
    panelPadding: CoreEdgeInsets.all(16),
    placementOffset: 8,
    triggerSizing: CorePopoverTriggerSizing.expand,
    openAnimationDuration: Duration(milliseconds: 150),
    closeAnimationDuration: Duration(milliseconds: 100),
    triggerStyle: CoreButtonStyle(           // chrome 미세 조정
      paddingH: 16,
      labelStyle: CoreTextStyle(fontWeight: CoreFontWeight.semiBold),
    ),
  ),
)

CorePopoverStyle 필드#

필드타입설명
panelStyle CorePopupStyle? Panel chrome override. Nested [CorePopupStyle] — the popover panel box is a composed Popup , so its chrome (background / border / radius / shadow / padding / min-width / content colour) is absorbed through this single nested slot and forwarded raw to Popup (child-component-composition). null → [defaultPanelStyle] (which itself defers most fields to CorePopupStyle 's defaults).
placementOffset double? Trigger-to-panel placement offset (logical px) — applied along the placement axis (top/bottom or left/right depending on CorePopoverPlacement ) to push the floating panel away from the trigger edge. Maps to a Flutter Offset via placement_ext.toPopoverOffset and to the Web floating-controller placement-axis distance.
viewportPadding CoreEdgeInsets? Viewport-edge padding enforced when applying [CorePopoverCollision.shift] — the panel won't paint within this much of the visible viewport edge regardless of placement. Per CLAUDE.md rule 2 (multi-side padding/margin → [CoreEdgeInsets]), each side accepts its own value so popovers can avoid colliding with fixed headers, banners, or safe-area insets (e.g. CoreEdgeInsets.only(top: 64) reserves a 64-px sticky-header strip). null → token default ([defaultViewportPadding]).
triggerSizing CorePopoverTriggerSizing? Trigger sizing behaviour (intrinsic / expand / matchPanel).
openAnimationDuration Duration? Open animation duration override.
closeAnimationDuration Duration? Close animation duration override.
triggerStyle CoreButtonStyle? Trigger button style override. Nested CoreButtonStyle so any chrome / dimensional / nested-slot subset can be overridden without touching the panel chrome. To change the trigger's variant (e.g. outline vs primary), inject the trigger widget directly via Popover(trigger:...) rather than packing variant into this style (asChild pattern). Carries no defaultTriggerStyle . A popover diverges from Button 's own chrome in nothing — it anchors whatever trigger it is handed — so the only fillable partial would restate the child's defaults, which style-contract.md forbids (the parent must not reach into the child's defaults) and which would silently stop tracking Button the next time those defaults move. Absence also keeps a live fallback reachable: DatePicker and TimePicker resolve their trailing icon as merged.trailingIconStyle ?? popoverStyle.triggerStyle ?.trailingIconStyle ?? defaultTrailingIconStyle , so a default carrying a trailingIconStyle would make that third rung dead on both platforms.

asChild 패턴 — trigger variant 변경#

trigger 의 시맨틱 (variant) 변경이 필요할 때는 popoverStyle.triggerStyle 안에 packed 하지 말고 위젯 자체를 슬롯으로 주입합니다:

Popover(
  trigger: Button(variant: .outline, child: Text('Filter')),  // ← 변형 직접
  content: ...,
)

popoverStyle.triggerStyle 은 chrome 미세 조정용입니다 (padding / labelStyle 등).

Resolve chain#

design system default
  → CorePopoverTheme.style                       // 프로젝트 공통
  → 부모 컴포넌트 슬롯 오버라이드 (예: CoreSelectStyle.popoverStyle)
  → widget.popoverStyle                          // 인스턴스별

빠른 오버라이드 (Chain)#

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

class PopoverChainExample extends StatefulWidget {
  const PopoverChainExample({super.key});

  @override
  State<PopoverChainExample> createState() => _PopoverChainExampleState();
}

class _PopoverChainExampleState extends State<PopoverChainExample> {
  final _controller = PopoverController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Popover(
      controller: _controller,
      openTrigger: CorePopoverTrigger.manual,
      trigger: Button(
        onPressed: _controller.toggle,
        variant: CoreButtonVariant.outline,
        child: Text('Open Popover').labelLarge,
      ),
      content: Text('This is the popover content.').bodyMedium,
    ).withStyle(
      const CorePopoverStyle(
        placementOffset: CoreSpace.space24,
        openAnimationDuration: Duration(milliseconds: CoreDuration.moderate),
        panelStyle: CorePopupStyle(
          padding: CoreEdgeInsets.all(CoreSpace.space20),
          borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
          borderWidth: CoreStrokeWidth.stroke2,
          borderColor: CoreColor.token(CoreColors.primary),
        ),
      ),
    );
  }
}
class PopoverChainExample extends StatefulComponent {
  const PopoverChainExample({super.key});

  @override
  State<PopoverChainExample> createState() => _PopoverChainExampleState();
}

class _PopoverChainExampleState extends State<PopoverChainExample> {
  final _controller = PopoverController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Component build(BuildContext context) {
    return Popover(
      controller: _controller,
      openTrigger: CorePopoverTrigger.manual,
      trigger: Button(
        onPressed: _controller.toggle,
        variant: CoreButtonVariant.outline,
        child: Text('Open Popover').labelLarge,
      ),
      content: Text('This is the popover content.').bodyMedium,
    ).withStyle(
      const CorePopoverStyle(
        placementOffset: CoreSpace.space24,
        openAnimationDuration: Duration(milliseconds: CoreDuration.moderate),
        panelStyle: CorePopupStyle(
          padding: CoreEdgeInsets.all(CoreSpace.space20),
          borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
          borderWidth: CoreStrokeWidth.stroke2,
          borderColor: CoreColor.token(CoreColors.primary),
        ),
      ),
    );
  }
}

배치 옵션 (12-direction Placement)#

// 기본 4방향
CorePopoverPlacement.top
CorePopoverPlacement.bottom
CorePopoverPlacement.left
CorePopoverPlacement.right

// 8개 코너 (자동 flip/shift 시 활용)
CorePopoverPlacement.topStart
CorePopoverPlacement.topEnd
CorePopoverPlacement.bottomStart
CorePopoverPlacement.bottomEnd
CorePopoverPlacement.leftStart
CorePopoverPlacement.leftEnd
CorePopoverPlacement.rightStart
CorePopoverPlacement.rightEnd

충돌 정책#

// 기본 (flip + shift) — viewport 부딪히면 반대편으로 flip, 안 되면 axis shift
collision: const {CorePopoverCollision.flip, CorePopoverCollision.shift}

// flip만 — 반대편 공간 부족하면 그대로 잘림
collision: const {CorePopoverCollision.flip}

// 충돌 무시
collision: const {CorePopoverCollision.none}

Constraint (anchor-relative 사이징)#

// panel 너비를 trigger와 정확히 일치 (Select 패턴)
widthConstraint: CorePopoverConstraint.anchorFixedSize

// panel 너비 ≤ trigger 너비
widthConstraint: CorePopoverConstraint.anchorMaxSize

// panel 너비 ≥ trigger 너비
widthConstraint: CorePopoverConstraint.anchorMinSize

// content 자연 너비 (기본)
widthConstraint: CorePopoverConstraint.flexible

테마 커스터마이징#

CoreComponentTheme(
  popover: CorePopoverTheme(
    style: CorePopoverStyle(
      panelBackgroundColor: cs.surface,
      panelBorderColor: cs.outline,
      panelBorderRadius: CoreBorderRadius.all(12),
      panelPadding: CoreEdgeInsets.all(20),
      placementOffset: 12,
      openAnimationDuration: Duration(milliseconds: 250),
      closeAnimationDuration: Duration(milliseconds: 100),
    ),
  ),
)

크로스 플랫폼 패리티#

항목FlutterWeb
표시 방식 Overlay.of(rootOverlay: true).insert OverlayHost portal (popover 레이어, position: fixed)
Trigger 이벤트 GestureDetector / MouseRegion / Focus DOM click / mouseenter / focus
외부 클릭 닫기 Listener overlay barrier document.addEventListener('click')
ESC 닫기 FocusScope + dismissBackdropFocus document.addEventListener('keydown')
자동 flip/shift RenderShiftedBox collision getBoundingClientRect + viewport 비교
Anchor follow Ticker 매 프레임 ResizeObserver + window scroll/resize
Modal backdrop OverlayEntry barrier portal 레이어의 <div fixed inset-0 z-{N}>
Open animation TweenAnimationBuilder (scale 0.9→1.0, opacity 0→1) CSS transition (scale-90→scale-100, opacity-0→opacity-100)
Open duration CorePopoverStyle.defaultOpenAnimationDuration (150ms) 동일 — inline transition-duration: 150ms
Close duration CorePopoverStyle.defaultCloseAnimationDuration (100ms) 동일 — close 시 transition-duration 가 100ms × 2/3 = 66ms 로 emit (CSS 가 Interval(0, 2/3) 를 직접 표현 못 해 가시 시간 압축으로 등가 매핑)
Open curve Curves.linear (CorePopoverCurve.linear) transition-timing-function: linear
Close curve Interval(0, 2/3) (CorePopoverCurve.accelerate23) linear timing + duration 단축으로 동등 효과
Controller API 동일 (PopoverController) 동일 (PopoverController)

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

✅ Do#

키보드로 열어야 하는 트리거는 .manual + controller로

final controller = PopoverController();

Popover(
  controller: controller,
  openTrigger: CorePopoverTrigger.manual,
  trigger: Button(
    onPressed: controller.toggle,
    child: const Text('Toggle'),
  ),
  content: const Text('Driven by controller.toggle()'),
)

ButtononPressed는 포커스된 상태에서 Enter/Space로도 활성화되므로, 키보드로 열어야 하는 트리거는 openTrigger: .manual + controller.toggle 조합이 안전합니다.


❌ Don't#

기본 .click 트리거가 Flutter에서 키보드로 열릴 거라 기대하지 않기

// ❌ 기본 .click 트리거가 키보드로 열릴 거라 기대
Popover(
  openTrigger: CorePopoverTrigger.click, // 기본값
  trigger: myFocusableTrigger,
  content: someContent,
)

Flutter의 .click 트리거는 raw 포인터 Listener로 구현되어 있어 포커스된 트리거에서 Enter/Space를 눌러도 열리지 않습니다(Web은 열립니다). 키보드 접근이 필요하면 위 Do처럼 .manual + controller를 쓰세요.

접근성 (Accessibility)#

역할 / Semantics#

두 플랫폼이 다르게 말합니다.

FlutterWeb
트리거 Semantics(container: true, expanded: …) — 열림/닫힘 상태만, 역할 없음 aria-haspopup="dialog" + aria-expanded
패널 합성된 PopupSemantics(container: true)dialog 역할 없음 role="dialog" (+ data-placement)
modal 배경 modal: true일 때만 BlockSemantics + ExcludeSemantics aria-hidden="true" (backdrop 요소만)

aria-modal · aria-label/aria-labelledby · aria-describedby · aria-controls어느 플랫폼에도 없습니다 — 패널에는 접근 가능한 이름이 없고, 트리거와 패널은 서로 연결되어 있지 않습니다.

키보드#

처리하는 키는 Escape 하나뿐이며, 그 조건이 플랫폼마다 다릅니다.

FlutterWeb
Escape modal: true 이고 dismissOnOutsideTap: true일 때만 닫힘 dismissOnEscape: true(기본값)이면 닫힘 — document 레벨 리스너라 modal 여부·포커스 위치와 무관

Flutter에서 dismissOnEscape는 Escape를 제어하지 않습니다. 이 파라미터는 내부적으로 오버레이의 dismissBackdropFocus(= FocusScope의 autofocus)로 전달되고, Escape 단축키는 modal && dismissOnOutsideTap일 때만 설치됩니다. 따라서 기본 설정(modal: false, dismissOnEscape: true)에서 Flutter는 Escape로 닫히지 않습니다. Web은 같은 설정에서 닫힙니다.

키보드로 여는 것도 갈립니다. openTrigger: .click일 때 Web은 트리거 래퍼의 click 이벤트를 듣기 때문에 안쪽 버튼을 Enter/Space로 활성화하면 합성된 DOM click이 올라와 열립니다. Flutter는 raw 포인터 Listener(TriggerTapDetector)를 쓰므로 포커스된 트리거에서 Enter/Space를 눌러도 열리지 않습니다.

패널 안쪽에는 방향키 이동 · Tab 순환 · Home/End 처리가 양 플랫폼 모두 없습니다.

포커스#

  • Flutter (modal: true) — 전용 FocusScopeNode를 만들고, post-frame에 requestFocus()포커스를 패널 안으로 옮깁니다.
  • Flutter (modal: false, 기본값) — scope 노드가 없어 실질적인 포커스 격리가 없습니다.
  • Web — 포커스 관리가 전혀 없습니다. 패널에 tabindex도, autofocus도, 포커스 트랩도 없고, 배경에 inert/aria-hidden도 걸리지 않습니다. backdrop 자체는 aria-hidden이지만 그 뒤의 페이지 콘텐츠는 그대로 탭 순서에 남아 있어, 열린 modal 팝오버 뒤로 Tab이 걸어 들어갑니다.

닫을 때 트리거로 포커스를 되돌리는 동작은 양 플랫폼 모두 없습니다.

스크린 리더#

  • Web — 트리거는 "dialog 팝업 있음 / 펼침·접힘"으로 읽힙니다. 열면 패널은 이름 없는 role="dialog"이고 포커스가 그리로 이동하지 않으므로, 사용자가 읽기 순서에서 직접 찾아가야 합니다. backdrop은 숨겨지지만 나머지 페이지는 계속 읽힙니다.
  • Flutter — 트리거는 역할도 팝업 힌트도 없이 펼침/접힘 상태만 읽힙니다. 패널은 이름도 역할도 없는 시맨틱 컨테이너입니다. modal: true이면 배경이 트리에서 제거되고 포커스가 패널로 들어가지만, 기본값 modal: false에서는 배경이 그대로 읽히고 포커스도 이동하지 않습니다.

알려진 제약#

  • Flutter는 패널에 dialog 역할을, 트리거에 팝업 힌트를 내보내지 않습니다(Web은 둘 다 내보냅니다).
  • Flutter는 기본 .click 트리거를 키보드로 열 수 없습니다.
  • Flutter의 dismissOnEscape는 이름과 달리 Escape를 켜지 않습니다(위 경고 참조).
  • Web에는 포커스 트랩·초기 포커스·포커스 복귀·배경 비활성화가 없습니다.
  • 어느 플랫폼도 패널에 접근 가능한 이름을 주지 않고, aria-controls로 트리거↔패널을 잇지 않으며, aria-modal을 설정하지 않습니다.
  • openTrigger: .hover는 양 플랫폼 모두 포인터 전용이라 포커스 대응이 없습니다. Web의 .focus 트리거는 tabindex 없는 래퍼 <div>버블링하지 않는 focus/blur를 바인딩합니다.

따라서 접근 가능한 이름과 포커스 관리가 필요한 용도(모달 대화 상자 등)에는 지금 이 컴포넌트만으로 충분하지 않습니다 — 이름은 content 안에서, 포커스 이동·복귀는 호출자 코드에서 직접 처리해야 합니다.

전역으로 보장되는 항목(동작 줄이기·고대비·색 강제 모드 등)은 전역 접근성 축을 참고하세요.

레거시 vs 통일 비교 (Migration Notes)#

Flutter 의 앵커드 오버레이(Popover·Tooltip·HoverCard·DropdownMenu·Menubar·FormField)는 전부 이 문서의 Popover 가 쓰는 것과 같은 단일 엔진(PopoverOverlayHandler)을 직접 호출합니다. 이전 버전의 CoUI 를 참조하는 코드에는 컴포넌트마다 갈아끼우는 중앙 OverlayManager + PopoverController 경유 방식이 남아 있을 수 있습니다.

항목 레거시 (중앙 OverlayManager + 컴포넌트별 핸들러) 통일 (PopoverOverlayHandler 직접 호출)
위치·dismiss·theme capture 로직컴포넌트마다 갈아끼우는 핸들러에 분산엔진 하나에 집중
신규 앵커드 컴포넌트 추가새 핸들러 작성 필요같은 엔진을 그대로 호출
public API PopoverController 동일 (PopoverController) — 사용자 코드 변경 불필요

이 사용자 API(PopoverController, Popover/Tooltip/HoverCard 생성자)는 두 방식 모두에서 동일합니다 — 엔진 통합은 내부 구현 정리이며 이 문서의 위 표는 라이브러리 유지보수 맥락을 이해하는 데 참고용입니다.

관련 컴포넌트#

  • Tooltip: 호버 시 짧은 텍스트 정보
  • Dialog: 모달 방식의 대화 상자
  • HoverCard: 호버 시 카드 형태 정보