Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export default defineConfig({
text: '컴포넌트 설계',
items: [
{ text: '개요', link: '/component-design/' },
{ text: '단일 책임 원칙으로 컴포넌트 경계 세우기', link: '/component-design/single-responsibility' },
{ text: 'Compound Components로 복합 UI 설계하기', link: '/component-design/compound-components' },
{ text: 'Headless UI 패턴으로 로직과 표현 분리하기', link: '/component-design/headless-ui' },
],
},
],
Expand Down
118 changes: 118 additions & 0 deletions docs/component-design/compound-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Compound Components로 복합 UI 설계하기

Compound Components는 여러 하위 컴포넌트를 하나의 UI 단위로 묶는 패턴이다. 부모 컴포넌트는 함께 쓰는 상태와 동작을 관리하고, 하위 컴포넌트는 필요한 값만 꺼내 각자의 역할을 수행한다.

## 규칙: 관련된 하위 컴포넌트를 맥락으로 단위로 묶으세요

`Tabs`, `Accordion`, `Select`, `Menu`, `Modal`처럼 여러 파트가 하나의 상태를 기준으로 함께 움직이는 UI는 하나의 거대한 컴포넌트보다 역할이 나뉜 하위 컴포넌트 구조가 더 적합하다. 호출하는 쪽은 구조를 선언적으로 배치하고, 내부 구현은 파트 간 협력을 책임진다.

:::tabs
== Bad

```tsx
function Tabs({
items,
activeValue,
layout = "horizontal",
showDivider = false,
renderTrigger,
renderPanel,
}: TabsProps) {
return (
<section data-layout={layout} data-divider={showDivider}>
{items.map((item) => (
<div key={item.value}>
{renderTrigger ? renderTrigger(item) : item.label}
{activeValue === item.value && (
<div>{renderPanel ? renderPanel(item) : item.content}</div>
)}
</div>
))}
</section>
);
}
```

== Good

```tsx
<Tabs defaultValue="profile">
<Tabs.List>
<Tabs.Trigger value="profile">프로필</Tabs.Trigger>
<Tabs.Trigger value="security">보안</Tabs.Trigger>
</Tabs.List>

<Tabs.Panel value="profile">프로필 내용</Tabs.Panel>
<Tabs.Panel value="security">보안 내용</Tabs.Panel>
</Tabs>
```

:::

Bad 예시는 `Tabs` 하나가 데이터, 배치 옵션, 트리거 렌더링, 패널 렌더링을 모두 받는다. Good 예시는 `Tabs.List`, `Tabs.Trigger`, `Tabs.Panel`이 각각 자기 역할을 드러내고, 사용하는 쪽이 필요한 구조를 직접 조립한다.

## 규칙: 함께 쓰는 상태는 부모에서 관리하세요

Compound Components의 핵심은 단순히 `children`을 받는 것이 아니라 관련 하위 컴포넌트들이 같은 상태를 함께 쓴다는 점이다. 부모가 선택된 값 같은 상태를 관리하고, 하위 컴포넌트는 context를 통해 필요한 값과 변경 함수를 꺼내 쓴다.

```tsx
const TabsContext = createContext<TabsContextValue | null>(null);

function Tabs({ defaultValue, children }: TabsProps) {
const [value, setValue] = useState(defaultValue);

return (
<TabsContext.Provider value={{ value, setValue }}>
{children}
</TabsContext.Provider>
);
}

function TabsTrigger({ value, children }: TabsTriggerProps) {
const tabs = useTabsContext();
const selected = tabs.value === value;

return (
<button
role="tab"
aria-selected={selected}
onClick={() => tabs.setValue(value)}
>
{children}
</button>
);
}
```

중간 컴포넌트가 `activeValue`, `onChange`를 계속 전달하지 않아도 되므로 각 파트의 역할이 선명해진다.

## 규칙: 자식 요소를 강제로 변형하지 마세요

`Children`과 `cloneElement`로 자식에게 props를 주입하는 방식도 가능하지만 데이터 흐름을 추적하기 어렵고 구조 변화에 약하다. 기본 구현은 context 기반으로 두고, 자식 요소를 직접 변형하는 방식은 예외적으로만 사용한다.

## 빠른 참조

| 상황 | 권장 설계 |
| ----------------------------------------- | ---------------------------------------------------------------- |
| 여러 파트가 하나의 상태를 공유함 | Compound Components + context |
| 호출자가 UI 구조를 선언적으로 배치해야 함 | `Tabs.List`, `Tabs.Trigger`, `Tabs.Panel` 같은 하위 컴포넌트 API |
| 중간 컴포넌트가 props를 전달만 함 | 부모 context로 공유 상태 제공 |
| 자식에게 props를 자동 주입하고 싶음 | `cloneElement`보다 context 우선 |

## 주의사항

- 관련 파트가 실제로 협력할 때만 적용한다.
- 독립적인 컴포넌트를 억지로 하나의 사용 단위로 묶지 않는다.
- 이 문서는 generic `children`, render props, slot 선택 기준을 다루지 않는다.
- 상태를 어디에 둘지에 대한 일반 원칙은 상태 관리 문서에서 다룬다.
- `Tabs`, `Accordion`, `Menu`, `Modal`에는 ARIA 역할과 키보드 상호작용이 함께 따라와야 한다.

## 참고 자료

- [React 공식 문서 - Passing Props to a Component](https://react.dev/learn/passing-props-to-a-component)
- [React 공식 문서 - Passing Data Deeply with Context](https://react.dev/learn/passing-data-deeply-with-context)
- [React 공식 문서 - Children](https://react.dev/reference/react/Children)
- [React 공식 문서 - cloneElement](https://react.dev/reference/react/cloneElement)
- [Patterns.dev - Compound Pattern](https://www.patterns.dev/react/compound-pattern/)
- [Epic React - Compound Components: Truly Flexible React APIs](https://www.epicreact.dev/compound-components-truly-flexible-react-apis-5nu15)
- [WAI-ARIA APG Patterns](https://www.w3.org/WAI/ARIA/apg/patterns/)
124 changes: 124 additions & 0 deletions docs/component-design/headless-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Headless UI 패턴으로 로직과 표현 분리하기

Headless UI는 동작, 접근성, 상태 관리는 컴포넌트가 맡고 마크업과 스타일은 사용하는 쪽이 결정하게 하는 설계다. 같은 동작을 여러 디자인으로 재사용해야 할 때, 컴포넌트를 "기능이 있는 뼈대"와 "표현"으로 나누어 확장성을 확보한다.

## 규칙: 동작과 마크업을 한 컴포넌트에 묶지 마세요

컴포넌트 내부에 색상, 간격, 레이아웃, 그림자 같은 시각적 결정이 고정되면 다른 화면에서 같은 동작을 재사용하기 어렵다. Headless UI는 열림/닫힘, 선택, 포커스, 키보드 조작 같은 동작만 제공하고 표현은 사용하는 쪽에 맡긴다.

:::tabs == Bad

```tsx
function UserMenu() {
const [open, setOpen] = useState(false);

return (
<div className="relative inline-block">
<button
className="rounded-full bg-slate-900 px-4 py-2 text-white"
onClick={() => setOpen((value) => !value)}
>
프로필
</button>

{open && (
<div className="absolute right-0 mt-2 w-48 rounded-xl border bg-white p-2 shadow-lg">
<a className="block rounded-lg px-3 py-2 text-sm" href="/profile">
내 정보
</a>
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm">
로그아웃
</button>
</div>
)}
</div>
);
}
```

== Good

```tsx
function UserMenu() {
return (
<Menu>
<Menu.Trigger className="rounded-full bg-slate-900 px-4 py-2 text-white">
프로필
</Menu.Trigger>

<Menu.Content className="rounded-xl border bg-white p-2 shadow-lg">
<Menu.Item asChild>
<a className="block rounded-lg px-3 py-2 text-sm" href="/profile">
내 정보
</a>
</Menu.Item>
<Menu.Item className="block w-full rounded-lg px-3 py-2 text-left text-sm">
로그아웃
</Menu.Item>
</Menu.Content>
</Menu>
);
}
```

:::

Bad 예시는 메뉴의 동작과 디자인이 `UserMenu` 안에 강하게 묶여 있다. Good 예시는 `Menu` 계열 컴포넌트가 열림/닫힘, 포커스, 키보드 조작, ARIA 계약을 담당하고, 사용하는 쪽이 `className`과 마크업을 결정한다.

## 규칙: 상태는 스타일링 가능한 레이어로 노출하세요

Headless UI는 보이지 않는 컴포넌트가 아니다. 상태를 외부에서 스타일링할 수 있도록 `data-state`, `data-selected`, `data-disabled` 같은 표면을 제공해야 한다. 상태가 밖으로 드러나야 사용하는 쪽에서 디자인만 바꿔도 동작은 그대로 유지된다.

```tsx
function MenuTrigger({ open, ...props }: MenuTriggerProps) {
return (
<button
data-state={open ? "open" : "closed"}
aria-expanded={open}
{...props}
/>
);
}

function MenuItem({ disabled, ...props }: MenuItemProps) {
return <button data-disabled={disabled} disabled={disabled} {...props} />;
}
```

- 열림/닫힘은 `data-state="open"`처럼 노출한다.
- 선택됨, 비활성, 하이라이트 같은 상태는 스타일링 가능한 속성으로 드러낸다.
- 상태를 노출하되 상태 변경 방식 자체를 사용처마다 다시 구현하게 만들지 않는다.

## 규칙: 접근성과 키보드 동작은 컴포넌트 계약에 포함하세요

Headless UI의 가치는 스타일 자유도보다 먼저 접근성에 있다. `role`, `aria-*`, 포커스 이동, 키보드 조작은 선택 사항이 아니라 컴포넌트가 기본으로 보장해야 하는 계약이다.

- `Escape`로 닫기, 방향키 이동, 포커스 복귀 같은 키보드 동작을 정의한다.
- WAI-ARIA 패턴에 맞는 역할과 속성을 기본값으로 제공한다.
- 접근성을 사용하는 쪽의 스타일 코드에 떠넘기지 않는다.

## 빠른 참조

| 상황 | 판단 |
| ---------------------------------------------------- | ------------------------------------ |
| 동작은 같지만 디자인만 화면마다 다름 | Headless UI로 분리 |
| open, selected, disabled 상태에 따라 스타일이 달라짐 | `data-*` 속성으로 상태 노출 |
| 포커스, 키보드, ARIA 처리가 필요한 위젯 | 컴포넌트 계약에 접근성 포함 |
| 단순 정적 UI | Headless 패턴보다 일반 컴포넌트 유지 |

## 주의사항

- 모든 컴포넌트를 Headless로 만들 필요는 없다.
- 기본 스타일을 강하게 고정하면 Headless UI의 장점이 줄어든다.
- 이 문서는 커스텀 훅 추출 기준이나 상태 관리 전략을 다루지 않는다.
- `children`, render props, slot 선택 기준은 Props/인터페이스 문서에서 다룬다.
- 복합 컴포넌트의 가족 API 설계는 Compound Components 문서에서 다룬다.

## 참고 자료

- [Headless UI](https://headlessui.com/)
- [Radix Primitives - Introduction](https://www.radix-ui.com/primitives/docs/overview/introduction)
- [Radix Primitives - Styling](https://www.radix-ui.com/primitives/docs/guides/styling)
- [React Aria - Getting started](https://react-spectrum.adobe.com/react-aria/getting-started.html)
- [Headless Component: a pattern for composing React UIs](https://martinfowler.com/articles/headless-component.html)
- [WAI-ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/)
20 changes: 17 additions & 3 deletions docs/component-design/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@

## 다루는 내용

- **단일 책임 원칙**: 하나의 컴포넌트는 하나의 역할만 담당하도록 분리합니다
- **Headless UI 패턴**: 기능(로직)과 형태(스타일)를 분리하여 재사용성을 극대화합니다
- **Compound Components**: Select, Modal, Tab 등 복잡한 UI를 사용하는 쪽에서 자유롭게 조립할 수 있는 인터페이스를 설계합니다
컴포넌트 설계는 컴포넌트를 어떤 책임 단위로 나누고, 복잡한 UI를 어떤 구조로 제공할지 정하는 기준을 다룹니다. 이 문서에서는 상태 위치, 커스텀 훅 추출, Props 세부 설계보다 컴포넌트의 책임 경계와 복합 UI 설계 패턴에 집중합니다.

- **단일 책임 원칙**: 변경 이유가 다른 UI 영역을 분리하여 컴포넌트 경계를 명확히 합니다
- **Compound Components**: Tabs, Select, Accordion처럼 여러 하위 컴포넌트가 같은 상태를 공유하는 복합 UI를 설계합니다
- **Headless UI 패턴**: 기능과 표현을 분리하여 스타일과 마크업을 사용하는 쪽에서 제어할 수 있게 설계합니다

다음 내용은 다른 토픽에서 다룹니다.

- **훅 설계**: 커스텀 훅 추출 기준, 훅 합성 패턴, 훅의 책임 범위
- **상태 관리**: Local vs Global 판단, 상태 위치, 서버 상태와 클라이언트 상태 분리
- **Props/인터페이스**: Props 설계 원칙, children/render props/slot 선택 기준, API 응답 매핑

## 지침 목록

- [단일 책임 원칙으로 컴포넌트 경계 세우기](./single-responsibility) - 하나의 컴포넌트는 하나의 변경 이유만 가지도록 책임을 분리합니다
- [Compound Components로 복합 UI 설계하기](./compound-components) - 여러 하위 컴포넌트가 같은 상태를 공유하도록 설계합니다
- [Headless UI 패턴으로 로직과 표현 분리하기](./headless-ui) - 기능과 형태를 분리하여 재사용 가능한 컴포넌트를 설계합니다
Loading