컴포넌트

Dialog

현재 화면 위에서 입력이나 작업을 수행합니다.

모션

97.5% 크기에서 300ms 동안 부드럽게 나타나고, 200ms 동안 작아지며 사라집니다. 중심 위치는 유지합니다. 움직임 감소 설정에서는 전환을 생략합니다. 공통 모션 규칙을 따릅니다.

설치

pnpm dlx ply-ui add dialog button input field form layout

공통 설치 설정을 먼저 적용합니다.

사용법

현재 화면 위에서 입력하거나 작업할 때 사용합니다. Title로 목적을 설명하고 open과 onOpenChange로 열림을 제어합니다. 예제의 저장 동작은 로컬 상태만 변경합니다.

구성

구성 요소역할
Root열림 상태를 관리합니다. open과 onOpenChange로 제어할 수 있습니다.
Trigger대화상자를 여는 버튼입니다.
Portal대화상자와 배경을 별도 영역에 렌더합니다.
Backdrop뒤 화면을 덮는 배경입니다.
Popup내용과 포커스를 담습니다. size로 너비를 지정합니다.
Title대화상자의 접근 가능한 이름입니다.
Description대화상자의 목적이나 입력 안내입니다.
Body본문 여백을 구성합니다. Popup의 직접 자식으로 둡니다.
Footer하단 작업 버튼의 여백과 정렬을 구성합니다.
Close닫기 동작을 연결한 버튼입니다.
CloseAffordancePopup 오른쪽 위의 닫기 아이콘 버튼입니다.

크기 SM

Popup size="sm"의 최대 너비는 320px입니다. 화면이 좁으면 화면 너비에 맞게 줄어듭니다.

크기 MD

Popup size="md"의 최대 너비는 480px입니다. 화면이 좁으면 화면 너비에 맞게 줄어듭니다. 기본값입니다.

크기 LG

Popup size="lg"의 최대 너비는 640px입니다. 화면이 좁으면 화면 너비에 맞게 줄어듭니다.

닫기 아이콘

CloseAffordance는 오른쪽 위 닫기 버튼입니다. 기본 이름은 "닫기"입니다. 여러 단계가 있는 대화상자처럼 결과가 다를 수 있으면 aria-label로 구체적인 작업 이름을 지정합니다.

첫 포커스와 복귀 포커스

대화상자가 열리면 기본으로 안의 첫 번째 포커스 가능한 요소(대개 오른쪽 위 닫기 버튼)로 포커스가 갑니다. 이름 바꾸기처럼 입력이 목적이면 Popup의 initialFocus에 입력칸의 ref를 넘겨 바로 입력할 수 있게 합니다. 긴 본문을 읽어야 하는 대화상자는 제목 같은 읽기 시작점을 지정합니다.

닫힌 뒤에는 연 버튼으로 포커스가 돌아갑니다. 작업 결과로 그 버튼이 사라지거나 비활성이 되면 포커스가 페이지 처음(body)으로 빠지므로, finalFocus에 남아 있는 요소의 ref를 넘깁니다. 삭제 확인의 예는 Alert Dialog를 봅니다.

속성

이 프로젝트에서 추가하거나 제한한 속성입니다.

속성

타입

구현 코드

components/ui/dialog.tsx
dialog.tsx
"use client";

import type { ComponentProps } from "react";
import { Dialog as Primitive } from "@base-ui/react/dialog";
import { cx, withClassName } from "../../lib/cx";
import { Icon } from "./icon";
import { createThemedPortal } from "../../lib/create-themed-portal";
import "./dialog.css";

const ThemedPortal = createThemedPortal(Primitive.Portal);

// 동작과 접근성은 Base UI가 담당합니다. 이 파일은 스타일 연결만 담당합니다.
function DialogBackdrop({
  className,
  ...props
}: ComponentProps<typeof Primitive.Backdrop>) {
  return (
    <Primitive.Backdrop
      {...props}
      className={withClassName("rbx-backdrop", className)}
    />
  );
}

export type DialogPopupProps = ComponentProps<typeof Primitive.Popup> & {
  /** 대화상자 너비입니다. @defaultValue "md" */
  size?: "sm" | "md" | "lg";
};
function DialogPopup({ size = "md", className, ...props }: DialogPopupProps) {
  return (
    <Primitive.Popup
      {...props}
      data-size={size}
      className={withClassName("rbx-dialog", className)}
    />
  );
}

function DialogTitle({
  className,
  ...props
}: ComponentProps<typeof Primitive.Title>) {
  return (
    <Primitive.Title
      {...props}
      className={withClassName("rbx-dialog-title", className)}
    />
  );
}

function DialogDescription({
  className,
  ...props
}: ComponentProps<typeof Primitive.Description>) {
  return (
    <Primitive.Description
      {...props}
      className={withClassName("rbx-description", className)}
    />
  );
}

function DialogTrigger({
  className,
  ...props
}: ComponentProps<typeof Primitive.Trigger>) {
  return (
    <Primitive.Trigger
      {...props}
      className={withClassName("rbx-button", className)}
    />
  );
}

function DialogClose({
  className,
  ...props
}: ComponentProps<typeof Primitive.Close>) {
  return (
    <Primitive.Close
      {...props}
      className={withClassName("rbx-button", className)}
    />
  );
}

function DialogCloseAffordance({
  className,
  ...props
}: ComponentProps<typeof Primitive.Close>) {
  return (
    <Primitive.Close
      aria-label="닫기"
      {...props}
      className={withClassName("rbx-dialog-close-affordance", className)}
    >
      <Icon name="icon-regular-x" size={20} />
    </Primitive.Close>
  );
}
/** Body와 Footer를 사용하면 Popup이 여백을 중복해서 만들지 않습니다. */
function DialogBody({ className, ...props }: ComponentProps<"div">) {
  return <div {...props} className={cx("rbx-dialog-body", className)} />;
}
function DialogFooter({ className, ...props }: ComponentProps<"div">) {
  return <div {...props} className={cx("rbx-dialog-footer", className)} />;
}
// Root/Portal 등 스타일 없는 파트와 제네릭 API는 원본을 그대로 보존합니다.
export const Dialog = {
  ...Primitive,
  Portal: ThemedPortal,
  Body: DialogBody,
  CloseAffordance: DialogCloseAffordance,
  Footer: DialogFooter,
  Backdrop: DialogBackdrop,
  Popup: DialogPopup,
  Title: DialogTitle,
  Description: DialogDescription,
  Trigger: DialogTrigger,
  Close: DialogClose,
};
components/ui/dialog.css
dialog.css
@layer theme, base, components, utilities;
@layer components.ply-base, components.ply;
@import "./button.css";
@layer components.ply {
  /* Foundation Web Dialog: size controls width and 16/20px body/footer insets. */
  .rbx-dialog {
    --rbx-dialog-inset: 20px;
    max-width: calc(100vw - 24px);
  }
  .rbx-dialog[data-size="sm"] {
    width: min(320px, calc(100vw - 24px));
    --rbx-dialog-inset: 16px;
    padding: var(--rbx-dialog-inset);
  }
  .rbx-dialog[data-size="lg"] {
    width: min(640px, calc(100vw - 24px));
  }
  .rbx-dialog:has(> .rbx-dialog-body) {
    padding: 0;
  }
  .rbx-dialog-body {
    padding: var(--rbx-dialog-inset);
    min-width: 0;
    overflow-wrap: anywhere;
  }
  .rbx-dialog-footer {
    display: flex;
    justify-content: flex-end;
    gap: 8px;
    padding: 0 var(--rbx-dialog-inset) var(--rbx-dialog-inset);
    flex-wrap: wrap;
  }

  .rbx-dialog-title {
    font: var(--rbx-typography-heading-small-font);
    letter-spacing: var(--rbx-typography-heading-small-letter-spacing);
  }
  .rbx-dialog-close-affordance {
    position: absolute;
    top: 12px;
    inset-inline-end: 12px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 36px;
    height: 36px;
    padding: 8px;
    border: 0;
    border-radius: 9999px;
    background: var(--rbx-color-over-media-100);
    color: var(--rbx-color-content-emphasis);
    cursor: pointer;
  }
  .rbx-dialog-close-affordance:hover {
    background-image: linear-gradient(
      var(--rbx-color-state-hover),
      var(--rbx-color-state-hover)
    );
  }
  .rbx-dialog:has(.rbx-dialog-close-affordance) .rbx-dialog-title {
    padding-inline-end: 36px;
  }
}

목차