Files
Gnuboard7/docs/frontend/layout-json-components-slots.md
T
2026-04-01 10:30:52 +09:00

350 lines
10 KiB
Markdown

# 레이아웃 JSON - 슬롯 시스템
> **메인 문서**: [layout-json-components.md](layout-json-components.md)
> **관련 문서**: [components.md](components.md)
---
## 목차
1. [동적 슬롯 시스템 (slot, SlotContainer)](#동적-슬롯-시스템-slot-slotcontainer)
2. [컴포넌트 내부 커스텀 렌더링 (component_layout)](#컴포넌트-내부-커스텀-렌더링-component_layout)
---
## 동적 슬롯 시스템 (slot, SlotContainer)
**목적**: 상태에 따라 컴포넌트를 다른 위치(슬롯)로 동적 이동시킵니다.
### 슬롯 시스템 핵심 원칙
```text
중요: 슬롯 시스템은 레이아웃 상속의 slot과 다릅니다
✅ 레이아웃 상속 slot: 부모 레이아웃의 슬롯을 자식이 채우는 구조
✅ 동적 슬롯 시스템: 상태 변화에 따라 컴포넌트가 다른 슬롯으로 이동
✅ 필수: SlotProvider가 상위에 있어야 동작 (DynamicRenderer가 자동 제공)
```
### 동작 원리
1. `slot` 속성이 있는 컴포넌트는 **원래 위치에서 렌더링되지 않음**
2. 해당 컴포넌트는 SlotContext에 등록됨
3. `SlotContainer`가 해당 슬롯 ID의 컴포넌트들을 렌더링
```text
[원래 위치 - hidden] [SlotContainer - 실제 렌더링]
┌─────────────────┐ ┌─────────────────┐
│ slot="basic" │ ──────> │ slotId="basic" │
│ (등록만 함) │ │ (여기서 렌더링) │
└─────────────────┘ └─────────────────┘
```
### 기본 구조
```json
{
"children": [
{
"id": "slot_registration_area",
"type": "basic",
"name": "Div",
"props": { "className": "hidden" },
"children": [
{
"id": "filter_row_1",
"type": "basic",
"name": "Div",
"slot": "{{condition ? 'slot_a' : 'slot_b'}}",
"slotOrder": 1,
"children": [...]
}
]
},
{
"id": "slot_a_container",
"type": "composite",
"name": "SlotContainer",
"props": { "slotId": "slot_a" }
},
{
"id": "slot_b_container",
"type": "composite",
"name": "SlotContainer",
"props": { "slotId": "slot_b" }
}
]
}
```
### 필드 설명
| 필드 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `slot` | string | ✅ | 슬롯 ID (표현식 지원, 예: `"basic_filters"` 또는 `"{{condition ? 'a' : 'b'}}"`) |
| `slotOrder` | number | ❌ | 슬롯 내 정렬 순서 (기본: 등록 순서) |
### SlotContainer Props
| Prop | 타입 | 필수 | 설명 |
|------|------|------|------|
| `slotId` | string | ✅ | 렌더링할 슬롯 ID |
| `className` | string | ❌ | 컨테이너 CSS 클래스 |
### 실제 사용 예시: 필터 편집 모드
사용자가 체크박스를 클릭하면 필터가 "기본 필터"와 "상세 필터" 사이를 이동:
```json
{
"id": "category_filter_row",
"type": "basic",
"name": "Div",
"slot": "{{(_local.visibleFilters || []).includes('category') ? 'basic_filters' : 'detail_filters'}}",
"slotOrder": 1,
"props": { "className": "filter-row" },
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "{{_local.isEditMode ? 'checkbox mr-2' : 'hidden'}}",
"checked": "{{(_local.visibleFilters || []).includes('category')}}"
},
"actions": [
{
"type": "change",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "_local",
"visibleFilters": "{{(_local.visibleFilters || []).includes('category') ? (_local.visibleFilters || []).filter(f => f !== 'category') : [...(_local.visibleFilters || []), 'category']}}"
}
},
{
"handler": "saveToLocalStorage",
"params": {
"key": "g7_product_filter_visible_filters",
"value": "{{...}}"
}
}
]
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:category_label"
}
]
}
```
**SlotContainer 배치**:
```json
{
"id": "basic_filter_section",
"type": "basic",
"name": "Div",
"children": [
{
"id": "basic_filters_slot",
"type": "composite",
"name": "SlotContainer",
"props": {
"slotId": "basic_filters",
"className": "flex flex-col"
}
}
]
},
{
"id": "detail_filter_wrapper",
"type": "basic",
"name": "Div",
"if": "{{_local.showDetailFilter}}",
"children": [
{
"id": "detail_filters_slot",
"type": "composite",
"name": "SlotContainer",
"props": {
"slotId": "detail_filters",
"className": "flex flex-col"
}
}
]
}
```
### 동작 흐름
```text
1. 초기 상태 (_local.visibleFilters = ['category', 'date'])
- category_filter_row → slot="basic_filters"
- date_filter_row → slot="basic_filters"
- brand_filter_row → slot="detail_filters"
2. 체크박스 클릭 (category 해제)
- visibleFilters에서 'category' 제거
- slot 표현식 재평가
- category_filter_row → slot="detail_filters"로 변경
- SlotContainer 자동 업데이트
3. 결과
- basic_filters: [date_filter_row]
- detail_filters: [category_filter_row, brand_filter_row]
```
### 슬롯 등록 영역 패턴 (권장)
슬롯에 등록할 컴포넌트는 **hidden 영역**에 배치하는 것을 권장:
```json
{
"id": "slot_registration_area",
"type": "basic",
"name": "Div",
"props": {
"className": "hidden",
"aria-hidden": "true"
},
"children": [
{ "id": "comp_1", "slot": "slot_a", "slotOrder": 1, ... },
{ "id": "comp_2", "slot": "slot_b", "slotOrder": 2, ... }
]
}
```
**이유**:
- `slot` 속성이 있는 컴포넌트는 원래 위치에서 `null`을 반환
- 하지만 부모 Div 구조는 렌더링되므로 hidden으로 숨김
- 실제 렌더링은 SlotContainer에서 수행
### slotOrder 사용
여러 컴포넌트가 같은 슬롯에 등록될 때 순서 지정:
```json
{
"id": "category_filter", "slot": "basic_filters", "slotOrder": 1
},
{
"id": "date_filter", "slot": "basic_filters", "slotOrder": 2
},
{
"id": "status_filter", "slot": "basic_filters", "slotOrder": 3
}
```
**결과**: SlotContainer에서 slotOrder 순으로 렌더링
### 내부 구현 참고 (개발자용)
슬롯 시스템은 다음 컴포넌트로 구현됩니다:
| 파일 | 역할 |
|------|------|
| `SlotContext.tsx` | SlotProvider, useSlotContext 제공 |
| `SlotContainer.tsx` | 슬롯 ID에 등록된 컴포넌트 렌더링 |
| `DynamicRenderer.tsx` | slot 속성 평가, 슬롯 등록 처리 |
**전역 접근**:
- `window.__slotContextValue`로 슬롯 컨텍스트에 직접 접근 가능
- React Context 타이밍 이슈 방지를 위해 전역 변수 사용
### 주의사항
- ✅ `slot` 표현식은 `{{}}` 구문 사용 필수 (동적 슬롯인 경우)
- ✅ 정적 슬롯은 문자열로 직접 지정 가능: `"slot": "basic_filters"`
- ✅ SlotContainer는 해당 slotId가 없으면 빈 상태로 렌더링
- ✅ slotOrder가 같으면 등록 순서대로 렌더링
- slot 속성이 있는 컴포넌트는 원래 위치에서 렌더링되지 않음
- 슬롯 시스템 사용 시 hidden 영역에 등록 컴포넌트 배치 권장
- ❌ SlotProvider 없이는 슬롯 시스템 동작 안 함 (DynamicRenderer가 자동 제공)
### 트러블슈팅
---
## 컴포넌트 내부 커스텀 렌더링 (component_layout)
**목적**: 컴포넌트 내부 항목의 렌더링을 커스터마이징합니다 (RichSelect, CardGrid 등).
### 핵심 원칙
```text
중요: component_layout의 item 컨텍스트는 options/data 배열의 각 항목을 그대로 전달
✅ 필수: {{item.xxx}}로 참조하는 모든 필드가 options/data 배열에 포함되어야 함
❌ 금지: label, value만 포함하고 커스텀 필드 누락
```
### component_layout 기본 구조
```json
{
"type": "composite",
"name": "RichSelect",
"props": {
"value": "{{_global.selectedFile}}",
"options": "{{files?.data?.map(f => ({ value: f.name, label: f.name, name: f.name, size: f.size }))}}",
"placeholder": "$t:common.select_file"
},
"component_layout": {
"item": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex flex-col" },
"children": [
{ "type": "basic", "name": "Span", "text": "{{item.name}}" },
{ "type": "basic", "name": "Span", "text": "{{item.size}}" }
]
}
],
"selected": [
{ "type": "basic", "name": "Span", "text": "{{item.name}}" }
]
}
}
```
### component_layout 지원 컴포넌트
| 컴포넌트 | component_layout 키 | 설명 |
|------------|---------------------|--------------------------------------|
| RichSelect | `item`, `selected` | 드롭다운 항목 및 선택된 항목 렌더링 |
| CardGrid | `item` | 카드 항목 렌더링 |
### component_layout 컨텍스트 변수
| 변수 | 타입 | 설명 |
|--------------|---------|----------------------------------------|
| `item` | object | 현재 항목 객체 (options 배열의 요소) |
| `index` | number | 현재 항목의 인덱스 |
| `isSelected` | boolean | 선택 여부 (RichSelect에서 제공) |
### component_layout 주의사항
- ✅ `options` 배열 생성 시 `component_layout`에서 참조하는 모든 필드 포함 필수
- ✅ `{{item.xxx}}` 바인딩은 `options` 배열의 각 항목에서 `xxx` 필드를 참조
- ❌ `options`에 없는 필드를 `{{item.xxx}}`로 참조하면 빈 값 표시
- 기본 `label`, `value`만 포함하면 커스텀 렌더링 시 데이터 누락
### component_layout 트러블슈팅
---
## 관련 문서
- [레이아웃 JSON 컴포넌트 인덱스](layout-json-components.md)
- [조건부/반복 렌더링](layout-json-components-rendering.md)
- [데이터 로딩 및 생명주기](layout-json-components-loading.md)
- [컴포넌트 목록](components.md)