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

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#

구조 (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> 는 같은 플랫폼의 콘텐츠 목록입니다.

속성타입기본값설명
openbooltrue다이얼로그 표시 여부
titleW?null다이얼로그 제목 콘텐츠
contentW?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 는 각 행동을 자기 폭으로 패널 끝에 붙입니다.

Web
Flutter
Loading Flutter...
코드 보기
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)#

열기/닫기#

  • 양 플랫폼 모두 open boolean 으로 표시 여부를 제어하고, 닫힘은 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 에 마운트되어 ancestor ClipRect / Transform 영향 없이 항상 최상위에 표시
  • Web: OverlayHost.dialog 레이어로 portal — position: fixed 기반이라 ancestor overflow: 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 합니다. 이 두 키는 caller attributes 뒤에 놓여 컴포넌트가 고정하지만, 나머지 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)#

파라미터 이름·순서·기본값은 양 플랫폼 동일합니다. 아래는 렌더 엔진 차이만 나열합니다.

항목FlutterWeb
클래스명DialogDialog
제목title: Widget?title: Component?
내용content: Widget?content: Component?
액션 actions: List<Widget>? actions: List<Component>?
스크림modal route 의 배리어배경 <div> + 클릭 핸들러
포커스 FocusScope 의 closed-loop traversal 호스트의 배경 inert; 패널 내부 순환·복원 없음
애니메이션ScaleTransition + FadeTransitionCSS 트랜지션
긴 본문 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 의 가로 배치에서도 같습니다.
  • 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),
)