feat: FoxModal 추가
디자인시스템 파일의 modal(190:1136)을 옮겼다. 크기 넷(sm 400 · md 560 · lg 760 · full)이 모두 폭 토큰이고, 모바일에서는 넷 다 같은 폭으로 모이며 여백·모서리·닫기 버튼도 함께 줄어든다 — 반응형 토큰이 그렇게 정의돼 있다. 네이티브 `
@c3b83647781eb455786bba41c56ea830075fef54
+++ @fox/core/components/fox-modal/fox-modal.tsx
... | ... | @@ -0,0 +1,121 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useEffect, useRef, type ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxXIcon } from "../../icons"; | |
| 6 | + | |
| 7 | +export type FoxModalSize = "sm" | "md" | "lg" | "full"; | |
| 8 | + | |
| 9 | +export interface FoxModalProps { | |
| 10 | + /** 열림 여부. 호출부가 소유한다 — 모달이 스스로 닫지 않는다. */ | |
| 11 | + open: boolean; | |
| 12 | + /** 닫기 버튼·Esc·바깥 클릭에서 부른다. */ | |
| 13 | + onClose: () => void; | |
| 14 | + children?: ReactNode; | |
| 15 | + size?: FoxModalSize; | |
| 16 | + /** 넘기지 않으면 제목 줄을 그리지 않는다. */ | |
| 17 | + title?: ReactNode; | |
| 18 | + /** 아래쪽 오른편. 보통 버튼 묶음이다. */ | |
| 19 | + actions?: ReactNode; | |
| 20 | + /** 아래쪽 왼편. 시안은 "오늘 하루 보지 않기" 같은 체크박스 자리다. */ | |
| 21 | + utility?: ReactNode; | |
| 22 | + /** 닫기 버튼을 감춘다. */ | |
| 23 | + closable?: boolean; | |
| 24 | + closeLabel?: string; | |
| 25 | + /** 바깥을 눌러도 닫히지 않게 한다. */ | |
| 26 | + dismissible?: boolean; | |
| 27 | + /** 배치 조정용. */ | |
| 28 | + className?: string; | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * @fox 모달. 네이티브 `<dialog>`를 쓴다 — 초점 가두기·Esc 닫기·뒷배경·맨 위 레이어를 | |
| 33 | + * 브라우저가 처리하므로 그걸 직접 구현하지 않는다. | |
| 34 | + * | |
| 35 | + * 열림 여부는 호출부가 소유한다. 사용자가 Esc를 누르거나 바깥을 눌러도 모달이 스스로 | |
| 36 | + * 닫지 않고 `onClose`만 부른다 — 닫을지 말지는 호출부가 정한다. | |
| 37 | + * | |
| 38 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 39 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 40 | + */ | |
| 41 | +export function FoxModal({ | |
| 42 | + open, | |
| 43 | + onClose, | |
| 44 | + children, | |
| 45 | + size = "md", | |
| 46 | + title, | |
| 47 | + actions, | |
| 48 | + utility, | |
| 49 | + closable = true, | |
| 50 | + closeLabel = "닫기", | |
| 51 | + dismissible = true, | |
| 52 | + className, | |
| 53 | +}: FoxModalProps) { | |
| 54 | + const ref = useRef<HTMLDialogElement>(null); | |
| 55 | + | |
| 56 | + // `open` 속성만 세우면 뒷배경도 초점 가두기도 없는 그냥 상자가 된다. 모달로 띄우려면 | |
| 57 | + // `showModal()`을 불러야 한다. | |
| 58 | + useEffect(() => { | |
| 59 | + const dialog = ref.current; | |
| 60 | + if (!dialog) { | |
| 61 | + return; | |
| 62 | + } | |
| 63 | + if (open && !dialog.open) { | |
| 64 | + dialog.showModal(); | |
| 65 | + } else if (!open && dialog.open) { | |
| 66 | + dialog.close(); | |
| 67 | + } | |
| 68 | + }, [open]); | |
| 69 | + | |
| 70 | + return ( | |
| 71 | + <dialog | |
| 72 | + ref={ref} | |
| 73 | + className={cx("fox-modal", `fox-modal--${size}`, className)} | |
| 74 | + // Esc는 브라우저가 닫아 버리므로 막고, 닫을지는 호출부가 정하게 한다. | |
| 75 | + onCancel={(event) => { | |
| 76 | + event.preventDefault(); | |
| 77 | + onClose(); | |
| 78 | + }} | |
| 79 | + onClick={(event) => { | |
| 80 | + // `<dialog>`는 뒷배경을 눌러도 자기 자신이 대상이 된다 — 상자 밖을 눌렀는지는 | |
| 81 | + // 좌표로 가린다. | |
| 82 | + if (!dismissible || event.target !== event.currentTarget) { | |
| 83 | + return; | |
| 84 | + } | |
| 85 | + const box = event.currentTarget.getBoundingClientRect(); | |
| 86 | + const outside = | |
| 87 | + event.clientX < box.left || | |
| 88 | + event.clientX > box.right || | |
| 89 | + event.clientY < box.top || | |
| 90 | + event.clientY > box.bottom; | |
| 91 | + if (outside) { | |
| 92 | + onClose(); | |
| 93 | + } | |
| 94 | + }} | |
| 95 | + > | |
| 96 | + <div className="fox-modal__body"> | |
| 97 | + {title && ( | |
| 98 | + <div className="fox-modal__tit"> | |
| 99 | + <h2 className="fox-modal__title">{title}</h2> | |
| 100 | + </div> | |
| 101 | + )} | |
| 102 | + <div className="fox-modal__contents">{children}</div> | |
| 103 | + </div> | |
| 104 | + | |
| 105 | + {(utility || actions) && ( | |
| 106 | + <div className="fox-modal__foot"> | |
| 107 | + {utility && <div className="fox-modal__utility">{utility}</div>} | |
| 108 | + {actions && <div className="fox-modal__actions">{actions}</div>} | |
| 109 | + </div> | |
| 110 | + )} | |
| 111 | + | |
| 112 | + {closable && ( | |
| 113 | + <button className="fox-modal__close" type="button" aria-label={closeLabel} onClick={onClose}> | |
| 114 | + <span className="fox-modal__close-icon" aria-hidden="true"> | |
| 115 | + <FoxXIcon /> | |
| 116 | + </span> | |
| 117 | + </button> | |
| 118 | + )} | |
| 119 | + </dialog> | |
| 120 | + ); | |
| 121 | +} |
+++ @fox/core/components/fox-modal/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxModal, type FoxModalProps, type FoxModalSize } from "./fox-modal"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -25,6 +25,7 @@ |
| 25 | 25 |
export * from "./fox-icon-button"; |
| 26 | 26 |
export * from "./fox-input"; |
| 27 | 27 |
export * from "./fox-link-button"; |
| 28 |
+export * from "./fox-modal"; |
|
| 28 | 29 |
export * from "./fox-page-header"; |
| 29 | 30 |
export * from "./fox-pagination"; |
| 30 | 31 |
export * from "./fox-phone-number"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -57,6 +57,7 @@ |
| 57 | 57 |
import { FoxSegmentedControlItem } from "../core/components/fox-segmented-control-item";
|
| 58 | 58 |
import { FoxToggleQuantity } from "../core/components/fox-toggle-quantity";
|
| 59 | 59 |
import { FoxPageHeader } from "../core/components/fox-page-header";
|
| 60 |
+import { FoxModal } from "../core/components/fox-modal";
|
|
| 60 | 61 |
import styles from "./dev-test.module.scss"; |
| 61 | 62 |
|
| 62 | 63 |
export interface ComponentExample {
|
... | ... | @@ -594,6 +595,53 @@ |
| 594 | 595 |
<rect x="2" y="3" width="12" height="11" rx="1.5" /> |
| 595 | 596 |
<path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3" /> |
| 596 | 597 |
</svg> |
| 598 |
+ ); |
|
| 599 |
+} |
|
| 600 |
+ |
|
| 601 |
+/** 모달은 열어 봐야 보이므로 여는 버튼과 함께 낸다. */ |
|
| 602 |
+function ModalDemo({
|
|
| 603 |
+ size, |
|
| 604 |
+ title, |
|
| 605 |
+ withUtility = false, |
|
| 606 |
+ withActions = true, |
|
| 607 |
+ closable = true, |
|
| 608 |
+}: {
|
|
| 609 |
+ size: "sm" | "md" | "lg" | "full"; |
|
| 610 |
+ title?: string; |
|
| 611 |
+ withUtility?: boolean; |
|
| 612 |
+ withActions?: boolean; |
|
| 613 |
+ closable?: boolean; |
|
| 614 |
+}) {
|
|
| 615 |
+ const [open, setOpen] = useState(false); |
|
| 616 |
+ return ( |
|
| 617 |
+ <> |
|
| 618 |
+ <FoxButton size="md" type="secondary" label={`${size} 열기`} onAction={() => setOpen(true)} />
|
|
| 619 |
+ <FoxModal |
|
| 620 |
+ open={open}
|
|
| 621 |
+ onClose={() => setOpen(false)}
|
|
| 622 |
+ size={size}
|
|
| 623 |
+ title={title}
|
|
| 624 |
+ closable={closable}
|
|
| 625 |
+ utility={
|
|
| 626 |
+ withUtility ? ( |
|
| 627 |
+ <FoxCheckbox size="md" label="오늘 하루 보지 않기" /> |
|
| 628 |
+ ) : undefined |
|
| 629 |
+ } |
|
| 630 |
+ actions={
|
|
| 631 |
+ withActions ? ( |
|
| 632 |
+ <> |
|
| 633 |
+ <FoxButton size="md" label="취소" onAction={() => setOpen(false)} />
|
|
| 634 |
+ <FoxButton size="md" type="primary" label="확인" onAction={() => setOpen(false)} />
|
|
| 635 |
+ </> |
|
| 636 |
+ ) : undefined |
|
| 637 |
+ } |
|
| 638 |
+ > |
|
| 639 |
+ <p> |
|
| 640 |
+ 대화 상자는 사용자에게 작업에 대해 알리고 중요한 정보를 포함하거나 결정이 필요하거나 |
|
| 641 |
+ 여러 작업을 포함할 수 있습니다. |
|
| 642 |
+ </p> |
|
| 643 |
+ </FoxModal> |
|
| 644 |
+ </> |
|
| 597 | 645 |
); |
| 598 | 646 |
} |
| 599 | 647 |
|
... | ... | @@ -3297,4 +3345,41 @@ |
| 3297 | 3345 |
}, |
| 3298 | 3346 |
], |
| 3299 | 3347 |
}, |
| 3348 |
+ {
|
|
| 3349 |
+ id: "fox-modal", |
|
| 3350 |
+ name: "FoxModal", |
|
| 3351 |
+ description: |
|
| 3352 |
+ "Figma 시안(modal 190:1136)을 옮겼습니다. 네이티브 <dialog>를 showModal()로 띄워 초점 가두기·Esc 닫기·뒷배경·맨 위 레이어를 브라우저가 처리하게 합니다. 열림 여부는 호출부가 소유하고, Esc나 바깥 클릭에도 모달이 스스로 닫지 않고 onClose만 부릅니다. 창을 768px 아래로 좁히면 네 크기가 같은 폭으로 모이고 여백·모서리·닫기 버튼도 함께 줄어듭니다.", |
|
| 3353 |
+ variants: [ |
|
| 3354 |
+ {
|
|
| 3355 |
+ label: "크기 — sm 400 · md 560 · lg 760 · full", |
|
| 3356 |
+ node: ( |
|
| 3357 |
+ <div className={styles.demoRow}>
|
|
| 3358 |
+ <ModalDemo size="sm" title="타이틀" /> |
|
| 3359 |
+ <ModalDemo size="md" title="타이틀" /> |
|
| 3360 |
+ <ModalDemo size="lg" title="타이틀" /> |
|
| 3361 |
+ <ModalDemo size="full" title="타이틀" /> |
|
| 3362 |
+ </div> |
|
| 3363 |
+ ), |
|
| 3364 |
+ }, |
|
| 3365 |
+ {
|
|
| 3366 |
+ label: "아래쪽 왼편 (utility) — 시안의 체크박스 자리", |
|
| 3367 |
+ node: ( |
|
| 3368 |
+ <div className={styles.demoRow}>
|
|
| 3369 |
+ <ModalDemo size="md" title="타이틀" withUtility /> |
|
| 3370 |
+ </div> |
|
| 3371 |
+ ), |
|
| 3372 |
+ }, |
|
| 3373 |
+ {
|
|
| 3374 |
+ label: "제목 없이 / 버튼 없이 / 닫기 없이", |
|
| 3375 |
+ node: ( |
|
| 3376 |
+ <div className={styles.demoRow}>
|
|
| 3377 |
+ <ModalDemo size="sm" /> |
|
| 3378 |
+ <ModalDemo size="sm" title="타이틀" withActions={false} />
|
|
| 3379 |
+ <ModalDemo size="sm" title="타이틀" closable={false} />
|
|
| 3380 |
+ </div> |
|
| 3381 |
+ ), |
|
| 3382 |
+ }, |
|
| 3383 |
+ ], |
|
| 3384 |
+ }, |
|
| 3300 | 3385 |
]; |
+++ @fox/styles/_fox-modal.scss
... | ... | @@ -0,0 +1,185 @@ |
| 1 | +// FoxModal — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// modal (190:1136 · 섹션 391:4640) | |
| 3 | +// | |
| 4 | +// 마크업 계약 (React 밖 소비자용): | |
| 5 | +// <dialog class="fox-modal fox-modal--md"> | |
| 6 | +// <div class="fox-modal__body"> | |
| 7 | +// <div class="fox-modal__tit"><h2 class="fox-modal__title">타이틀</h2></div> <!-- 선택 --> | |
| 8 | +// <div class="fox-modal__contents">…</div> | |
| 9 | +// </div> | |
| 10 | +// <div class="fox-modal__foot"> <!-- 선택 --> | |
| 11 | +// <div class="fox-modal__utility">…</div> <!-- 선택 --> | |
| 12 | +// <div class="fox-modal__actions">…</div> <!-- 선택 --> | |
| 13 | +// </div> | |
| 14 | +// <button class="fox-modal__close" type="button" aria-label="닫기">…svg…</button> | |
| 15 | +// </dialog> | |
| 16 | +// `<dialog>`는 `showModal()`로 열어야 뒷배경과 초점 가두기가 생긴다. | |
| 17 | +// | |
| 18 | +// 크기 넷(sm·md·lg·full)이 모두 폭 토큰이다. 모바일에서는 넷 다 같은 폭(348px)으로 | |
| 19 | +// 모이고 여백·모서리·닫기 버튼도 함께 줄어든다 — 반응형 토큰이 그렇게 정의돼 있다. | |
| 20 | +// | |
| 21 | +// 제목 줄에만 오른쪽 여백을 준다 — 닫기 버튼이 그 자리에 겹쳐 앉기 때문이다. 본문과 | |
| 22 | +// 아래쪽은 그 여백이 필요 없다. | |
| 23 | +// | |
| 24 | +// 닫기 버튼은 64px 칸의 아래쪽에 아이콘을 붙인다(시안 items-end). 그래야 아이콘이 제목 | |
| 25 | +// 글줄과 눈높이가 맞는다. 상자 테두리 위에 얹히도록 1px 밖으로 내민다. | |
| 26 | +// | |
| 27 | +// 토큰이 아닌 값: | |
| 28 | +// - 그림자 색: 시안이 테마 토큰이 아니라 `alpha/black10` 원시값에 직접 물려 놓았다 | |
| 29 | +// (fox-carousel-button과 같은 사정). | |
| 30 | +// - 뒷배경을 덮는 방식(`::backdrop`)과 가운데 정렬: 구조 규칙. | |
| 31 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 32 | +// export하지 않는다. | |
| 33 | + | |
| 34 | +@use "abstracts" as fox; | |
| 35 | + | |
| 36 | +.fox-modal { | |
| 37 | + box-sizing: border-box; | |
| 38 | + // `<dialog>`는 닫혀 있을 때 브라우저가 숨기는데, `display`를 주면 그 기본값을 덮어 | |
| 39 | + // 닫힌 모달이 화면에 그대로 남는다. 열렸을 때만 배치를 준다. | |
| 40 | + display: none; | |
| 41 | + flex-direction: column; | |
| 42 | + align-items: flex-start; | |
| 43 | + // `<dialog>`의 브라우저 기본 여백·테두리를 지우고 가운데에 놓는다. | |
| 44 | + max-inline-size: calc(100% - #{fox.padding(7)} * 2); | |
| 45 | + max-block-size: calc(100% - #{fox.padding(7)} * 2); | |
| 46 | + margin: auto; | |
| 47 | + padding: fox.modal(padding-default); | |
| 48 | + overflow: auto; | |
| 49 | + border: fox.border(1) solid fox.color(border-neutral-subtle); | |
| 50 | + border-radius: fox.card(radius-xlg); | |
| 51 | + background: fox.color(surface-neutral-default); | |
| 52 | + color: fox.color(font-neutral-default); | |
| 53 | + // 시안 neutral/lv3 — 그림자 둘이 겹친다. | |
| 54 | + box-shadow: | |
| 55 | + fox.shadow(location4) fox.shadow(location4) fox.shadow(blur4) 0 | |
| 56 | + fox.primitive(alpha-black10), | |
| 57 | + fox.shadow(location2) fox.shadow(location2) fox.shadow(blur3) 0 | |
| 58 | + fox.primitive(alpha-black10); | |
| 59 | + | |
| 60 | + &::backdrop { | |
| 61 | + background: fox.color(background-dim); | |
| 62 | + } | |
| 63 | + | |
| 64 | + // 닫기 버튼이 상자 모서리에 얹히므로 기준점이 필요하다. | |
| 65 | + position: relative; | |
| 66 | + | |
| 67 | + &[open] { | |
| 68 | + display: flex; | |
| 69 | + } | |
| 70 | + | |
| 71 | + // ── 크기 ────────────────────────────────────────────────────────────────── | |
| 72 | + &--sm { | |
| 73 | + inline-size: fox.modal(width-sm); | |
| 74 | + } | |
| 75 | + | |
| 76 | + &--md { | |
| 77 | + inline-size: fox.modal(width-md); | |
| 78 | + } | |
| 79 | + | |
| 80 | + &--lg { | |
| 81 | + inline-size: fox.modal(width-lg); | |
| 82 | + } | |
| 83 | + | |
| 84 | + &--full { | |
| 85 | + inline-size: fox.modal(width-full); | |
| 86 | + } | |
| 87 | + | |
| 88 | + // ── 본문 ────────────────────────────────────────────────────────────────── | |
| 89 | + &__body { | |
| 90 | + display: flex; | |
| 91 | + flex-direction: column; | |
| 92 | + align-items: flex-start; | |
| 93 | + gap: fox.gap(5); | |
| 94 | + inline-size: 100%; | |
| 95 | + } | |
| 96 | + | |
| 97 | + // 닫기 버튼이 오른쪽 위에 앉으므로 제목만 그만큼 비켜 준다. | |
| 98 | + &__tit { | |
| 99 | + display: flex; | |
| 100 | + flex-direction: column; | |
| 101 | + align-items: flex-start; | |
| 102 | + gap: fox.gap(3); | |
| 103 | + inline-size: 100%; | |
| 104 | + padding-inline-end: fox.padding(8); | |
| 105 | + } | |
| 106 | + | |
| 107 | + &__title { | |
| 108 | + inline-size: 100%; | |
| 109 | + margin: 0; | |
| 110 | + color: fox.color(font-neutral-strong); | |
| 111 | + font-family: fox.font-family(title); | |
| 112 | + font-size: fox.font-size(heading-md); | |
| 113 | + font-weight: fox.font-weight(bold); | |
| 114 | + line-height: 1.4; | |
| 115 | + letter-spacing: -0.025em; | |
| 116 | + word-break: break-word; | |
| 117 | + } | |
| 118 | + | |
| 119 | + &__contents { | |
| 120 | + display: flex; | |
| 121 | + flex-direction: column; | |
| 122 | + align-items: flex-start; | |
| 123 | + gap: fox.gap(5); | |
| 124 | + inline-size: 100%; | |
| 125 | + font-family: fox.font-family(body); | |
| 126 | + font-size: fox.font-size(body-md); | |
| 127 | + font-weight: fox.font-weight(regular); | |
| 128 | + line-height: 1.5; | |
| 129 | + letter-spacing: -0.025em; | |
| 130 | + } | |
| 131 | + | |
| 132 | + // ── 아래쪽 ──────────────────────────────────────────────────────────────── | |
| 133 | + &__foot { | |
| 134 | + display: flex; | |
| 135 | + align-items: center; | |
| 136 | + justify-content: flex-end; | |
| 137 | + gap: fox.gap(3); | |
| 138 | + inline-size: 100%; | |
| 139 | + padding-block-start: fox.padding(8); | |
| 140 | + } | |
| 141 | + | |
| 142 | + // 왼편은 남는 폭을 먹어 버튼을 오른쪽 끝으로 민다. | |
| 143 | + &__utility { | |
| 144 | + display: flex; | |
| 145 | + flex: 1 0 0; | |
| 146 | + align-items: flex-start; | |
| 147 | + gap: fox.gap(3); | |
| 148 | + min-inline-size: 0; | |
| 149 | + } | |
| 150 | + | |
| 151 | + &__actions { | |
| 152 | + display: flex; | |
| 153 | + flex-shrink: 0; | |
| 154 | + align-items: center; | |
| 155 | + justify-content: flex-end; | |
| 156 | + gap: fox.gap(3); | |
| 157 | + margin-inline-start: auto; | |
| 158 | + } | |
| 159 | + | |
| 160 | + // ── 닫기 ────────────────────────────────────────────────────────────────── | |
| 161 | + // 시안대로 아이콘을 칸 아래쪽에 붙여 제목 글줄과 눈높이를 맞춘다. | |
| 162 | + &__close { | |
| 163 | + position: absolute; | |
| 164 | + inset-block-start: calc(-1 * #{fox.border(1)}); | |
| 165 | + inset-inline-end: calc(-1 * #{fox.border(1)}); | |
| 166 | + display: flex; | |
| 167 | + align-items: flex-end; | |
| 168 | + inline-size: fox.modal(btn-close); | |
| 169 | + block-size: fox.modal(btn-close); | |
| 170 | + padding: fox.padding(3); | |
| 171 | + border: none; | |
| 172 | + background: none; | |
| 173 | + color: fox.color(icon-neutral-default); | |
| 174 | + cursor: pointer; | |
| 175 | + } | |
| 176 | + | |
| 177 | + &__close-icon { | |
| 178 | + display: inline-flex; | |
| 179 | + | |
| 180 | + > * { | |
| 181 | + inline-size: fox.icon(5); | |
| 182 | + block-size: fox.icon(5); | |
| 183 | + } | |
| 184 | + } | |
| 185 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -52,3 +52,4 @@ |
| 52 | 52 |
@use "fox-spinner"; |
| 53 | 53 |
@use "fox-toggle-quantity"; |
| 54 | 54 |
@use "fox-page-header"; |
| 55 |
+@use "fox-modal"; |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?