# sellmate-design-system-react

Sellmate 디자인 시스템의 React 컴포넌트 라이브러리 (React + TypeScript + Tailwind CSS v4).

> ⚠️ **소비 앱은 Tailwind CSS v4가 필요합니다.** (v3에서는 클래스가 purge되어 스타일이 적용되지 않습니다.)

## 요구 사항

- React 18 이상 (`react`, `react-dom`)
- 방식 1 사용 시 Tailwind CSS v4

## 설치

```bash
npm install sellmate-design-system-react
npx sellmate-ds init
```

`init` 이 소비 앱 설정을 자동으로 연결합니다.

| 대상                      | 하는 일                                                       |
| ------------------------- | ------------------------------------------------------------- |
| `CLAUDE.md` / `AGENTS.md` | AI 에이전트가 규칙(`llms.txt`)을 먼저 읽도록 지침 추가        |
| `eslint.config.mjs`       | 디자인 시스템 ESLint 프리셋 연결                              |
| 전역 CSS                  | `theme.css` import 와 `@source` 경로(파일 기준 상대경로) 추가 |

- **이미 되어 있는 항목은 건너뜁니다** — 여러 번 실행해도 안전합니다.
- 무엇이 바뀌는지 먼저 보려면 `npx sellmate-ds init --dry-run`.
- 자동으로 고치기 어려운 형태(예: `defineConfig(...)` 로 감싼 ESLint 설정)는 **파일을 건드리지 않고** 붙여넣을 스니펫을 출력합니다.

수동으로 설정하려면 아래 절을 따르세요.

## 설정 (방식 1 — 권장, Tailwind v4)

소비 앱의 전역 CSS에 다음을 추가합니다.

```css
/* app globals.css */
@import 'tailwindcss';
@import 'sellmate-design-system-react/theme.css';
@source "../node_modules/sellmate-design-system-react/dist";
```

- `theme.css` — 디자인 토큰(`@theme`). 라이브러리와 앱이 같은 토큰을 공유합니다.
  **앱의 기존 Tailwind 스케일은 그대로 둡니다** — 간격 토큰은 `--spacing-sd-*` 로 나가므로
  `p-4`(16px) 같은 기본 유틸리티의 의미가 바뀌지 않습니다. 디자인 시스템 간격은
  `sd-` 접두로 씁니다: `p-sd-16`(16px) · `gap-sd-8`(8px). 자세한 규칙은 `AGENTS.md` §2-2.
- `@source` — 라이브러리 `dist`를 Tailwind 스캔 대상에 포함시켜 컴포넌트 클래스가 생성되게 합니다. **경로는 여러분의 CSS 파일 위치 기준 상대경로**이며, `node_modules` 전체가 아니라 반드시 이 패키지 `dist`만 지정하세요.

## 설정 (방식 2 — 폴백, Tailwind 미사용)

Tailwind를 쓰지 않는 앱은 컴파일된 CSS를 직접 import 합니다.

```ts
import 'sellmate-design-system-react/styles.css';
```

## Next.js (App Router)

별도 설정 없이 동작합니다. `dist` 가 이미 빌드되어 있으므로 `transpilePackages` 는 필요 없습니다.

전역 CSS 는 위 **방식 1** 과 같습니다. `@source` 는 **CSS 파일 기준 상대경로**이므로 `app/globals.css` 에 둔다면 다음과 같습니다.

```css
/* app/globals.css */
@import 'tailwindcss';
@import 'sellmate-design-system-react/theme.css';
@source "../node_modules/sellmate-design-system-react/dist";
```

### 서버 컴포넌트에서 그대로 import 할 수 있습니다

패키지 엔트리가 `"use client"` 로 선언된 클라이언트 경계라, 서버 컴포넌트에서 바로 import 해도 됩니다. 서버에서 렌더(SSR)된 뒤 클라이언트에서 하이드레이션됩니다.

```tsx
// app/page.tsx — "use client" 없음
import { SButton } from 'sellmate-design-system-react';

export default function Page() {
  return <SButton color="primary" size="md" label="확인" />;
}
```

### 이벤트 핸들러가 필요하면 소비자 쪽에 `"use client"`

RSC 경계를 넘어 **함수는 전달할 수 없습니다.** 위 페이지에 `onClick` 을 추가하면 빌드가 실패합니다.

```text
Error: Event handlers cannot be passed to Client Component props.
```

이는 라이브러리 제약이 아니라 React 서버 컴포넌트의 규칙입니다. 핸들러·상태가 필요한 화면은 그 파일을 클라이언트 컴포넌트로 만드세요.

```tsx
'use client';

import { useState } from 'react';
import { SCheckbox } from 'sellmate-design-system-react';

export function AgreeField() {
  const [value, setValue] = useState(false);
  return <SCheckbox value={value} onValueChange={setValue} label="동의" />;
}
```

동작을 직접 확인하려면 Next playground 를 띄우세요 — 이 패키지를 소비 앱과 동일하게 `dist` 로 소비합니다.

```bash
npm run playground:next   # http://localhost:3010
```

## 사용법

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

export function Example() {
  return <SButton color="primary" size="md" label="확인" onClick={() => alert('clicked')} />;
}
```

모든 컴포넌트는 **`S` 접두어**를 씁니다(`SButton`, `SInput` …). 공개 타입도 마찬가지입니다(`SButtonProps`).

`SButton` 은 텍스트를 **`label`(문자열)로만** 받습니다 — `children` 은 받지 않으며, 아이콘은 `icon`/`rightIcon` 을 씁니다. 색상 prop 은 `color` 입니다(`variant` 아님).

### 앱 부트스트랩 — `SModalOutlet`

명령형 모달(`SModal.confirm` / `loading` / `create`)을 쓴다면 앱 진입점의 **Provider 안쪽에 `<SModalOutlet />` 을 한 번** 렌더하세요.

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

<QueryClientProvider client={queryClient}>
  <RouterProvider router={router} />
  <SModalOutlet />
</QueryClientProvider>;
```

outlet 이 있으면 모달이 앱 렌더 트리의 자식으로 그려져 **QueryClient·Router·Theme 등 Context 를 그대로 상속**합니다(DOM 상으로는 `body` 로 portal 되므로 레이아웃에는 영향이 없습니다). 렌더하지 않아도 모달은 뜨지만 별도 React 루트로 마운트되어 앱의 Provider 가 닿지 않고, 개발 모드에서 경고가 찍힙니다. 자세한 내용은 [`SModal` 문서](./src/components/SModal/README.md)를 보세요.

## 화면 작성 규칙 (AI 에이전트 포함)

컴포넌트 조합·간격·타이포 등 **화면을 만들 때 지켜야 할 규칙**은 패키지에 함께 배포됩니다.

| 파일                                                                         | 용도                                                                     |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `node_modules/sellmate-design-system-react/AGENTS.md`                        | 사람이 읽는 사용 규칙서                                                  |
| `node_modules/sellmate-design-system-react/dist/llms.txt`                    | **AI 가 항상 읽는 문서** — 규칙 + 토큰 어휘 + 컴포넌트 인덱스 (약 32KB)  |
| `node_modules/sellmate-design-system-react/dist/components/<이름>/README.md` | 컴포넌트별 Props/Events — 쓸 컴포넌트만 골라서 읽습니다                  |
| `node_modules/sellmate-design-system-react/dist/llms-full.txt`               | 위 둘을 한 파일에 합친 판본 (약 106KB) — 단일 파일만 물릴 수 있는 도구용 |

Props 를 `llms.txt` 에서 뺀 이유: 전체 컴포넌트 Props 가 분량의 70% 를 차지하는데,
prop 오류는 TypeScript 가 잡아주지만 **디자인 규칙은 아무도 잡아주지 않습니다.**
한정된 컨텍스트를 규칙에 쓰고, Props 는 필요한 것만 정확히 읽게 하는 편이 낫습니다.

Claude 등 AI 에이전트를 쓴다면 소비 앱의 `CLAUDE.md` 에 다음 한 줄을 넣어두면 됩니다.

```md
UI 작업 전 `node_modules/sellmate-design-system-react/dist/llms.txt` 를 먼저 읽을 것.
```

## ESLint 설정

위 규칙 중 **정적으로 검출 가능한 것**은 린트로 강제할 수 있습니다. 소비 앱의 `eslint.config.mjs` 에 추가하세요.

```js
// eslint.config.mjs
import sellmate from 'sellmate-design-system-react/eslint';

export default [
  // ... 기존 설정
  ...sellmate.configs.recommended,
];
```

**`error` 는 "지키지 않으면 깨지는 것" 에만 씁니다.** 앱 화면을 Tailwind 로 자유롭게 만드는 것은 정상이고, 디자인 시스템이 그 자유까지 막지 않습니다. 나머지는 권고(`warn`)입니다.

| 규칙                             | 기본      | 검출 대상                                                                                                                                                                                               |
| -------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sellmate/no-off-scale-spacing`  | **error** | 스케일 밖 디자인 시스템 간격 (`gap-sd-13`) — Tailwind v4 에서 **조용히 무시되어** 스타일이 사라진다                                                                                                     |
| `sellmate/no-raw-html-control`   | **error** | 대응 컴포넌트가 있는 생 HTML (`<button>` `<input>` `<select>` `<textarea>` `<table>` `<form>` `<dialog>` `<hr>` `<details>` `<progress>`), `alert()`/`confirm()` — 디자인 시스템을 통째로 우회하는 경우 |
| `sellmate/prefer-typo-preset`    | warn      | `text-14 font-bold` 같은 낱개 조합 → `typo-*` 프리셋                                                                                                                                                    |
| `sellmate/component-group-gap`   | warn      | 같은 컴포넌트를 나열할 때의 그룹 간격 — 배열 방향에 따라 값이 다르다(체크박스 가로 24 / 세로 8)                                                                                                         |
| `sellmate/table-numeric-align`   | warn      | 수량 컬럼(금액·수량 등)에 `align: 'right'` 누락 — **`--fix` 로 자동 교정**                                                                                                                              |
| `sellmate/require-locale-number` | warn      | 수량 컬럼의 `toLocaleString()` 누락 — 세 자리 콤마                                                                                                                                                      |
| `sellmate/no-arbitrary-class`    | **off**   | 토큰이 있는 속성(색·타이포·간격·모서리)의 임의 값 — `text-[14px]`, `bg-[#eee]`. 앱 고유 화면에는 정당한 사용이 많아 기본값은 끕니다                                                                     |

`className` 뿐 아니라 `cn()`/`clsx()` 인자, 템플릿 리터럴, 객체 키 안까지 검사합니다.

앱이 원래 쓰던 Tailwind 코드(`p-4` `gap-2` `text-sm` `bg-[#eee]` `w-[280px]`)는 **기본 설정에서 error 가 나지 않습니다.** 디자인 시스템 규칙을 처음부터 전면 적용하는 신규 프로젝트는 `...sellmate.configs.strict` 를 쓰면 전 규칙이 error 가 되고 간격도 `sd-` 접두를 강제합니다.

### 일부러 잡지 않는 것

과검출을 피하려고 다음은 기본 설정에서 **통과**시킵니다.

```tsx
// 디자인 토큰이 없는 앱 고유 레이아웃 치수 — 정당한 사용
<div className="w-[280px] max-w-[1200px] grid-cols-[200px_1fr] top-[64px]" />

// 토큰을 var() 로 참조하는 형태
<div className="h-[var(--sys-size-control-md-height)]" />

// 시각 요소가 없어 대체 컴포넌트가 없다
<input type="hidden" name="csrf" />

// 로고 SVG·본문 안 시맨틱 목록·DS 밖 컨트롤의 레이블
<svg viewBox="0 0 24 24" />
<ul><li>…</li></ul>
```

핵심은 **"디자인 토큰을 하드코딩하지 마라"** 이지 "임의 값을 절대 쓰지 마라" 가 아닙니다.

테이블 두 규칙(`table-numeric-align` · `require-locale-number`)도 아래는 통과시킵니다.

```tsx
// 번호·코드 등 식별자 — 크기를 비교하지 않으므로 우측 정렬도 콤마도 요구하지 않는다
{ name: 'waybillNo', label: '송장번호', field: 'waybillNo', align: 'center' }

// 헤더 가운데 + 셀 우측 — align 은 th·td 공통이라 tdClass 로만 표현된다
{ name: 'views', label: '조회수', field: 'views', align: 'center', tdClass: 'text-right!',
  format: (v: number) => Number(v).toLocaleString() }

// render / renderCell 로 셀을 직접 그리는 컬럼 — 라벨만으로 숫자 여부를 판단하지 않는다
{ name: 'unitPrice', label: '단가', field: 'unitPrice', align: 'right',
  renderCell: ({ row }) => `${row.unitPrice.toLocaleString()}원` }

// 수량 어휘를 부분 문자열로 포함하지만 값은 불리언인 상태 컬럼
{ name: 'isCostInput', label: '비용 입력 여부', field: 'isCostInput', align: 'center' }
```

그래도 남는 예외는 컬럼 `name` 을 `allow` 로 지정해 뺍니다.

```js
'sellmate/table-numeric-align': ['error', { allow: ['rank'] }],
```

### 더 엄격하게 / 더 느슨하게

`configs.strict` 는 전 규칙을 error 로 올리고, `<ul>` `<ol>` `<li>` `<svg>` `<label>` 까지 검사하며, 간격 유틸리티에 `sd-` 접두를 강제합니다(`requirePrefix`). 디자인 시스템 규칙을 처음부터 전면 적용하는 신규 프로젝트용입니다.

```js
export default [...sellmate.configs.strict];
```

규칙별 예외도 줄 수 있습니다.

```js
{
  rules: {
    // strict 를 쓰되 로고 SVG 는 허용
    'sellmate/no-raw-html-control': ['error', { strict: true, allow: ['svg'] }],
    // 그림자만 임의 값 허용
    'sellmate/no-arbitrary-class': ['error', { ignorePrefixes: ['shadow'] }],
  },
}
```

허용 값 스케일은 `theme.css` 에서 **빌드 시 자동 추출**되므로 토큰이 바뀌면 규칙도 따라갑니다.

### 기존 Tailwind 코드는 그대로 둡니다

`no-off-scale-spacing` 은 기본값에서 **`sd-` 접두가 붙은 클래스만** 검사합니다. 앱이 원래 쓰던 `p-4`·`gap-2`·`mt-8` 은 걸리지 않습니다.

간격을 디자인 시스템 스케일로 통일하려는 팀만 옵션을 켭니다.

```js
{ rules: { 'sellmate/no-off-scale-spacing': ['error', { requirePrefix: true }] } }
```

접두 없는 `p-16` 은 **깨진 코드가 아니라 Tailwind 기본 스케일로 64px 이 적용되는 코드**입니다. 이걸 `p-sd-16`(16px)으로 옮기는 것은 표기 정규화가 아니라 값 변경이라, **자동 수정하지 않고 두 해석을 제안(suggestion)으로만** 냅니다.

```text
`p-4` 은 현재 16px 입니다 (Tailwind 기본 스케일).
  4px 을 의도했다면  → `p-sd-4`
  16px 을 유지하려면 → `p-sd-16`
```

편집기에서 둘 중 하나를 골라 적용하고, 스케일에 없는 값이면 대응 토큰이 없다는 안내가 나옵니다.

#### 3.x 에서 한 번에 옮길 때

3.x 이하의 간격 스케일(숫자 = px)을 쓰던 앱은 `unsafeFix` 로 예전의 일괄 수정을 켤 수 있습니다.

```js
{ rules: { 'sellmate/no-off-scale-spacing': ['error', { requirePrefix: true, unsafeFix: true }] } }
```

> ⚠️ `unsafeFix` 는 **"클래스의 숫자 = px"** 를 단정해 `p-16` → `p-sd-16` 으로 바꿉니다. 기본 Tailwind 스케일(`p-4` = 16px)로 쓰던 앱에서 켜면 **여백이 1/4 로 줄어듭니다.** 마이그레이션 커밋에서만 켜고 `eslint --fix` 를 돌린 뒤 다시 끄세요.

## 제공 컴포넌트

분류는 `src/index.ts` 의 export 그룹과 동일합니다.

| 분류        | 컴포넌트                                                                                                                                                                                                                                                                                       |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Atoms       | `SIcon` · `SBadge` · `SCard` · `SDraggableItem` · `SDraggableList` · `SExpansionItem` · `SExpansionList` · `SList` · `SListItem` · `STree` · `SSectionHeaderCard` · `SDivider` · `SScrollArea` · `STag` · `SButton` · `SCallout` · `SLinearProgress` · `SCircleProgress` · `SLoadingContainer` |
| Control     | `STextLink` · `SDropdownButton` · `SGhostButton` · `SSwitch` · `SToggle`                                                                                                                                                                                                                       |
| Overlays    | `STooltip` · `SPopover` · `SPortal` · `SPopup` · `SGuide`                                                                                                                                                                                                                                      |
| Modals      | `SConfirmModal` · `SActionModal` · `SLoadingModal` · `SDrawer` · `SModal` · `SModalOutlet`                                                                                                                                                                                                     |
| Layout      | `SGnb` · `SLayout` · `SPage`                                                                                                                                                                                                                                                                   |
| Navigation  | `STabs` · `SPagination` · `SStepper`                                                                                                                                                                                                                                                           |
| Data        | `STable` · `STableBar` · `SKeyValueTable`                                                                                                                                                                                                                                                      |
| Feedback    | `SToast`                                                                                                                                                                                                                                                                                       |
| SField (폼) | `SForm` · `SField` · `SCheckbox` · `SRadio` · `SRadioButton` · `SInput` · `STextarea` · `SSelect` · `SNumberInput` · `SChip` · `SChipInput` · `SBarcodeInput` · `SCalendar` · `SDatePicker` · `SDateRangePicker` · `STimePicker` · `STimeRangePicker` · `SFilePicker`                          |

`SModalContainer` 는 모달 3종의 내부 공통 컨테이너로 **공개 export 가 아닙니다.** `SOverlayHeader` 는 모달·드로어 내부 제목 영역, `SFooter` 는 모달·드로어·팝업 하단 액션 영역 조합용 컴포넌트라 public export 에 포함하지 않습니다. 소비자는 `SConfirmModal`/`SActionModal`/`SLoadingModal`/`SDrawer`/`SPopup` 을 사용하세요.

유틸리티도 함께 export 합니다.

- `cn` — 클래스 병합 헬퍼
- `resolveColor` · `isColorKey` · `COLOR_KEYS` · `SColor` · `SColorKey` — 색상 토큰 유틸
- `Rule` — `rules` prop(`SField` · `SChip` · `SBarcodeInput` 등)에 넘기는 검증 함수 타입

## 특징

- **폼 컴포넌트는 form-agnostic** — `forwardRef` + 표준 `value`/`onChange`를 사용하며 특정 폼 라이브러리에 의존하지 않습니다. react-hook-form 등과 자유롭게 조합할 수 있습니다.
- **단일 라이트 테마** — 다크모드는 지원하지 않습니다.
- **타입 포함** — 모든 컴포넌트에 TypeScript 타입 정의(`.d.ts`)가 함께 제공됩니다.

## 버전 업 / 릴리스

Stencil 쪽 `lerna version` 과 동일하게 **Conventional Commits 기반**으로 버전을 올리고 `CHANGELOG.md` 를 누적합니다. 이 패키지는 lerna 관리 대상이 아니라 전용 스크립트를 씁니다.

```bash
# 마지막 릴리스 이후 커밋으로 상승 폭을 자동 추론 → package.json/CHANGELOG 갱신 + 릴리스 커밋
npm run version

# 실제로 바꾸지 않고 결과만 미리보기
npm run version:dry

# 루트에서도 실행 가능
npm run version:rn        # (design-system 루트)
```

- 마지막 `chore(release): react-native <ver>` 커밋 이후 `react-native/` 를 건드린 커밋을 모읍니다.
- `BREAKING CHANGE → major`, `feat → minor`, 그 외(`fix` 등) `→ patch` 로 버전을 올립니다.
- 주요 옵션: `--bump <major|minor|patch>` · `--release-as <x.y.z>` · `--pre-major`(0.x 유지) · `--tag` · `--no-commit` · `--dry-run` · `--force` (`node scripts/version.mjs --help`).
- push 는 하지 않습니다. 커밋/태그 확인 후 직접 push 하세요.

## 라이선스

UNLICENSED — Sellmate 내부용 독점 패키지입니다. 무단 사용·복제·재배포를 금합니다.
