Dialog
다이얼로그/모달 컴포넌트
목차
사용자에게 확인, 입력, 정보를 요청하는 모달 다이얼로그 컴포넌트입니다.
기본 사용법 (Basic Usage)#
// 확인 다이얼로그
Dialog(
open: isDeleteDialogOpen,
title: Text('삭제 확인'),
content: Text('이 항목을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.'),
actions: [
Button(
variant: CoreButtonVariant.outline,
onPressed: handleCancel,
child: Text('취소'),
),
Button(
variant: CoreButtonVariant.destructive,
onPressed: handleDelete,
child: Text('삭제'),
),
],
onClose: handleCancel,
)
// 커스텀 다이얼로그
Dialog(
open: isEditDialogOpen,
title: Text('프로필 편집'),
content: Column(
children: [
TextField(label: '이름', onChanged: handleNameChange),
TextField(label: '이메일', onChanged: handleEmailChange),
],
),
actions: [
Button(
variant: CoreButtonVariant.ghost,
onPressed: handleCancel,
child: Text('취소'),
),
Button(
variant: CoreButtonVariant.primary,
onPressed: handleSave,
child: Text('저장'),
),
],
onClose: handleCancel,
)
// 확인 다이얼로그 (open 속성으로 상태 제어)
Dialog(
open: isDeleteDialogOpen,
title: Text('삭제 확인'),
content: Text('이 항목을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.'),
actions: [
Button(
variant: CoreButtonVariant.outline,
onPressed: handleCancel,
child: Text('취소'),
),
Button(
variant: CoreButtonVariant.destructive,
onPressed: handleDelete,
child: Text('삭제'),
),
],
onClose: handleCancel,
)
// 커스텀 콘텐츠 다이얼로그 (프로필 편집 폼)
Dialog(
open: isEditDialogOpen,
title: Text('프로필 편집'),
content: div([
TextField(label: '이름', onChanged: handleNameChange),
TextField(label: '이메일', onChanged: handleEmailChange),
]),
actions: [
Button(
variant: CoreButtonVariant.ghost,
onPressed: handleCancel,
child: Text('취소'),
),
Button(
variant: CoreButtonVariant.primary,
onPressed: handleSave,
child: Text('저장'),
),
],
onClose: handleCancel,
)
// 알림 다이얼로그 (단순 확인 버튼만)
Dialog(
open: isAlertOpen,
title: Text('오류'),
content: Text('네트워크 연결에 실패했습니다.'),
actions: [
Button(
variant: CoreButtonVariant.primary,
onPressed: handleClose,
child: Text('확인'),
),
],
onClose: handleClose,
)
Live Preview#
코드 보기
class DialogDefaultExample extends StatefulComponent {
const DialogDefaultExample({super.key});
@override
State<DialogDefaultExample> createState() => _DialogDefaultExampleState();
}
class _DialogDefaultExampleState extends State<DialogDefaultExample> {
bool _open = false;
@override
Component build(BuildContext context) {
return div([
Button(
variant: CoreButtonVariant.primary,
onPressed: () => setState(() => _open = true),
child: Text('Open Dialog'),
),
Dialog(
open: _open,
onClose: () => setState(() => _open = false),
title: Text('Are you sure?'),
content: Text('This action cannot be undone.'),
actions: [
Button(
variant: CoreButtonVariant.outline,
onPressed: () => setState(() => _open = false),
child: Text('Cancel'),
),
Button(
variant: CoreButtonVariant.primary,
onPressed: () => setState(() => _open = false),
child: Text('Continue'),
),
],
),
]);
}
}
class DialogDefaultExample extends StatefulWidget {
const DialogDefaultExample({super.key});
@override
State<DialogDefaultExample> createState() => _DialogDefaultExampleState();
}
class _DialogDefaultExampleState extends State<DialogDefaultExample> {
bool _open = false;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Button(
variant: CoreButtonVariant.primary,
onPressed: () => setState(() => _open = true),
child: const Text('Open Dialog'),
),
Dialog(
open: _open,
onClose: () => setState(() => _open = false),
title: const Text('Are you sure?'),
content: const Text('This action cannot be undone.'),
actions: [
Button(
variant: CoreButtonVariant.outline,
onPressed: () => setState(() => _open = false),
child: const Text('Cancel'),
),
Button(
variant: CoreButtonVariant.primary,
onPressed: () => setState(() => _open = false),
child: const Text('Continue'),
),
],
),
],
);
}
}
구조 (Anatomy)#
코드가 정본입니다. 부분 이름은 위젯 슬롯과 같고, Figma ❖ Dialog
레이어와도 맞습니다.
| 부분 | 역할 | 슬롯 |
|---|---|---|
leading | 제목 앞 | leading |
title | 제목 | title |
trailing | 제목 뒤 | trailing |
content | 본문 | content |
actions | 하단 액션 | actions |
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 사용자에게 중요한 확인을 요청할 때 (삭제, 저장 등 되돌리기 어려운 작업)
- 추가 정보를 입력받아야 할 때 (폼 다이얼로그)
- 중요한 알림이나 경고를 표시할 때
대신 다른 컴포넌트를 사용하세요:
- Window: 모달 응답 표면 대신 비모달 이동·크기 조절·다중 창이 필요할 때. 창 프레임만 조립하면 WindowPanel 입니다
- Toast: 모달 응답을 받지 않는 임시 오버레이 피드백일 때
- Drawer: 중앙 패널 대신 화면 가장자리에서 열리는 sheet 표면이 필요할 때
- Popover: 화면 중심 대신 특정 트리거에 앵커된 패널이 필요할 때
- Tooltip: 응답·액션 없이 짧은 도움말 문자열만 필요할 때
Props / Parameters#
W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. W?
는 선택 슬롯, List<W> 는 같은 플랫폼의 콘텐츠 목록입니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
open | bool | true | 다이얼로그 표시 여부 |
title | W? | null | 다이얼로그 제목 콘텐츠 |
content | W? | null | 본문 콘텐츠 |
leading |
W? |
null |
제목 앞 콘텐츠 (아이콘 등) |
trailing |
W? |
null |
제목 뒤 콘텐츠 (닫기 버튼 등) |
actions |
List<W>? |
null |
하단 액션 버튼 |
onClose |
VoidCallback? |
null |
닫기 콜백 |
dialogStyle |
CoreDialogStyle? |
null |
panel chrome / nested 슬롯 묶음 |
빠른 오버라이드 (Chain)#
이미 만든 Dialog 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class DialogChainExample extends StatefulWidget {
const DialogChainExample({super.key});
@override
State<DialogChainExample> createState() => _DialogChainExampleState();
}
class _DialogChainExampleState extends State<DialogChainExample> {
bool _open = false;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Button(
variant: CoreButtonVariant.primary,
onPressed: () => setState(() => _open = true),
child: const Text('Open Dialog'),
),
Dialog(
open: _open,
onClose: () => setState(() => _open = false),
title: const Text('Are you sure?'),
content: const Text('This action cannot be undone.'),
actions: [
Button(
variant: CoreButtonVariant.outline,
onPressed: () => setState(() => _open = false),
child: const Text('Cancel'),
),
Button(
variant: CoreButtonVariant.primary,
onPressed: () => setState(() => _open = false),
child: const Text('Continue'),
),
],
).withStyle(
const CoreDialogStyle(
panelBackgroundColor: CoreColor.token(
CoreColors.surfaceContainerHigh,
),
panelBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
panelPadding: CoreEdgeInsets.all(CoreSpace.space24),
sectionGapStyle: CoreGapStyle(size: CoreSpace.space12),
actionSpacing: CoreSpace.space12,
),
),
],
);
}
}
class DialogChainExample extends StatefulComponent {
const DialogChainExample({super.key});
@override
State<DialogChainExample> createState() => _DialogChainExampleState();
}
class _DialogChainExampleState extends State<DialogChainExample> {
bool _open = false;
@override
Component build(BuildContext context) {
return div([
Button(
variant: CoreButtonVariant.primary,
onPressed: () => setState(() => _open = true),
child: Text('Open Dialog'),
),
Dialog(
open: _open,
onClose: () => setState(() => _open = false),
title: Text('Are you sure?'),
content: Text('This action cannot be undone.'),
actions: [
Button(
variant: CoreButtonVariant.outline,
onPressed: () => setState(() => _open = false),
child: Text('Cancel'),
),
Button(
variant: CoreButtonVariant.primary,
onPressed: () => setState(() => _open = false),
child: Text('Continue'),
),
],
).withStyle(
const CoreDialogStyle(
panelBackgroundColor: CoreColor.token(
CoreColors.surfaceContainerHigh,
),
panelBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
panelPadding: CoreEdgeInsets.all(CoreSpace.space24),
sectionGapStyle: CoreGapStyle(size: CoreSpace.space12),
actionSpacing: CoreSpace.space12,
),
),
]);
}
}
스타일 시스템 (Style System)#
Dialog 의 panel chrome / 슬롯 미세 조정은 단일 dialogStyle
(CoreDialogStyle) 으로 흐릅니다. 동작 / 콘텐츠 슬롯은 위젯 파라미터 그대로.
Dialog(
open: isOpen,
title: Text('확인'),
content: Text('정말 삭제하시겠습니까?'),
actions: [
Button(variant: .outline, onPressed: cancel, child: Text('취소')),
Button(variant: .destructive, onPressed: confirm, child: Text('삭제')),
],
onClose: handleClose,
dialogStyle: CoreDialogStyle(
panelBackgroundColor: CoreColor.token(CoreColors.surface),
panelBorderRadius: CoreBorderRadius.all(CoreRadius.radius16),
panelPadding: CoreEdgeInsets.all(CoreSpace.space24),
surfaceBlur: 0,
surfaceOpacity: 1,
barrierColor: CoreColor.token(CoreColors.scrim),
barrierOpacity: CoreOpacity.opacity40,
titleStyle: CoreTextStyle.token(CoreTextStyles.titleLarge),
contentStyle: CoreTextStyle.token(
CoreTextStyles.bodyMedium,
color: CoreColor.token(CoreColors.onSurfaceVariant),
),
// actionButtonStyle 은 actions 안의 Button 이 자동 적용
// (위 actions 처럼 variant 직접 주입)
),
)
CoreDialogStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
panelBackgroundColor |
CoreColor? |
Panel background fill colour override. When null, defers to [defaultsByVariant]
background
(
surfaceBright
token).
|
panelBorderColor |
CoreColor? |
Panel border stroke colour override. When null, defers to [defaultPanelBorderColor] (
outlineSubtle
token).
|
panelBorderWidth |
double? |
Panel border width override (logical pixels). When null, defers to [defaultPanelBorderWidth] (1 logical px). |
panelBorderRadius |
CoreBorderRadius? |
Panel border radius override. When null, defers to [defaultPanelBorderRadius] (20 logical px uniform). |
panelPadding |
CoreEdgeInsets? |
Panel content padding override. When null, defers to [defaultPanelPadding] (24 above and on both sides, 20 below). |
panelMaxWidth |
double? |
Panel maximum width override (logical pixels). When null, defers to [defaultPanelMaxWidth] (
CoreLayout.layout640
, 640).
|
surfaceBlur |
double? |
Backdrop blur sigma applied behind the panel. |
surfaceOpacity |
double? |
Panel surface opacity multiplier (0.0 – 1.0). |
panelBoxShadow |
List<CoreShadowLayer>? |
Elevation shadow of the dialog panel. When null, defers to [defaultPanelBoxShadow] (
CoreShadow.xl
, the Modal/Dialog rung).
|
shadowBaseColor |
CoreColor? |
Base colour the [panelBoxShadow] layers tint against. When null, defers to [defaultShadowBaseColor] (
shadow
token).
|
barrierColor |
CoreColor? |
Backdrop barrier overlay colour override. When null, defers to [defaultsByVariant]
overlayColor
(
scrim
token).
|
barrierOpacity |
double? |
Backdrop (scrim) opacity multiplier override (0.0 – 1.0). When null, defers to [defaultBarrierOpacity] (0.6). |
sectionGapStyle |
CoreGapStyle? |
Gap slot between the dialog's head and body — header row / content — and inside the header row between leading / title / trailing. Forwarded straight to
Gap(gapStyle: …)
(Flutter) and to the inter-slot inline
gap
CSS (Web).
null
defers to [defaultSectionGapStyle] (14 logical px).
|
actionsGapStyle |
CoreGapStyle? |
Gap slot between the dialog's body and its actions row. Forwarded straight to
Gap(gapStyle: …)
(Flutter) and to the inline
gap
CSS above the footer (Web).
null
defers to [defaultActionsGapStyle] (20 logical px).
|
actionSpacing |
double? |
Gap between adjacent action buttons inside the actions row (logical pixels), in either [actionsLayout]. Rendered as the spacing of the actions row (Flutter — the
Wrap
at the end, the stretched row when stretched) / inline
column-gap
CSS on the footer row (Web, where the stretched share subtracts it too).
null
defers to [defaultActionSpacing] (8 logical px).
|
actionRunSpacing |
double? |
Gap between the rows the actions wrap onto once they no longer fit on one line (logical pixels), in either [actionsLayout] — the run spacing of the actions row (Flutter) / inline
row-gap
CSS on the wrapping footer (Web); two stretched actions that stack sit this far apart. One line of actions never reads it.
null
defers to [defaultActionRunSpacing] (8 logical px).
|
actionsLayout |
CoreDialogActionsLayout? |
How the actions row spreads its actions. [CoreDialogActionsLayout.end] keeps each action its own width at the panel's end; [CoreDialogActionsLayout.stretch] starts every action at the same share of the row so together they fill it — one action the whole row, two half each, and, when either does not fit its half, both stacked at the full width with [actionRunSpacing] between them (three or more: see [CoreDialogActionsLayout.stretch]). The order is always the order the actions were handed in.
null
defers to [defaultActionsLayout] ([CoreDialogActionsLayout.end]). A Flutter action list that asks for flex distribution itself (a
Flexible
/
Spacer
action) keeps the single
Row
it asked for in either layout.
|
contentClipMargin |
double? |
How far past its own box the body keeps painting before the dialog clips it (logical pixels), on every side. The body scrolls when it is taller than the room the route leaves, so it is a clipping region; this is how much of what the body paints outside its box — a focus ring, a cast — survives the clip. Both platforms clip at the same place whether or not the body scrolls. A value larger than the gaps around the body lets scrolled text show over the title or the actions.
null
defers to [defaultContentClipMargin] (4 logical px, the focus outline's reach).
|
routePadding |
double? |
The room the dialog route leaves between the panel and the edges of the screen, in logical pixels — one length on every side.
null
defers to [defaultRoutePadding] (16 logical px). Each platform puts it where its panel can meet an edge: Flutter pads the route by it (inside the safe area, on all four sides — nothing in the route is laid out past it), Web caps the centred panel's width at the viewport less it on both sides. Spacing either way — a theme density moves a stated value too, the way it moves every padding. A full-screen dialog has no room around it, and ignores this.
|
transitionDuration |
Duration? |
Open/close transition duration override. When null, defers to [defaultTransitionDuration] (200 ms). |
scaleAnimationStart |
double? |
Panel open-animation start scale override. When null, defers to [defaultScaleAnimationStart] (0.9). |
scaleAnimationEnd |
double? |
Panel open-animation end scale override. When null, defers to [defaultScaleAnimationEnd] (1.0). |
titleStyle |
CoreTextStyle? |
Title text style override. |
contentStyle |
CoreTextStyle? |
Content (body) text style override. |
actionButtonStyle |
CoreButtonStyle? |
Action button style override. To change an action button's
variant
, inject the button widget directly via
Dialog(actions: [Button(...)])
rather than packing variant into this style — 원칙 8 (asChild pattern).
No default, and must not have one.
Null means the actions the caller handed in are rendered as they were handed in: Web branches on
when resolved.actionButtonStyle != null
and otherwise emits
action
untouched, taking no copy. A constant would rebuild every action
Button
in every dialog with dialog-level chrome merged underneath its own. And the value it would have to carry does not exist as a constant. A
Button
's baseline chrome is per-variant (
CoreButtonStyle.defaultsByVariant
), and anything named here outranks that table inside
Button
's resolver — so one constant would repaint a
.ghost
Cancel and a filled Save the same, which is the exact opposite of what the two buttons are for.
|
asChild 패턴 — action 버튼 변경#
action 버튼의 시맨틱 (variant) 변경이 필요할 때는 actions 슬롯에
직접 위젯을 주입합니다:
Dialog(
actions: [
Button(variant: .outline, child: Text('취소')), // ← variant 직접
Button(variant: .destructive, child: Text('삭제')),
],
)
dialogStyle.actionButtonStyle 은 chrome 미세 조정용 (paddingH /
labelStyle 등).
Resolve chain#
CoreDialogStyle.defaultX / defaultsVariant (static const, 단일 출처)
→ CoreDialogTheme.style // 프로젝트 공통
→ widget.dialogStyle // 인스턴스별
변형 (Variants)#
알림 다이얼로그#
Dialog(
open: isAlertOpen,
title: Text('오류'),
content: Text('네트워크 연결에 실패했습니다.'),
actions: [
Button(
variant: CoreButtonVariant.primary,
onPressed: handleClose,
child: Text('확인'),
),
],
onClose: handleClose,
)
아이콘 포함#
Dialog(
open: isOpen,
leading: Icon(LucideIcons.triangleAlert),
title: Text('경고'),
content: Text('이 작업은 취소할 수 없습니다.'),
onClose: handleClose,
)
모바일 확인창#
휴대폰 확인창처럼 행동 버튼이 행의 폭을 채우게 하려면 dialogStyle 에 actionsLayout: CoreDialogActionsLayout.stretch
를 줍니다. 행동이 하나면 행 전체를, 둘이면 받은 순서대로 반씩 차지합니다. 반에 들어가지 않는 행동이 있을 때의 배치는 아래 긴 본문과 행동 배치에 있습니다. 기본값
CoreDialogActionsLayout.end 는 각 행동을 자기 폭으로 패널 끝에 붙입니다.
코드 보기
class DialogStretchExample extends StatefulComponent {
const DialogStretchExample({super.key});
@override
State<DialogStretchExample> createState() => _DialogStretchExampleState();
}
class _DialogStretchExampleState extends State<DialogStretchExample> {
bool _open = false;
@override
Component build(BuildContext context) {
return div([
Button(
variant: CoreButtonVariant.primary,
onPressed: () => setState(() => _open = true),
child: Text('Open Dialog'),
),
Dialog(
open: _open,
onClose: () => setState(() => _open = false),
title: Text('Connection lost'),
content: Text('Check your network and try again.'),
actions: [
Button(
variant: CoreButtonVariant.outline,
onPressed: () => setState(() => _open = false),
child: Text('Cancel'),
),
Button(
variant: CoreButtonVariant.primary,
onPressed: () => setState(() => _open = false),
child: Text('Retry'),
),
],
// 두 행동이 행의 폭을 반씩 나눈다.
// 패널 폭은 휴대폰 확인창에 맞춘다.
dialogStyle: const CoreDialogStyle(
panelMaxWidth: CoreLayout.layout320,
actionsLayout: CoreDialogActionsLayout.stretch,
),
),
]);
}
}
class DialogStretchExample extends StatefulWidget {
const DialogStretchExample({super.key});
@override
State<DialogStretchExample> createState() => _DialogStretchExampleState();
}
class _DialogStretchExampleState extends State<DialogStretchExample> {
bool _open = false;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Button(
variant: CoreButtonVariant.primary,
onPressed: () => setState(() => _open = true),
child: const Text('Open Dialog'),
),
Dialog(
open: _open,
onClose: () => setState(() => _open = false),
title: const Text('Connection lost'),
content: const Text('Check your network and try again.'),
actions: [
Button(
variant: CoreButtonVariant.outline,
onPressed: () => setState(() => _open = false),
child: const Text('Cancel'),
),
Button(
variant: CoreButtonVariant.primary,
onPressed: () => setState(() => _open = false),
child: const Text('Retry'),
),
],
// 두 행동이 행의 폭을 반씩 나눈다.
// 패널 폭은 휴대폰 확인창에 맞춘다.
dialogStyle: const CoreDialogStyle(
panelMaxWidth: CoreLayout.layout320,
actionsLayout: CoreDialogActionsLayout.stretch,
),
),
],
);
}
}
Dialog(
open: isRetryDialogOpen,
title: Text('연결이 끊겼습니다'),
content: Text('네트워크를 확인한 뒤 다시 시도해 주세요.'),
actions: [
Button(
variant: CoreButtonVariant.outline,
onPressed: handleCancel,
child: Text('취소'),
),
Button(
variant: CoreButtonVariant.primary,
onPressed: handleRetry,
child: Text('다시 시도'),
),
],
onClose: handleCancel,
dialogStyle: CoreDialogStyle(
actionsLayout: CoreDialogActionsLayout.stretch,
),
)
Dialog(
open: isRetryDialogOpen,
title: Text('연결이 끊겼습니다'),
content: Text('네트워크를 확인한 뒤 다시 시도해 주세요.'),
actions: [
Button(
variant: CoreButtonVariant.outline,
onPressed: handleCancel,
child: Text('취소'),
),
Button(
variant: CoreButtonVariant.primary,
onPressed: handleRetry,
child: Text('다시 시도'),
),
],
onClose: handleCancel,
dialogStyle: CoreDialogStyle(
actionsLayout: CoreDialogActionsLayout.stretch,
),
)
-
미리보기는 넓은 화면에서도 휴대폰 확인창으로 보이도록
panelMaxWidth: CoreLayout.layout320을 함께 줍니다. 휴대폰에서는 화면 폭이 패널 폭을 정하므로 따로 줄 필요가 없습니다 -
프로젝트의 모든 다이얼로그에 켜려면 테마에서
CoreDialogTheme(style: CoreDialogStyle(actionsLayout: CoreDialogActionsLayout.stretch))로 한 번 지정합니다. 한 다이얼로그만이면 위처럼dialogStyle로 지정합니다
동작 스펙 (Behavior)#
열기/닫기#
- 양 플랫폼 모두
openboolean 으로 표시 여부를 제어하고, 닫힘은onClose콜백으로 통보합니다 -
Flutter:
open이 true 가 되면 내부적으로 dialog route 를 push, false 가 되면 자기가 연 route 를 닫습니다 — 그 위에 다른 페이지나 다이얼로그가 열려 있어도 그것은 그대로 두고 이 다이얼로그만 빠집니다.Dialog를 그리던 위젯이 트리에서 사라질 때도 같습니다 - Web:
open이 true 일 때만OverlayHost.dialog레이어에 패널을 마운트
긴 본문과 행동 배치#
-
본문이 화면보다 길면 본문만 스크롤됩니다. 제목 줄과 행동 줄은 제자리에 있고, 패널은 화면에서 네 변을
routePadding만큼 띄운 높이에서 멈춥니다. 짧은 본문은 스크롤 영역이 되어도 이전과 같은 자리·크기로 그려집니다 -
스크롤 영역은 본문 상자 밖으로
contentClipMargin(기본 4) 까지 그린 것을 남기고 잘라냅니다 — 본문 가장자리에 붙은 입력 필드가 포커스돼도 링(필드 밖 2 + 굵기 2) 이 잘리지 않는 거리입니다. 두 플랫폼이 스크롤 여부와 상관없이 같은 자리에서 자릅니다 -
행동이 한 줄에 들어가지 않으면 다음 줄로 넘어갑니다. 기본 배치(
actionsLayout: end)에서는 줄마다 끝에 붙고 세로로는 가운데에 맞추며, 줄 사이는actionRunSpacing(기본 8,actionSpacing과 같은 길이) 입니다 -
actionsLayout: stretch에서는 행동이 행의 폭을 채웁니다. 모든 행동이 같은 몫(행 폭에서 행동 사이 간격을 뺀 길이를 행동 수로 나눈 폭)에서 시작합니다. 행동이 하나면 행 전체를 차지하고, 둘이 각자 반에 들어가면 받은 순서대로 반씩 나눕니다. 둘 중 하나라도 반에 들어가지 않으면 둘 다 전체 폭으로 세로로 쌓이고 줄 사이는actionRunSpacing입니다 — 순서는 받은 그대로라 먼저 준 보조 행동이 위, 주요 행동이 아래입니다. 셋 이상이면 한 줄에 들어가는 만큼 담고 나머지는 다음 줄로 넘어가며, 줄마다 그 줄의 폭을 채웁니다 — 그 줄에서 자기 몫보다 넓은 행동은 자기 폭을 지키고, 남은 폭을 나머지가 나눕니다. 행동 사이 간격은 두 배치 모두actionSpacing입니다 -
Flutter 에서 행동 목록에
Flexible/Spacer가 있으면 그 목록이 배분을 스스로 정하므로,actionsLayout과 상관없이 한 줄Row로 그립니다
스크림 (배경 오버레이)#
- 다이얼로그가 열리면 뒤쪽에 반투명 배경이 표시되어 배경 콘텐츠와 분리
- 스크림 색·불투명도는
dialogStyle.barrierColor/barrierOpacity로 조정 -
스크림 클릭 / Escape 로 닫기는
onClose가 있을 때만 활성화됩니다 —onClose를 주지 않으면 dismiss 경로가 없는 다이얼로그가 됩니다
포커스 관리#
- Flutter: dialog route 가
FocusScope로 감싸져 배경 위젯이 traversal 에서 빠집니다 -
Web: 정상
OverlayHost에 modal로 마운트되어 앱 배경에inert를 붙입니다. 별도의 패널 내부 순환 트랩·초기 포커스·복원 코드는 없으며, 마지막 요소의 Tab은 브라우저 UI로 나갈 수 있습니다. 호스트 없는 인라인 fallback에는 배경 격리가 없습니다
애니메이션#
-
양 플랫폼 모두
dialogStyle.transitionDuration과scaleAnimationStart/scaleAnimationEnd를 씁니다 - Flutter:
ScaleTransition+FadeTransition - Web: CSS 트랜지션
Overlay 마운트#
-
Flutter: 루트
Overlay에 마운트되어 ancestorClipRect/Transform영향 없이 항상 최상위에 표시 -
Web:
OverlayHost.dialog레이어로 portal —position: fixed기반이라 ancestoroverflow: hidden/transform컨테이너 안에Dialog를 배치해도 잘리거나 좌표 어긋남이 없음. 같은 페이지에 popover / menu / tooltip 이 열려있어도 z-순서상 dialog 가 그 위에 위치
사용 가이드라인 (Usage Guidelines)#
✅ Do#
위험한 작업은 결과를 명확히 설명하세요.
Dialog(
open: isOpen,
title: Text('계정 삭제'),
content: Text('계정을 삭제하면 모든 데이터가 영구적으로 제거됩니다. 이 작업은 취소할 수 없습니다.'),
actions: [
Button(variant: CoreButtonVariant.outline, onPressed: handleCancel, child: Text('취소')),
Button(variant: CoreButtonVariant.destructive, onPressed: handleDeleteAccount, child: Text('계정 삭제')),
],
onClose: handleCancel,
)
사용자가 결과를 충분히 이해한 뒤 결정할 수 있습니다.
❌ Don't#
모호한 메시지로 확인을 요청하지 마세요.
Dialog(
open: isOpen,
title: Text('확인'),
content: Text('진행하시겠습니까?'),
actions: [
Button(variant: CoreButtonVariant.primary, onPressed: handleDeleteAccount, child: Text('확인')),
],
onClose: handleCancel,
)
무엇이 진행되는지 알 수 없어 실수로 위험한 작업을 실행할 수 있습니다.
✅ Do#
다이얼로그는 간결하게 유지하세요.
Dialog(
open: isOpen,
title: Text('변경사항 저장'),
content: Text('저장하지 않은 변경사항이 있습니다. 저장하시겠습니까?'),
actions: [
Button(variant: CoreButtonVariant.outline, onPressed: handleDiscard, child: Text('저장하지 않음')),
Button(variant: CoreButtonVariant.primary, onPressed: handleSave, child: Text('저장')),
],
onClose: handleDiscard,
)
하나의 결정에 집중하면 사용자가 빠르게 판단할 수 있습니다.
❌ Don't#
다이얼로그에 과도한 내용을 넣지 마세요.
Dialog(
open: isOpen,
title: Text('설정'),
content: ComplexSettingsForm(), // 긴 폼, 여러 탭
onClose: handleClose,
)
복잡한 내용은 별도 페이지나 Drawer를 사용하세요. 다이얼로그는 간단한 확인/입력에 적합합니다.
✅ Do#
닫을 수 있는 방법을 항상 제공하세요.
Dialog(
open: isOpen,
title: Text('알림'),
content: Text('작업이 완료되었습니다.'),
actions: [
Button(variant: CoreButtonVariant.primary, onPressed: handleClose, child: Text('확인')),
],
onClose: handleClose,
)
ESC 키, 스크림 클릭, 닫기 버튼 중 하나 이상의 닫기 방법이 있어야 합니다.
❌ Don't#
닫기 방법 없이 다이얼로그를 표시하지 마세요.
Dialog(
open: isOpen,
title: Text('알림'),
content: Text('작업이 완료되었습니다.'),
// actions 없음, onClose 없음 — 닫을 수 없음
)
사용자가 다이얼로그에 갇혀 앱을 사용할 수 없게 됩니다.
접근성 (Accessibility)#
역할#
-
Flutter: 패널에
Semantics가 하나도 없습니다 — 다이얼로그임을 알리는 것은 위젯이 아니라 모달 라우트 자체(showDialog→DialogRoute extends RawDialogRoute)입니다. 배리어는CoUILocalizations.of(context).dismissLabel(ko: "닫기")을 이름으로 갖습니다. -
Web: 패널에
role="dialog"+aria-modal="true"를 emit 합니다. 이 두 키는 callerattributes뒤에 놓여 컴포넌트가 고정하지만, 나머지aria-*는 그대로 통과합니다.
키보드#
| 키 | 동작 |
|---|---|
Escape |
다이얼로그 닫기 —
onClose 를 준 경우에만
. Web 은
onClose
가 있을 때만 document keydown 리스너를 등록하고, Flutter 는
barrierDismissible: onClose != null
이 프레임워크의
DismissIntent
를 켭니다
|
Tab / Shift+Tab | 다이얼로그 안 요소 간 이동 |
Enter / Space | 포커스된 버튼 활성화 |
화살표 키 처리는 없습니다 — actions 버튼 사이 이동은 Tab 뿐입니다. (Flutter 네이티브는 프레임워크 기본 단축키가 화살표를 방향 포커스 이동에 쓰지만 Flutter Web 에서는 스크롤로 가므로, 화살표에 의존하지 마세요.)
스크린 리더#
-
접근 가능한 이름이 없습니다. 양 플랫폼 모두
aria-labelledby/aria-describedby에 해당하는 배선이 없어title/content는 시각적 제목·본문일 뿐 다이얼로그의 이름·설명으로 프로그래밍적으로 연결되지 않습니다. Web 은Dialog(attributes: {'aria-label': …})로 caller 가 직접 붙일 수 있고(role / aria-modal 만 컴포넌트가 고정), Flutter 에는 대응 진입점이 없습니다. -
배경은 양쪽 모두 가려집니다. Flutter 는 모달 라우트의
ModalBarrier가BlockSemantics로 배경을 접근성 트리에서 뺍니다. Web 은modal: true로 마운트된 오버레이가 열려 있는 동안 앱 콘텐츠 서브트리에inert가 붙어 접근성 트리·탭 순서·히트 테스트에서 함께 빠집니다. - 상태 통보(live region)는 없습니다 — 열림/닫힘 자체는 통보되지 않습니다.
포커스 관리#
-
진입 — Flutter: 모달 라우트가 자기 포커스 스코프를 첫 포커스로 세우므로 포커스가 다이얼로그 안으로 들어갑니다. Web: 포커스를 옮기는 코드가
없습니다(오버레이 경로 전체에
focus()호출 0건). 대신 배경이inert가 되어 직전 포커스 요소가 탭 순서에서 빠집니다. - 이탈 — Flutter: 라우트가 pop 되면서 포커스가 이전 스코프로 돌아갑니다. Web: 트리거로 포커스를 되돌리는 코드가 없습니다.
-
트랩 — Flutter:
traversalEdgeBehavior: closedLoop라Tab이 다이얼로그 안에서 순환합니다. Web: 배경이inert라 배경 요소로 넘어가지는 않지만 패널 안에서 순환하지도 않습니다 — 마지막 요소에서Tab은 브라우저 UI 로 나갔다가 패널로 돌아옵니다(의도된 동작).
알려진 제약#
-
접근 가능한 이름을 잇는 파라미터가 없습니다. Web 은
attributes로 우회할 수 있으나 Flutter 는 우회 경로도 없습니다. - Web 은 열 때 포커스를 옮기지 않고 닫을 때 되돌리지도 않습니다. 첫 요소를 자동으로 잡아야 한다면 호출부에서 직접 처리해야 합니다.
-
onClose를 주지 않으면 Escape 도 스크림 클릭도 동작하지 않습니다 — 닫기 버튼을trailing/actions에 반드시 두세요. -
호스트 오버레이 밖에서는
inert배경 차단이 적용되지 않습니다.CoUIWeb/OverlayHost가 조상에 없으면(SSR·테스트 fallback) 패널이 호출 위치에 인라인 렌더되고 배경은 계속 탭·읽기 가능합니다.
전역으로 적용되는 축(동작 줄이기·고대비·색 강제 모드·최소 터치 타겟)은 전역 접근성 축에 있습니다.
크로스 플랫폼 차이점 (Platform Differences)#
파라미터 이름·순서·기본값은 양 플랫폼 동일합니다. 아래는 렌더 엔진 차이만 나열합니다.
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | Dialog | Dialog |
| 제목 | title: Widget? | title: Component? |
| 내용 | content: Widget? | content: Component? |
| 액션 | actions: List<Widget>? |
actions: List<Component>? |
| 스크림 | modal route 의 배리어 | 배경 <div> + 클릭 핸들러 |
| 포커스 | FocusScope 의 closed-loop traversal |
호스트의 배경 inert; 패널 내부 순환·복원 없음 |
| 애니메이션 | ScaleTransition + FadeTransition | CSS 트랜지션 |
| 긴 본문 |
Flexible
안의
SingleChildScrollView
— 클립을 본문 상자에서
contentClipMargin
만큼 부풀린
ClipRect
가 맡음
|
overflow-y: auto
스크롤 컨테이너 — 음수
margin
과 같은 길이의
padding
으로 클립 경계를
contentClipMargin
만큼 넓힘
|
| 패널 최대 높이 | route 가 safe area 안에서 네 변을 routePadding 만큼 띄움 |
max-height: calc(100% - 2 × routePadding) |
| 행동 줄바꿈 |
Wrap(alignment: .end, crossAxisAlignment: .center)
— 행동에
Flexible
/
Spacer
가 있으면 그 배분을 위해 한 줄
Row
를 유지
|
flex-wrap + justify-content: flex-end + align-items: center |
행을 채우는 행동 (stretch) |
행동을 놓는 레이아웃 렌더 오브젝트가 Core 의 공유 규칙
CoreDialogActionsStretch
로 행동마다 줄 · 위치 · 폭을 정함. 행동에
Flexible
/
Spacer
가 있으면
stretch
에서도 한 줄
Row
를 유지 (Web 의 행동에는 배분 계수가 없어 Flutter 에만 있는 경로)
|
같은
flex-wrap
footer 안에서 행동마다 칸 하나 —
flex-basis
는 같은 몫,
min-width: fit-content
,
flex-grow: 1
— 줄 · 위치 · 폭은 브라우저가 정함
|
| 스크롤바 | 앱의 ScrollBehavior 가 그리는 스크롤바 (본문 위에 겹침) |
브라우저 스크롤바 (넘칠 때 자리를 차지) |
| 오버레이 마운트 | dialog route (루트 Navigator) |
OverlayHost.dialog 레이어 portal — popover / menu / tooltip 위에 위치 |
| ARIA | FocusScope + modal route |
role="dialog", aria-modal="true" |
stretch 는 두 플랫폼이 서로 다른 방법으로 배치하므로, 입력과 기대 배치를 적은 표 하나를 Core 테스트 · Flutter 테스트 · 실제 Chrome 측정이 함께 읽어 같은 자리에 놓이는지 대조합니다.
stretch 에서 두 플랫폼이 다르게 그리는 자리 — 표가 플랫폼별 기대값으로 고정하거나, 아직 맞추지 못한 것입니다.
-
행보다 좁아질 수 없는 행동. Web 은 CSS 대로 그 행동을 행보다 넓게 두어 시작 쪽으로 넘치게 하고, Flutter 는 자식을 부모보다 넓게 놓지 않으므로 행 폭에 맞춰 줄입니다. 표가 이 차이를 Flutter 기대값(
flutterCells)으로 따로 적어 둡니다. 글자가 줄바꿈되는 보통의 버튼은 두 플랫폼 모두 행 안에서 줄바꿈되므로 이 차이가 나지 않습니다. -
Flutter 의 행동은 크기를 물어볼 수 있어야 합니다. 행동마다 자연 폭과 가장 좁은 폭을 재서 놓기 때문에, 크기 질의에 답하지 않는 위젯(
LayoutBuilder등)을 행동으로 넣으면 디버그 빌드에서 assert 가 납니다.end는 재지 않으므로 그대로 그립니다. -
트리거로 감싼 행동 (Web).
Tooltip·Popover·HoverCard로 감싼 버튼은 Web 에서 칸 폭을 다 채우지 못하고 자기 폭으로 남습니다 — 칸은 바로 아래 자식만 늘리는데, 트리거가 내용 폭의inline-flex로 버튼을 한 번 더 감싸기 때문입니다. Flutter 는 폭 제약이 트리거를 지나 버튼까지 내려가 칸을 채웁니다. -
아이콘이 있는 버튼 (기존 Button 차이).
leading/trailing이 있는 버튼을 넓게 늘리면 Flutter 는 내용을 시작 쪽에, Web 은 가운데에 둡니다.stretch가 만든 차이가 아니라 넓은 Button 의 기존 차이이며,BottomActionBar의 가로 배치에서도 같습니다.
관련 컴포넌트 (Related Components)#
- Drawer: 화면 측면에서 슬라이드되는 패널. 복잡한 내용이나 긴 폼에 적합
- Toast: 간단한 알림 메시지. 사용자 확인이 필요 없는 피드백에 사용
- Popover: 특정 요소에 부착되는 작은 오버레이. 간단한 추가 정보 표시에 적합
조합 예제#
// 삭제 확인 패턴
Button(
variant: CoreButtonVariant.destructive,
onPressed: () => setState(() => _isDeleteDialogOpen = true),
child: Text('삭제'),
)
// 다이얼로그 (위젯 트리에 배치)
Dialog(
open: _isDeleteDialogOpen,
title: Text('항목 삭제'),
content: Text('선택한 ${items.length}개 항목을 삭제하시겠습니까?'),
actions: [
Button(
variant: CoreButtonVariant.outline,
onPressed: () => setState(() => _isDeleteDialogOpen = false),
child: Text('취소'),
),
Button(
variant: CoreButtonVariant.destructive,
onPressed: () {
handleDelete();
setState(() => _isDeleteDialogOpen = false);
},
child: Text('삭제'),
),
],
onClose: () => setState(() => _isDeleteDialogOpen = false),
)