임동욱 임동욱 08-18
feat: FoxModal 추가
디자인시스템 파일의 modal(190:1136)을 옮겼다. 크기 넷(sm 400 · md 560 · lg 760 · full)이
모두 폭 토큰이고, 모바일에서는 넷 다 같은 폭으로 모이며 여백·모서리·닫기 버튼도 함께
줄어든다 — 반응형 토큰이 그렇게 정의돼 있다.

네이티브 ``를 `showModal()`로 띄운다. 초점 가두기·Esc·뒷배경·맨 위 레이어를
브라우저가 처리하므로 직접 구현하지 않는다. 다만 Esc와 바깥 클릭에도 모달이 스스로 닫지
않고 `onClose`만 부른다 — 닫을지는 호출부가 정한다(저장하지 않은 입력이 있을 때 막아야
하는 경우가 있다).

제목 줄에만 오른쪽 여백을 준다 — 닫기 버튼이 그 자리에 겹쳐 앉기 때문이다. 닫기 아이콘은
64px 칸의 아래쪽에 붙어 제목 글줄과 눈높이가 맞는다(시안 items-end).

작업 중 잡은 것: `display: flex`를 그냥 주면 ``가 닫혀 있을 때 브라우저가 숨기는
기본값을 덮어, 닫힌 모달이 페이지에 그대로 남는다. 열렸을 때만 배치를 주도록 고쳤다.

검증: 폭 560 · 여백 40 · 모서리 24 · 그림자 8px8px16px + 2px2px8px · 제목 20px w800 ·
닫기 칸 64×64가 시안과 일치(15항목). 열면 `:modal`로 잡히고 초점이 안에 갇힌다.
닫힌 모달 7개가 모두 화면에서 빠진다. sass 652규칙 · tsc · eslint 통과.

Co-Authored-By: Claude Opus 5 
@c3b83647781eb455786bba41c56ea830075fef54
 
@fox/core/components/fox-modal/fox-modal.tsx (added)
+++ @fox/core/components/fox-modal/fox-modal.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import { useEffect, useRef, type ReactNode } from "react";
+import { cx } from "../../utils";
+import { FoxXIcon } from "../../icons";
+
+export type FoxModalSize = "sm" | "md" | "lg" | "full";
+
+export interface FoxModalProps {
+  /** 열림 여부. 호출부가 소유한다 — 모달이 스스로 닫지 않는다. */
+  open: boolean;
+  /** 닫기 버튼·Esc·바깥 클릭에서 부른다. */
+  onClose: () => void;
+  children?: ReactNode;
+  size?: FoxModalSize;
+  /** 넘기지 않으면 제목 줄을 그리지 않는다. */
+  title?: ReactNode;
+  /** 아래쪽 오른편. 보통 버튼 묶음이다. */
+  actions?: ReactNode;
+  /** 아래쪽 왼편. 시안은 "오늘 하루 보지 않기" 같은 체크박스 자리다. */
+  utility?: ReactNode;
+  /** 닫기 버튼을 감춘다. */
+  closable?: boolean;
+  closeLabel?: string;
+  /** 바깥을 눌러도 닫히지 않게 한다. */
+  dismissible?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+}
+
+/**
+ * @fox 모달. 네이티브 `<dialog>`를 쓴다 — 초점 가두기·Esc 닫기·뒷배경·맨 위 레이어를
+ * 브라우저가 처리하므로 그걸 직접 구현하지 않는다.
+ *
+ * 열림 여부는 호출부가 소유한다. 사용자가 Esc를 누르거나 바깥을 눌러도 모달이 스스로
+ * 닫지 않고 `onClose`만 부른다 — 닫을지 말지는 호출부가 정한다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxModal({
+  open,
+  onClose,
+  children,
+  size = "md",
+  title,
+  actions,
+  utility,
+  closable = true,
+  closeLabel = "닫기",
+  dismissible = true,
+  className,
+}: FoxModalProps) {
+  const ref = useRef<HTMLDialogElement>(null);
+
+  // `open` 속성만 세우면 뒷배경도 초점 가두기도 없는 그냥 상자가 된다. 모달로 띄우려면
+  // `showModal()`을 불러야 한다.
+  useEffect(() => {
+    const dialog = ref.current;
+    if (!dialog) {
+      return;
+    }
+    if (open && !dialog.open) {
+      dialog.showModal();
+    } else if (!open && dialog.open) {
+      dialog.close();
+    }
+  }, [open]);
+
+  return (
+    <dialog
+      ref={ref}
+      className={cx("fox-modal", `fox-modal--${size}`, className)}
+      // Esc는 브라우저가 닫아 버리므로 막고, 닫을지는 호출부가 정하게 한다.
+      onCancel={(event) => {
+        event.preventDefault();
+        onClose();
+      }}
+      onClick={(event) => {
+        // `<dialog>`는 뒷배경을 눌러도 자기 자신이 대상이 된다 — 상자 밖을 눌렀는지는
+        // 좌표로 가린다.
+        if (!dismissible || event.target !== event.currentTarget) {
+          return;
+        }
+        const box = event.currentTarget.getBoundingClientRect();
+        const outside =
+          event.clientX < box.left ||
+          event.clientX > box.right ||
+          event.clientY < box.top ||
+          event.clientY > box.bottom;
+        if (outside) {
+          onClose();
+        }
+      }}
+    >
+      <div className="fox-modal__body">
+        {title && (
+          <div className="fox-modal__tit">
+            <h2 className="fox-modal__title">{title}</h2>
+          </div>
+        )}
+        <div className="fox-modal__contents">{children}</div>
+      </div>
+
+      {(utility || actions) && (
+        <div className="fox-modal__foot">
+          {utility && <div className="fox-modal__utility">{utility}</div>}
+          {actions && <div className="fox-modal__actions">{actions}</div>}
+        </div>
+      )}
+
+      {closable && (
+        <button className="fox-modal__close" type="button" aria-label={closeLabel} onClick={onClose}>
+          <span className="fox-modal__close-icon" aria-hidden="true">
+            <FoxXIcon />
+          </span>
+        </button>
+      )}
+    </dialog>
+  );
+}
 
@fox/core/components/fox-modal/index.ts (added)
+++ @fox/core/components/fox-modal/index.ts
@@ -0,0 +1,1 @@
+export { FoxModal, type FoxModalProps, type FoxModalSize } from "./fox-modal";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -25,6 +25,7 @@
 export * from "./fox-icon-button";
 export * from "./fox-input";
 export * from "./fox-link-button";
+export * from "./fox-modal";
 export * from "./fox-page-header";
 export * from "./fox-pagination";
 export * from "./fox-phone-number";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -57,6 +57,7 @@
 import { FoxSegmentedControlItem } from "../core/components/fox-segmented-control-item";
 import { FoxToggleQuantity } from "../core/components/fox-toggle-quantity";
 import { FoxPageHeader } from "../core/components/fox-page-header";
+import { FoxModal } from "../core/components/fox-modal";
 import styles from "./dev-test.module.scss";
 
 export interface ComponentExample {
@@ -594,6 +595,53 @@
       <rect x="2" y="3" width="12" height="11" rx="1.5" />
       <path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3" />
     </svg>
+  );
+}
+
+/** 모달은 열어 봐야 보이므로 여는 버튼과 함께 낸다. */
+function ModalDemo({
+  size,
+  title,
+  withUtility = false,
+  withActions = true,
+  closable = true,
+}: {
+  size: "sm" | "md" | "lg" | "full";
+  title?: string;
+  withUtility?: boolean;
+  withActions?: boolean;
+  closable?: boolean;
+}) {
+  const [open, setOpen] = useState(false);
+  return (
+    <>
+      <FoxButton size="md" type="secondary" label={`${size} 열기`} onAction={() => setOpen(true)} />
+      <FoxModal
+        open={open}
+        onClose={() => setOpen(false)}
+        size={size}
+        title={title}
+        closable={closable}
+        utility={
+          withUtility ? (
+            <FoxCheckbox size="md" label="오늘 하루 보지 않기" />
+          ) : undefined
+        }
+        actions={
+          withActions ? (
+            <>
+              <FoxButton size="md" label="취소" onAction={() => setOpen(false)} />
+              <FoxButton size="md" type="primary" label="확인" onAction={() => setOpen(false)} />
+            </>
+          ) : undefined
+        }
+      >
+        <p>
+          대화 상자는 사용자에게 작업에 대해 알리고 중요한 정보를 포함하거나 결정이 필요하거나
+          여러 작업을 포함할 수 있습니다.
+        </p>
+      </FoxModal>
+    </>
   );
 }
 
@@ -3297,4 +3345,41 @@
       },
     ],
   },
+  {
+    id: "fox-modal",
+    name: "FoxModal",
+    description:
+      "Figma 시안(modal 190:1136)을 옮겼습니다. 네이티브 <dialog>를 showModal()로 띄워 초점 가두기·Esc 닫기·뒷배경·맨 위 레이어를 브라우저가 처리하게 합니다. 열림 여부는 호출부가 소유하고, Esc나 바깥 클릭에도 모달이 스스로 닫지 않고 onClose만 부릅니다. 창을 768px 아래로 좁히면 네 크기가 같은 폭으로 모이고 여백·모서리·닫기 버튼도 함께 줄어듭니다.",
+    variants: [
+      {
+        label: "크기 — sm 400 · md 560 · lg 760 · full",
+        node: (
+          <div className={styles.demoRow}>
+            <ModalDemo size="sm" title="타이틀" />
+            <ModalDemo size="md" title="타이틀" />
+            <ModalDemo size="lg" title="타이틀" />
+            <ModalDemo size="full" title="타이틀" />
+          </div>
+        ),
+      },
+      {
+        label: "아래쪽 왼편 (utility) — 시안의 체크박스 자리",
+        node: (
+          <div className={styles.demoRow}>
+            <ModalDemo size="md" title="타이틀" withUtility />
+          </div>
+        ),
+      },
+      {
+        label: "제목 없이 / 버튼 없이 / 닫기 없이",
+        node: (
+          <div className={styles.demoRow}>
+            <ModalDemo size="sm" />
+            <ModalDemo size="sm" title="타이틀" withActions={false} />
+            <ModalDemo size="sm" title="타이틀" closable={false} />
+          </div>
+        ),
+      },
+    ],
+  },
 ];
 
@fox/styles/_fox-modal.scss (added)
+++ @fox/styles/_fox-modal.scss
@@ -0,0 +1,185 @@
+// FoxModal — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
+//                   modal (190:1136 · 섹션 391:4640)
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <dialog class="fox-modal fox-modal--md">
+//     <div class="fox-modal__body">
+//       <div class="fox-modal__tit"><h2 class="fox-modal__title">타이틀</h2></div>  <!-- 선택 -->
+//       <div class="fox-modal__contents">…</div>
+//     </div>
+//     <div class="fox-modal__foot">                                                <!-- 선택 -->
+//       <div class="fox-modal__utility">…</div>                                    <!-- 선택 -->
+//       <div class="fox-modal__actions">…</div>                                    <!-- 선택 -->
+//     </div>
+//     <button class="fox-modal__close" type="button" aria-label="닫기">…svg…</button>
+//   </dialog>
+//   `<dialog>`는 `showModal()`로 열어야 뒷배경과 초점 가두기가 생긴다.
+//
+// 크기 넷(sm·md·lg·full)이 모두 폭 토큰이다. 모바일에서는 넷 다 같은 폭(348px)으로
+// 모이고 여백·모서리·닫기 버튼도 함께 줄어든다 — 반응형 토큰이 그렇게 정의돼 있다.
+//
+// 제목 줄에만 오른쪽 여백을 준다 — 닫기 버튼이 그 자리에 겹쳐 앉기 때문이다. 본문과
+// 아래쪽은 그 여백이 필요 없다.
+//
+// 닫기 버튼은 64px 칸의 아래쪽에 아이콘을 붙인다(시안 items-end). 그래야 아이콘이 제목
+// 글줄과 눈높이가 맞는다. 상자 테두리 위에 얹히도록 1px 밖으로 내민다.
+//
+// 토큰이 아닌 값:
+//   - 그림자 색: 시안이 테마 토큰이 아니라 `alpha/black10` 원시값에 직접 물려 놓았다
+//     (fox-carousel-button과 같은 사정).
+//   - 뒷배경을 덮는 방식(`::backdrop`)과 가운데 정렬: 구조 규칙.
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
+//     export하지 않는다.
+
+@use "abstracts" as fox;
+
+.fox-modal {
+  box-sizing: border-box;
+  // `<dialog>`는 닫혀 있을 때 브라우저가 숨기는데, `display`를 주면 그 기본값을 덮어
+  // 닫힌 모달이 화면에 그대로 남는다. 열렸을 때만 배치를 준다.
+  display: none;
+  flex-direction: column;
+  align-items: flex-start;
+  // `<dialog>`의 브라우저 기본 여백·테두리를 지우고 가운데에 놓는다.
+  max-inline-size: calc(100% - #{fox.padding(7)} * 2);
+  max-block-size: calc(100% - #{fox.padding(7)} * 2);
+  margin: auto;
+  padding: fox.modal(padding-default);
+  overflow: auto;
+  border: fox.border(1) solid fox.color(border-neutral-subtle);
+  border-radius: fox.card(radius-xlg);
+  background: fox.color(surface-neutral-default);
+  color: fox.color(font-neutral-default);
+  // 시안 neutral/lv3 — 그림자 둘이 겹친다.
+  box-shadow:
+    fox.shadow(location4) fox.shadow(location4) fox.shadow(blur4) 0
+      fox.primitive(alpha-black10),
+    fox.shadow(location2) fox.shadow(location2) fox.shadow(blur3) 0
+      fox.primitive(alpha-black10);
+
+  &::backdrop {
+    background: fox.color(background-dim);
+  }
+
+  // 닫기 버튼이 상자 모서리에 얹히므로 기준점이 필요하다.
+  position: relative;
+
+  &[open] {
+    display: flex;
+  }
+
+  // ── 크기 ──────────────────────────────────────────────────────────────────
+  &--sm {
+    inline-size: fox.modal(width-sm);
+  }
+
+  &--md {
+    inline-size: fox.modal(width-md);
+  }
+
+  &--lg {
+    inline-size: fox.modal(width-lg);
+  }
+
+  &--full {
+    inline-size: fox.modal(width-full);
+  }
+
+  // ── 본문 ──────────────────────────────────────────────────────────────────
+  &__body {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: fox.gap(5);
+    inline-size: 100%;
+  }
+
+  // 닫기 버튼이 오른쪽 위에 앉으므로 제목만 그만큼 비켜 준다.
+  &__tit {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: fox.gap(3);
+    inline-size: 100%;
+    padding-inline-end: fox.padding(8);
+  }
+
+  &__title {
+    inline-size: 100%;
+    margin: 0;
+    color: fox.color(font-neutral-strong);
+    font-family: fox.font-family(title);
+    font-size: fox.font-size(heading-md);
+    font-weight: fox.font-weight(bold);
+    line-height: 1.4;
+    letter-spacing: -0.025em;
+    word-break: break-word;
+  }
+
+  &__contents {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: fox.gap(5);
+    inline-size: 100%;
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-md);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  // ── 아래쪽 ────────────────────────────────────────────────────────────────
+  &__foot {
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    gap: fox.gap(3);
+    inline-size: 100%;
+    padding-block-start: fox.padding(8);
+  }
+
+  // 왼편은 남는 폭을 먹어 버튼을 오른쪽 끝으로 민다.
+  &__utility {
+    display: flex;
+    flex: 1 0 0;
+    align-items: flex-start;
+    gap: fox.gap(3);
+    min-inline-size: 0;
+  }
+
+  &__actions {
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: flex-end;
+    gap: fox.gap(3);
+    margin-inline-start: auto;
+  }
+
+  // ── 닫기 ──────────────────────────────────────────────────────────────────
+  // 시안대로 아이콘을 칸 아래쪽에 붙여 제목 글줄과 눈높이를 맞춘다.
+  &__close {
+    position: absolute;
+    inset-block-start: calc(-1 * #{fox.border(1)});
+    inset-inline-end: calc(-1 * #{fox.border(1)});
+    display: flex;
+    align-items: flex-end;
+    inline-size: fox.modal(btn-close);
+    block-size: fox.modal(btn-close);
+    padding: fox.padding(3);
+    border: none;
+    background: none;
+    color: fox.color(icon-neutral-default);
+    cursor: pointer;
+  }
+
+  &__close-icon {
+    display: inline-flex;
+
+    > * {
+      inline-size: fox.icon(5);
+      block-size: fox.icon(5);
+    }
+  }
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -52,3 +52,4 @@
 @use "fox-spinner";
 @use "fox-toggle-quantity";
 @use "fox-page-header";
+@use "fox-modal";
Add a comment
List