@lerx/promise-modal 0.10.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1193 @@
1
+ # @lerx/promise-modal 스펙 문서
2
+
3
+ > Promise 기반 API를 제공하는 범용 React 모달 유틸리티
4
+
5
+ ## 개요
6
+
7
+ `@lerx/promise-modal`은 다음 기능을 제공하는 React 기반 범용 모달 유틸리티입니다:
8
+
9
+ - **Promise 기반 상호작용**: Alert, confirm, prompt 모달이 Promise를 반환
10
+ - **범용 사용**: React 컴포넌트 내부와 외부 모두에서 사용 가능
11
+ - **높은 커스터마이징성**: 모든 컴포넌트를 커스터마이즈 가능
12
+ - **자동 생명주기 관리**: 마운트/언마운트 및 애니메이션 자동 처리
13
+
14
+ ---
15
+
16
+ ## 목차
17
+
18
+ 1. [설치](#설치)
19
+ 2. [빠른 시작](#빠른-시작)
20
+ 3. [아키텍처](#아키텍처)
21
+ 4. [핵심 API](#핵심-api)
22
+ - [alert](#alert)
23
+ - [confirm](#confirm)
24
+ - [prompt](#prompt)
25
+ 5. [Hooks](#hooks)
26
+ - [useModal](#usemodal)
27
+ - [useActiveModalCount](#useactivemodalcount)
28
+ - [useModalAnimation](#usemodalanimation)
29
+ - [useModalDuration](#usemodalduration)
30
+ - [useDestroyAfter](#usedestroyafter)
31
+ - [useSubscribeModal](#usesubscribemodal)
32
+ - [useInitializeModal](#useinitializemodal)
33
+ - [useModalOptions](#usemodaloptions)
34
+ - [useModalBackdrop](#usemodalbackdrop)
35
+ 6. [컴포넌트](#컴포넌트)
36
+ - [ModalProvider](#modalprovider)
37
+ - [커스텀 컴포넌트](#커스텀-컴포넌트)
38
+ 7. [타입 정의](#타입-정의)
39
+ 8. [사용 패턴](#사용-패턴)
40
+ 9. [고급 예제](#고급-예제)
41
+ 10. [AbortSignal 지원](#abortsignal-지원)
42
+
43
+ ---
44
+
45
+ ## 설치
46
+
47
+ ```bash
48
+ # yarn 사용
49
+ yarn add @lerx/promise-modal
50
+
51
+ # npm 사용
52
+ npm install @lerx/promise-modal
53
+ ```
54
+
55
+ ### 피어 의존성
56
+
57
+ - React 18-19
58
+ - React DOM 18-19
59
+
60
+ ### 호환성
61
+
62
+ - Node.js 16.11.0 이상
63
+ - 최신 브라우저 (Chrome 94+, Firefox 93+, Safari 15+)
64
+
65
+ ---
66
+
67
+ ## 빠른 시작
68
+
69
+ ### 1. Provider 설정
70
+
71
+ ```tsx
72
+ import { ModalProvider } from '@lerx/promise-modal';
73
+
74
+ function App() {
75
+ return (
76
+ <ModalProvider>
77
+ <YourApp />
78
+ </ModalProvider>
79
+ );
80
+ }
81
+ ```
82
+
83
+ ### 2. 모달 함수 사용
84
+
85
+ ```tsx
86
+ import { alert, confirm, prompt } from '@lerx/promise-modal';
87
+
88
+ // 알림
89
+ await alert({
90
+ title: '알림',
91
+ content: '작업이 완료되었습니다.',
92
+ });
93
+
94
+ // 확인
95
+ const result = await confirm({
96
+ title: '확인',
97
+ content: '계속하시겠습니까?',
98
+ });
99
+
100
+ // 입력
101
+ const name = await prompt<string>({
102
+ title: '이름 입력',
103
+ defaultValue: '',
104
+ Input: ({ value, onChange }) => (
105
+ <input value={value} onChange={(e) => onChange(e.target.value)} />
106
+ ),
107
+ });
108
+ ```
109
+
110
+ ---
111
+
112
+ ## 아키텍처
113
+
114
+ ### 계층 구조
115
+
116
+ ```
117
+ ┌─────────────────────────────────────────────────────────────┐
118
+ │ 애플리케이션 │
119
+ ├─────────────────────────────────────────────────────────────┤
120
+ │ Core API 계층 │
121
+ │ ├── alert() │
122
+ │ ├── confirm() │
123
+ │ └── prompt() │
124
+ ├─────────────────────────────────────────────────────────────┤
125
+ │ Application 계층 │
126
+ │ └── ModalManager (싱글톤) │
127
+ │ ├── DOM 앵커링 │
128
+ │ ├── 스타일 주입 │
129
+ │ └── 모달 생명주기 │
130
+ ├─────────────────────────────────────────────────────────────┤
131
+ │ Bootstrap 계층 │
132
+ │ └── ModalProvider │
133
+ │ ├── 초기화 │
134
+ │ └── 컴포넌트 설정 │
135
+ ├─────────────────────────────────────────────────────────────┤
136
+ │ Provider 계층 │
137
+ │ ├── ModalManagerContext │
138
+ │ ├── ConfigurationContext │
139
+ │ └── UserDefinedContext │
140
+ ├─────────────────────────────────────────────────────────────┤
141
+ │ Component 계층 │
142
+ │ ├── Anchor │
143
+ │ ├── Background │
144
+ │ ├── Foreground │
145
+ │ └── Fallback Components │
146
+ └─────────────────────────────────────────────────────────────┘
147
+ ```
148
+
149
+ ### 디자인 패턴
150
+
151
+ | 패턴 | 용도 |
152
+ |------|------|
153
+ | **Promise 기반 API** | 모달 함수가 Promise 반환 |
154
+ | **Provider 패턴** | 설정을 위한 Context providers |
155
+ | **Factory 패턴** | 모달 타입을 위한 Node factory |
156
+ | **Observer 패턴** | 상태를 위한 구독 시스템 |
157
+ | **Singleton 패턴** | 전역 상태를 위한 ModalManager |
158
+
159
+ ### 모달 노드 시스템
160
+
161
+ ```
162
+ AbstractNode (기본 클래스)
163
+ ├── AlertNode → 간단한 알림
164
+ ├── ConfirmNode → 예/아니오 확인
165
+ └── PromptNode → 입력 수집
166
+ ```
167
+
168
+ 각 노드가 제공하는 기능:
169
+ - 구독 기반 상태 관리
170
+ - Promise 해결 처리
171
+ - 생명주기 관리
172
+
173
+ ### 설정 우선순위
174
+
175
+ 설정은 계층적으로 적용되며, 하위 레벨이 상위 레벨을 덮어씁니다:
176
+
177
+ ```
178
+ Provider 설정 (최하위) < Hook 설정 < Handler 설정 (최상위)
179
+ ```
180
+
181
+ | 레벨 | 위치 | 설명 |
182
+ |------|------|------|
183
+ | **Provider** | `ModalProvider` props | 앱 전역 기본 설정 |
184
+ | **Hook** | `useModal(config)` | 컴포넌트 레벨 설정 |
185
+ | **Handler** | `alert/confirm/prompt(options)` | 개별 모달 설정 |
186
+
187
+ #### 예제
188
+
189
+ ```typescript
190
+ // Provider 레벨: 전역 기본값
191
+ <ModalProvider options={{ duration: '500ms', closeOnBackdropClick: true }}>
192
+ <App />
193
+ </ModalProvider>
194
+
195
+ // Hook 레벨: 컴포넌트 기본값 (Provider 설정을 덮어씀)
196
+ const modal = useModal({
197
+ ForegroundComponent: CustomForeground,
198
+ });
199
+
200
+ // Handler 레벨: 개별 모달 (Hook 설정을 덮어씀)
201
+ modal.alert({
202
+ title: '알림',
203
+ duration: 200, // 500ms → 200ms로 오버라이드
204
+ ForegroundComponent: SpecialForeground, // CustomForeground 오버라이드
205
+ });
206
+ ```
207
+
208
+ ---
209
+
210
+ ## 핵심 API
211
+
212
+ ### alert
213
+
214
+ 간단한 알림 모달을 엽니다.
215
+
216
+ #### 시그니처
217
+
218
+ ```typescript
219
+ function alert<B = any>(options: AlertProps<B>): Promise<void>;
220
+ ```
221
+
222
+ #### 매개변수
223
+
224
+ | 옵션 | 타입 | 기본값 | 설명 |
225
+ |------|------|--------|------|
226
+ | `title` | `ReactNode` | - | 모달 제목 |
227
+ | `subtitle` | `ReactNode` | - | 제목 아래 부제목 |
228
+ | `content` | `ReactNode \| ComponentType<AlertContentProps>` | - | 모달 내용 |
229
+ | `subtype` | `'info' \| 'success' \| 'warning' \| 'error'` | `'info'` | 모달 타입 |
230
+ | `dimmed` | `boolean` | `true` | 배경 어둡게 |
231
+ | `closeOnBackdropClick` | `boolean` | `true` | 배경 클릭 시 닫기 |
232
+ | `manualDestroy` | `boolean` | `false` | 수동 제거 모드 |
233
+ | `duration` | `number \| string` | - | 애니메이션 지속 시간 (Handler 레벨 오버라이드) |
234
+ | `background` | `ModalBackground<B>` | - | 배경 설정 |
235
+ | `footer` | `AlertFooterRender \| FooterOptions \| false` | - | 푸터 설정 |
236
+ | `ForegroundComponent` | `ComponentType<ModalFrameProps>` | - | 커스텀 전경 |
237
+ | `BackgroundComponent` | `ComponentType<ModalFrameProps>` | - | 커스텀 배경 |
238
+ | `signal` | `AbortSignal` | - | 모달 취소를 위한 AbortSignal |
239
+
240
+ #### 예제
241
+
242
+ ```typescript
243
+ await alert({
244
+ title: '성공',
245
+ content: '변경사항이 저장되었습니다.',
246
+ subtype: 'success',
247
+ footer: { confirm: '확인' },
248
+ });
249
+ ```
250
+
251
+ ---
252
+
253
+ ### confirm
254
+
255
+ 사용자 결정을 위한 확인 모달을 엽니다.
256
+
257
+ #### 시그니처
258
+
259
+ ```typescript
260
+ function confirm<B = any>(options: ConfirmProps<B>): Promise<boolean>;
261
+ ```
262
+
263
+ #### 매개변수
264
+
265
+ `alert`의 모든 옵션 포함, 추가:
266
+
267
+ | 옵션 | 타입 | 기본값 | 설명 |
268
+ |------|------|--------|------|
269
+ | `footer` | `ConfirmFooterRender \| FooterOptions \| false` | - | 푸터 설정 |
270
+
271
+ #### confirm용 FooterOptions
272
+
273
+ ```typescript
274
+ interface FooterOptions {
275
+ confirm?: string; // 확인 버튼 텍스트
276
+ cancel?: string; // 취소 버튼 텍스트
277
+ hideConfirm?: boolean; // 확인 버튼 숨김
278
+ hideCancel?: boolean; // 취소 버튼 숨김
279
+ }
280
+ ```
281
+
282
+ #### 반환값
283
+
284
+ - `true` - 사용자가 확인 버튼 클릭
285
+ - `false` - 사용자가 취소 또는 배경 클릭
286
+
287
+ #### 예제
288
+
289
+ ```typescript
290
+ const shouldDelete = await confirm({
291
+ title: '항목 삭제',
292
+ content: '이 작업은 되돌릴 수 없습니다.',
293
+ subtype: 'warning',
294
+ footer: {
295
+ confirm: '삭제',
296
+ cancel: '취소',
297
+ },
298
+ });
299
+
300
+ if (shouldDelete) {
301
+ await deleteItem();
302
+ }
303
+ ```
304
+
305
+ ---
306
+
307
+ ### prompt
308
+
309
+ 사용자 입력을 수집하는 프롬프트 모달을 엽니다.
310
+
311
+ #### 시그니처
312
+
313
+ ```typescript
314
+ function prompt<T, B = any>(options: PromptProps<T, B>): Promise<T>;
315
+ ```
316
+
317
+ #### 매개변수
318
+
319
+ `alert`의 모든 옵션 포함, 추가:
320
+
321
+ | 옵션 | 타입 | 필수 | 설명 |
322
+ |------|------|------|------|
323
+ | `Input` | `(props: PromptInputProps<T>) => ReactNode` | 예 | 입력 컴포넌트 |
324
+ | `defaultValue` | `T` | 아니오 | 기본값 |
325
+ | `disabled` | `(value: T) => boolean` | 아니오 | 확인 버튼 비활성화 |
326
+ | `returnOnCancel` | `boolean` | 아니오 | 취소 시 기본값 반환 |
327
+
328
+ #### PromptInputProps
329
+
330
+ ```typescript
331
+ interface PromptInputProps<T> {
332
+ value?: T; // 현재 값
333
+ defaultValue?: T; // 기본값
334
+ onChange: (value: T | undefined) => void; // 값 변경 핸들러
335
+ onConfirm: () => void; // 확인 핸들러
336
+ onCancel: () => void; // 취소 핸들러
337
+ context: any; // 사용자 정의 context
338
+ }
339
+ ```
340
+
341
+ #### 예제
342
+
343
+ ```typescript
344
+ // 간단한 입력
345
+ const email = await prompt<string>({
346
+ title: '이메일 입력',
347
+ defaultValue: '',
348
+ Input: ({ value, onChange }) => (
349
+ <input
350
+ type="email"
351
+ value={value}
352
+ onChange={(e) => onChange(e.target.value)}
353
+ />
354
+ ),
355
+ disabled: (value) => !value?.includes('@'),
356
+ });
357
+
358
+ // 복잡한 객체
359
+ interface UserData {
360
+ name: string;
361
+ age: number;
362
+ }
363
+
364
+ const userData = await prompt<UserData>({
365
+ title: '사용자 정보',
366
+ defaultValue: { name: '', age: 0 },
367
+ Input: ({ value, onChange }) => (
368
+ <form>
369
+ <input
370
+ value={value.name}
371
+ onChange={(e) => onChange({ ...value, name: e.target.value })}
372
+ placeholder="이름"
373
+ />
374
+ <input
375
+ type="number"
376
+ value={value.age}
377
+ onChange={(e) => onChange({ ...value, age: Number(e.target.value) })}
378
+ placeholder="나이"
379
+ />
380
+ </form>
381
+ ),
382
+ });
383
+ ```
384
+
385
+ ---
386
+
387
+ ## Hooks
388
+
389
+ ### useModal
390
+
391
+ 컴포넌트 생명주기에 연결된 모달 핸들러를 반환합니다.
392
+
393
+ ```typescript
394
+ function useModal(config?: Partial<ModalOptions>): {
395
+ alert: typeof alert;
396
+ confirm: typeof confirm;
397
+ prompt: typeof prompt;
398
+ };
399
+ ```
400
+
401
+ #### 핵심 기능
402
+
403
+ 컴포넌트가 언마운트될 때 모달이 자동으로 정리됩니다.
404
+
405
+ #### 비교
406
+
407
+ | 기능 | 정적 함수 | useModal 훅 |
408
+ |------|----------|------------|
409
+ | 생명주기 | 독립적 | 컴포넌트에 연결 |
410
+ | 정리 | 수동 | 자동 |
411
+ | 사용 위치 | 어디서나 | 컴포넌트 내부 |
412
+
413
+ #### 예제
414
+
415
+ ```typescript
416
+ function DeleteButton({ id }) {
417
+ const modal = useModal();
418
+
419
+ const handleDelete = async () => {
420
+ if (await modal.confirm({ content: '삭제하시겠습니까?' })) {
421
+ await deleteItem(id);
422
+ }
423
+ };
424
+
425
+ return <button onClick={handleDelete}>삭제</button>;
426
+ }
427
+ ```
428
+
429
+ ---
430
+
431
+ ### useActiveModalCount
432
+
433
+ 활성 모달의 개수를 반환합니다.
434
+
435
+ ```typescript
436
+ function useActiveModalCount(
437
+ validate?: (modal?: ModalNode) => boolean,
438
+ refreshKey?: string | number
439
+ ): number;
440
+ ```
441
+
442
+ #### 매개변수
443
+
444
+ | 매개변수 | 타입 | 설명 |
445
+ |----------|------|------|
446
+ | `validate` | `(modal) => boolean` | 필터 함수 |
447
+ | `refreshKey` | `string \| number` | 강제 새로고침 키 |
448
+
449
+ #### 예제
450
+
451
+ ```typescript
452
+ function ModalCounter() {
453
+ const total = useActiveModalCount();
454
+ const alerts = useActiveModalCount((m) => m?.type === 'alert');
455
+
456
+ return <div>전체: {total}, 알림: {alerts}</div>;
457
+ }
458
+ ```
459
+
460
+ ---
461
+
462
+ ### useModalAnimation
463
+
464
+ 애니메이션 상태 콜백을 제공합니다.
465
+
466
+ ```typescript
467
+ function useModalAnimation(
468
+ visible: boolean,
469
+ options: {
470
+ onVisible?: () => void;
471
+ onHidden?: () => void;
472
+ }
473
+ ): void;
474
+ ```
475
+
476
+ #### 특징
477
+
478
+ - 최적의 타이밍을 위해 `requestAnimationFrame` 사용
479
+ - 진입/퇴장 애니메이션 분리
480
+ - CSS 트랜지션과 함께 작동
481
+
482
+ #### 예제
483
+
484
+ ```typescript
485
+ function AnimatedModal({ visible, children }) {
486
+ const ref = useRef<HTMLDivElement>(null);
487
+
488
+ useModalAnimation(visible, {
489
+ onVisible: () => {
490
+ ref.current?.classList.add('fade-in');
491
+ },
492
+ onHidden: () => {
493
+ ref.current?.classList.remove('fade-in');
494
+ },
495
+ });
496
+
497
+ return <div ref={ref}>{children}</div>;
498
+ }
499
+ ```
500
+
501
+ ---
502
+
503
+ ### useModalDuration
504
+
505
+ 모달 애니메이션 지속 시간을 반환합니다.
506
+
507
+ ```typescript
508
+ function useModalDuration(modalId?: number): {
509
+ duration: string; // 예: '300ms'
510
+ milliseconds: number; // 예: 300
511
+ };
512
+ ```
513
+
514
+ ---
515
+
516
+ ### useDestroyAfter
517
+
518
+ 지정된 시간 후 모달을 자동으로 제거합니다.
519
+
520
+ ```typescript
521
+ function useDestroyAfter(
522
+ modalId: number,
523
+ duration: string | number
524
+ ): void;
525
+ ```
526
+
527
+ #### 동작
528
+
529
+ - 모달이 숨겨지면 타이머 시작
530
+ - 모달이 다시 보이면 타이머 취소
531
+ - 지속 시간 후 DOM에서 모달 제거
532
+
533
+ #### 예제
534
+
535
+ ```typescript
536
+ function ToastMessage({ id }) {
537
+ useDestroyAfter(id, 300); // 숨겨진 후 300ms 후 제거
538
+ return <div>토스트</div>;
539
+ }
540
+ ```
541
+
542
+ ---
543
+
544
+ ### useSubscribeModal
545
+
546
+ 모달 상태 변경을 구독합니다.
547
+
548
+ ```typescript
549
+ function useSubscribeModal(modal?: ModalNode): number;
550
+ ```
551
+
552
+ #### 반환값
553
+
554
+ 각 상태 변경 시 증가하는 버전 번호.
555
+
556
+ #### 예제
557
+
558
+ ```typescript
559
+ function ModalDebugger({ modal }) {
560
+ const version = useSubscribeModal(modal);
561
+
562
+ useEffect(() => {
563
+ console.log('상태 변경:', modal?.visible);
564
+ }, [version]);
565
+ }
566
+ ```
567
+
568
+ ---
569
+
570
+ ### useInitializeModal
571
+
572
+ 모달 서비스를 수동으로 초기화합니다.
573
+
574
+ ```typescript
575
+ function useInitializeModal(options?: {
576
+ mode?: 'auto' | 'manual';
577
+ }): {
578
+ initialize: (anchor?: HTMLElement) => void;
579
+ portal: ReactPortal | null;
580
+ };
581
+ ```
582
+
583
+ #### 모드
584
+
585
+ | 모드 | 설명 |
586
+ |------|------|
587
+ | `auto` | 자동 초기화 |
588
+ | `manual` | `initialize()` 호출 필요 |
589
+
590
+ ---
591
+
592
+ ### useModalOptions
593
+
594
+ 모달 옵션 설정을 반환합니다.
595
+
596
+ ```typescript
597
+ function useModalOptions(): ModalOptions;
598
+ ```
599
+
600
+ #### 반환값
601
+
602
+ ```typescript
603
+ interface ModalOptions {
604
+ duration?: number | string; // 애니메이션 지속 시간
605
+ backdrop?: string; // 배경 오버레이 색상
606
+ manualDestroy?: boolean; // 수동 제거 모드
607
+ closeOnBackdropClick?: boolean; // 배경 클릭 시 닫기
608
+ zIndex?: number; // CSS z-index
609
+ }
610
+ ```
611
+
612
+ #### 예제
613
+
614
+ ```typescript
615
+ function ModalDebugInfo() {
616
+ const options = useModalOptions();
617
+
618
+ return (
619
+ <div>
620
+ <p>Duration: {options.duration}</p>
621
+ <p>Backdrop: {options.backdrop}</p>
622
+ </div>
623
+ );
624
+ }
625
+ ```
626
+
627
+ ---
628
+
629
+ ### useModalBackdrop
630
+
631
+ 모달 배경 설정만 반환합니다.
632
+
633
+ ```typescript
634
+ function useModalBackdrop(): string | CSSProperties;
635
+ ```
636
+
637
+ #### 예제
638
+
639
+ ```typescript
640
+ function BackdropInfo() {
641
+ const backdrop = useModalBackdrop();
642
+
643
+ return <p>현재 배경: {backdrop}</p>;
644
+ }
645
+ ```
646
+
647
+ ---
648
+
649
+ ## 컴포넌트
650
+
651
+ ### ModalProvider
652
+
653
+ 초기화를 위한 메인 Provider 컴포넌트입니다.
654
+
655
+ ```typescript
656
+ interface ModalProviderProps {
657
+ children: ReactNode;
658
+ ForegroundComponent?: ComponentType<ModalFrameProps>;
659
+ BackgroundComponent?: ComponentType<ModalFrameProps>;
660
+ TitleComponent?: ComponentType<WrapperComponentProps>;
661
+ SubtitleComponent?: ComponentType<WrapperComponentProps>;
662
+ ContentComponent?: ComponentType<WrapperComponentProps>;
663
+ FooterComponent?: ComponentType<FooterComponentProps>;
664
+ options?: ModalOptions;
665
+ context?: Record<string, any>;
666
+ usePathname?: () => { pathname: string }; // 라우터 통합
667
+ root?: HTMLElement; // 커스텀 루트 엘리먼트
668
+ }
669
+ ```
670
+
671
+ #### ModalOptions
672
+
673
+ ```typescript
674
+ interface ModalOptions {
675
+ duration?: number | string; // 애니메이션 지속 시간
676
+ backdrop?: string; // 배경 오버레이 색상
677
+ manualDestroy?: boolean; // 수동 제거 모드
678
+ closeOnBackdropClick?: boolean; // 배경 클릭 시 닫기
679
+ }
680
+ ```
681
+
682
+ #### usePathname (라우터 통합)
683
+
684
+ `usePathname` prop을 사용하면 라우터와 통합할 수 있습니다. 경로가 변경되면 모달이 자동으로 닫힙니다.
685
+
686
+ ```typescript
687
+ import { useLocation } from 'react-router-dom';
688
+
689
+ <ModalProvider
690
+ usePathname={useLocation} // react-router-dom 통합
691
+ // ...
692
+ >
693
+ <App />
694
+ </ModalProvider>
695
+ ```
696
+
697
+ #### 예제
698
+
699
+ ```typescript
700
+ import { useLocation } from 'react-router-dom';
701
+
702
+ <ModalProvider
703
+ usePathname={useLocation}
704
+ ForegroundComponent={CustomForeground}
705
+ TitleComponent={CustomTitle}
706
+ SubtitleComponent={CustomSubtitle}
707
+ ContentComponent={CustomContent}
708
+ FooterComponent={CustomFooter}
709
+ options={{
710
+ duration: '200ms',
711
+ backdrop: 'rgba(0, 0, 0, 0.35)',
712
+ manualDestroy: true,
713
+ closeOnBackdropClick: true,
714
+ }}
715
+ context={{
716
+ theme: 'dark',
717
+ locale: 'ko-KR',
718
+ }}
719
+ >
720
+ <App />
721
+ </ModalProvider>
722
+ ```
723
+
724
+ ---
725
+
726
+ ### 커스텀 컴포넌트
727
+
728
+ #### ModalFrameProps
729
+
730
+ Foreground/Background 컴포넌트에 전달되는 Props입니다.
731
+
732
+ ```typescript
733
+ interface ModalFrameProps<Context = any, B = any> {
734
+ id: number; // 모달 ID
735
+ type: 'alert' | 'confirm' | 'prompt'; // 모달 타입
736
+ alive: boolean; // 활성 상태
737
+ visible: boolean; // 표시 상태
738
+ initiator: string; // 초기화 출처
739
+ manualDestroy: boolean; // 수동 제거 모드
740
+ closeOnBackdropClick: boolean; // 배경 클릭 닫기
741
+ background?: ModalBackground<B>; // 배경 데이터
742
+ onConfirm: () => void; // 확인 핸들러
743
+ onClose: () => void; // 닫기 핸들러
744
+ onChange: (value: any) => void; // 값 변경 핸들러
745
+ onDestroy: () => void; // 제거 핸들러
746
+ onChangeOrder: Function; // 순서 변경 핸들러
747
+ context: Context; // 사용자 정의 context
748
+ children: ReactNode; // 자식 요소
749
+ }
750
+ ```
751
+
752
+ #### FooterComponentProps
753
+
754
+ 푸터 컴포넌트용 Props입니다.
755
+
756
+ ```typescript
757
+ interface FooterComponentProps {
758
+ type: 'alert' | 'confirm' | 'prompt'; // 모달 타입
759
+ onConfirm: (value?: any) => void; // 확인 핸들러
760
+ onClose: () => void; // 닫기 핸들러
761
+ onCancel?: () => void; // 취소 핸들러
762
+ disabled?: boolean; // 비활성화 상태
763
+ footer?: FooterOptions; // 푸터 옵션
764
+ context: any; // 사용자 정의 context
765
+ }
766
+ ```
767
+
768
+ #### WrapperComponentProps
769
+
770
+ title/subtitle/content 컴포넌트용 Props입니다.
771
+
772
+ ```typescript
773
+ interface WrapperComponentProps {
774
+ children: ReactNode; // 자식 요소
775
+ context: any; // 사용자 정의 context
776
+ }
777
+ ```
778
+
779
+ #### 커스텀 컴포넌트 예제
780
+
781
+ ```typescript
782
+ const CustomForeground: FC<ModalFrameProps> = ({
783
+ id,
784
+ visible,
785
+ children,
786
+ onClose,
787
+ }) => {
788
+ const ref = useRef<HTMLDivElement>(null);
789
+ const { duration } = useModalDuration();
790
+
791
+ useModalAnimation(visible, {
792
+ onVisible: () => ref.current?.classList.add('visible'),
793
+ onHidden: () => ref.current?.classList.remove('visible'),
794
+ });
795
+
796
+ useDestroyAfter(id, duration);
797
+
798
+ return (
799
+ <div
800
+ ref={ref}
801
+ style={{
802
+ background: 'white',
803
+ borderRadius: 12,
804
+ padding: 24,
805
+ opacity: 0,
806
+ transition: `opacity ${duration}ms`,
807
+ }}
808
+ >
809
+ {children}
810
+ </div>
811
+ );
812
+ };
813
+ ```
814
+
815
+ ---
816
+
817
+ ## 타입 정의
818
+
819
+ ### 모달 타입
820
+
821
+ ```typescript
822
+ type ModalType = 'alert' | 'confirm' | 'prompt';
823
+ type ModalSubtype = 'info' | 'success' | 'warning' | 'error';
824
+ ```
825
+
826
+ ### ModalBackground
827
+
828
+ ```typescript
829
+ interface ModalBackground<T = any> {
830
+ data?: T;
831
+ [key: string]: any;
832
+ }
833
+ ```
834
+
835
+ ### Content Props
836
+
837
+ ```typescript
838
+ // Alert 컨텐츠 Props
839
+ interface AlertContentProps {
840
+ onConfirm: () => void;
841
+ context: any;
842
+ }
843
+
844
+ // Confirm 컨텐츠 Props
845
+ interface ConfirmContentProps {
846
+ onConfirm: () => void;
847
+ onCancel: () => void;
848
+ context: any;
849
+ }
850
+
851
+ // Prompt 컨텐츠 Props
852
+ interface PromptContentProps<T> {
853
+ value?: T;
854
+ onChange: (value: T) => void;
855
+ onConfirm: () => void;
856
+ onCancel: () => void;
857
+ context: any;
858
+ }
859
+ ```
860
+
861
+ ---
862
+
863
+ ## 사용 패턴
864
+
865
+ ### 패턴 1: 정적 API (가장 간단)
866
+
867
+ ```typescript
868
+ import { alert, confirm, prompt } from '@lerx/promise-modal';
869
+
870
+ // React 외부에서도 사용 가능
871
+ async function handleSubmit() {
872
+ if (await confirm({ content: '변경사항을 저장하시겠습니까?' })) {
873
+ await saveData();
874
+ await alert({ content: '저장되었습니다!' });
875
+ }
876
+ }
877
+ ```
878
+
879
+ ### 패턴 2: useModal 훅 (컴포넌트에 권장)
880
+
881
+ ```typescript
882
+ function EditForm() {
883
+ const modal = useModal();
884
+
885
+ const handleSave = async () => {
886
+ if (await modal.confirm({ content: '저장하시겠습니까?' })) {
887
+ // 컴포넌트 언마운트 시 모달 자동 정리
888
+ }
889
+ };
890
+ }
891
+ ```
892
+
893
+ ### 패턴 3: 전체 커스터마이징
894
+
895
+ ```typescript
896
+ <ModalProvider
897
+ ForegroundComponent={CustomForeground}
898
+ FooterComponent={CustomFooter}
899
+ options={{ duration: 400 }}
900
+ context={{ theme: 'dark' }}
901
+ >
902
+ <App />
903
+ </ModalProvider>
904
+ ```
905
+
906
+ ### 패턴 4: 개별 모달 오버라이드
907
+
908
+ ```typescript
909
+ await alert({
910
+ content: '특별한 모달',
911
+ ForegroundComponent: SpecialForeground,
912
+ background: { variant: 'special' },
913
+ });
914
+ ```
915
+
916
+ ---
917
+
918
+ ## 고급 예제
919
+
920
+ ### 토스트 알림
921
+
922
+ ```typescript
923
+ const ToastForeground: FC<ModalFrameProps> = ({
924
+ id,
925
+ visible,
926
+ children,
927
+ onClose,
928
+ }) => {
929
+ const ref = useRef<HTMLDivElement>(null);
930
+ const { duration } = useModalDuration();
931
+
932
+ useEffect(() => {
933
+ const timer = setTimeout(onClose, 3000);
934
+ return () => clearTimeout(timer);
935
+ }, [onClose]);
936
+
937
+ useModalAnimation(visible, {
938
+ onVisible: () => ref.current?.classList.add('visible'),
939
+ onHidden: () => ref.current?.classList.remove('visible'),
940
+ });
941
+
942
+ useDestroyAfter(id, duration);
943
+
944
+ return (
945
+ <div
946
+ ref={ref}
947
+ style={{
948
+ position: 'fixed',
949
+ bottom: 20,
950
+ left: '50%',
951
+ transform: 'translateX(-50%) translateY(100px)',
952
+ opacity: 0,
953
+ transition: `all ${duration}ms`,
954
+ }}
955
+ >
956
+ {children}
957
+ </div>
958
+ );
959
+ };
960
+
961
+ export const toast = (message: ReactNode) => {
962
+ return alert({
963
+ content: message,
964
+ ForegroundComponent: ToastForeground,
965
+ footer: false,
966
+ dimmed: false,
967
+ closeOnBackdropClick: false,
968
+ });
969
+ };
970
+
971
+ // 사용
972
+ toast('작업이 완료되었습니다!');
973
+ ```
974
+
975
+ ### 다단계 마법사
976
+
977
+ ```typescript
978
+ async function registrationWizard() {
979
+ // 1단계: 약관 동의
980
+ const accepted = await confirm({
981
+ title: '이용약관',
982
+ content: <TermsContent />,
983
+ footer: { confirm: '동의합니다', cancel: '거부' },
984
+ });
985
+
986
+ if (!accepted) return null;
987
+
988
+ // 2단계: 사용자 정보
989
+ const userInfo = await prompt<{
990
+ name: string;
991
+ email: string;
992
+ }>({
993
+ title: '사용자 정보',
994
+ defaultValue: { name: '', email: '' },
995
+ Input: RegistrationForm,
996
+ disabled: (v) => !v.name || !v.email?.includes('@'),
997
+ });
998
+
999
+ if (!userInfo) return null;
1000
+
1001
+ // 3단계: 완료
1002
+ await alert({
1003
+ title: '환영합니다!',
1004
+ content: `${userInfo.name}님의 계정이 생성되었습니다`,
1005
+ subtype: 'success',
1006
+ });
1007
+
1008
+ return userInfo;
1009
+ }
1010
+ ```
1011
+
1012
+ ### 커스텀 앵커 (Iframe/Portal)
1013
+
1014
+ ```typescript
1015
+ function IframedModals() {
1016
+ const { initialize } = useInitializeModal({ mode: 'manual' });
1017
+ const containerRef = useRef<HTMLDivElement>(null);
1018
+
1019
+ useEffect(() => {
1020
+ if (containerRef.current) {
1021
+ initialize(containerRef.current);
1022
+ }
1023
+ }, [initialize]);
1024
+
1025
+ return (
1026
+ <div style={{ position: 'relative', height: 600 }}>
1027
+ <div ref={containerRef} style={{ height: '100%' }} />
1028
+ <ModalTriggerButtons />
1029
+ </div>
1030
+ );
1031
+ }
1032
+ ```
1033
+
1034
+ ---
1035
+
1036
+ ## 생명주기
1037
+
1038
+ ```
1039
+ 생성 → 표시 → 숨김 → 제거
1040
+ ↓ ↓ ↓ ↓
1041
+ open() visible onHide onDestroy
1042
+ ↓ true ↓ ↓
1043
+ nodeFactory ↓ visible alive
1044
+ ↓ ↓ false false
1045
+ Promise animation ↓ ↓
1046
+ ↓ starts duration DOM에서
1047
+ ... ↓ passes 제거됨
1048
+ ↓ ↓
1049
+ interaction destroy
1050
+
1051
+ resolve
1052
+ ```
1053
+
1054
+ ---
1055
+
1056
+ ## 모범 사례
1057
+
1058
+ 1. **앱 루트에 ModalProvider 배치** - 최상위 레벨에 Provider 래핑
1059
+ 2. **컴포넌트 내에서는 useModal 사용** - 자동 정리를 위해
1060
+ 3. **유틸리티에는 정적 API 사용** - React 외부 코드에서
1061
+ 4. **Provider 레벨에서 커스터마이징** - 일관된 스타일링을 위해
1062
+ 5. **의미론적으로 subtype 사용** - info, success, warning, error
1063
+ 6. **항상 await 또는 처리** - Promise 거부 처리
1064
+ 7. **모달 내용은 간단하게** - 복잡한 상태 피하기
1065
+ 8. **접근성 테스트** - 키보드 네비게이션 확인
1066
+
1067
+ ---
1068
+
1069
+ ## AbortSignal 지원
1070
+
1071
+ 모달을 프로그래밍 방식으로 취소할 수 있는 `AbortSignal` 지원을 제공합니다.
1072
+
1073
+ ### 기본 사용법
1074
+
1075
+ ```typescript
1076
+ const controller = new AbortController();
1077
+
1078
+ alert({
1079
+ title: '취소 가능한 모달',
1080
+ content: '3초 후 자동으로 닫힙니다.',
1081
+ signal: controller.signal,
1082
+ });
1083
+
1084
+ // 3초 후 모달 취소
1085
+ setTimeout(() => {
1086
+ controller.abort();
1087
+ }, 3000);
1088
+ ```
1089
+
1090
+ ### 수동 취소 제어
1091
+
1092
+ ```typescript
1093
+ function ManualAbortControl() {
1094
+ const [controller, setController] = useState<AbortController | null>(null);
1095
+
1096
+ const handleOpen = () => {
1097
+ const newController = new AbortController();
1098
+ setController(newController);
1099
+
1100
+ alert({
1101
+ title: '수동 취소 가능',
1102
+ content: '"취소" 버튼을 클릭하면 모달이 닫힙니다.',
1103
+ signal: newController.signal,
1104
+ closeOnBackdropClick: false,
1105
+ }).then(() => {
1106
+ setController(null);
1107
+ });
1108
+ };
1109
+
1110
+ const handleAbort = () => {
1111
+ if (controller) {
1112
+ controller.abort();
1113
+ }
1114
+ };
1115
+
1116
+ return (
1117
+ <div>
1118
+ <button onClick={handleOpen} disabled={!!controller}>
1119
+ 모달 열기
1120
+ </button>
1121
+ <button onClick={handleAbort} disabled={!controller}>
1122
+ 모달 취소
1123
+ </button>
1124
+ </div>
1125
+ );
1126
+ }
1127
+ ```
1128
+
1129
+ ### 여러 모달 일괄 취소
1130
+
1131
+ ```typescript
1132
+ function MultipleModalsAbort() {
1133
+ const [controllers, setControllers] = useState<AbortController[]>([]);
1134
+
1135
+ const handleOpenMultiple = () => {
1136
+ const newControllers: AbortController[] = [];
1137
+
1138
+ for (let i = 0; i < 3; i++) {
1139
+ const controller = new AbortController();
1140
+ newControllers.push(controller);
1141
+
1142
+ alert({
1143
+ title: `모달 ${i + 1}`,
1144
+ content: `이것은 ${i + 1}번째 모달입니다.`,
1145
+ signal: controller.signal,
1146
+ closeOnBackdropClick: false,
1147
+ });
1148
+ }
1149
+
1150
+ setControllers(newControllers);
1151
+ };
1152
+
1153
+ const handleAbortAll = () => {
1154
+ controllers.forEach((controller) => controller.abort());
1155
+ setControllers([]);
1156
+ };
1157
+
1158
+ return (
1159
+ <div>
1160
+ <button onClick={handleOpenMultiple}>3개 모달 열기</button>
1161
+ <button onClick={handleAbortAll}>모든 모달 취소</button>
1162
+ </div>
1163
+ );
1164
+ }
1165
+ ```
1166
+
1167
+ ### 이미 취소된 Signal 처리
1168
+
1169
+ ```typescript
1170
+ // Signal이 이미 취소된 상태로 전달되면 모달이 즉시 닫힙니다
1171
+ const controller = new AbortController();
1172
+ controller.abort(); // 먼저 취소
1173
+
1174
+ alert({
1175
+ title: '즉시 닫힘',
1176
+ content: 'Signal이 이미 취소되어 즉시 닫힙니다.',
1177
+ signal: controller.signal,
1178
+ }).then(() => {
1179
+ console.log('모달이 즉시 닫혔습니다.');
1180
+ });
1181
+ ```
1182
+
1183
+ ---
1184
+
1185
+ ## 라이선스
1186
+
1187
+ MIT License
1188
+
1189
+ ---
1190
+
1191
+ ## 버전
1192
+
1193
+ 현재: package.json 참조