# PDS 합성 패턴 가이드 PDS를 사용하는 에이전트가 TypeScript type/JSDoc만으로는 파악하기 어려운 컴포넌트 합성 제약과 잘못된 사용 패턴을 8개 클래스로 정리한다. > **이 가이드에 없는 것**: prop 이름, optional/required 여부, default value, type. 이런 정보는 `*.props.txt` 파일의 Props 표에서 읽어라. 이 가이드는 type/JSDoc만으로 자명하지 않은 _합성 규칙_만 다룬다. --- ## 클래스 #1 — Flat API (dot-notation 없음) ### 무엇이 문제인가 React 컴포넌트 라이브러리 중 일부(Radix UI, ShadCN, Chakra 등)는 `\`, `\`, `\` 같은 dot-notation compound 패턴을 쓴다. PDS는 이 패턴을 **사용하지 않는다**. 자식 컴포넌트는 항상 별도의 named export다. ### 잘못된 패턴 ```tsx ``` ### 올바른 PDS 패턴 ```tsx ``` ### 실제 컴포넌트 코드 근거 `src/components/menu/index.ts`, `src/components/tabs/index.ts`를 보면 `Menu`, `MenuItem`, `MenuGroup`, `LineTabs`, `LineTab`, `BoxTabs`, `BoxTab`, `PanelTabs`, `PanelTab`, `PanelTabGroup`이 각각 별도 named export로 등록되어 있다. `Modal.Body` 같은 static property로 붙여 둔 하위 컴포넌트는 소스 어디에도 없다. --- ## 클래스 #2 — requiredParent (context throw) ### 무엇이 문제인가 일부 PDS 컴포넌트는 특정 부모 컴포넌트의 Context 없이 단독으로 사용하면 런타임 오류가 발생한다. TypeScript 타입은 이 제약을 표현하지 못한다. ### 잘못된 패턴 ```tsx ``` ### 올바른 PDS 패턴 | 자식 컴포넌트 | 필수 부모 | 오류 메시지 | | ----------------------- | ----------- | -------------------------------- | | `LineTab` | `LineTabs` | `LineTabContext is not defined` | | `BoxTab` | `BoxTabs` | `BoxTabContext is not defined` | | `PanelTab` | `PanelTabs` | `PanelTabContext is not defined` | | `MenuItem`, `MenuGroup` | `Menu` | `MenuContext is not defined` | `PanelTabGroup`은 context를 직접 소비하지 않아 단독 사용이 가능하지만, `PanelTabs` 안에서 그룹화 역할로 쓰는 것이 설계 의도다(`src/components/tabs/PanelTabs/PanelTabGroup.tsx` 확인). ```tsx ``` --- ## 클래스 #3 — Portal 위치 (FloatingPortal) ### 무엇이 문제인가 일부 PDS 컴포넌트는 `FloatingPortal`을 통해 `document.body`가 아닌 `id="pds-floating-root"` DOM 노드에 렌더링된다. 이 노드가 없으면 컴포넌트가 `document.body`에 폴백 렌더링될 수 있다. 에이전트가 이 동작을 모르고 z-index 조정이나 CSS overflow 처리 시 "팝업이 특정 컨테이너 밖으로 나온다"는 예상 동작을 이해하지 못하는 경우가 있다. Portal 노드를 수동으로 생성하려 하거나, `FloatingTree`를 추가로 선언해야 한다고 오해하는 경우도 있다. ### Portal 사용 컴포넌트 목록 | 컴포넌트 | Portal 동작 | | -------------------------------------------------------------------------- | ---------------------------------------------------------------- | | `Modal` (BasicModal, AlertModal, ConfirmModal, NoticeModal, FloatingModal) | `pds-floating-root`에 렌더링. `FloatingTreeContext` 래핑 | | `BottomSheet` | `pds-floating-root`에 렌더링. `noUsePortal=true`로 비활성화 가능 | | `Drawer` | `pds-floating-root`에 렌더링. `noUsePortal=true`로 비활성화 가능 | | `Popover` | `pds-floating-root`에 렌더링 | | `Tooltip` | `pds-floating-root`에 렌더링 | | `Dropdown`, `DropdownFilter`, `DropdownInput` | 옵션 목록이 `pds-floating-root`에 렌더링 | | `DropdownComposite` | 옵션 목록이 `pds-floating-root`에 렌더링 | | `DatePicker` | 캘린더 패널이 `pds-floating-root`에 렌더링 | | `TimePicker`, `TimeRangePicker` | 시간 선택 패널이 `pds-floating-root`에 렌더링 | | `ColorPicker` | 컬러 팔레트가 `pds-floating-root`에 렌더링 | ### 올바른 PDS 패턴 ```tsx ``` ### 실제 컴포넌트 코드 근거 `src/constants/floating.ts`의 `FLOATING_ROOT_ID = 'pds-floating-root'` 상수를 `BottomSheet.tsx`, `Drawer.tsx`, `ModalOverlay.tsx`, `Popover.tsx`, `Tooltip.tsx`, `DropdownOptions.tsx` 등이 `FloatingPortal`에 전달한다. --- ## 클래스 #4 — asChild 패턴 (Radix Slot) ### 무엇이 문제인가 Button 계열 컴포넌트에는 `asChild` prop이 있다. `asChild=true`로 설정하면 `\` DOM 요소를 렌더링하지 않고 children의 단일 ReactElement에 모든 props와 이벤트 핸들러를 전달한다(Radix Slot 패턴). 에이전트가 이 패턴을 모르고 `\` 태그 링크 버튼을 만들 때 `\` 안에 `\`를 중첩(button 안의 a — HTML 비유효)하는 경우가 있다. 커스텀 컴포넌트에 버튼 스타일을 적용하는 방법을 찾지 못하는 경우도 있다. ### asChild 지원 컴포넌트 `Button`, `TextButton`, `IconButton`, `FloatingButton`, `AccordionButton`, `PopoverButton` ### 잘못된 패턴 ```tsx ``` ### 올바른 PDS 패턴 ```tsx ``` > **주의**: `asChild=true` 시 children은 반드시 단일 ReactElement여야 한다. Fragment(`\<\>`)나 여러 요소를 전달하면 Slot이 올바르게 작동하지 않는다. ### 실제 컴포넌트 코드 근거 `src/components/button/Button.tsx:71` — `const Comp = asChild ? Slot : 'button';` `src/components/button/types.ts:20` — `asChild?: boolean;` `@radix-ui/react-slot`의 `Slot` 컴포넌트를 사용한다. --- ## 클래스 #5 — directChildInjection (FormField의 cloneElement) ### 무엇이 문제인가 `FormField`는 `status` prop을 **React Context가 아니라 `cloneElement`로** 직접 자식에 주입한다. 이 메커니즘은 children이 Fragment(`\<\>`) 또는 래퍼 div 안에 있으면 작동하지 않는다. 에이전트가 이 제약을 모르고 children을 Fragment나 래퍼로 감싸는 경우가 있다. `DatePicker` 같이 주입 대상이 아닌 컴포넌트도 자동으로 status를 받는다고 가정하는 경우도 있다. ### status 자동 주입 대상 컴포넌트 `Input`, `Dropdown`, `DropdownInput`, `NumericInput` (총 4개) ### 잘못된 패턴 ```tsx ``` ### 올바른 PDS 패턴 ```tsx ``` ### 실제 컴포넌트 코드 근거 `src/components/form/FormField.tsx:60–80` — ```javascript ``` --- ## 클래스 #6 — renderForbidden (Hook-only API) ### 무엇이 문제인가 `Toast`와 `Notification`은 JSX 선언형 렌더링이 **금지**된다. TypeScript 타입 정의가 있고 Storybook에도 등장하지만, 이는 내부 구조 문서화용이지 직접 렌더링을 허용하는 것이 아니다. 에이전트가 `\` 또는 `\`를 JSX로 직접 렌더링하는 코드를 작성하는 경우가 있다. ### 잘못된 패턴 ```tsx ``` ### 올바른 PDS 패턴 ```tsx ``` > **내부 동작**: `Toast`와 `Notification`은 `MessageManager`가 관리하는 별도 DOM에 주입된다. `useToast()`/`useNotification()`은 컴포넌트 함수 바디 최상위에서 호출해야 한다 (React Hook 규칙). ### 실제 컴포넌트 코드 근거 `src/components/toast/Toast.stories.tsx`의 주석(삭제 전): > "`\` JSX 직접 렌더링 금지. `useToast()` Hook 전용 API." `src/components/notification/Notification.stories.tsx`의 주석(삭제 전): > "`\` JSX 직접 렌더링 금지. `useNotification()` Hook 전용 API." --- ## 클래스 #7 — Named Slot Props (명시적 슬롯) ### 무엇이 문제인가 일부 PDS 컴포넌트는 `ReactNode` 타입의 named prop을 "슬롯"으로 사용한다. 타입은 prop 이름과 `ReactNode` 타입을 보여주지만, _어떤 prop이 슬롯이고 어떤 UI 영역을 교체하는지_는 타입만으로 자명하지 않다. 슬롯을 모르는 에이전트가 `children`에 헤더/푸터까지 직접 구현하거나, `header` prop이 있는데도 직접 구현하는 경우가 있다. ### 주요 Named Slot 패턴 **Modal (BasicModal)** ```tsx ``` **BottomSheet** ```tsx ``` **Input** ```tsx ``` **FormField** ```tsx ``` **Popover / FloatingModal** ```tsx ``` --- ## 클래스 #8 — Negative Guidance (흔한 잘못된 패턴) ### 무엇이 문제인가 특정 컴포넌트는 API 형태가 직관적이지 않아 흔히 잘못 사용된다. 이 클래스는 타입 레벨에서 막기 어려운 _의미론적 오용 패턴_을 차단한다. --- ### 8-A. DropdownComposite: top-level options 없음 ```tsx ``` `DropdownComposite`는 Dropdown + Input의 복합 컴포넌트다. Dropdown 관련 props는 `dropdownProps`에, Input 관련 props는 `inputProps`에 전달한다. --- ### 8-B. Tooltip/Popover children: Fragment 금지 ```tsx ``` `Tooltip`과 `Popover`는 `cloneElement`로 children에 `ref`와 이벤트 핸들러를 주입한다. children이 Fragment이거나 여러 요소면 주입이 실패한다. --- ### 8-C. Dropdown: value만 전달하면 read-only ```tsx ``` `Dropdown`은 내부 선택 state가 없다. `value`+`onChange` 없이 사용하면 옵션이 선택되지 않는 것처럼 보인다. --- ### 8-D. Alert/Confirm: JSX 렌더링 vs 함수 호출 ```tsx ``` > `Confirm()`: `Promise\` — 확인 클릭 시 `true`, 취소/ESC 시 `false`. `Alert()`: `Promise\` — 닫힘 시 `undefined`. `onConfirm`이 `Promise\`를 반환하면 resolve 전까지 확인 버튼이 자동 `disabled` 상태가 되므로 별도 loading state를 구현할 필요가 없다. --- ### 8-E. tabs onChange는 사실상 필수 ```tsx ``` `LineTabs`, `BoxTabs`, `PanelTabs`는 내부 탭 선택 state가 없다. `activeTabId`와 `onChange`는 타입상 optional이지만, 외부 제어 없이는 탭이 전환되지 않는다.