Skillsreact-headless-dev
react-headless-dev

react-headless-dev

SEED React Headless component development specialist. Use when developing unstyled, logic-only components in packages/react-headless folder. Focuses on data-driven primitives, custom hooks, and state management without styling concerns.

react-headless-devdaangn
815 stars
16.3k downloads
Updated 6d ago

Readme

react-headless-dev follows the SKILL.md standard. Use the install command to add it to your agent stack.

---
name: react-headless-dev
description: SEED React Headless component development specialist. Use when developing unstyled, logic-only components in packages/react-headless folder. Focuses on data-driven primitives, custom hooks, and state management without styling concerns.
allowed-tools: Read, Write, Edit, MultiEdit, Bash, Glob, Grep
---

# React Headless Component Developer

Develop unstyled React components following SEED headless architecture patterns.

## Purpose

이 스킬은 SEED Design System의 React Headless 컴포넌트를 개발합니다. Headless 컴포넌트는 스타일 없이 순수한 데이터 로직과 상태 관리만 제공하며, `@seed-design/react` 패키지에서 스타일을 입힐 수 있는 기반을 제공합니다.

## When to Use

다음 상황에서 이 스킬을 사용하세요:

1. **새 Headless 컴포넌트 생성**: `packages/react-headless/` 폴더에 새로운 컴포넌트 추가
2. **Headless 로직 리팩토링**: 기존 컴포넌트의 비즈니스 로직 개선 또는 분리
3. **Custom Hook 구현**: 컴포넌트의 상태 관리와 이벤트 핸들링 로직 작성
4. **Primitive 조합**: React 기본 요소들을 조합한 컴포지션 패턴 구현
5. **Data Attributes 정의**: 컴포넌트 상태를 표현하는 data attributes 설계

**트리거 키워드**: "headless component", "unstyled component", "custom hook", "primitive composition", "packages/react-headless"

## Architecture Principles

### 1. Style-Free Logic

**원칙**: 스타일 관련 로직 없이 순수 컴포넌트 데이터 로직만 제공

```typescript
// ❌ Bad: 스타일 관련 로직 포함
const Button = () => {
  const className = size === 'large' ? 'btn-lg' : 'btn-sm'
  return <button className={className} />
}

// ✅ Good: 데이터만 제공, 스타일은 @seed-design/react에서 처리
const Button = () => {
  return <button data-size={size} />
}
```

**스타일 관련 컴포넌트 로직 및 옵션은 `@seed-design/react` 패키지에서 제공합니다.**

### 2. Custom Hook Pattern

**원칙**: 중요 비즈니스 로직은 커스텀 훅 파일에 작성

```typescript
// use{Component}.ts
export function useCheckbox(props: UseCheckboxProps) {
  const [checked, setChecked] = useState(props.defaultChecked)
  const [focused, setFocused] = useState(false)

  const handleChange = useCallback(() => {
    setChecked(prev => !prev)
    props.onChange?.(!checked)
  }, [checked, props.onChange])

  return {
    rootProps: {
      'data-checked': checked,
      'data-focused': focused,
      onClick: handleChange,
    },
    inputProps: {
      type: 'checkbox',
      checked,
      onChange: handleChange,
      onFocus: () => setFocused(true),
      onBlur: () => setFocused(false),
    },
  }
}
```

**가이드라인**:
- 파일명: `use{Component}.ts` (예: `useCheckbox.ts`, `useRadio.ts`)
- 컴포넌트 복잡도에 따라 여러 개의 커스텀 훅 파일 작성 가능
- 각 hook은 parts별 props를 반환 (rootProps, inputProps, labelProps 등)
- 상태 관리, 이벤트 핸들링, 접근성 로직을 캡슐화

### 3. Primitive Composition

**원칙**: 컴포넌트 파일은 커스텀 훅의 parts를 spread하여 조합된 Primitive 컴포넌트들을 내보냄

```typescript
// {Component}.tsx
import { useCheckbox } from './useCheckbox'

export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
  (props, ref) => {
    const { rootProps, inputProps } = useCheckbox(props)

    return (
      <button ref={ref} {...rootProps}>
        <input {...inputProps} />
        {props.children}
      </button>
    )
  }
)
```

**가이드라인**:
- 파일명: `{Component}.tsx` (예: `Checkbox.tsx`, `Radio.tsx`)
- 단순히 커스텀 훅에서 반환된 props를 spread
- DOM 요소 조합 및 children 배치만 담당
- 복잡한 로직은 hook에 위임

### 4. State-Driven Data Attributes

**원칙**: Data attributes는 컴포넌트의 상태를 나타내는 데이터 위주로 작성

```typescript
// ✅ Good: 상태를 나타내는 data attributes
<button
  data-checked={checked}
  data-disabled={disabled}
  data-invalid={invalid}
  data-required={required}
  data-focused={focused}
/>

// ❌ Bad: 스타일을 위한 computed prop
<button
  data-button-color="red"
  data-button-size="large"
  data-should-have-shadow={true}
/>
```

**일반적인 Data Attributes**:
- `data-checked`: 선택 상태 (checkbox, radio, switch)
- `data-disabled`: 비활성 상태
- `data-invalid`: 유효하지 않은 상태 (form fields)
- `data-required`: 필수 입력 (form fields)
- `data-focused`: 포커스 상태
- `data-pressed`: 눌린 상태 (button)
- `data-selected`: 선택된 상태 (list items, tabs)
- `data-expanded`: 확장된 상태 (accordion, dropdown)
- `data-loading`: 로딩 상태

### 5. Namespace Pattern (Multi-Part Components)

**원칙**: Parts가 여러 개인 경우 `{Component}.namespace.ts` barrel file을 정의하여 내보냄

```typescript
// Dialog.namespace.ts
export { Dialog as Root } from './Dialog'
export { DialogTrigger as Trigger } from './DialogTrigger'
export { DialogContent as Content } from './DialogContent'
export { DialogHeader as Header } from './DialogHeader'
export { DialogTitle as Title } from './DialogTitle'
export { DialogDescription as Description } from './DialogDescription'
export { DialogFooter as Footer } from './DialogFooter'
export { DialogClose as Close } from './DialogClose'
```

```typescript
// index.ts
import * as Dialog from './Dialog.namespace'
export { Dialog }
```

**사용 예시**:
```typescript
import { Dialog } from '@seed-design/react-headless'

<Dialog.Root>
  <Dialog.Trigger>Open</Dialog.Trigger>
  <Dialog.Content>
    <Dialog.Header>
      <Dialog.Title>Title</Dialog.Title>
    </Dialog.Header>
  </Dialog.Content>
</Dialog.Root>
```

## Development Workflow

### Step 1: Requirements Analysis

사용자에게 다음 정보를 요청합니다:

**필수 정보**:
- **Component Name**: 예) `Checkbox`, `Radio`, `Dialog`
- **Component Type**:
  - **Single**: 단일 컴포넌트 (예: Checkbox, Switch)
  - **Multi-Part**: 여러 parts로 구성 (예: Dialog, Dropdown)
- **State Requirements**: 관리할 상태 목록 (checked, open, selected 등)
- **Event Handlers**: 필요한 이벤트 핸들러 (onChange, onOpen, onClose 등)

**선택 정보**:
- **Data Attributes**: 제공할 data attributes 목록
- **Accessibility**: ARIA attributes 요구사항
- **Controlled vs Uncontrolled**: 제어 컴포넌트 vs 비제어 컴포넌트

### Step 2: Package Structure Setup

Headless 컴포넌트는 `packages/react-headless/` 폴더 내에 위치합니다:

```
packages/react-headless/
├── checkbox/
│   ├── src/
│   │   ├── useCheckbox.ts        # Custom hook
│   │   ├── Checkbox.tsx           # Component
│   │   └── index.ts               # Public exports
│   ├── package.json
│   └── tsconfig.json
├── dialog/
│   ├── src/
│   │   ├── useDialog.ts           # Main hook
│   │   ├── Dialog.tsx             # Root component
│   │   ├── DialogTrigger.tsx      # Trigger part
│   │   ├── DialogContent.tsx      # Content part
│   │   ├── Dialog.namespace.ts    # Namespace barrel
│   │   └── index.ts
│   ├── package.json
│   └── tsconfig.json
```

**디렉토리 생성**:
```bash
mkdir -p packages/react-headless/{component-name}/src
```

### Step 3: Implement Custom Hook

**Step 3-1**: `use{Component}.ts` 파일 생성

```typescript
import { useCallback, useState } from 'react'

export interface Use{Component}Props {
  defaultValue?: boolean
  value?: boolean
  disabled?: boolean
  onChange?: (value: boolean) => void
}

export interface Use{Component}Return {
  // Part별 props 반환
  rootProps: {
    'data-checked': boolean
    'data-disabled': boolean
    onClick: () => void
  }
  inputProps: {
    type: 'checkbox'
    checked: boolean
    disabled: boolean
    onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
  }
}

export function use{Component}(props: Use{Component}Props): Use{Component}Return {
  // 1. State management (controlled vs uncontrolled)
  const [internalValue, setInternalValue] = useState(props.defaultValue ?? false)
  const checked = props.value ?? internalValue

  // 2. Event handlers
  const handleChange = useCallback(() => {
    if (props.disabled) return

    const newValue = !checked
    setInternalValue(newValue)
    props.onChange?.(newValue)
  }, [checked, props.disabled, props.onChange])

  // 3. Return parts props
  return {
    rootProps: {
      'data-checked': checked,
      'data-disabled': props.disabled ?? false,
      onClick: handleChange,
    },
    inputProps: {
      type: 'checkbox',
      checked,
      disabled: props.disabled ?? false,
      onChange: handleChange,
    },
  }
}
```

**Hook 작성 가이드**:
1. **Controlled & Uncontrolled 모두 지원**:
   - `defaultValue` + `value` props 제공
   - `value`가 있으면 controlled, 없으면 uncontrolled
2. **이벤트 핸들러 최적화**:
   - `useCallback`으로 메모이제이션
   - 의존성 배열 정확히 명시
3. **접근성 고려**:
   - ARIA attributes 포함 (aria-checked, aria-disabled 등)
4. **타입 안정성**:
   - Props와 Return 타입 명확히 정의
   - Generic 타입 활용 가능

### Step 4: Implement Component

**Step 4-1**: `{Component}.tsx` 파일 생성

```typescript
import { forwardRef } from 'react'
import { use{Component}, Use{Component}Props } from './use{Component}'

export interface {Component}Props extends Use{Component}Props {
  children?: React.ReactNode
  className?: string
}

export const {Component} = forwardRef<HTMLButtonElement, {Component}Props>(
  (props, ref) => {
    const { children, className, ...hookProps } = props
    const { rootProps, inputProps } = use{Component}(hookProps)

    return (
      <button
        ref={ref}
        className={className}
        {...rootProps}
      >
        <input {...inputProps} />
        {children}
      </button>
    )
  }
)

{Component}.displayName = '{Component}'
```

**Component 작성 가이드**:
1. **Props Spreading**:
   - Hook props와 DOM props 분리
   - Hook에서 반환된 props를 spread
2. **Ref Forwarding**:
   - `forwardRef` 사용하여 ref 전달
   - 적절한 DOM 요소에 ref 연결
3. **Children Composition**:
   - children의 위치와 렌더링 방식 고려
4. **DisplayName**:
   - 디버깅을 위해 displayName 설정

### Step 5: Multi-Part Components (선택)

Parts가 여러 개인 경우:

**Step 5-1**: 각 Part별 파일 생성
```typescript
// DialogTrigger.tsx
export const DialogTrigger = forwardRef<HTMLButtonElement, DialogTriggerProps>(
  (props, ref) => {
    const { triggerProps } = useDialogContext()
    return <button ref={ref} {...triggerProps} {...props} />
  }
)
```

**Step 5-2**: Context 생성 (필요 시)
```typescript
// DialogContext.tsx
const DialogContext = createContext<UseDialogReturn | null>(null)

export function useDialogContext() {
  const context = useContext(DialogContext)
  if (!context) throw new Error('Dialog parts must be used within Dialog.Root')
  return context
}
```

**Step 5-3**: Namespace 파일 생성
```typescript
// Dialog.namespace.ts
export { Dialog as Root } from './Dialog'
export { DialogTrigger as Trigger } from './DialogTrigger'
export { DialogContent as Content } from './DialogContent'
// ... 다른 parts
```

### Step 6: Public Exports

**Step 6-1**: `index.ts` 파일 작성

**Single Component**:
```typescript
// index.ts
export { Checkbox } from './Checkbox'
export type { CheckboxProps } from './Checkbox'
export { useCheckbox } from './useCheckbox'
export type { UseCheckboxProps, UseCheckboxReturn } from './useCheckbox'
```

**Multi-Part Component**:
```typescript
// index.ts
import * as Dialog from './Dialog.namespace'
export { Dialog }

export type { DialogProps } from './Dialog'
export type { DialogTriggerProps } from './DialogTrigger'
// ... 다른 types

export { useDialog } from './useDialog'
export type { UseDialogProps, UseDialogReturn } from './useDialog'
```

### Step 7: Package Configuration

**Step 7-1**: `package.json` 확인

```json
{
  "name": "@seed-design/react-headless-{component-name}",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.js"
    }
  },
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  }
}
```

## Examples

### Example 1: Simple Checkbox Component

```typescript
// useCheckbox.ts
export function useCheckbox(props: UseCheckboxProps) {
  const [checked, setChecked] = useState(props.defaultChecked ?? false)
  const isChecked = props.checked ?? checked

  const handleChange = useCallback(() => {
    if (props.disabled) return
    const newValue = !isChecked
    setChecked(newValue)
    props.onChange?.(newValue)
  }, [isChecked, props.disabled, props.onChange])

  return {
    rootProps: {
      'data-checked': isChecked,
      'data-disabled': props.disabled ?? false,
      role: 'checkbox',
      'aria-checked': isChecked,
      onClick: handleChange,
    },
    inputProps: {
      type: 'checkbox',
      checked: isChecked,
      disabled: props.disabled,
      onChange: handleChange,
    },
  }
}

// Checkbox.tsx
export const Checkbox = forwardRef<HTMLDivElement, CheckboxProps>(
  (props, ref) => {
    const { children, ...hookProps } = props
    const { rootProps, inputProps } = useCheckbox(hookProps)

    return (
      <div ref={ref} {...rootProps}>
        <input {...inputProps} />
        {children}
      </div>
    )
  }
)
```

### Example 2: Multi-Part Dialog Component

```typescript
// useDialog.ts
export function useDialog(props: UseDialogProps) {
  const [open, setOpen] = useState(props.defaultOpen ?? false)
  const isOpen = props.open ?? open

  const handleOpenChange = useCallback((newOpen: boolean) => {
    setOpen(newOpen)
    props.onOpenChange?.(newOpen)
  }, [props.onOpenChange])

  return {
    isOpen,
    triggerProps: {
      'data-state': isOpen ? 'open' : 'closed',
      onClick: () => handleOpenChange(true),
    },
    contentProps: {
      'data-state': isOpen ? 'open' : 'closed',
      hidden: !isOpen,
    },
    closeProps: {
      onClick: () => handleOpenChange(false),
    },
  }
}

// Dialog.tsx
export const Dialog = (props: DialogProps) => {
  const dialog = useDialog(props)

  return (
    <DialogContext.Provider value={dialog}>
      {props.children}
    </DialogContext.Provider>
  )
}

// DialogTrigger.tsx
export const DialogTrigger = forwardRef<HTMLButtonElement, DialogTriggerProps>(
  (props, ref) => {
    const { triggerProps } = useDialogContext()
    return <button ref={ref} {...triggerProps} {...props} />
  }
)

// Dialog.namespace.ts
export { Dialog as Root } from './Dialog'
export { DialogTrigger as Trigger } from './DialogTrigger'
export { DialogContent as Content } from './DialogContent'
export { DialogClose as Close } from './DialogClose'
```

## Testing Guidelines

Headless 컴포넌트는 스타일이 없으므로 데이터 로직과 상태 관리를 테스트합니다:

```typescript
describe('useCheckbox', () => {
  it('should toggle checked state', () => {
    const { result } = renderHook(() => useCheckbox({}))

    expect(result.current.rootProps['data-checked']).toBe(false)

    act(() => {
      result.current.rootProps.onClick()
    })

    expect(result.current.rootProps['data-checked']).toBe(true)
  })

  it('should call onChange callback', () => {
    const onChange = vi.fn()
    const { result } = renderHook(() => useCheckbox({ onChange }))

    act(() => {
      result.current.rootProps.onClick()
    })

    expect(onChange).toHaveBeenCalledWith(true)
  })
})
```

**테스트 항목**:
- 상태 변화 (checked, open, selected 등)
- 이벤트 핸들러 호출
- Controlled vs Uncontrolled 모드
- Data attributes 정확성
- 접근성 attributes (ARIA)

## Checklist

컴포넌트 개발 후 다음 사항을 확인합니다:

- [ ] 스타일 관련 로직이 없는가?
- [ ] 커스텀 훅이 올바른 parts props를 반환하는가?
- [ ] Data attributes가 상태를 정확히 표현하는가?
- [ ] Controlled & Uncontrolled 모드를 모두 지원하는가?
- [ ] Ref forwarding이 올바르게 구현되었는가?
- [ ] Multi-part 컴포넌트의 경우 namespace 파일이 있는가?
- [ ] 접근성 attributes (ARIA)가 포함되었는가?
- [ ] TypeScript 타입이 정확하게 정의되었는가?
- [ ] Public exports (`index.ts`)가 올바르게 설정되었는가?
- [ ] 테스트가 작성되었는가?

## Reference

**기존 Headless 컴포넌트**:
- `packages/react-headless/` 폴더의 다른 컴포넌트들 참조
- 유사한 컴포넌트의 패턴 활용

**외부 라이브러리 참고**:
- Radix UI Primitives
- React Aria Components
- Headless UI

## Tips

1. **로직 분리**:
   - 비즈니스 로직은 hook에
   - DOM 조합은 컴포넌트에
   - 스타일은 `@seed-design/react`에

2. **상태 관리**:
   - Controlled와 Uncontrolled 모두 지원
   - `value`와 `defaultValue` 패턴 사용

3. **접근성 우선**:
   - ARIA attributes 항상 포함
   - 키보드 네비게이션 고려
   - 스크린 리더 호환성 확보

4. **타입 안정성**:
   - Props와 Return 타입 명확히
   - Generic 타입 적극 활용
   - JSDoc 주석으로 문서화

5. **테스트 작성**:
   - 상태 변화 테스트
   - 이벤트 핸들러 테스트
   - 엣지 케이스 커버

6. **Performance**:
   - `useCallback`, `useMemo` 활용
   - 불필요한 리렌더링 방지
   - 의존성 배열 정확히 관리

Install

Requires askill CLI v1.0+

Metadata

LicenseUnknown
Version-
Updated6d ago
Publisherdaangn

Tags

github-actions