# SModal

> 수작성 문서 — SModal 은 컴포넌트가 아니라 **명령형 모달 서비스**라 `docs:gen`(Props/Events 표) 대상이 아니다.

버튼 핸들러에서 바로 호출해 모달을 띄우는 명령형 API. 선언형 `<SConfirmModal open>` / `<SLoadingModal open>` 을 **대체하지 않고 추가로** 제공한다. (원본 디자인 시스템 `sdModal` 파리티)

호출 시마다 `document.body` 에 컨테이너를 만들어 모달을 렌더하고, 닫힘 애니메이션이 끝나면 자동으로 언마운트한다. 모든 메서드는 체이닝 핸들 [`SModalRef`](#smodalref) 를 반환한다.

> **앱 부트스트랩에 [`<SModalOutlet />`](#smodaloutlet) 을 한 번 렌더한다.** 그래야 명령형 모달이 앱 렌더 트리의 자식으로 그려져 QueryClient·Router·Theme 등 Context 를 상속한다. 없으면 예전처럼 별도 React 루트로 떠서 **앱의 Provider 가 하나도 닿지 않는다.**

| 메서드 | 띄우는 모달 | 용도 | 주요 콜백/제어 |
|---|---|---|---|
| [`SModal.confirm(options)`](#smodalconfirm) | `SConfirmModal` | 확인/취소 | `onOk` / `onCancel` / `onClose` |
| [`SModal.loading(options?)`](#smodalloading) | `SLoadingModal` | 로딩·에러 (persistent 기본 true) | `onClick` / `update` |
| [`SModal.create({ component })`](#smodalcreate) | `SActionModal` | 액션 모달 | `modalRef` 주입 → `ok/cancel/close/submit` |

> **띄울 수 있는 모달은 `SActionModal` / `SConfirmModal` / `SLoadingModal` 세 가지뿐이고, 위 메서드가 1:1 로 대응한다.** 그 밖의 스타일로 모달을 띄우는 경로는 제공하지 않는다. `create` 는 컨테이너를 덧씌우지 않고 `component` 를 **그대로** 렌더하므로, `component` 는 루트에 `SActionModal` 을 렌더해야 한다 — 그러지 않으면 딤·카드 없이 콘텐츠만 뜨며, 개발 모드에서 `console.warn` 으로 경고한다.

```ts
import { SModal } from 'sellmate-design-system-react';
```

---

## SModalOutlet

명령형 모달이 **그려지는 자리**. 앱 부트스트랩에서 Provider 안쪽에 **한 번만** 렌더한다. props 는 없다.

```tsx
import { SModalOutlet } from 'sellmate-design-system-react';

<QueryClientProvider client={queryClient}>
  <RouterProvider router={router} />
  <SModalOutlet />   {/* 앱 전체에 하나 */}
</QueryClientProvider>;
```

`SModal.confirm/loading/create` 는 전역 스토어에 모달을 넣기만 하고, outlet 이 그것을 `createPortal` 로 `body` 에 그린다. **DOM 위치·쌓임 순서는 outlet 유무와 무관하게 같고**, 달라지는 것은 렌더 트리다 — outlet 이 있으면 모달이 앱 트리의 자식이 되어 Context 를 상속한다.

- outlet 을 어디에 두든(Provider 안쪽이기만 하면) 모달은 `body` 로 portal 되므로 레이아웃·`overflow`·`transform` 의 영향을 받지 않는다.
- **호출부 API 는 그대로다.** outlet 도입 전 코드를 고칠 필요가 없다.
- outlet 이 없으면 예전처럼 별도 React 루트(`createRoot`)로 마운트되어 모달은 뜨지만 앱의 Provider 가 닿지 않는다 (개발 모드에서 1회 `console.warn`).
- 모달 본문이 렌더 중 예외를 던지면 모달만 닫히고 앱 트리는 유지된다 — 원인을 지목하는 `console.error` 가 함께 찍힌다.
- 모달이 떠 있는 채로 outlet 이 언마운트되면(앱 언마운트·Provider 교체) 그 모달은 정리되고 `onDismissed` 가 발화한다 — 남아서 되살아나지 않는다.

---

## SModal.confirm

아이콘 + 제목 + 메시지 + 확인/취소 버튼. `type` 에 따라 아이콘·메인 버튼 색이 결정된다.

```tsx
SModal.confirm({
  type: 'negative',                 // 'positive' | 'negative' | 'default'
  modalTitle: '삭제하시겠습니까?',
  topMessage: ['이 작업은 되돌릴 수 없습니다.'],
  mainButtonLabel: '삭제',
  subButtonLabel: '취소',
})
  .onOk(() => deleteItem())
  .onCancel(() => {});
```

**옵션 (`SConfirmOptions`)** — 선언형 `SConfirmModalProps` 에서 제어 흐름 props(`open`/`onOpenChange`/`onOk`/`onCancel`/`onClose`)를 제외한 전부. 주요 키: `type`, `modalTitle`, `topMessage`/`bottomMessage`, `mainButtonLabel`/`mainButtonName`, `subButtonLabel`, `tagSlot`/`optionSlot`/`contentSlot`, `persistent`.

---

## SModal.loading

스피너/에러 모달. 로딩 중 임의 닫힘을 막기 위해 **`persistent` 기본값이 `true`** 다(백드롭·ESC로 안 닫힘). "띄우고 → 작업 → 결과 반영" 흐름을 `update()` / `close()` 로 제어한다.

```tsx
const ref = SModal.loading({ message: '업로드 중...' });
try {
  await upload();
  ref.close();
} catch {
  ref.update({ state: 'error', message: '업로드 실패' })  // 표시 중 상태 갱신
     .onClick(() => retry());                             // 「다시 시도」 버튼
}
```

진행률:

```tsx
const ref = SModal.loading({ progress: 0, message: '파일 업로드 중...' });
ref.update({ progress: 60 });          // 0–100
ref.update({ progress: 100 });
ref.close();
```

> **버튼 클릭은 자동 닫힘이 아니다.** error 상태의 버튼(기본 "다시 시도")은 `onClick` 만 발화하고 모달은 유지된다. consumer 가 `update()`(다시 로딩) / `close()` 로 후속 동작을 결정한다.

**옵션 (`SLoadingOptions`)** — `state`('loading' | 'error'), `progress`(0–100), `message`(string | string[]), `useButton`, `buttonLabel`, `width`/`height`, `persistent`.

---

## SModal.create

**`SActionModal` 을 루트로 렌더하는 컴포넌트**를 띄운다. `create` 는 컨테이너를 덧씌우지 않고 `component` 를 **그대로** 렌더하며, 표시 제어(`open` / `onOpenChange` / `onClose`)와 [`modalRef`](#smodalref) 를 주입하고 닫힘 애니메이션 종료 후 언마운트를 담당한다.

컴포넌트는 주입받은 `open` / `onOpenChange` / `onClose` 를 **SActionModal 에 그대로 전달**해야 한다. 전달하지 않으면 모달이 열리지도, 닫히지도 않는다.

```tsx
import { SModal, SActionModal, type SModalCreateComponentProps } from 'sellmate-design-system-react';

// componentProps 로 넘긴 값 + 주입 prop 을 함께 받는다
interface OrderModalProps extends SModalCreateComponentProps {
  orderId: string;
}

function OrderModal({ open, onOpenChange, onClose, modalRef, orderId }: OrderModalProps) {
  return (
    <SActionModal
      open={open}
      onOpenChange={onOpenChange}
      onClose={onClose}
      modalTitle="주문 처리"
      button={{ label: '처리', onClick: () => modalRef.ok() }}
    >
      <p>주문번호 {orderId} 를 접수합니다.</p>
    </SActionModal>
  );
}

SModal.create({ component: OrderModal, componentProps: { orderId: 'ORD-001' } })
  .onOk(() => toast('저장 완료'))
  .onDismissed(() => cleanup());
```

**옵션 (`SCreateOptions<P>`)**

| 키 | 타입 | 설명 |
|---|---|---|
| `component` | `ComponentType<P & SModalCreateComponentProps>` | 루트에 `SActionModal` 을 렌더하는 컴포넌트 |
| `componentProps?` | `P` | 컴포넌트에 전달할 추가 props |

**주입되는 prop (`SModalCreateComponentProps`)**

| 키 | 타입 | 설명 |
|---|---|---|
| `open` | `boolean` | SActionModal 의 `open` 에 그대로 전달 |
| `onOpenChange` | `(open: boolean) => void` | SActionModal 의 `onOpenChange` 에 그대로 전달 |
| `onClose` | `() => void` | SActionModal 의 `onClose` 에 그대로 전달 |
| `modalRef` | `SModalRef` | `ok()`/`cancel()`/`close()`/`submit()` 으로 자기 모달 제어 |

### 비동기 제출 — 응답 보고 닫기

**하단 버튼은 본문에 직접 두지 않는다.** 주 액션은 `button`(의도적으로 단수), 보조 버튼은 `footerLeft` 슬롯에 넣는다 — 그래야 푸터 배경·여백·양끝 분리가 컴포넌트 규칙대로 잡힌다.

`button` 은 클릭해도 **모달을 닫지 않는다.** `onClick` 만 발화하므로 저장 API 응답을 보고 `modalRef.ok()` 로 닫으면 된다.

```tsx
function OrderModal({ open, onOpenChange, onClose, modalRef, orderId }: OrderModalProps) {
  const [error, setError] = useState('');
  const [saving, setSaving] = useState(false);
  const handleSubmit = async () => {
    setSaving(true);
    try {
      await save(orderId);
      modalRef.ok();          // 성공 → onOk + 닫기
    } catch {
      setError('저장 실패');   // 실패 → 모달 유지
    } finally {
      setSaving(false);
    }
  };
  return (
    // button 도 footerLeft 도 주지 않으면 푸터가 렌더되지 않는다
    <SActionModal
      open={open} onOpenChange={onOpenChange} onClose={onClose} persistent modalTitle="주문 처리"
      button={{ label: saving ? '저장 중...' : '저장', disabled: saving, onClick: handleSubmit }}
      footerLeft={<SButton color="neutral" outline size="md" label="취소" disabled={saving} onClick={() => modalRef.cancel()} />}
    >
      {error && <p>{error}</p>}
    </SActionModal>
  );
}
```

`button` 은 `label` · `color`(기본 `primary`) · `outline` · `size`(기본 `md`) · `disabled` · `onClick` 을 받는다. `footerLeft` 는 슬롯이라 `SButton` 을 직접 배치하며, 푸터 규칙상 `size="md"` 를 명시한다.

---

## SModalRef

모든 `SModal.*` 호출이 반환하고, `create` 에서는 컴포넌트 prop 으로도 주입되는 제어 핸들. 콜백 등록(체이닝)과 트리거/갱신 메서드를 함께 제공한다. 모달 종류에 따라 관련 있는 메서드만 실제로 발화한다.

### 콜백 등록 (체이닝)

| 메서드 | 발화 시점 |
|---|---|
| `onOk(fn)` | 확인 버튼(confirm) 또는 `ok()` |
| `onCancel(fn)` | 취소 버튼(confirm) 또는 `cancel()` |
| `onClose(fn)` | 닫기(X) 버튼 또는 `close()` |
| `onClick(fn)` | 단일 버튼 모달(loading error)의 버튼 클릭 — **닫힘 없음** |
| `onSubmit(fn)` | create 커스텀 모달의 `submit()` — **닫힘 없음** |
| `onDismissed(fn)` | 사유 무관 완전히 닫혀 언마운트된 뒤(백드롭·ESC 포함) |

### 트리거 / 제어

| 메서드 | 동작 |
|---|---|
| `ok()` | `onOk` 발화 + 닫기 (저장/처리 성공) |
| `cancel()` | `onCancel` 발화 + 닫기 (작업 취소) |
| `close()` | `onClose` 발화 + 닫기 (중립적 닫기) |
| `submit()` | `onSubmit` 발화 (닫힘 없음) |
| `update(patch)` | 표시 중 옵션 갱신 (loading→error, progress 등). create 는 미지원. |

모든 닫힘은 애니메이션 종료 후 `onDismissed` 로 수렴한다. 이미 닫힘이 시작된 뒤의 중복 트리거는 무시된다.

---

## 주의사항

- **선언형과 공존**: 서비스는 추가 API다. open 상태가 앱 상태/라우트에 묶인 경우엔 선언형 `<SConfirmModal open>` / `<SLoadingModal open>` 이 더 적합하다.
- **백드롭·ESC = 중립적 닫힘**: 특정 콜백(onClose 등) 없이 `onDismissed` 만 발화한다. 명시적 버튼·메서드만 onOk/onCancel/onClose 를 발화한다.
- **Context 는 outlet 이 있어야 상속된다**: [`<SModalOutlet />`](#smodaloutlet) 을 앱 부트스트랩에 렌더하면 `create` 의 커스텀 컴포넌트(와 `confirm/loading` 의 `contentSlot`)가 앱 트리의 자식으로 렌더되어 QueryClient·Router·Theme 등을 그대로 쓴다. outlet 이 없으면 별도 React 트리에서 렌더되어 Provider 가 닿지 않는다 — `useQuery` 는 `No QueryClient set`, `useNavigate` 는 `may be used only in the context of a <Router>` 로 죽는다. (토큰은 `:root` CSS 변수라 어느 쪽이든 무관)
- **`create` 의 `component` 는 SActionModal 을 루트로**: `create` 는 컨테이너를 덧씌우지 않으므로, 본문만 렌더하는 컴포넌트를 넘기면 딤·카드 없이 콘텐츠가 그대로 화면에 붙는다. TypeScript 는 이를 막지 못한다(`component` 타입이 아무 컴포넌트나 허용). 개발 모드에서는 마운트 직후 렌더 결과로 이를 감지해 `console.warn` 으로 경고한다 — 세 모달은 모두 Portal 로 `body` 에 렌더되므로 `create` 가 만든 host 는 비어 있어야 하는데, host 에 엘리먼트가 남아 있으면 모달이 아닌 것으로 판정한다.

## Dependencies

### Depends on

- [SActionModal](../SActionModal)
- [SConfirmModal](../SConfirmModal)
- [SLoadingModal](../SLoadingModal)
