File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
08-14
08-14
08-18
08-18
08-14
08-18
08-18
08-18
File name
Commit message
Commit date
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { cx } from "../../utils";
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) => {
// 파일 선택 취소도 cancel을 버블링시킨다 — 대상이 dialog일 때만 닫기로 본다.
if (event.target !== event.currentTarget) {
return;
}
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">
{/* 제목이 없어도 줄은 남긴다 — 그래야 닫기 버튼과 내용이 겹치지 않는다.
빈 제목을 심으면 이름 없는 heading이 되므로 `<h2>`는 있을 때만 낸다. */}
<div className="fox-modal__tit">{title && <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">
<i className="fox-ico fox-ico-X" aria-hidden="true" />
</span>
</button>
)}
</dialog>
);
}