Files
Gnuboard7/docs/frontend/modal-usage.md
T
2026-04-01 10:30:52 +09:00

919 lines
27 KiB
Markdown

# Modal 컴포넌트 사용 가이드
> **그누보드7 템플릿 엔진에서 Modal 컴포넌트를 올바르게 사용하는 방법**
## TL;DR (5초 요약)
```text
1. modals 섹션 모달은 openModal 핸들러로 열고, closeModal 핸들러로 닫음
2. 부모 레이아웃 상태 접근: $parent._local, $parent._global (engine-v1.16.0+)
3. 부모 상태 수정: G7Core.state.setParentLocal() 또는 setState target="$parent._local"
4. 로딩 상태 등 공유 상태는 _global 사용 권장 (모달 간 공유 필요 시)
5. 삭제/위험 작업 버튼은 로딩 중 흐림 + 스피너 + 텍스트 변경 필수
```
---
## 목차
1. [모달 위치에 따른 제어 방식](#모달-위치에-따른-제어-방식)
2. [modals 섹션 모달 구조](#modals-섹션-모달-구조)
3. [모달 열기/닫기](#모달-열기닫기)
4. [$parent 바인딩 컨텍스트 (engine-v1.16.0+)](#parent-바인딩-컨텍스트-v1160)
5. [Footer 버튼 배치](#footer-버튼-배치)
6. [위험 작업 버튼 로딩 상태](#위험-작업-버튼-로딩-상태)
7. [일반 액션 버튼 로딩 상태](#일반-액션-버튼-로딩-상태)
8. [완전한 예제](#완전한-예제)
9. [잘못된 패턴](#잘못된-패턴)
10. [체크리스트](#체크리스트)
---
## 모달 위치에 따른 제어 방식
그누보드7 모달 시스템에는 두 가지 배치 방식이 있습니다.
### 1. `modals` 섹션 (권장)
```json
{
"modals": {
"delete_confirm_modal": {
"$ref": "partials/xxx/_modal_delete_confirm.json"
}
}
}
```
**특징:**
- 초기 렌더 트리에 포함되지 않음
- `openModal` 핸들러 호출 시 `_global.modalStack`에 동적 추가
- 레이아웃 전체에서 재사용 가능
- **반드시 `openModal`/`closeModal` 핸들러로 제어**
### 2. `children`/`slots` 내부 인라인 (특수 케이스)
```json
{
"children": [
{
"type": "composite",
"name": "Modal",
"props": {
"show": "{{_local.showModal}}"
}
}
]
}
```
**특징:**
- 초기 렌더 트리에 포함됨
- `show` prop으로 표시/숨김 제어
- 해당 컴포넌트 컨텍스트에서만 사용
### 사용 방식 결정 표
| 모달 위치 | 제어 방식 | show prop | id 필수 |
|----------|----------|-----------|---------|
| `modals` 섹션 | `openModal`/`closeModal` | ❌ 사용 금지 | ✅ 필수 |
| `children` 인라인 | `show` prop 바인딩 | ✅ 필수 | ❌ 선택 |
---
## modals 섹션 모달 구조
### 필수 구조
```json
{
"meta": {
"is_partial": true,
"description": "Modal Description"
},
"id": "modal_unique_id",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:module.modal.title",
"size": "medium"
},
"children": [
{ "/* 모달 본문 내용 */" },
{ "/* Footer 버튼 Div */" }
]
}
```
### 필수 속성
| 속성 | 필수 | 설명 |
|------|------|------|
| `id` | ✅ | `openModal` 핸들러에서 참조하는 고유 ID |
| `type` | ✅ | `"composite"` |
| `name` | ✅ | `"Modal"` |
| `props.title` | ✅ | 모달 제목 (다국어 권장) |
| `props.size` | ❌ | `"sm"`, `"medium"`, `"lg"` 등 |
| `children` | ✅ | 모달 내용 (footer 포함) |
### 금지 속성
| 속성 | 이유 |
|------|------|
| `props.show` | modals 섹션에서는 무시됨 |
| `slots` | 지원되지 않음 - children 사용 |
| `slots.footer` | 지원되지 않음 - children 끝에 Div로 배치 |
| `slots.default` | 지원되지 않음 - children 사용 |
---
## 모달 열기/닫기
### 모달 열기 - `openModal`
```json
{
"type": "click",
"handler": "openModal",
"target": "delete_confirm_modal"
}
```
**주의:** `target` 값은 모달의 `id` 속성과 일치해야 합니다.
### 모달 닫기 - `closeModal`
```json
{
"type": "click",
"handler": "closeModal"
}
```
**주의:** 파라미터 없이 호출하면 현재 열린 모달(스택 최상단)이 닫힙니다.
### 데이터와 함께 모달 열기
모달에 데이터를 전달하려면 `openModal` 전에 `setState`로 데이터를 **반드시 `_global`에** 설정합니다.
```json
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"deleteProduct": {
"target": "{{$item}}",
"canDelete": true
}
}
},
{
"handler": "openModal",
"target": "delete_confirm_modal"
}
]
}
```
모달 내에서 `{{_global.deleteProduct.target.id}}`로 접근합니다.
### 모달 데이터는 `_global` 또는 `$parent` 사용
| 저장 위치 | 모달에서 접근 | 권장 용도 |
|----------|--------------|----------|
| `_global` | ✅ 가능 | 로딩 상태, 모달 간 공유 데이터 |
| `$parent._local` | ✅ 가능 (engine-v1.16.0+) | 부모 폼 데이터 읽기/수정 |
| `_local` | ❌ 불가 | 모달 자체의 로컬 상태만 |
```json
// ✅ 올바른 패턴 - _global 사용 (로딩 상태 등)
{
"handler": "setState",
"params": {
"target": "global",
"isDeleting": true
}
}
// ✅ 올바른 패턴 - $parent 사용 (부모 폼 데이터 접근, engine-v1.16.0+)
{
"text": "{{$parent._local.form.product_name}}"
}
// ❌ 잘못된 패턴 - _local은 모달 자체 상태만 접근
{
"text": "{{_local.form.product_name}}"
}
```
---
## $parent 바인딩 컨텍스트 (engine-v1.16.0+)
모달에서 부모 레이아웃의 상태에 직접 접근하고 수정할 수 있습니다.
### 부모 상태 읽기
```json
{
"type": "basic",
"name": "P",
"text": "상품명: {{$parent._local.form.product_name}}"
}
```
| 경로 | 설명 |
|------|------|
| `$parent._local` | 부모 레이아웃의 로컬 상태 |
| `$parent._global` | 부모 레이아웃의 전역 상태 |
| `$parent._computed` | 부모 레이아웃의 계산된 값 |
### 부모 상태 수정 - 레이아웃 JSON
```json
{
"type": "click",
"handler": "setState",
"params": {
"target": "$parent._local",
"form.label_assignments": "{{filteredAssignments}}"
}
}
```
### 부모 상태 수정 - 커스텀 핸들러
```typescript
export const myHandler: ActionHandler = async (action, context) => {
const G7Core = (window as any).G7Core;
// 부모 상태 읽기
const parentContext = G7Core?.state?.getParent?.();
const parentLocal = parentContext?._local || {};
// 부모 상태 수정
G7Core?.state?.setParentLocal?.({
'form.field': newValue,
hasChanges: true,
});
// 모달 닫기
G7Core?.modal?.close?.();
};
```
### $parent vs _global 선택 기준
| 상황 | 권장 방식 | 이유 |
|------|----------|------|
| 부모 폼 데이터 읽기 | `$parent._local` | 데이터 복사 불필요, 항상 최신 값 |
| 부모 폼 데이터 수정 | `setParentLocal()` | 부모 상태 직접 수정 |
| 로딩 상태 | `_global` | 모달 간 공유, 단순함 |
| 모달 결과 전달 | `_global` 또는 `setParentLocal()` | 용도에 따라 선택 |
### 주의사항
1. **핸들러 순서**: 부모 상태 수정 핸들러를 `closeModal`보다 **먼저** 실행
```json
{
"actions": [
{ "handler": "myModule.updateParentState" },
{ "handler": "closeModal" }
]
}
```
2. **캐시 스킵**: `$parent` 경로는 자동으로 캐시되지 않음 (항상 최신 값)
3. **중첩 모달**: 모달 안의 모달에서 `$parent`는 바로 위 부모만 참조
### 선택적 리렌더링 (engine-v1.17.0+)
`$parent._local` 상태 변경 시 **모달만** 선택적으로 리렌더링됩니다.
**내부 메커니즘:**
```text
$parent._local 변경
↓
triggerModalParentUpdate() 호출
↓
ParentContextProvider의 version 상태만 업데이트
↓
모달 컴포넌트만 리렌더링 (다른 컴포넌트 영향 없음)
```
**관련 파일:**
| 파일 | 역할 |
|------|------|
| `ParentContextProvider.tsx` | 모달 전용 Context, version 상태 관리 |
| `DynamicRenderer.tsx` | `useParentContext` 훅으로 부모 컨텍스트 구독 |
| `ActionDispatcher.ts` | `$parent._local` 변경 시 트리거 호출 |
**성능 고려사항:**
- engine-v1.16.0: `_global` 변경으로 전체 앱 리렌더링 (성능 이슈)
- engine-v1.17.0+: `ParentContextProvider`로 모달만 리렌더링 (최적화)
---
## Footer 버튼 배치
### 올바른 패턴 ✅
Footer 버튼은 `children` 배열의 **마지막 요소**로 Div에 감싸서 배치합니다.
```json
{
"children": [
{ "/* 본문 내용들 */" },
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
},
"actions": [{ "type": "click", "handler": "closeModal" }],
"children": [{ "type": "basic", "name": "Span", "text": "$t:common.cancel" }]
},
{
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
},
"actions": [{ "/* 삭제 등 주요 액션 */" }],
"children": [{ "type": "basic", "name": "Span", "text": "$t:common.delete" }]
}
]
}
]
}
```
### Footer 스타일 규칙
| 요소 | 클래스 |
|------|--------|
| Footer 컨테이너 | `flex justify-end gap-3 mt-6` |
| 취소 버튼 | `px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700` |
| 위험 버튼 (삭제) | `px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700` |
| 주요 버튼 (확인) | `px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700` |
---
## 위험 작업 버튼 로딩 상태
삭제, 제거, 해제 등 위험한 작업을 수행하는 모달 버튼은 **반드시** 로딩 상태를 표시해야 합니다.
### 필수 구현 요소
| 요소 | 설명 | 필수 |
|------|------|------|
| **`_global` 상태 사용** | 로딩 상태는 반드시 `_global`에 저장 (모달은 `_local` 접근 불가) | ✅ |
| 버튼 비활성화 | `disabled` prop으로 중복 클릭 방지 | ✅ |
| 취소 버튼 비활성화 | 작업 중 취소 버튼도 비활성화 | ✅ |
| 스피너 아이콘 | 로딩 중 회전 아이콘 표시 | ✅ |
| 텍스트 변경 | "삭제" → "삭제 중..." 등 | ✅ |
| opacity 감소 | `disabled:opacity-50` 클래스 | ✅ |
### 모달 내 상태는 `_global` 사용 필수
```json
// ✅ 올바른 패턴 - _global 사용
"disabled": "{{_global.isDeleting}}"
"if": "{{_global.isDeleting}}"
{ "handler": "setState", "params": { "target": "global", "isDeleting": true } }
// ❌ 잘못된 패턴 - _local은 모달에서 접근 불가
"disabled": "{{_global.isDeleting}}"
"if": "{{_global.isDeleting}}"
{ "handler": "setState", "params": { "target": "global", "isDeleting": true } }
```
### 표준 패턴
```json
{
"type": "basic",
"name": "Div",
"props": { "className": "flex justify-end gap-3 mt-6" },
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isDeleting}}"
},
"actions": [{ "type": "click", "handler": "closeModal" }],
"children": [{ "type": "basic", "name": "Span", "text": "$t:common.cancel" }]
},
{
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isDeleting}}"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "global", "isDeleting": true } },
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/items/{{_global.deleteItem.id}}",
"params": { "method": "DELETE" },
"onSuccess": [
{ "handler": "closeModal" },
{ "handler": "setState", "params": { "target": "global", "isDeleting": false } },
{ "handler": "toast", "params": { "type": "success", "message": "$t:messages.deleted" } },
{ "handler": "refetchDataSource", "params": { "dataSourceId": "items" } }
],
"onError": [
{ "handler": "setState", "params": { "target": "global", "isDeleting": false } },
{ "handler": "toast", "params": { "type": "error", "message": "{{$error.message}}" } }
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_global.isDeleting}}",
"props": { "name": "spinner", "className": "w-4 h-4 animate-spin" }
},
{
"type": "basic",
"name": "Span",
"text": "{{_global.isDeleting ? '$t:common.deleting' : '$t:common.delete'}}"
}
]
}
]
}
```
### 핵심 구현 포인트
- **버튼 클래스에 `flex items-center gap-2` 추가**: 아이콘과 텍스트를 나란히 배치
- **스피너 아이콘**: `if` 조건으로 로딩 중에만 표시
```json
{
"type": "basic",
"name": "Icon",
"if": "{{_global.isDeleting}}",
"props": { "name": "spinner", "className": "w-4 h-4 animate-spin" }
}
```
- **텍스트 삼항 연산자**: 상태에 따라 텍스트 변경
```json
{
"text": "{{_global.isDeleting ? '$t:common.deleting' : '$t:common.delete'}}"
}
```
- **취소 버튼도 비활성화**: 작업 중 모달 닫기 방지
### 다국어 키
| 키 | 한국어 | 영어 |
| --- | ------ | ---- |
| `$t:common.delete` | 삭제 | Delete |
| `$t:common.deleting` | 삭제 중... | Deleting... |
| `$t:common.cancel` | 취소 | Cancel |
---
## 일반 액션 버튼 로딩 상태
저장, 전송, 확인, 다운로드 등 **API를 호출하는 모든 모달 버튼**은 반드시 로딩 상태를 표시해야 합니다.
위험 작업뿐 아니라 일반 액션도 동일한 스피너 패턴을 적용합니다.
### 액션별 상태 변수 네이밍
| 액션 유형 | `_global` 상태 변수 | 버튼 색상 | 텍스트 변경 |
|----------|-------------------|-----------|------------|
| 삭제/제거 | `_global.isDeleting` | `bg-red-600` | 삭제 → 삭제 중... |
| 저장/수정 | `_global.isSaving` | `bg-blue-600` 또는 `bg-indigo-600` | 저장 → 저장 중... |
| 전송 (SMS/Email) | `_global.isSending` | `bg-blue-600` | 전송 → 전송 중... |
| 확인/실행 | `_global.isProcessing` | `bg-blue-600` 또는 해당 색상 | 확인 → 처리 중... |
| 다운로드 | `_global.isDownloading` | `bg-blue-600` | 다운로드 → 다운로드 중... |
### 일반 액션 표준 패턴 (저장 예시)
```json
{
"type": "basic",
"name": "Div",
"props": { "className": "flex justify-end gap-3 mt-6" },
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isSaving}}"
},
"text": "$t:common.cancel",
"actions": [{ "type": "click", "handler": "closeModal" }]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "flex items-center gap-2 px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isSaving}}"
},
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_global.isSaving}}",
"props": { "name": "spinner", "className": "w-4 h-4 animate-spin" }
},
{
"type": "basic",
"name": "Span",
"text": "{{_global.isSaving ? '$t:common.saving' : '$t:common.save'}}"
}
],
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "global", "isSaving": true } },
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/items",
"params": { "method": "POST", "body": {} },
"onSuccess": [
{ "handler": "setState", "params": { "target": "global", "isSaving": false } },
{ "handler": "closeModal" },
{ "handler": "toast", "params": { "type": "success", "message": "{{response.message}}" } },
{ "handler": "refetchDataSource", "params": { "dataSourceId": "items" } }
],
"onError": [
{ "handler": "setState", "params": { "target": "global", "isSaving": false } },
{ "handler": "toast", "params": { "type": "error", "message": "{{$error.message}}" } }
]
}
]
}
]
}
]
}
```
### 커스텀 핸들러 호출 시 스피너 패턴
커스텀 핸들러를 사용하는 경우에도 동일한 스피너 패턴을 적용합니다.
핸들러 내부에서 `setState`를 직접 관리하는 경우, 버튼의 `disabled`와 `children` 스피너는 동일하게 구현합니다.
```json
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "flex items-center gap-2 px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isProcessing}}"
},
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_global.isProcessing}}",
"props": { "name": "spinner", "className": "w-4 h-4 animate-spin" }
},
{
"type": "basic",
"name": "Span",
"text": "{{_global.isProcessing ? '$t:common.processing' : '$t:common.confirm'}}"
}
],
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "global", "isProcessing": true } },
{
"handler": "myModule.customHandler",
"params": { "/* 핸들러 파라미터 */" },
"onSuccess": [
{ "handler": "setState", "params": { "target": "global", "isProcessing": false } },
{ "handler": "closeModal" }
],
"onError": [
{ "handler": "setState", "params": { "target": "global", "isProcessing": false } },
{ "handler": "toast", "params": { "type": "error", "message": "{{$error.message}}" } }
]
}
]
}
]
}
```
### 일반 액션 다국어 키
| 키 | 한국어 | 영어 |
| --- | ------ | ---- |
| `$t:common.save` | 저장 | Save |
| `$t:common.saving` | 저장 중... | Saving... |
| `$t:common.confirm` | 확인 | Confirm |
| `$t:common.processing` | 처리 중... | Processing... |
| `$t:common.send` | 전송 | Send |
| `$t:common.sending` | 전송 중... | Sending... |
| `$t:common.download` | 다운로드 | Download |
| `$t:common.downloading` | 다운로드 중... | Downloading... |
---
## 완전한 예제
### 삭제 확인 모달
```json
{
"meta": {
"is_partial": true,
"description": "Delete Confirmation Modal"
},
"id": "delete_confirm_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:module.delete_modal.title",
"size": "medium"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "space-y-4" },
"children": [
{
"type": "basic",
"name": "P",
"props": { "className": "text-base text-gray-900 dark:text-white" },
"text": "$t:module.delete_modal.confirm_message"
},
{
"type": "basic",
"name": "P",
"props": { "className": "text-sm text-red-600 dark:text-red-400 font-medium" },
"text": "$t:module.delete_modal.warning"
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex justify-end gap-3 mt-6" },
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isDeleting}}"
},
"actions": [{ "type": "click", "handler": "closeModal" }],
"children": [{ "type": "basic", "name": "Span", "text": "$t:common.cancel" }]
},
{
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isDeleting}}"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": { "target": "global", "isDeleting": true }
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/items/{{_global.modal_data.itemId}}",
"params": { "method": "DELETE" },
"onSuccess": [
{ "handler": "closeModal" },
{ "handler": "setState", "params": { "target": "global", "isDeleting": false } },
{ "handler": "refetchDataSource", "params": { "dataSourceId": "items" } },
{ "handler": "toast", "params": { "type": "success", "message": "$t:module.messages.deleted" } }
],
"onError": [
{ "handler": "setState", "params": { "target": "global", "isDeleting": false } },
{ "handler": "toast", "params": { "type": "error", "message": "{{$error.message}}" } }
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_global.isDeleting}}",
"props": { "name": "spinner", "className": "w-4 h-4 animate-spin" }
},
{
"type": "basic",
"name": "Span",
"text": "{{_global.isDeleting ? '$t:common.deleting' : '$t:common.delete'}}"
}
]
}
]
}
]
}
```
---
## 잘못된 패턴
### ❌ slots 사용
```json
{
"id": "my_modal",
"type": "composite",
"name": "Modal",
"children": [{ "/* 본문 */" }],
"slots": {
"footer": [
{ "/* 버튼들 - 이렇게 하면 렌더링되지 않음! */" }
]
}
}
```
**문제:** Modal 컴포넌트는 `slots` 속성을 지원하지 않습니다. footer가 렌더링되지 않습니다.
### ❌ show prop 사용 (modals 섹션)
```json
{
"modals": {
"my_modal": {
"id": "my_modal",
"type": "composite",
"name": "Modal",
"props": {
"show": "{{_local.showModal}}"
}
}
}
}
```
**문제:** modals 섹션의 모달은 초기에 렌더링되지 않으므로 `show` prop이 무시됩니다.
### ❌ slots.default 사용
```json
{
"id": "my_modal",
"type": "composite",
"name": "Modal",
"slots": {
"default": [{ "/* 본문 */" }]
}
}
```
**문제:** `slots.default` 대신 `children`을 사용해야 합니다.
---
## 모달 내 Extension Point (v1.17.0+)
모달 내부에서도 `extension_point`를 정의할 수 있으며, 플러그인/모듈이 자동으로 주입됩니다.
```json
{
"id": "address_modal",
"type": "composite",
"name": "Modal",
"children": [
{
"type": "extension_point",
"name": "address_search_slot",
"props": {
"onAddressSelect": {
"handler": "setState",
"params": {
"target": "local",
"form.zipcode": "{{$event.zipcode}}"
}
}
}
}
]
}
```
```text
✅ modals 섹션 내부의 extension_point도 백엔드에서 재귀적으로 처리됨
✅ 동일 extension_point name이 components와 modals에 모두 있으면 양쪽 모두 주입됨
모달 내 extension_point의 callbackAction에서 setState 시 target은 "local" 사용
(모달 자체의 _local 스코프에 쓰기 — 부모 상태는 "$parent._local" 사용)
```
> 상세: [layout-extensions.md](../../extension/layout-extensions.md) "지원 위치" 섹션 참조
---
## 체크리스트
### 모달 파일 작성 시
```
□ id 속성이 있는가?
□ type: "composite", name: "Modal"인가?
□ props에 show가 없는가? (modals 섹션인 경우)
□ slots 속성이 없는가?
□ 모든 내용이 children에 있는가?
□ footer 버튼이 children 마지막 Div에 있는가?
□ footer Div에 "flex justify-end gap-3 mt-6" 클래스가 있는가?
□ 취소 버튼에 closeModal 핸들러가 있는가?
```
### 위험 작업 버튼 (삭제/제거 등) 작성 시
```
□ 로딩 상태가 _global에 저장되는가? (_local 사용 금지)
□ 삭제 버튼에 disabled:opacity-50 disabled:cursor-not-allowed 클래스가 있는가?
□ 삭제 버튼에 flex items-center gap-2 클래스가 있는가?
□ 취소 버튼도 로딩 중 비활성화되는가?
□ 로딩 중 스피너 아이콘(Icon name="spinner" animate-spin)이 표시되는가?
□ 버튼 텍스트가 로딩 상태에 따라 변경되는가? (삼항 연산자 사용)
□ setState target이 "global"인가?
□ onSuccess에서 closeModal이 setState보다 먼저 호출되는가?
□ onError에서 로딩 상태가 false로 복원되는가?
```
### 모달 호출 시
```
□ openModal 핸들러의 target이 모달 id와 일치하는가?
□ 필요한 데이터를 setState로 미리 설정했는가?
□ 데이터는 _global.modal_data 등에 저장했는가?
```
---
## 참고 자료
- [actions-handlers.md](./actions-handlers.md) - openModal, closeModal 핸들러 상세
- [layout-json.md](./layout-json.md) - 레이아웃 JSON 전체 구조
- [components.md](./components.md) - Modal 컴포넌트 props
---
## 변경 이력
| 날짜 | 버전 | 변경 내용 |
| ---- | ---- | --------- |
| 2026-01-21 | 1.0 | 초기 작성 - 정상 동작 모달 기준 규정화 |
| 2026-01-21 | 1.1 | 위험 작업 버튼 로딩 상태 패턴 추가 (스피너, 텍스트 변경, 취소 버튼 비활성화) |
| 2026-01-21 | 1.2 | 모달 내 로딩 상태는 반드시 `_global` 사용 (수정: `_local` → `_global`) |
| 2026-01-30 | 1.3 | `$parent` 바인딩 컨텍스트 추가 (engine-v1.16.0) - 부모 레이아웃 상태 접근/수정 지원 |