Language : 영어 | 한국어
Grunfeld는 React 애플리케이션을 위한 간단하고 직관적인 대화상자 관리 라이브러리입니다. 복잡한 상태 관리 없이 몇 줄의 코드로 모달, 알림, 확인 대화상자를 구현할 수 있습니다.
- 🚀 간단한 API - 복잡한 설정 없이 바로 사용 가능
- 🎯 동기/비동기 지원 - 알림부터 사용자 입력까지 모든 시나리오 지원
- 📱 유연한 위치 설정 - 9분할 그리드로 정확한 위치 배치
- 🔄 스마트 스택 관리 - 논리적인 LIFO 순서로 대화상자 관리
- ⚡ Top-layer 지원 - 네이티브
<dialog>요소 활용 - 🎨 완전한 커스터마이징 - 스타일과 동작 자유롭게 설정
npm install grunfeld
# 또는
yarn add grunfeld앱의 최상위에 GrunfeldProvider를 추가하세요:
import { GrunfeldProvider } from "grunfeld";
function App() {
return <GrunfeldProvider>{/* 앱 내용 */}</GrunfeldProvider>;
}import { grunfeld } from "grunfeld";
function MyComponent() {
const showAlert = () => {
// 간단한 알림 - void 반환 (반환값 없음)
grunfeld.add(() => <div>안녕하세요!</div>);
};
return <button onClick={showAlert}>알림 표시</button>;
}const showConfirm = async () => {
const result = await grunfeld.add<boolean>((removeWith) => ({
element: (
<div>
<p>정말 삭제하시겠습니까?</p>
<button onClick={() => removeWith(true)}>확인</button>
<button onClick={() => removeWith(false)}>취소</button>
</div>
),
}));
if (result) {
console.log("사용자가 확인을 클릭했습니다");
} else {
console.log("사용자가 취소를 클릭했습니다");
}
};매개변수 없는 팩토리 함수를 사용하면 간단한 알림으로 동작합니다 (void 반환):
// 기본 알림 - React 요소 직접 반환
grunfeld.add(() => (
<div
style={{
padding: "20px",
background: "white",
borderRadius: "8px",
textAlign: "center",
}}
>
<p>저장이 완료되었습니다!</p>
<button onClick={() => grunfeld.remove()}>확인</button>
</div>
));사용자의 선택을 기다리는 확인 대화상자:
const confirmed = await grunfeld.add<boolean>((removeWith) => ({
element: (
<div
style={{
padding: "20px",
background: "white",
borderRadius: "8px",
textAlign: "center",
}}
>
<p>정말 삭제하시겠습니까?</p>
<div>
<button onClick={() => removeWith(true)}>삭제</button>
<button onClick={() => removeWith(false)}>취소</button>
</div>
</div>
),
}));
if (confirmed) {
console.log("사용자가 삭제를 확인했습니다");
// 삭제 로직 실행
} else {
console.log("사용자가 취소했습니다");
}사용자로부터 데이터를 입력받는 대화상자:
const InputModal = ({ onClose }: { onClose: (name: string) => void }) => {
const [name, setName] = useState("");
return (
<div
style={{
padding: "20px",
background: "white",
borderRadius: "8px",
minWidth: "300px",
}}
>
<h2>이름을 입력하세요</h2>
<input
autoFocus
type="text"
placeholder="이름"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && name.trim() && onClose(name.trim())
}
style={{ width: "100%", padding: "8px", marginBottom: "10px" }}
/>
<div>
<button
onClick={() => name.trim() && onClose(name.trim())}
disabled={!name.trim()}
style={{ marginRight: "10px" }}
>
확인
</button>
<button onClick={() => onClose("")}>취소</button>
</div>
</div>
);
};
export default function GrunfeldPage() {
return (
<button
onClick={async () => {
const value = await grunfeld.add<string>((removeWith) => ({
element: <InputModal onClose={removeWith} />,
}));
console.log(value);
}}
>
테스트 버튼
</button>
);
}대화상자 생성 시 비동기 작업도 수행할 수 있습니다:
const result = await grunfeld.add<string>(async (removeWith) => {
// 로딩 표시
const loadingElement = (
<div style={{ padding: "20px", textAlign: "center" }}>
<p>사용자 정보를 불러오는 중...</p>
<div>⏳</div>
</div>
);
// 먼저 로딩 다이얼로그 표시
setTimeout(() => {
// 실제 데이터 로드 후 내용 업데이트
fetch("/api/user")
.then((res) => res.json())
.then((data) => {
// 성공적으로 로드된 후의 UI로 업데이트하려면
// 새로운 다이얼로그를 생성하거나 상태 관리를 사용해야 합니다
})
.catch(() => {
removeWith("로드 실패");
});
}, 100);
return {
element: loadingElement,
};
});
// 더 실용적인 예제: 선택 리스트
const selectedItem = await grunfeld.add<string>(async (removeWith) => {
const items = await fetch("/api/items").then((res) => res.json());
return {
element: (
<div style={{ padding: "20px", minWidth: "250px" }}>
<h3>항목을 선택하세요</h3>
<ul style={{ listStyle: "none", padding: 0 }}>
{items.map((item: any) => (
<li key={item.id}>
<button
onClick={() => removeWith(item.name)}
style={{
width: "100%",
padding: "8px",
marginBottom: "4px",
textAlign: "left",
}}
>
{item.name}
</button>
</li>
))}
</ul>
<button onClick={() => removeWith("")}>취소</button>
</div>
),
};
});<GrunfeldProvider
options={{
defaultPosition: "center", // 기본 위치
defaultLightDismiss: true, // 배경 클릭으로 닫기
defaultRenderMode: "inline", // 렌더링 모드
defaultBackdropStyle: { // 기본 백드롭 스타일
backgroundColor: "rgba(0, 0, 0, 0.5)"
}
}}
>grunfeld.add(() => ({
element: <MyDialog />,
position: "top-right", // 위치 (9분할 그리드)
lightDismiss: false, // 배경 클릭 비활성화
renderMode: "top-layer", // top-layer 렌더링
backdropStyle: {
// 커스텀 백드롭
backgroundColor: "rgba(0, 0, 0, 0.7)",
backdropFilter: "blur(5px)",
},
dismissCallback: () => {
// 닫힐 때 실행할 함수
console.log("대화상자가 닫혔습니다");
},
}));
// 스타일링 예제
grunfeld.add(() => ({
element: (
<>
<h2>🎉 축하합니다!</h2>
<p>작업이 성공적으로 완료되었습니다.</p>
<button onClick={() => grunfeld.remove()}>확인</button>
</>
),
position: "center",
backdropStyle: {
backgroundColor: "rgba(102, 126, 234, 0.1)",
backdropFilter: "blur(8px)",
},
}));화면을 9분할로 나누어 정확한 위치에 대화상자를 배치할 수 있습니다:
top-left | top-center | top-right
center-left | center | center-right
bottom-left | bottom-center | bottom-right
참고: 중앙 위치는
center또는center-center모두 사용 가능합니다.
사용 예시:
// 중앙 배치 - 두 방식 모두 동일하게 작동
grunfeld.add(() => ({
element: <Modal />,
position: "center", // 또는 "center-center"
}));
// 우상단 알림
grunfeld.add(() => ({
element: <Notification />,
position: "top-right",
}));
// 하단 액션 시트
grunfeld.add(() => ({
element: <ActionSheet />,
position: "bottom-center",
}));- z-index 기반의 안정적인 방식
- 모든 브라우저 지원
- 커스터마이징 유연함
- JavaScript 기반 ESC 키 처리
- 네이티브
<dialog>요소 사용 - z-index 충돌 없음
- 브라우저 네이티브 ESC 키 처리
- 최신 브라우저만 지원 (Chrome 37+, Firefox 98+, Safari 15.4+)
grunfeld.add(() => ({
element: <MyDialog />,
renderMode: "top-layer", // 네이티브 dialog 사용
}));// 가장 최근 대화상자 제거
grunfeld.remove();
// 모든 대화상자 제거
grunfeld.clear();
// ESC 키로 닫기
// 또는 lightDismiss: true일 때 배경 클릭으로 닫기대화상자는 LIFO(Last In First Out) 순서로 제거됩니다. 이는 대화상자들 간의 맥락적 관계를 유지하기 위함입니다.
grunfeld.remove() 또는 grunfeld.clear()를 호출하여 대화상자를 강제로 닫으면, 해당 대화상자의 Promise는 undefined로 resolve됩니다:
// Promise가 진행 중일 때 외부에서 제거하는 경우
const promise = grunfeld.add<boolean>((removeWith) => ({
element: (
<div>
<p>확인하시겠습니까?</p>
<button onClick={() => removeWith(true)}>예</button>
<button onClick={() => removeWith(false)}>아니오</button>
</div>
),
}));
// 다른 곳에서 대화상자를 강제로 제거
setTimeout(() => {
grunfeld.remove(); // promise는 undefined로 resolve됨
}, 1000);
const result = await promise; // result는 undefined
if (result === undefined) {
console.log("대화상자가 중단되었습니다");
} else if (result) {
console.log("사용자가 예를 선택했습니다");
} else {
console.log("사용자가 아니오를 선택했습니다");
}실용적인 예제:
const showConfirmWithTimeout = async () => {
const confirmPromise = grunfeld.add<boolean>((removeWith) => ({
element: (
<div>
<p>10초 안에 응답해주세요. 확인하시겠습니까?</p>
<button onClick={() => removeWith(true)}>확인</button>
<button onClick={() => removeWith(false)}>취소</button>
</div>
),
}));
// 10초 후 자동으로 제거
const timeoutId = setTimeout(() => {
grunfeld.remove(); // Promise는 undefined로 resolve됨
}, 10000);
const result = await confirmPromise;
clearTimeout(timeoutId); // 사용자가 응답한 경우 타이머 제거
if (result === undefined) {
console.log("시간 초과로 대화상자가 닫혔습니다");
} else if (result) {
console.log("사용자가 확인을 선택했습니다");
} else {
console.log("사용자가 취소를 선택했습니다");
}
};이 동작은 메모리 누수를 방지하고 hanging Promise 문제를 해결합니다. 모든 Promise는 적절히 정리되므로 안전하게 사용할 수 있습니다.
import React, { useState } from "react";
import { grunfeld, GrunfeldProvider } from "grunfeld";
function MyApp() {
const [message, setMessage] = useState("");
const showNotification = () => {
grunfeld.add(() => ({
element: <div>알림이 표시되었습니다!</div>,
position: "top-right",
}));
// 2초 후 자동으로 제거
setTimeout(() => grunfeld.remove(), 2000);
};
const showConfirm = async () => {
const result = await grunfeld.add<boolean>((removeWith) => ({
element: (
<div
style={{ padding: "20px", background: "white", borderRadius: "8px" }}
>
<h3>확인</h3>
<p>정말 진행하시겠습니까?</p>
<button onClick={() => removeWith(true)}>예</button>
<button onClick={() => removeWith(false)}>아니오</button>
</div>
),
}));
setMessage(result ? "확인됨" : "취소됨");
};
const showInput = async () => {
const input = await grunfeld.add<string>((removeWith) => ({
element: <InputDialog onSubmit={removeWith} />,
}));
setMessage(input ? `입력값: ${input}` : "취소됨");
};
return (
<GrunfeldProvider>
<div style={{ padding: "20px" }}>
<h1>Grunfeld 예제</h1>
<button onClick={showNotification}>알림 표시</button>
<button onClick={showConfirm}>확인 대화상자</button>
<button onClick={showInput}>입력 대화상자</button>
<p>상태: {message}</p>
</div>
</GrunfeldProvider>
);
}
const InputDialog = ({ onSubmit }: { onSubmit: (value: string) => void }) => {
const [value, setValue] = useState("");
return (
<div style={{ padding: "20px", background: "white", borderRadius: "8px" }}>
<h3>입력</h3>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="값을 입력하세요"
autoFocus
/>
<div style={{ marginTop: "10px" }}>
<button onClick={() => onSubmit(value)}>확인</button>
<button onClick={() => onSubmit("")}>취소</button>
</div>
</div>
);
};복잡한 사용자 플로우를 단계별로 정의하고 관리할 수 있는 시나리오 기능입니다. 로그인, 결제, 온보딩 등의 다단계 프로세스를 체계적으로 구성할 수 있습니다.
Grunfeld는 두 가지 시나리오 패턴을 지원합니다:
가장 간단하고 직관적인 방식으로, 객체 형태로 시나리오를 정의합니다.
// 로그인 시나리오 정의
const loginScenario = grunfeld.scenario("login", {
showLoginForm: () => {
grunfeld.add(() => ({
element: <LoginForm />,
position: "center",
}));
},
showLoading: () => {
grunfeld.remove(); // 이전 단계 정리
grunfeld.add(() => ({
element: "Loading...",
position: "center",
}));
},
showSuccess: () => {
grunfeld.remove();
grunfeld.add(() => ({
element: "로그인 성공!",
position: "top-right",
}));
},
});
// 사용법 - 동적 메서드 접근
await loginScenario.showLoginForm(); // 특정 단계 실행
await loginScenario.showLoading();
await loginScenario.showSuccess();제어 로직과 구현을 분리하여 더 복잡한 시나리오를 구성할 수 있습니다.
// 분리된 시나리오 정의
const advancedScenario = grunfeld.scenario(
"user-management",
// 제어 로직 (factory)
(cliche) => ({
processUser: async (user) => {
if (user.isPremium) {
await cliche.showPremiumContent();
} else {
await cliche.showBasicContent();
}
await cliche.logActivity();
},
}),
// 실제 구현 (implementation)
{
showPremiumContent: () => {
grunfeld.add(() => "프리미엄 콘텐츠");
},
showBasicContent: () => {
grunfeld.add(() => "기본 콘텐츠");
},
logActivity: () => {
console.log("사용자 활동 기록됨");
},
}
);
// 사용법
await advancedScenario.processUser({ isPremium: true });시나리오 단계에 매개변수를 전달하여 동적인 동작을 구현할 수 있습니다.
// 매개변수를 받는 시나리오 정의
const userScenario = grunfeld.scenario("user-flow", {
welcomeUser: ({ userName, userType }) => {
grunfeld.add(() => ({
element: `${userName}님 (${userType}) 환영합니다!`,
position: "center",
}));
},
showDashboard: ({ permissions = [] }) => {
grunfeld.add(() => ({
element: `대시보드 (권한: ${permissions.join(", ")})`,
position: "center",
}));
},
});
// 개별 단계에 매개변수 전달 (동적 메서드 접근)
await userScenario.welcomeUser({
userName: "홍길동",
userType: "관리자",
});
await userScenario.showDashboard({
permissions: ["read", "write", "admin"],
});const registrationScenario = grunfeld.scenario("registration", {
getUserName: async () => {
const name = await grunfeld.add<string>((removeWith) => ({
element: (
<div>
<h3>이름을 입력하세요</h3>
<input
type="text"
onKeyPress={(e) => {
if (e.key === "Enter") {
removeWith(e.target.value);
}
}}
/>
</div>
),
position: "center",
}));
console.log("입력받은 이름:", name);
return name;
},
confirmData: async () => {
const confirmed = await grunfeld.add<boolean>((removeWith) => ({
element: (
<div>
<p>정보가 맞습니까?</p>
<button onClick={() => removeWith(true)}>확인</button>
<button onClick={() => removeWith(false)}>취소</button>
</div>
),
position: "center",
}));
if (!confirmed) {
throw new Error("사용자가 취소했습니다");
}
},
});const advancedScenario = grunfeld.scenario(
"advanced",
{
step1: () => console.log("1단계"),
step2: () => {
throw new Error("오류 발생");
},
step3: () => console.log("3단계"),
},
{
stopOnError: false, // 오류 발생 시에도 계속 진행
stepDelay: 1000, // 단계 간 1초 지연
onStepStart: (stepName) => console.log(`시작: ${stepName}`),
onStepEnd: (stepName) => console.log(`완료: ${stepName}`),
onStepError: (stepName, error) => console.log(`오류: ${stepName}`),
}
);scenario.step(stepName, params?)- 특정 단계 실행 (매개변수 선택적 전달)scenario.run(paramsMap?)- 모든 단계 순차 실행 (단계별 매개변수 맵 선택적 전달)scenario.getSteps()- 사용 가능한 단계 목록scenario.hasStep(stepName)- 단계 존재 여부 확인scenario.clone(newName?)- 시나리오 복제
두 가지 오버로드:
- 간단한 알림 (매개변수 없음):
grunfeld.add(() => React.ReactNode | GrunfeldProps): void- 즉시 실행되고 반환값 없음 (동기)
- 사용자 응답 받기 (매개변수 있음):
grunfeld.add<T>((removeWith: (data: T) => void) => GrunfeldProps): Promise<T | undefined>- 사용자 응답을 기다림 (비동기)
사용 예시:
// 1. 간단한 알림 - 반환값 없음
grunfeld.add(() => <div>간단한 알림</div>);
// 옵션과 함께
grunfeld.add(() => ({
element: <div>위치가 지정된 알림</div>,
position: "top-right",
lightDismiss: false,
}));
// 2. 사용자 응답 받기 - Promise 반환
const result = await grunfeld.add<boolean>((removeWith) => ({
element: (
<div>
<p>확인하시겠습니까?</p>
<button onClick={() => removeWith(true)}>예</button>
<button onClick={() => removeWith(false)}>아니오</button>
</div>
),
}));
// 문자열 입력 받기
const input = await grunfeld.add<string>((removeWith) => ({
element: <InputForm onSubmit={removeWith} />,
}));GrunfeldProps:
{
element: React.ReactNode; // 표시할 내용
position?: Position; // 위치 (기본: "center")
lightDismiss?: boolean; // 배경 클릭으로 닫기 (기본: true)
backdropStyle?: React.CSSProperties; // 백드롭 스타일
dismissCallback?: () => unknown; // 닫힐 때 실행할 함수 (주의: 여기서 grunfeld.remove() 호출 금지)
renderMode?: "inline" | "top-layer"; // 렌더링 모드
}dismissCallback은 대화상자가 제거될 때 실행되므로, 이 함수 내에서 grunfeld.remove()나 grunfeld.clear()를 호출하면 안 됩니다. 자동으로 사라지는 알림을 만들려면 setTimeout을 대화상자 생성 후에 별도로 실행하세요:
// ❌ 잘못된 방법
grunfeld.add(() => ({
element: <div>알림</div>,
dismissCallback: () => {
setTimeout(() => grunfeld.remove(), 2000); // 무한 루프 위험
},
}));
// ✅ 올바른 방법
grunfeld.add(() => ({
element: <div>알림</div>,
}));
setTimeout(() => grunfeld.remove(), 2000);가장 최근 대화상자를 제거합니다.
모든 대화상자를 제거합니다.
type PositionX = "left" | "center" | "right";
type PositionY = "top" | "center" | "bottom";
type Position = `${PositionY}-${PositionX}` | "center";Inline 렌더링: 모든 모던 브라우저 + IE 11+ Top-layer 렌더링: Chrome 37+, Firefox 98+, Safari 15.4+, Edge 79+