521 lines
13 KiB
JavaScript
521 lines
13 KiB
JavaScript
/**
|
|
* 모달 관련 기본 클래스
|
|
* @module ModalBase
|
|
*/
|
|
class ModalBase {
|
|
constructor(config = {}) {
|
|
this.config = {
|
|
closeOnEscape: true,
|
|
closeOnBackdrop: true,
|
|
showCloseButton: true,
|
|
animation: "fade",
|
|
animationDuration: 300,
|
|
backdrop: true,
|
|
keyboard: true,
|
|
...config,
|
|
};
|
|
|
|
this.modal = null;
|
|
this.backdrop = null;
|
|
this.isOpen = false;
|
|
this.onOpen = config.onOpen || null;
|
|
this.onClose = config.onClose || null;
|
|
}
|
|
|
|
/**
|
|
* 모달 생성
|
|
* @param {Object} options - 옵션
|
|
* @returns {HTMLElement}
|
|
*/
|
|
create(options = {}) {
|
|
const {
|
|
id = `modal-${Date.now()}`,
|
|
className = "",
|
|
content = "",
|
|
header = null,
|
|
footer = null,
|
|
} = options;
|
|
|
|
// 모달 컨테이너
|
|
this.modal = document.createElement("div");
|
|
this.modal.id = id;
|
|
this.modal.className = `modal ${className}`;
|
|
this.modal.setAttribute("role", "dialog");
|
|
this.modal.setAttribute("aria-modal", "true");
|
|
this.modal.style.cssText = `
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
z-index: 1000;
|
|
overflow: auto;
|
|
`;
|
|
|
|
// 백드롭
|
|
if (this.config.backdrop) {
|
|
this.backdrop = document.createElement("div");
|
|
this.backdrop.className = "modal-backdrop";
|
|
this.backdrop.style.cssText = `
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
z-index: -1;
|
|
`;
|
|
this.modal.appendChild(this.backdrop);
|
|
}
|
|
|
|
// 모달 다이얼로그
|
|
const dialog = document.createElement("div");
|
|
dialog.className = "modal-dialog";
|
|
dialog.style.cssText = `
|
|
position: relative;
|
|
margin: 50px auto;
|
|
max-width: 600px;
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
`;
|
|
|
|
// 모달 컨텐츠
|
|
const modalContent = document.createElement("div");
|
|
modalContent.className = "modal-content";
|
|
|
|
// 헤더
|
|
if (header !== null) {
|
|
const modalHeader = document.createElement("div");
|
|
modalHeader.className = "modal-header";
|
|
modalHeader.style.cssText = `
|
|
padding: 20px;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
`;
|
|
|
|
if (typeof header === "string") {
|
|
modalHeader.innerHTML = header;
|
|
} else {
|
|
modalHeader.appendChild(header);
|
|
}
|
|
|
|
// 닫기 버튼
|
|
if (this.config.showCloseButton) {
|
|
const closeBtn = document.createElement("button");
|
|
closeBtn.className = "modal-close";
|
|
closeBtn.innerHTML = "×";
|
|
closeBtn.style.cssText = `
|
|
background: none;
|
|
border: none;
|
|
font-size: 28px;
|
|
cursor: pointer;
|
|
color: #999;
|
|
`;
|
|
closeBtn.onclick = () => this.close();
|
|
modalHeader.appendChild(closeBtn);
|
|
}
|
|
|
|
modalContent.appendChild(modalHeader);
|
|
}
|
|
|
|
// 바디
|
|
const modalBody = document.createElement("div");
|
|
modalBody.className = "modal-body";
|
|
modalBody.style.cssText = `
|
|
padding: 20px;
|
|
`;
|
|
|
|
if (typeof content === "string") {
|
|
modalBody.innerHTML = content;
|
|
} else {
|
|
modalBody.appendChild(content);
|
|
}
|
|
|
|
modalContent.appendChild(modalBody);
|
|
|
|
// 푸터
|
|
if (footer !== null) {
|
|
const modalFooter = document.createElement("div");
|
|
modalFooter.className = "modal-footer";
|
|
modalFooter.style.cssText = `
|
|
padding: 20px;
|
|
border-top: 1px solid #e0e0e0;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 10px;
|
|
`;
|
|
|
|
if (typeof footer === "string") {
|
|
modalFooter.innerHTML = footer;
|
|
} else {
|
|
modalFooter.appendChild(footer);
|
|
}
|
|
|
|
modalContent.appendChild(modalFooter);
|
|
}
|
|
|
|
dialog.appendChild(modalContent);
|
|
this.modal.appendChild(dialog);
|
|
|
|
// 이벤트 리스너 등록
|
|
this._setupEventListeners();
|
|
|
|
return this.modal;
|
|
}
|
|
|
|
/**
|
|
* 이벤트 리스너 설정
|
|
* @private
|
|
*/
|
|
_setupEventListeners() {
|
|
// 백드롭 클릭
|
|
if (this.config.closeOnBackdrop && this.backdrop) {
|
|
this.backdrop.onclick = () => this.close();
|
|
}
|
|
|
|
// ESC 키
|
|
if (this.config.closeOnEscape) {
|
|
this._escapeHandler = (e) => {
|
|
if (e.key === "Escape" && this.isOpen) {
|
|
this.close();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", this._escapeHandler);
|
|
}
|
|
|
|
// 모달 외부 클릭
|
|
if (this.config.closeOnBackdrop) {
|
|
this.modal.onclick = (e) => {
|
|
if (e.target === this.modal) {
|
|
this.close();
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모달 열기
|
|
* @param {Object} data - 전달할 데이터
|
|
* @returns {Promise}
|
|
*/
|
|
async open(data = null) {
|
|
if (this.isOpen) return;
|
|
|
|
if (!this.modal) {
|
|
console.error("Modal not created");
|
|
return;
|
|
}
|
|
|
|
// DOM에 추가
|
|
if (!this.modal.parentElement) {
|
|
document.body.appendChild(this.modal);
|
|
}
|
|
|
|
// onOpen 콜백
|
|
if (this.onOpen) {
|
|
await this.onOpen(data);
|
|
}
|
|
|
|
// 애니메이션
|
|
this.modal.style.display = "block";
|
|
await this._animate("in");
|
|
|
|
this.isOpen = true;
|
|
|
|
// body 스크롤 방지
|
|
document.body.style.overflow = "hidden";
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* 모달 닫기
|
|
* @returns {Promise}
|
|
*/
|
|
async close() {
|
|
if (!this.isOpen) return;
|
|
|
|
// 애니메이션
|
|
await this._animate("out");
|
|
|
|
this.modal.style.display = "none";
|
|
this.isOpen = false;
|
|
|
|
// body 스크롤 복원
|
|
document.body.style.overflow = "";
|
|
|
|
// onClose 콜백
|
|
if (this.onClose) {
|
|
await this.onClose();
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* 모달 토글
|
|
* @param {Object} data - 전달할 데이터
|
|
*/
|
|
toggle(data = null) {
|
|
if (this.isOpen) {
|
|
this.close();
|
|
} else {
|
|
this.open(data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 애니메이션 처리
|
|
* @private
|
|
*/
|
|
async _animate(direction) {
|
|
const dialog = this.modal.querySelector(".modal-dialog");
|
|
const { animation, animationDuration } = this.config;
|
|
|
|
if (animation === "fade") {
|
|
if (direction === "in") {
|
|
this.modal.style.opacity = "0";
|
|
await this._delay(10);
|
|
this.modal.style.transition = `opacity ${animationDuration}ms`;
|
|
this.modal.style.opacity = "1";
|
|
await this._delay(animationDuration);
|
|
} else {
|
|
this.modal.style.transition = `opacity ${animationDuration}ms`;
|
|
this.modal.style.opacity = "0";
|
|
await this._delay(animationDuration);
|
|
}
|
|
} else if (animation === "slide") {
|
|
if (direction === "in") {
|
|
dialog.style.transform = "translateY(-50px)";
|
|
dialog.style.opacity = "0";
|
|
await this._delay(10);
|
|
dialog.style.transition = `transform ${animationDuration}ms, opacity ${animationDuration}ms`;
|
|
dialog.style.transform = "translateY(0)";
|
|
dialog.style.opacity = "1";
|
|
await this._delay(animationDuration);
|
|
} else {
|
|
dialog.style.transition = `transform ${animationDuration}ms, opacity ${animationDuration}ms`;
|
|
dialog.style.transform = "translateY(-50px)";
|
|
dialog.style.opacity = "0";
|
|
await this._delay(animationDuration);
|
|
}
|
|
} else if (animation === "zoom") {
|
|
if (direction === "in") {
|
|
dialog.style.transform = "scale(0.7)";
|
|
dialog.style.opacity = "0";
|
|
await this._delay(10);
|
|
dialog.style.transition = `transform ${animationDuration}ms, opacity ${animationDuration}ms`;
|
|
dialog.style.transform = "scale(1)";
|
|
dialog.style.opacity = "1";
|
|
await this._delay(animationDuration);
|
|
} else {
|
|
dialog.style.transition = `transform ${animationDuration}ms, opacity ${animationDuration}ms`;
|
|
dialog.style.transform = "scale(0.7)";
|
|
dialog.style.opacity = "0";
|
|
await this._delay(animationDuration);
|
|
}
|
|
}
|
|
|
|
// transition 초기화
|
|
this.modal.style.transition = "";
|
|
if (dialog) {
|
|
dialog.style.transition = "";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 딜레이
|
|
* @private
|
|
*/
|
|
_delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* 모달 파괴
|
|
*/
|
|
destroy() {
|
|
if (this.isOpen) {
|
|
this.close();
|
|
}
|
|
|
|
// 이벤트 리스너 제거
|
|
if (this._escapeHandler) {
|
|
document.removeEventListener("keydown", this._escapeHandler);
|
|
}
|
|
|
|
// DOM에서 제거
|
|
if (this.modal && this.modal.parentElement) {
|
|
this.modal.parentElement.removeChild(this.modal);
|
|
}
|
|
|
|
this.modal = null;
|
|
this.backdrop = null;
|
|
}
|
|
|
|
/**
|
|
* 모달 컨텐츠 업데이트
|
|
* @param {string|Element} content - 새 컨텐츠
|
|
*/
|
|
updateContent(content) {
|
|
const modalBody = this.modal.querySelector(".modal-body");
|
|
if (modalBody) {
|
|
if (typeof content === "string") {
|
|
modalBody.innerHTML = content;
|
|
} else {
|
|
modalBody.innerHTML = "";
|
|
modalBody.appendChild(content);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모달 헤더 업데이트
|
|
* @param {string|Element} header - 새 헤더
|
|
*/
|
|
updateHeader(header) {
|
|
const modalHeader = this.modal.querySelector(".modal-header");
|
|
if (modalHeader) {
|
|
if (typeof header === "string") {
|
|
modalHeader.innerHTML = header;
|
|
} else {
|
|
modalHeader.innerHTML = "";
|
|
modalHeader.appendChild(header);
|
|
}
|
|
|
|
// 닫기 버튼 재추가
|
|
if (this.config.showCloseButton) {
|
|
const closeBtn = document.createElement("button");
|
|
closeBtn.className = "modal-close";
|
|
closeBtn.innerHTML = "×";
|
|
closeBtn.style.cssText = `
|
|
background: none;
|
|
border: none;
|
|
font-size: 28px;
|
|
cursor: pointer;
|
|
color: #999;
|
|
`;
|
|
closeBtn.onclick = () => this.close();
|
|
modalHeader.appendChild(closeBtn);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 확인 모달 (Confirm Dialog)
|
|
*/
|
|
class ConfirmModal extends ModalBase {
|
|
constructor(config = {}) {
|
|
super(config);
|
|
this.promise = null;
|
|
}
|
|
|
|
/**
|
|
* 확인 모달 표시
|
|
* @param {Object} options - 옵션
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
show(options = {}) {
|
|
const {
|
|
title = "확인",
|
|
message = "계속하시겠습니까?",
|
|
confirmText = "확인",
|
|
cancelText = "취소",
|
|
confirmClass = "btn-primary",
|
|
cancelClass = "btn-secondary",
|
|
} = options;
|
|
|
|
return new Promise((resolve) => {
|
|
// 헤더
|
|
const header = document.createElement("div");
|
|
header.innerHTML = `<h3 style="margin: 0;">${title}</h3>`;
|
|
|
|
// 컨텐츠
|
|
const content = document.createElement("div");
|
|
content.innerHTML = message;
|
|
|
|
// 푸터
|
|
const footer = document.createElement("div");
|
|
|
|
const cancelBtn = document.createElement("button");
|
|
cancelBtn.className = `btn ${cancelClass}`;
|
|
cancelBtn.textContent = cancelText;
|
|
cancelBtn.onclick = () => {
|
|
this.close();
|
|
resolve(false);
|
|
};
|
|
|
|
const confirmBtn = document.createElement("button");
|
|
confirmBtn.className = `btn ${confirmClass}`;
|
|
confirmBtn.textContent = confirmText;
|
|
confirmBtn.onclick = () => {
|
|
this.close();
|
|
resolve(true);
|
|
};
|
|
|
|
footer.appendChild(cancelBtn);
|
|
footer.appendChild(confirmBtn);
|
|
|
|
// 모달 생성 및 열기
|
|
this.create({ header, content, footer });
|
|
this.open();
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 알림 모달 (Alert Dialog)
|
|
*/
|
|
class AlertModal extends ModalBase {
|
|
/**
|
|
* 알림 모달 표시
|
|
* @param {Object} options - 옵션
|
|
* @returns {Promise}
|
|
*/
|
|
show(options = {}) {
|
|
const {
|
|
title = "알림",
|
|
message = "",
|
|
confirmText = "확인",
|
|
confirmClass = "btn-primary",
|
|
} = options;
|
|
|
|
return new Promise((resolve) => {
|
|
// 헤더
|
|
const header = document.createElement("div");
|
|
header.innerHTML = `<h3 style="margin: 0;">${title}</h3>`;
|
|
|
|
// 컨텐츠
|
|
const content = document.createElement("div");
|
|
content.innerHTML = message;
|
|
|
|
// 푸터
|
|
const footer = document.createElement("div");
|
|
|
|
const confirmBtn = document.createElement("button");
|
|
confirmBtn.className = `btn ${confirmClass}`;
|
|
confirmBtn.textContent = confirmText;
|
|
confirmBtn.onclick = () => {
|
|
this.close();
|
|
resolve();
|
|
};
|
|
|
|
footer.appendChild(confirmBtn);
|
|
|
|
// 모달 생성 및 열기
|
|
this.create({ header, content, footer });
|
|
this.open();
|
|
});
|
|
}
|
|
}
|
|
|
|
// ES6 모듈 내보내기
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = { ModalBase, ConfirmModal, AlertModal };
|
|
}
|