Popup#
메뉴·드롭다운·툴팁처럼 떠 있는 콘텐츠를 담는 chrome 스타일 컨테이너입니다. 보더, 그림자, 둥근 모서리, 배경을 가진 패널 안에 자식을 배치합니다.
Popup은 레거시 Flutter MenuPopup과 Web Popup을 통일한 컴포넌트입니다.
Flutter와 Web이 동일한 파라미터 이름·동작·시각을 갖습니다.
Live Preview#
class PopupDefaultExample extends StatelessComponent {
const PopupDefaultExample({super.key});
@override
Component build(BuildContext context) {
return 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}',
),
],
);
}
}
class PopupDefaultExample extends StatelessWidget {
const PopupDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return 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,
),
],
);
}
}
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',
);
}
}
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,
),
),
),
],
);
}
}
사용 시기 (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#
Popup#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
children | List<W> | 필수 | 패널 내부에 배치되는 콘텐츠 |
open | bool | true | 패널 표시 여부 |
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 컨테이너입니다 — 위치 계산이나 외부 클릭 닫기 같은
오버레이 동작은 포함하지 않습니다. open이 false면 아무것도 렌더링하지
않습니다. 위치 계산이 필요하면 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),
),
],
)
Popup은 IntrinsicWidth로 콘텐츠 크기에 맞춰 패널을 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자신은 포커스를 옮기지 않습니다. modalPopover로 감쌌을 때만 오버레이가 패널 scope 에 포커스를 줍니다(Flutter). - 이탈 — 트리거로 되돌리는 코드 경로가 양 플랫폼 어디에도 없습니다.
-
트랩 —
Popup자신에는 없습니다. Flutter 에서 modal 오버레이로 감싸면 오버레이의FocusScope가Tab을 패널 안에 가둡니다.
알려진 제약#
- 패널을 닫은 뒤 포커스가 어디로 가는지 컴포넌트가 보장하지 않습니다.
- Flutter 는 패널 역할을 알리지 않습니다.
크로스 플랫폼 차이점 (Platform Differences)#
Popup은 Flutter와 Web이 동일한 children / open / direction
/
popupStyle API와 동작을 갖습니다. Flutter는 DecoratedBox
+ ClipRRect,
Web은 inline CSS로 동일한 보더 / 그림자 / 둥근 모서리를 렌더링합니다.
관련 컴포넌트 (Related Components)#
- Popover: anchor 위치 계산이 있는 팝오버
- DropdownMenu: 액션 목록 드롭다운
- Tooltip: 짧은 힌트 툴팁