sellmate-design-system-react 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +695 -0
- package/README.md +106 -0
- package/bin/sellmate-ds.mjs +337 -0
- package/dist/components/SActionModal/README.md +41 -0
- package/dist/components/SBadge/README.md +25 -0
- package/dist/components/SBarcodeInput/README.md +64 -0
- package/dist/components/SButton/README.md +49 -0
- package/dist/components/SCalendar/README.md +42 -0
- package/dist/components/SCallout/README.md +29 -0
- package/dist/components/SCard/README.md +12 -0
- package/dist/components/SCheckbox/README.md +44 -0
- package/dist/components/SChip/README.md +57 -0
- package/dist/components/SChipInput/README.md +77 -0
- package/dist/components/SCircleProgress/README.md +35 -0
- package/dist/components/SConfirmModal/README.md +61 -0
- package/dist/components/SDatePicker/README.md +62 -0
- package/dist/components/SDateRangePicker/README.md +64 -0
- package/dist/components/SDivider/README.md +25 -0
- package/dist/components/SDraggableItem/README.md +40 -0
- package/dist/components/SDropdownButton/README.md +50 -0
- package/dist/components/SExpansionItem/README.md +40 -0
- package/dist/components/SField/README.md +91 -0
- package/dist/components/SFilePicker/README.md +72 -0
- package/dist/components/SForm/README.md +31 -0
- package/dist/components/SGhostButton/README.md +76 -0
- package/dist/components/SGnb/README.md +56 -0
- package/dist/components/SGuide/README.md +34 -0
- package/dist/components/SIcon/README.md +91 -0
- package/dist/components/SInput/README.md +66 -0
- package/dist/components/SKeyValueTable/README.md +59 -0
- package/dist/components/SLayout/README.md +42 -0
- package/dist/components/SLinearProgress/README.md +17 -0
- package/dist/components/SList/README.md +14 -0
- package/dist/components/SListItem/README.md +19 -0
- package/dist/components/SLoadingContainer/README.md +29 -0
- package/dist/components/SLoadingModal/README.md +52 -0
- package/dist/components/SModal/README.md +192 -0
- package/dist/components/SModalContainer/README.md +49 -0
- package/dist/components/SNumberInput/README.md +74 -0
- package/dist/components/SPage/README.md +25 -0
- package/dist/components/SPagination/README.md +40 -0
- package/dist/components/SPopover/README.md +54 -0
- package/dist/components/SPopup/README.md +38 -0
- package/dist/components/SPortal/README.md +51 -0
- package/dist/components/SRadio/README.md +57 -0
- package/dist/components/SRadioButton/README.md +24 -0
- package/dist/components/SScrollArea/README.md +16 -0
- package/dist/components/SSectionHeaderCard/README.md +51 -0
- package/dist/components/SSelect/README.md +79 -0
- package/dist/components/SStepper/README.md +30 -0
- package/dist/components/SSwitch/README.md +24 -0
- package/dist/components/STable/README.md +82 -0
- package/dist/components/STableBar/README.md +28 -0
- package/dist/components/STabs/README.md +37 -0
- package/dist/components/STag/README.md +44 -0
- package/dist/components/STextLink/README.md +48 -0
- package/dist/components/STextarea/README.md +60 -0
- package/dist/components/STimePicker/README.md +65 -0
- package/dist/components/STimeRangePicker/README.md +63 -0
- package/dist/components/SToast/README.md +70 -0
- package/dist/components/SToggle/README.md +22 -0
- package/dist/components/STooltip/README.md +57 -0
- package/dist/llms-full.txt +3530 -0
- package/dist/llms.txt +882 -0
- package/dist/styles.css +221 -0
- package/dist/theme.css +8 -0
- package/eslint/index.mjs +69 -0
- package/eslint/lib/class-names.mjs +119 -0
- package/eslint/lib/table-column.mjs +53 -0
- package/eslint/rules/component-group-gap.mjs +186 -0
- package/eslint/rules/no-arbitrary-class.mjs +91 -0
- package/eslint/rules/no-off-scale-spacing.mjs +67 -0
- package/eslint/rules/no-raw-html-control.mjs +117 -0
- package/eslint/rules/prefer-typo-preset.mjs +73 -0
- package/eslint/rules/require-locale-number.mjs +78 -0
- package/eslint/rules/table-numeric-align.mjs +93 -0
- package/eslint/scale.gen.mjs +16 -0
- package/package.json +15 -4
package/AGENTS.md
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
# sellmate-design-system-react — 사용 규칙 (AGENTS.md)
|
|
2
|
+
|
|
3
|
+
> **대상**: 이 패키지로 화면을 만드는 소비 앱의 개발자와 AI 코딩 에이전트(Claude 등).
|
|
4
|
+
> 이 문서는 "무엇을 언제 쓰고, 무엇을 쓰면 안 되는지"의 단일 기준이다.
|
|
5
|
+
> 개별 컴포넌트의 상세 Props/Events는 `node_modules/sellmate-design-system-react/dist/components/<이름>/README.md` 를 참조한다.
|
|
6
|
+
|
|
7
|
+
## 0. 최우선 원칙 — 디자인 시스템 컴포넌트가 먼저다
|
|
8
|
+
|
|
9
|
+
**화면 요소를 만들기 전에, 그 역할을 하는 컴포넌트가 이미 있는지 먼저 확인한다.**
|
|
10
|
+
있으면 반드시 그것을 쓴다. 직접 만드는 것은 대응 컴포넌트가 **없다는 것을 확인한 뒤**의 최후 수단이다.
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
❌ <button onClick={save}>저장</button> ✅ <SButton label="저장" onClick={save} />
|
|
14
|
+
❌ <table>…</table> ✅ <STable columns={columns} rows={rows} />
|
|
15
|
+
❌ <div className="rounded border p-16">…</div> ✅ <SSectionHeaderCard>…</SSectionHeaderCard>
|
|
16
|
+
❌ <ul><li>…</li></ul> ✅ <SList><SListItem title="…" /></SList>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
"비슷하게 생긴 것을 직접 만드는 것"이 어색함의 가장 큰 원인이다. 대응 컴포넌트를 쓰면 색·간격·상태·접근성이 전부 따라온다.
|
|
20
|
+
|
|
21
|
+
### 0-1. 전체 컴포넌트 인덱스
|
|
22
|
+
|
|
23
|
+
무엇을 만들지 정했으면 **이 표에서 먼저 찾는다.** 상세 Props 는 `dist/components/<이름>/README.md` 참조.
|
|
24
|
+
|
|
25
|
+
| 분류 | 컴포넌트 |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| **버튼·링크** | `SButton` `SGhostButton` `SDropdownButton` `STextLink` `SSwitch` `SToggle` |
|
|
28
|
+
| **입력 (폼)** | `SForm` `SField` `SInput` `SNumberInput` `STextarea` `SSelect` `SCheckbox` `SRadio` `SRadioButton` `SChip` `SChipInput` `SBarcodeInput` `SFilePicker` |
|
|
29
|
+
| **날짜·시간** | `SCalendar` `SDatePicker` `SDateRangePicker` `STimePicker` `STimeRangePicker` |
|
|
30
|
+
| **표·목록** | `STable` `STableBar` `SKeyValueTable` `SList` `SListItem` `SDraggableItem` |
|
|
31
|
+
| **레이아웃** | `SLayout` `SGnb` `SPage` `SSectionHeaderCard` `SCard` `SDivider` `SScrollArea` `SExpansionItem` |
|
|
32
|
+
| **내비게이션** | `STabs` `SPagination` `SStepper` |
|
|
33
|
+
| **표시·상태** | `STag` `SBadge` `SIcon` `SCallout` `SGuide` |
|
|
34
|
+
| **진행·로딩** | `SLinearProgress` `SCircleProgress` `SLoadingContainer` `SLoadingModal` |
|
|
35
|
+
| **오버레이** | `STooltip` `SPopover` `SPopup` `SPortal` |
|
|
36
|
+
| **모달** | `SModal.confirm()` `SModal.create()` + `SActionModal` `SConfirmModal` |
|
|
37
|
+
| **알림** | `SToast` `SToastContainer` |
|
|
38
|
+
|
|
39
|
+
표에 없는 UI 를 만들어야 할 때만 `div` 로 직접 조립하고, 그때도 §1-2 · §2 의 토큰 규칙을 지킨다.
|
|
40
|
+
|
|
41
|
+
### 0-2. 프로젝트 설정
|
|
42
|
+
|
|
43
|
+
설정(Tailwind v4 `theme.css` import, `@source` 지정, Next.js 주의사항)은 패키지 [README.md](./README.md)를 따른다. 이 문서는 설정이 끝난 상태에서의 **화면 작성 규칙**만 다룬다.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 1. 절대 규칙 (금지 목록)
|
|
48
|
+
|
|
49
|
+
AI 에이전트는 코드를 생성하기 전에 이 목록을 반드시 지킨다.
|
|
50
|
+
|
|
51
|
+
### 1-1. 생 HTML 컨트롤 금지
|
|
52
|
+
|
|
53
|
+
§0 원칙의 구체적 목록이다. 아래 요소는 **어떤 경우에도** 생 HTML 로 만들지 않는다.
|
|
54
|
+
|
|
55
|
+
| 금지 | 대신 사용 |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `<button>` | `SButton`, `SGhostButton`, `SDropdownButton`, `STextLink` |
|
|
58
|
+
| `<input type="text/password/...">` | `SInput` |
|
|
59
|
+
| `<input type="number">` | `SNumberInput` |
|
|
60
|
+
| `<input type="checkbox">` | `SCheckbox`, `SToggle`, `SSwitch` |
|
|
61
|
+
| `<input type="radio">` | `SRadio`, `SRadioButton` |
|
|
62
|
+
| `<input type="file">` | `SFilePicker` |
|
|
63
|
+
| `<select>` | `SSelect` |
|
|
64
|
+
| `<textarea>` | `STextarea` |
|
|
65
|
+
| `<table>` | `STable`, `SKeyValueTable` |
|
|
66
|
+
| `<form>` | `SForm` |
|
|
67
|
+
| `<dialog>`, 직접 만든 오버레이 | `SModal.confirm(...)`, `SModal.create(...)`, `SPopup` |
|
|
68
|
+
| `alert()`, `confirm()` | `SToast`, `SModal.confirm(...)` |
|
|
69
|
+
| 직접 만든 탭/페이지네이션/스텝퍼 | `STabs`, `SPagination`, `SStepper` |
|
|
70
|
+
| `<ul>`/`<li>` 로 만든 목록 UI | `SList` + `SListItem` (드래그 정렬은 `SDraggableItem`) |
|
|
71
|
+
| 직접 만든 섹션 카드(제목 바 + 본문 박스) | `SSectionHeaderCard` + `.Header` / `.Body` |
|
|
72
|
+
| `<svg>` 직접 삽입, 이모지 아이콘 | `SIcon` |
|
|
73
|
+
| `<hr>` | `SDivider` |
|
|
74
|
+
| `<details>` / `<summary>` | `SExpansionItem` |
|
|
75
|
+
| `<progress>` | `SLinearProgress`, `SCircleProgress` |
|
|
76
|
+
| `<label>` (폼 레이블) | `SField` 의 `label` prop |
|
|
77
|
+
| 직접 만든 카드·패널 박스 | `SCard`, `SSectionHeaderCard` |
|
|
78
|
+
| 직접 만든 스크롤 영역 | `SScrollArea` |
|
|
79
|
+
|
|
80
|
+
예외: 순수 레이아웃 요소(`div`, `section`, 시맨틱 `h1~h6`, `p`, `span`, `a`)는 허용. 단 스타일은 아래 규칙을 따른다.
|
|
81
|
+
|
|
82
|
+
### 1-2. 임의 값(arbitrary literal) 금지
|
|
83
|
+
|
|
84
|
+
Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
❌ text-[14px] font-[600] bg-[#eee] gap-[13px] p-[10px] rounded-[5px] text-[#333]
|
|
88
|
+
✅ typo-body-sm-default text-fg-tertiary bg-bg-frame gap-12 p-8 rounded-md
|
|
89
|
+
✅ w-[var(--sys-size-control-md-height)] ← 토큰을 var()로 참조하는 것은 허용
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- 하드코딩 hex/px/rem 리터럴은 어디에도 쓰지 않는다 (인라인 `style` 포함).
|
|
93
|
+
- 인라인 `style`은 런타임 계산값(동적 width 등)에만 허용한다.
|
|
94
|
+
|
|
95
|
+
**단, 디자인 토큰이 없는 속성의 임의 값은 정당하다.** 화면 고유의 레이아웃 치수가 여기 해당한다.
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
✅ <div className="w-[280px] max-w-[1200px] grid-cols-[200px_1fr]" /> // 앱 고유 치수
|
|
99
|
+
❌ <div className="text-[13px] bg-[#eee] gap-[13px] rounded-[5px]" /> // 토큰이 있는 속성
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
규칙은 **색 · 타이포 · 간격 · 모서리** 네 계열에만 적용된다 — 이 넷은 토큰이 이미 있으므로 임의 값은 곧 디자인 시스템 우회다.
|
|
103
|
+
|
|
104
|
+
### 1-3. 타이포그래피는 프리셋으로만
|
|
105
|
+
|
|
106
|
+
`text-14 font-bold` 같은 조합을 즉흥으로 만들지 않는다. §2-1의 `typo-*` 프리셋 클래스를 쓴다.
|
|
107
|
+
|
|
108
|
+
### 1-4. 숫자는 무조건 `toLocaleString()`
|
|
109
|
+
|
|
110
|
+
**숫자를 화면에 표시할 때는 예외 없이 `toLocaleString()` 을 거쳐 세 자리마다 콤마를 넣는다.**
|
|
111
|
+
금액·수량·건수·재고 무엇이든, 테이블·상세·요약 문구 어디에 놓이든 같다.
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
❌ <span>{price}원</span> ❌ {`${qty}개`} ❌ {String(count)}
|
|
115
|
+
✅ <span>{price.toLocaleString()}원</span>
|
|
116
|
+
✅ format: (v: number) => `${Number(v).toLocaleString()}개`
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
콤마 없는 `39000` 은 자릿수를 세어야 읽히지만 `39,000` 은 한눈에 읽힌다. 숫자를 그대로 출력하는 코드는 미완성으로 본다.
|
|
120
|
+
|
|
121
|
+
전화번호·사업자번호처럼 하이픈이 들어가는 값은 애초에 숫자가 아니라 **서식이 정해진 문자열**이므로 이 규칙의 대상이 아니다.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## 2. 조합 어휘 — 컴포넌트 "사이"를 채울 때 쓰는 것들
|
|
126
|
+
|
|
127
|
+
### 2-1. 타이포그래피 프리셋
|
|
128
|
+
|
|
129
|
+
역할(role) → 크기 → 굵기 순으로 조합된 클래스가 이미 준비되어 있다.
|
|
130
|
+
|
|
131
|
+
| 용도 | 클래스 |
|
|
132
|
+
| --- | --- |
|
|
133
|
+
| 페이지/섹션 제목 | `typo-heading-lg`(18px) · `typo-heading-md`(16px) · `typo-heading-sm`(14px) · `typo-heading-xs`(12px) |
|
|
134
|
+
| 본문 | `typo-body-lg-*` (16px) · `typo-body-md-*`(14px) · `typo-body-sm-*`(12px) · `typo-body-xs-default`(11px) — `*` = `default`/`medium`/`bold` |
|
|
135
|
+
| 테이블 | `typo-table-header` · `typo-table-body` · `typo-table-accent` |
|
|
136
|
+
| 컨트롤·필드·피드백·내비 | `typo-control-*` `typo-field-*` `typo-feedback-*` `typo-navigation-*` (컴포넌트 내부용 — 직접 쓸 일은 드묾) |
|
|
137
|
+
|
|
138
|
+
**기본 선택 — 이 조합을 쓴다.** 이 서비스는 정보 밀도가 높아 본문이 12px 이다. 14px 를 본문 기본으로 쓰지 않는다.
|
|
139
|
+
|
|
140
|
+
| 역할 | 클래스 | 크기 |
|
|
141
|
+
| --- | --- | --- |
|
|
142
|
+
| 페이지 헤더 제목 (h1) | `typo-heading-lg` | 18px |
|
|
143
|
+
| 섹션 제목 | `typo-heading-sm` | 14px |
|
|
144
|
+
| 본문 | `typo-body-sm-default` | 12px |
|
|
145
|
+
| 보조 설명 | `typo-body-sm-default` + `text-fg-tertiary` | 12px / `grey_65` |
|
|
146
|
+
|
|
147
|
+
페이지 제목만 18px 로 크게 두고 그 아래는 14 / 12 로 촘촘하게 간다. 중간 크기(16px)는 기본 골격에서 쓰지 않는다.
|
|
148
|
+
|
|
149
|
+
### 2-2. 간격 (spacing)
|
|
150
|
+
|
|
151
|
+
- 유틸리티 숫자 = px. 스케일: `2 4 6 8 10 12 16 19 20 22 24 28 32 36 40 48 60`
|
|
152
|
+
- 형제 요소 간격은 margin 대신 부모의 `flex`/`grid` + `gap-*`으로 잡는다.
|
|
153
|
+
- 시맨틱 간격 토큰 (텍스트 덩어리·요소 사이 기본 리듬):
|
|
154
|
+
|
|
155
|
+
| 상황 | 값 |
|
|
156
|
+
| --- | --- |
|
|
157
|
+
| **페이지 콘텐츠 패딩** | **`p-20`** |
|
|
158
|
+
| **섹션 ↔ 섹션** | **`gap-12`** |
|
|
159
|
+
| **페이지 내 블록 ↔ 블록** (헤더·필터·툴바·테이블 사이) | **`gap-12`** |
|
|
160
|
+
| 요소 ↔ 요소 (기본) | `gap-8` (`--sys-space-stack-gap-element-normal`) |
|
|
161
|
+
| 제목 ↔ 설명 텍스트 | `gap-4` (`--sys-space-stack-gap-text-normal`) |
|
|
162
|
+
|
|
163
|
+
정보 밀도가 높은 서비스라 블록 간격을 넓게 벌리지 않는다. `gap-16` / `gap-24` 를 페이지 골격의 기본값으로 쓰지 않는다.
|
|
164
|
+
|
|
165
|
+
#### 같은 컴포넌트를 여러 개 늘어놓을 때 (그룹 간격)
|
|
166
|
+
|
|
167
|
+
위 "요소 ↔ 요소 `gap-8`" 은 **서로 다른 요소** 사이의 기본값이다.
|
|
168
|
+
**같은 컴포넌트를 여러 개 나열할 때는 컴포넌트마다 정해진 그룹 간격**이 따로 있다.
|
|
169
|
+
|
|
170
|
+
| 컴포넌트 | 수평 배열 | 수직 배열 |
|
|
171
|
+
| --- | --- | --- |
|
|
172
|
+
| `SCheckbox` | **`gap-24`** | `gap-8` |
|
|
173
|
+
| `SRadio` | **`gap-24`** | `gap-8` |
|
|
174
|
+
| `STextLink` | **`gap-16`**(sm) / **`gap-24`**(md·lg) | `gap-4` |
|
|
175
|
+
| `SGhostButton` | `gap-4` | `gap-4` |
|
|
176
|
+
| `SButton` | `gap-8` (xs·sm·md) / **`gap-12`**(lg) | 〃 |
|
|
177
|
+
| `STag` | `gap-8` | `gap-8` |
|
|
178
|
+
| `SToggle` | `gap-8` | `gap-8` |
|
|
179
|
+
| `SListItem` (bordered) | — | `gap-8` (+ 컨테이너 `p-16`) |
|
|
180
|
+
|
|
181
|
+
**수평·수직이 다른 것에 주의한다** — 체크박스·라디오는 가로로 놓으면 `gap-24`, 세로로 놓으면 `gap-8` 로 3배 차이다. 가로 배열에 `gap-8` 을 쓰면 항목이 붙어 보인다.
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
✅ <div className="flex gap-24"> {/* 체크박스 가로 */}
|
|
185
|
+
<SCheckbox label="전체" … /><SCheckbox label="판매중" … />
|
|
186
|
+
</div>
|
|
187
|
+
✅ <div className="flex flex-col gap-8"> {/* 체크박스 세로 */}
|
|
188
|
+
<SCheckbox label="전체" … /><SCheckbox label="판매중" … />
|
|
189
|
+
</div>
|
|
190
|
+
❌ <div className="flex gap-8"> {/* 가로인데 8 — 붙어 보인다 */}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- **라디오는 `SRadioGroup` 을 쓴다.** `direction="horizontal" | "vertical"` 만 주면 간격을 알아서 맞춘다 — 직접 `flex` 로 감싸지 않는다.
|
|
194
|
+
- `SRadioButton` 그룹의 간격은 `-1px`(테두리 겹침 처리)이라 손으로 만들지 않는다.
|
|
195
|
+
- 정확한 값이 필요하면 토큰을 직접 참조해도 된다: `gap-[var(--cmp-checkbox-group-gap-horizontal)]`
|
|
196
|
+
|
|
197
|
+
### 2-3. 색상
|
|
198
|
+
|
|
199
|
+
**시맨틱 유틸리티를 우선 사용한다** — 의미가 이름에 담긴 토큰이 이미 유틸리티로 존재한다.
|
|
200
|
+
|
|
201
|
+
#### 텍스트 색은 두 단계뿐이다
|
|
202
|
+
|
|
203
|
+
| 용도 | 유틸리티 | 값 |
|
|
204
|
+
| --- | --- | --- |
|
|
205
|
+
| **본문·제목 (기본값)** | 지정하지 않는다 — 전역 기본색이 이미 적용된다 | `grey_95` `#222222` |
|
|
206
|
+
| **보조 설명 · 비활성** | **`text-fg-tertiary`** | `grey_65` `#888888` |
|
|
207
|
+
|
|
208
|
+
- `theme.css` 가 `body` 에 `grey_95` 를 깔아두므로 **본문에 텍스트 색 클래스를 붙이지 않는다.** `text-fg-primary` 를 매번 쓰는 것은 불필요하다.
|
|
209
|
+
- 흐리게 보여야 하는 것은 보조 설명이든 비활성이든 **전부 `text-fg-tertiary`** 다.
|
|
210
|
+
- 중간 톤(`text-fg-secondary` `#555`)은 텍스트 위계에 쓰지 않는다. 두 단계로만 간다.
|
|
211
|
+
|
|
212
|
+
```tsx
|
|
213
|
+
✅ <p>주문이 접수되었습니다.</p> // 색 지정 없음 = grey_95
|
|
214
|
+
✅ <p className="text-fg-tertiary">최근 30일 기준</p> // 보조 설명
|
|
215
|
+
✅ <span className="text-fg-tertiary">-</span> // 빈 값 (§3-4)
|
|
216
|
+
❌ <p className="text-fg-primary">주문이 접수되었습니다.</p> // 불필요
|
|
217
|
+
❌ <p className="text-fg-secondary">최근 30일 기준</p> // 중간 톤 사용
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
#### 그 밖의 색
|
|
221
|
+
|
|
222
|
+
| 용도 | 유틸리티 |
|
|
223
|
+
| --- | --- |
|
|
224
|
+
| 상태 텍스트 | `text-fg-danger` `text-fg-success` `text-fg-warning` `text-fg-accent` `text-fg-inverse` |
|
|
225
|
+
| 배경 | `bg-bg-frame`(흰 콘텐츠 면) · `bg-bg-neutralLight`/`bg-bg-neutralBright`(옅은 회색 면) · `bg-bg-screen`(앱 바탕) |
|
|
226
|
+
| 보더/구분선 | `border-border-default` · `border-border-strong` · `border-divider-default` |
|
|
227
|
+
| 비활성 배경·보더 | `bg-disabled-bg` `border-disabled-border` |
|
|
228
|
+
| 링크 | `text-link-accent` |
|
|
229
|
+
|
|
230
|
+
- 시맨틱 토큰에 맞는 항목이 없을 때만 원색 스케일(`bg-blue-subtle` 등)을 쓴다.
|
|
231
|
+
- 어느 쪽이든 **토큰 유틸리티만** 사용 — 리터럴 hex 금지.
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## 3. 컴포넌트 선택 규칙 — "언제 뭘 쓰나"
|
|
236
|
+
|
|
237
|
+
> ⚠️ 초안(개발 작성). <!-- TODO(디자인): 전체 검수·확정 --> 표시가 있는 행은 디자인 확정 전까지 초안 기준으로 사용.
|
|
238
|
+
|
|
239
|
+
### 3-1. 라벨/표시류 — STag vs SBadge vs SChip
|
|
240
|
+
|
|
241
|
+
| 상황 | 사용 |
|
|
242
|
+
| --- | --- |
|
|
243
|
+
| **상태·분류를 라벨로 표시 (기본값)** | **`STag`** — `<STag size="sm" color="..." label="판매중" />`. 목록의 상태 컬럼, 상세의 분류 태그 등 대부분이 여기 해당한다 |
|
|
244
|
+
| 라벨 없이 **색 점만**으로 상태를 찍을 때 | `SBadge` — 점(dot)만 그리는 인디케이터. 텍스트가 이미 있고 앞에 점만 붙이는 좁은 경우에만 |
|
|
245
|
+
| 사용자가 입력·삭제·편집하는 **토큰** | `SChip` (단독) / `SChipInput` (입력 필드 안에서) |
|
|
246
|
+
|
|
247
|
+
> 상태 표시는 **`STag size="sm"` 이 기본**이다. 색 점 + 텍스트 조합(`SBadge`)을 기본으로 쓰지 않는다.
|
|
248
|
+
|
|
249
|
+
### 3-2. 알림/안내류
|
|
250
|
+
|
|
251
|
+
| 상황 | 사용 |
|
|
252
|
+
| --- | --- |
|
|
253
|
+
| 화면에 **상시 노출**되는 안내·경고 문구 | `SCallout` — `type` + `message` 배열(중첩 = 들여쓰기) |
|
|
254
|
+
| 작업 결과를 **일시적으로** 알림 | `SToast` (+ 루트에 `SToastContainer`) |
|
|
255
|
+
| 진행 전 **확인/취소**를 받아야 할 때 | `SModal.confirm({...}).onOk(...)` |
|
|
256
|
+
| 특정 UI 요소에 대한 **온보딩·기능 안내** | `SGuide` |
|
|
257
|
+
| 로딩 중 화면 잠금 | `SLoadingModal` / 영역 로딩은 `SLoadingContainer` |
|
|
258
|
+
|
|
259
|
+
### 3-3. 플로팅/오버레이류
|
|
260
|
+
|
|
261
|
+
| 상황 | 사용 |
|
|
262
|
+
| --- | --- |
|
|
263
|
+
| hover 시 **짧은 보조 설명** (상호작용 없음) | `STooltip` |
|
|
264
|
+
| 클릭 시 **상호작용 가능한** 콘텐츠 (메뉴·미니 폼) | `SPopover` |
|
|
265
|
+
| **팝업** (별도 창 성격의 가벼운 표시) | `SPopup` |
|
|
266
|
+
| 페이지 흐름을 끊고 **작업을 완료**시키는 창 | **`SActionModal` 로 컴포넌트를 만들고 `SModal.create` 로 띄운다** (아래 참조) |
|
|
267
|
+
| 확인/취소만 받는 창 | `SModal.confirm(...)` |
|
|
268
|
+
| 로딩 중 화면 잠금 | `SModal.loading(...)` / `SLoadingModal` |
|
|
269
|
+
|
|
270
|
+
**모달 만드는 법** — 디자인 시스템이 띄우는 모달은 `SActionModal` · `SConfirmModal` · `SLoadingModal` 3종뿐이고 `create` · `confirm` · `loading` 이 1:1로 대응한다.
|
|
271
|
+
직접 오버레이를 만들지 않는다. 작업용 모달은 **`SActionModal` 을 루트로 하는 컴포넌트를 만들어 `SModal.create` 에 넘긴다.**
|
|
272
|
+
|
|
273
|
+
```tsx
|
|
274
|
+
// 1) SActionModal 을 루트로 하는 컴포넌트를 만든다
|
|
275
|
+
// create 가 주입하는 open / onOpenChange / onClose 를 그대로 SActionModal 에 전달해야 한다
|
|
276
|
+
function OrderModal({ orderId, open, onOpenChange, onClose, modalRef }: OrderModalProps) {
|
|
277
|
+
return (
|
|
278
|
+
<SActionModal
|
|
279
|
+
open={open}
|
|
280
|
+
onOpenChange={onOpenChange}
|
|
281
|
+
onClose={onClose}
|
|
282
|
+
modalTitle="주문 상세"
|
|
283
|
+
width={720}
|
|
284
|
+
// 주 액션은 button(단수), 보조 버튼은 footerLeft — 하단 버튼 양끝 분리 규칙과 같다
|
|
285
|
+
button={{ label: '접수', onClick: () => modalRef.ok() }}
|
|
286
|
+
footerLeft={<SButton color="secondary" label="취소" onClick={() => modalRef.cancel()} />}
|
|
287
|
+
>
|
|
288
|
+
<SKeyValueTable fields={orderFields} values={order} />
|
|
289
|
+
</SActionModal>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 2) SModal.create 로 띄운다
|
|
294
|
+
SModal.create({ component: OrderModal, componentProps: { orderId } })
|
|
295
|
+
.onOk(() => refetch())
|
|
296
|
+
.onDismissed(() => {});
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### 3-4. 테이블 컬럼 정렬
|
|
300
|
+
|
|
301
|
+
**숫자가 들어가는 컬럼은 예외 없이 오른쪽 정렬한다** (`align: 'right'`).
|
|
302
|
+
자릿수가 세로로 맞아야 값의 크기를 눈으로 비교할 수 있기 때문이다.
|
|
303
|
+
|
|
304
|
+
| 컬럼 내용 | 정렬 | 예 |
|
|
305
|
+
| --- | --- | --- |
|
|
306
|
+
| **금액·수량·개수·비율 등 단위가 붙은 숫자** | **`'right'`** | `39,000원` · `12개` · `3건` · `15%` |
|
|
307
|
+
| **숫자만 있는 값** | **`'right'`** | `1024` · `39000` |
|
|
308
|
+
| 텍스트 | 생략(기본 `left`) | 상품명, 카테고리 |
|
|
309
|
+
| 상태 태그·아이콘·체크박스 등 고정폭 요소 | `'center'` | `STag`, `SIcon` |
|
|
310
|
+
|
|
311
|
+
```tsx
|
|
312
|
+
const columns: STableColumn[] = [
|
|
313
|
+
{ name: 'id', label: 'ID', field: 'id', width: '80px', align: 'right' },
|
|
314
|
+
{ name: 'name', label: '상품명', field: 'name' }, // 텍스트 → 생략
|
|
315
|
+
{ name: 'qty', label: '수량', field: 'qty', width: '80px', align: 'right',
|
|
316
|
+
format: (v: number) => `${Number(v).toLocaleString()}개` },
|
|
317
|
+
{ name: 'price', label: '판매가', field: 'price', width: '120px', align: 'right',
|
|
318
|
+
format: (v: number) => `${Number(v).toLocaleString()}원` },
|
|
319
|
+
{ name: 'status', label: '상태', field: 'status', width: '100px', align: 'center',
|
|
320
|
+
render: () => <STag size="sm" color="green" label="판매중" /> },
|
|
321
|
+
];
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
- `format` 으로 단위를 붙이더라도 **숫자 기반이면 오른쪽 정렬**이다. 단위 때문에 문자열이 되는 것은 정렬 판단과 무관하다.
|
|
325
|
+
- 숫자는 §1-4 대로 **`toLocaleString()` 이 필수**다. 세 자리 콤마 없이 출력하지 않는다.
|
|
326
|
+
- `SKeyValueTable` 의 값 셀도 같은 기준을 따른다.
|
|
327
|
+
|
|
328
|
+
#### 값이 없는 셀은 회색 하이픈
|
|
329
|
+
|
|
330
|
+
셀을 **빈칸으로 두지 않는다.** 값이 `null` · `undefined` · 빈 문자열이면 `-` 를 `text-fg-tertiary`(`grey_65`)로 표시한다.
|
|
331
|
+
빈칸은 "데이터가 없음"인지 "렌더가 깨졌는지" 구분되지 않지만, 회색 하이픈은 없다는 사실을 명시한다.
|
|
332
|
+
|
|
333
|
+
```tsx
|
|
334
|
+
// 재사용 헬퍼를 하나 두고 모든 컬럼에서 쓴다
|
|
335
|
+
const emptyCell = <span className="text-fg-tertiary">-</span>;
|
|
336
|
+
const hasValue = (v: unknown) => v !== null && v !== undefined && v !== '';
|
|
337
|
+
|
|
338
|
+
const columns: STableColumn[] = [
|
|
339
|
+
{ name: 'memo', label: '메모', field: 'memo',
|
|
340
|
+
render: (row: SRow) => (hasValue(row.memo) ? row.memo : emptyCell) },
|
|
341
|
+
{ name: 'price', label: '판매가', field: 'price', width: '120px', align: 'right',
|
|
342
|
+
render: (row: SRow) =>
|
|
343
|
+
hasValue(row.price) ? `${Number(row.price).toLocaleString()}원` : emptyCell },
|
|
344
|
+
];
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
`0` 은 값이 있는 것이므로 하이픈으로 바꾸지 않는다 — `0원` 그대로 표시한다.
|
|
348
|
+
|
|
349
|
+
### 3-5. 버튼류
|
|
350
|
+
|
|
351
|
+
| 상황 | 사용 |
|
|
352
|
+
| --- | --- |
|
|
353
|
+
| 일반 액션 | `SButton` (`color`: `primary` / `secondary` / `neutral` / `danger`, `size`: xs~lg) |
|
|
354
|
+
| **삭제 등 파괴적 액션** | **`SButton color="danger"`** 또는 **`color="danger" outline`** |
|
|
355
|
+
| 텍스트만 있는 저강조 액션 | `SGhostButton` |
|
|
356
|
+
| 본문 속 이동 링크 | `STextLink` |
|
|
357
|
+
| 메인 액션 + 부가 메뉴 | `SDropdownButton` |
|
|
358
|
+
|
|
359
|
+
한 화면의 primary 버튼 개수에 대한 제약은 없다. 다만 **삭제·초기화 등 되돌릴 수 없는 액션은 반드시 `danger`** 로 표시한다.
|
|
360
|
+
|
|
361
|
+
**하단 버튼 배치 (모든 화면 공통)** — 그룹을 모아 정렬하지 않고 **양끝으로 벌린다**(`justify-between`).
|
|
362
|
+
|
|
363
|
+
- **왼쪽 끝**: 취소·닫기·목록 등 되돌리는 액션
|
|
364
|
+
- **오른쪽 끝**: 저장·등록·수정·삭제 등 실행 액션
|
|
365
|
+
- 체크박스·안내 문구 등 **부가 요소는 오른쪽 그룹 안, 실행 버튼 바로 왼쪽**에 둔다
|
|
366
|
+
|
|
367
|
+
```tsx
|
|
368
|
+
<div className="flex items-center justify-between">
|
|
369
|
+
<SButton color="secondary" label="취소" />
|
|
370
|
+
<div className="flex items-center gap-8">
|
|
371
|
+
<SCheckbox label="계속 등록하기" value={keep} onValueChange={setKeep} /> {/* 부가 요소 */}
|
|
372
|
+
<SButton label="저장" />
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
## 4. 페이지 레시피 — 표준 골격
|
|
380
|
+
|
|
381
|
+
> 새 페이지는 반드시 아래 골격에서 시작한다. 임의 골격을 발명하지 않는다.
|
|
382
|
+
>
|
|
383
|
+
> **핵심 원칙 — 표 형태의 정보는 `SKeyValueTable` 로 만든다.** 필터·등록/수정 폼·상세 정보가 모두 여기 해당한다.
|
|
384
|
+
> `SField` 컨트롤을 `div` 로 직접 나열해 폼을 만들지 않는다.
|
|
385
|
+
|
|
386
|
+
### 4-1. 앱 셸 (모든 페이지 공통)
|
|
387
|
+
|
|
388
|
+
```tsx
|
|
389
|
+
import { SLayout, SGnb, SPage, type SGnbMenuItem } from 'sellmate-design-system-react';
|
|
390
|
+
|
|
391
|
+
const MENU: SGnbMenuItem[] = [
|
|
392
|
+
{ label: '주문', value: 'orders', icon: 'bill' },
|
|
393
|
+
{ label: '상품', value: 'products', icon: 'box', children: [{ label: '목록', value: 'product-list' }] },
|
|
394
|
+
];
|
|
395
|
+
|
|
396
|
+
export default function AppShell({ children }: { children: React.ReactNode }) {
|
|
397
|
+
return (
|
|
398
|
+
<SLayout type="box" header="fix">
|
|
399
|
+
{/* type/header/folded 는 SLayout 에만 준다 — SGnb 는 context 에서 읽는다 */}
|
|
400
|
+
<SGnb items={MENU} value={current} onValueChange={navigate} logo={<Logo />} />
|
|
401
|
+
<SPage background="frame" className="p-20">{children}</SPage>
|
|
402
|
+
</SLayout>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### 4-2. 목록 페이지 (필터 + 테이블)
|
|
408
|
+
|
|
409
|
+
구조: **페이지 헤더(제목 + 가이드 링크) → 필터(`SKeyValueTable`) → `STableBar` → `STable`**
|
|
410
|
+
|
|
411
|
+
액션 버튼의 위치가 핵심이다:
|
|
412
|
+
|
|
413
|
+
- **페이지 제목 줄에는 액션 버튼을 두지 않는다.** 가이드·매뉴얼 링크 등 부가 정보만 온다.
|
|
414
|
+
- **주요 액션(등록 등)은 `STableBar` 의 `rightActions`** 에 둔다.
|
|
415
|
+
- **선택 상태 액션(선택 삭제 등)은 `STableBar` 의 `actions`** 에 둔다. `actions` 슬롯은 건수 요약이 있으면 앞에 구분선(`SDivider`)을 **자동으로** 넣으므로 직접 구분선을 만들지 않는다.
|
|
416
|
+
|
|
417
|
+
```tsx
|
|
418
|
+
import {
|
|
419
|
+
SButton, STextLink, SKeyValueTable, STableBar, STable, STag,
|
|
420
|
+
type STableColumn, type SRow, type SKeyValueField,
|
|
421
|
+
} from 'sellmate-design-system-react';
|
|
422
|
+
|
|
423
|
+
/** 필터도 표다 — SKeyValueTable 로 만든다 (서비스 전용 전역 필터가 따로 있는 경우 제외) */
|
|
424
|
+
const filterFields: SKeyValueField[][] = [
|
|
425
|
+
[
|
|
426
|
+
{ name: 'status', label: '상태', type: 'select',
|
|
427
|
+
options: { options: STATUS_OPTIONS, emitValue: true } },
|
|
428
|
+
{ name: 'keyword', label: '검색어', type: 'input',
|
|
429
|
+
options: { placeholder: '상품명 / 상품코드' } },
|
|
430
|
+
],
|
|
431
|
+
[
|
|
432
|
+
{ name: 'period', label: '등록일', type: 'date-range-picker', tdColSpan: 3 },
|
|
433
|
+
],
|
|
434
|
+
];
|
|
435
|
+
|
|
436
|
+
const columns: STableColumn[] = [
|
|
437
|
+
// 숫자 컬럼은 전부 align: 'right' — §3-4
|
|
438
|
+
{ name: 'id', label: 'ID', field: 'id', width: '80px', align: 'right' },
|
|
439
|
+
{ name: 'name', label: '상품명', field: 'name' },
|
|
440
|
+
{ name: 'stock', label: '재고', field: 'stock', width: '90px', align: 'right',
|
|
441
|
+
format: (v: number) => `${Number(v).toLocaleString()}개` },
|
|
442
|
+
{ name: 'price', label: '판매가', field: 'price', width: '120px', align: 'right',
|
|
443
|
+
format: (v: number) => `${Number(v).toLocaleString()}원` },
|
|
444
|
+
// 상태 컬럼은 STag size="sm" — SBadge 색 점을 기본으로 쓰지 않는다
|
|
445
|
+
{ name: 'status', label: '상태', field: 'status', width: '100px', align: 'center',
|
|
446
|
+
render: (row: SRow) =>
|
|
447
|
+
row.status === 'selling'
|
|
448
|
+
? <STag size="sm" color="green" label="판매중" />
|
|
449
|
+
: <STag size="sm" color="grey" label="판매중지" /> },
|
|
450
|
+
];
|
|
451
|
+
|
|
452
|
+
export default function ProductListPage() {
|
|
453
|
+
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
|
454
|
+
const [selected, setSelected] = useState<SRow[]>([]);
|
|
455
|
+
|
|
456
|
+
return (
|
|
457
|
+
<div className="flex flex-col gap-12">
|
|
458
|
+
{/* 페이지 헤더 — 액션 버튼 없음. 가이드/매뉴얼 링크 자리 */}
|
|
459
|
+
<div className="flex items-center justify-between">
|
|
460
|
+
<h1 className="typo-heading-lg m-0">상품 목록</h1>
|
|
461
|
+
<STextLink label="이용 가이드" rightArrow="chevron" onClick={openGuide} />
|
|
462
|
+
</div>
|
|
463
|
+
|
|
464
|
+
{/* 필터 — search 를 켜면 우측에 검색 패널이 붙는다 */}
|
|
465
|
+
<SKeyValueTable
|
|
466
|
+
fields={filterFields}
|
|
467
|
+
values={filters}
|
|
468
|
+
search
|
|
469
|
+
onChange={({ values }) => setFilters(values)}
|
|
470
|
+
onSearch={fetchList}
|
|
471
|
+
/>
|
|
472
|
+
|
|
473
|
+
{/* 툴바 — 좌: 건수 + (구분선 자동) + 선택 액션 / 우: 주요 액션 */}
|
|
474
|
+
<STableBar
|
|
475
|
+
total={total}
|
|
476
|
+
selected={selected.length}
|
|
477
|
+
actions={
|
|
478
|
+
<SButton size="sm" color="secondary" label="선택 삭제"
|
|
479
|
+
disabled={!selected.length} onClick={removeSelected} />
|
|
480
|
+
}
|
|
481
|
+
rightActions={<SButton size="sm" label="상품 등록" onClick={goCreate} />}
|
|
482
|
+
/>
|
|
483
|
+
|
|
484
|
+
<STable
|
|
485
|
+
columns={columns}
|
|
486
|
+
rows={rows}
|
|
487
|
+
rowKey="id"
|
|
488
|
+
selectable
|
|
489
|
+
selected={selected}
|
|
490
|
+
onSelectedChange={setSelected}
|
|
491
|
+
pagination={{ currentPage, lastPage }}
|
|
492
|
+
isLoading={isLoading}
|
|
493
|
+
/>
|
|
494
|
+
</div>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### 4-3. 폼 페이지 (등록/수정)
|
|
500
|
+
|
|
501
|
+
구조: **페이지 제목 → `SForm` + `SKeyValueTable` → 하단 버튼**
|
|
502
|
+
|
|
503
|
+
- 필드를 `div` 로 나열하지 않고 **`SKeyValueTable` 의 행으로 구성**한다.
|
|
504
|
+
- 검증 규칙은 각 field 의 `options.rules` 로 넘긴다. `SForm` 이 하위 컨트롤을 자동 수집해 submit 시 일괄 검증하고, 실패 시 첫 실패 필드로 포커스를 옮긴다.
|
|
505
|
+
- **버튼 순서: 취소·닫기가 왼쪽, 저장·등록·수정·삭제가 오른쪽.** 이 순서는 모든 화면에서 동일하다.
|
|
506
|
+
|
|
507
|
+
```tsx
|
|
508
|
+
import {
|
|
509
|
+
SForm, SKeyValueTable, SButton, SCheckbox,
|
|
510
|
+
type SFormHandle, type SKeyValueField, type Rule,
|
|
511
|
+
} from 'sellmate-design-system-react';
|
|
512
|
+
|
|
513
|
+
const required = (msg: string): Rule => v =>
|
|
514
|
+
v != null && String(v).trim() !== '' ? true : msg;
|
|
515
|
+
|
|
516
|
+
const fields: SKeyValueField[][] = [
|
|
517
|
+
[
|
|
518
|
+
{ name: 'name', label: '상품명', required: true, type: 'input',
|
|
519
|
+
options: { placeholder: '상품명 입력', rules: [required('상품명을 입력해 주세요.')] } },
|
|
520
|
+
{ name: 'code', label: '상품코드', type: 'input', options: { placeholder: '자동 생성' } },
|
|
521
|
+
],
|
|
522
|
+
[
|
|
523
|
+
{ name: 'category', label: '카테고리', required: true, type: 'select',
|
|
524
|
+
options: { options: CATEGORY_OPTIONS, emitValue: true,
|
|
525
|
+
rules: [required('카테고리를 선택해 주세요.')] } },
|
|
526
|
+
{ name: 'price', label: '판매가', type: 'number-input' },
|
|
527
|
+
],
|
|
528
|
+
[
|
|
529
|
+
{ name: 'memo', label: '메모', type: 'textarea', tdColSpan: 3,
|
|
530
|
+
helpText: ['내부 관리용 메모입니다.'] },
|
|
531
|
+
],
|
|
532
|
+
];
|
|
533
|
+
|
|
534
|
+
export default function ProductCreatePage() {
|
|
535
|
+
const formRef = useRef<SFormHandle>(null);
|
|
536
|
+
const [values, setValues] = useState<Record<string, unknown>>({});
|
|
537
|
+
|
|
538
|
+
return (
|
|
539
|
+
<div className="flex flex-col gap-12">
|
|
540
|
+
<h1 className="typo-heading-lg m-0">상품 등록</h1>
|
|
541
|
+
|
|
542
|
+
<SForm ref={formRef} formClass="flex flex-col gap-12" onSubmit={save}>
|
|
543
|
+
<SKeyValueTable
|
|
544
|
+
fields={fields}
|
|
545
|
+
values={values}
|
|
546
|
+
onChange={({ values }) => setValues(values)}
|
|
547
|
+
/>
|
|
548
|
+
|
|
549
|
+
{/* 하단 버튼은 양끝으로 벌린다. 부가 요소(체크박스 등)는 저장 바로 왼쪽 */}
|
|
550
|
+
<div className="flex items-center justify-between">
|
|
551
|
+
<SButton type="button" color="secondary" label="취소" onClick={goBack} />
|
|
552
|
+
<div className="flex items-center gap-8">
|
|
553
|
+
<SCheckbox label="계속 등록하기" value={keepOpen} onValueChange={v => setKeepOpen(v as boolean)} />
|
|
554
|
+
<SButton type="submit" label="저장" />
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
</SForm>
|
|
558
|
+
</div>
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### 4-4. 상세(조회) 페이지
|
|
564
|
+
|
|
565
|
+
구조: **페이지 헤더(제목) → 섹션별 `SSectionHeaderCard` + `SKeyValueTable` → 하단 버튼**
|
|
566
|
+
|
|
567
|
+
- 조회 값은 `type: 'text'` 행으로 표시한다. **상태·분류 태그도 별도 영역이 아니라 표의 한 행**으로 넣는다 (`render` 에 `STag`).
|
|
568
|
+
- 행이 많아지면 **유형별로 섹션을 나누고, 각 섹션을 `SSectionHeaderCard` 로 감싼다.**
|
|
569
|
+
합성 컴포넌트라 `SSectionHeaderCard.Header` / `SSectionHeaderCard.Body` 를 자식으로 쓴다.
|
|
570
|
+
- **수정·삭제 버튼은 하단에 둔다.** 내용이 짧아 우측 상단에 두는 변형도 있으나 기본은 하단이다.
|
|
571
|
+
|
|
572
|
+
```tsx
|
|
573
|
+
import {
|
|
574
|
+
SSectionHeaderCard, SKeyValueTable, SButton, STag, type SKeyValueField,
|
|
575
|
+
} from 'sellmate-design-system-react';
|
|
576
|
+
|
|
577
|
+
const basicFields: SKeyValueField[][] = [
|
|
578
|
+
[
|
|
579
|
+
{ name: 'code', label: '상품코드', type: 'text' },
|
|
580
|
+
{ name: 'createdAt', label: '등록일', type: 'text' },
|
|
581
|
+
],
|
|
582
|
+
[
|
|
583
|
+
{ name: 'category', label: '카테고리', type: 'text' },
|
|
584
|
+
// 상태 태그도 표의 한 행으로 표현한다
|
|
585
|
+
{ name: 'status', label: '상태',
|
|
586
|
+
render: <STag size="sm" color="green" label="판매중" /> },
|
|
587
|
+
],
|
|
588
|
+
];
|
|
589
|
+
|
|
590
|
+
const priceFields: SKeyValueField[][] = [
|
|
591
|
+
[
|
|
592
|
+
{ name: 'price', label: '판매가', type: 'text' },
|
|
593
|
+
{ name: 'cost', label: '원가', type: 'text' },
|
|
594
|
+
],
|
|
595
|
+
];
|
|
596
|
+
|
|
597
|
+
export default function ProductDetailPage() {
|
|
598
|
+
return (
|
|
599
|
+
<div className="flex flex-col gap-12">
|
|
600
|
+
<h1 className="typo-heading-lg m-0">클래식 셔츠</h1>
|
|
601
|
+
|
|
602
|
+
<SSectionHeaderCard>
|
|
603
|
+
<SSectionHeaderCard.Header title="기본 정보" marker thickness="accent" />
|
|
604
|
+
<SSectionHeaderCard.Body>
|
|
605
|
+
<SKeyValueTable fields={basicFields} values={product} />
|
|
606
|
+
</SSectionHeaderCard.Body>
|
|
607
|
+
</SSectionHeaderCard>
|
|
608
|
+
|
|
609
|
+
<SSectionHeaderCard>
|
|
610
|
+
{/* 헤더 우측에 액션이 필요하면 slot 을 쓴다 */}
|
|
611
|
+
<SSectionHeaderCard.Header
|
|
612
|
+
title="가격 정보"
|
|
613
|
+
marker
|
|
614
|
+
helpText={['부가세 포함 금액입니다.']}
|
|
615
|
+
slot={<SButton size="sm" color="secondary" label="이력" onClick={openHistory} />}
|
|
616
|
+
/>
|
|
617
|
+
<SSectionHeaderCard.Body>
|
|
618
|
+
<SKeyValueTable fields={priceFields} values={product} />
|
|
619
|
+
</SSectionHeaderCard.Body>
|
|
620
|
+
</SSectionHeaderCard>
|
|
621
|
+
|
|
622
|
+
{/* 액션은 하단 — 목록(되돌리기)은 왼쪽 끝, 실행 액션은 오른쪽 끝 */}
|
|
623
|
+
<div className="flex items-center justify-between">
|
|
624
|
+
<SButton color="secondary" label="목록" onClick={goList} />
|
|
625
|
+
<div className="flex items-center gap-8">
|
|
626
|
+
<SButton color="danger" outline label="삭제" onClick={confirmDelete} />
|
|
627
|
+
<SButton label="수정" onClick={goEdit} />
|
|
628
|
+
</div>
|
|
629
|
+
</div>
|
|
630
|
+
</div>
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
### 4-5. 섹션 카드 — SSectionHeaderCard
|
|
636
|
+
|
|
637
|
+
폼 페이지에서도 입력 항목이 많으면 유형별로 `SSectionHeaderCard` 로 나눈다. 주요 옵션:
|
|
638
|
+
|
|
639
|
+
| Prop (Header) | 용도 |
|
|
640
|
+
| --- | --- |
|
|
641
|
+
| `title` | 섹션 제목 (필수) |
|
|
642
|
+
| `marker` | 제목 앞 점 표시 |
|
|
643
|
+
| `required` | 제목 뒤 필수(\*) 표시 — 필수 입력 섹션에 |
|
|
644
|
+
| `helpText` | 도움말 툴팁 (`string[]`) |
|
|
645
|
+
| `subtitle` | 부제 |
|
|
646
|
+
| `slot` | 헤더 우측 영역 (버튼 등) |
|
|
647
|
+
| `thickness` | 상단 강조선 — `false`(기본) / `'default'` / `'accent'` |
|
|
648
|
+
|
|
649
|
+
---
|
|
650
|
+
|
|
651
|
+
## 5. 자가 점검 체크리스트
|
|
652
|
+
|
|
653
|
+
페이지를 완성하면 다음을 확인한다. 하나라도 어기면 수정 후 완료를 보고한다.
|
|
654
|
+
|
|
655
|
+
- [ ] 생 HTML 컨트롤(`<button>` `<input>` `<select>` `<table>` …)이 없는가
|
|
656
|
+
- [ ] `text-[14px]`, `bg-[#...]` 같은 리터럴 임의 값이 없는가 (`var(--sys-*)` 참조는 허용)
|
|
657
|
+
- [ ] 텍스트에 `typo-*` 프리셋을 썼는가
|
|
658
|
+
- [ ] 간격이 전부 토큰 스케일 값인가 (`gap-13` ❌ → `gap-12` ✅)
|
|
659
|
+
- [ ] 본문이 12px(`typo-body-sm-default`)이고 보조 설명이 `text-fg-tertiary` 인가 (14px 본문 ❌)
|
|
660
|
+
- [ ] 페이지 패딩이 `p-20`, 블록·섹션 간격이 `gap-12` 인가 (`gap-16`/`gap-24` ❌)
|
|
661
|
+
- [ ] 같은 컴포넌트를 나열할 때 §2-2 그룹 간격을 썼는가 (체크박스 가로 `gap-24` 등)
|
|
662
|
+
- [ ] 페이지가 §4의 표준 골격에서 시작했는가
|
|
663
|
+
- [ ] 필터·폼·상세 정보를 `SKeyValueTable` 로 만들었는가 (컨트롤을 `div` 로 나열하지 않았는가)
|
|
664
|
+
- [ ] 섹션 구분에 `SSectionHeaderCard` 를 썼는가 (직접 만든 카드가 아니라)
|
|
665
|
+
- [ ] 목록의 주요 액션이 `STableBar` 의 `rightActions` 에 있는가 (페이지 제목 줄이 아니라)
|
|
666
|
+
- [ ] 상태 표시에 `STag size="sm"` 을 썼는가
|
|
667
|
+
- [ ] 테이블의 숫자 컬럼(금액·수량·개수 등)이 전부 `align: 'right'` 인가
|
|
668
|
+
- [ ] 화면에 표시하는 모든 숫자에 `toLocaleString()` 을 써서 세 자리 콤마를 넣었는가
|
|
669
|
+
- [ ] 금액·수량 등 양을 나타내는 숫자에 `toLocaleString()` 을 썼는가 (번호·코드는 제외)
|
|
670
|
+
- [ ] 하단 버튼이 양끝 분리(`justify-between`)이고, 되돌리기가 왼쪽 끝 · 실행이 오른쪽 끝인가
|
|
671
|
+
- [ ] 삭제 등 파괴적 액션에 `color="danger"` 를 썼는가
|
|
672
|
+
- [ ] 작업용 모달을 `SActionModal` + `SModal.create` 로 만들었는가 (직접 오버레이 ❌)
|
|
673
|
+
- [ ] 상태 표시·알림·확인 다이얼로그가 §3의 선택 규칙을 따르는가
|
|
674
|
+
|
|
675
|
+
---
|
|
676
|
+
|
|
677
|
+
## 6. 린트로 강제되는 규칙
|
|
678
|
+
|
|
679
|
+
위 규칙 중 일부는 소비 앱의 ESLint 로 검출된다 (`sellmate-design-system-react/eslint`).
|
|
680
|
+
**코드를 넘기기 전에 린트를 통과시킨다.**
|
|
681
|
+
|
|
682
|
+
| 규칙 | 잡히는 것 |
|
|
683
|
+
| --- | --- |
|
|
684
|
+
| `sellmate/no-raw-html-control` | §1-1 생 HTML 컨트롤, `alert()`/`confirm()` |
|
|
685
|
+
| `sellmate/no-arbitrary-class` | §1-2 토큰 있는 속성의 임의 값 (`text-[14px]`, `bg-[#eee]`) |
|
|
686
|
+
| `sellmate/no-off-scale-spacing` | §2-2 스케일 밖 간격 (`gap-13`) |
|
|
687
|
+
| `sellmate/table-numeric-align` | §3-4 숫자 컬럼의 `align: 'right'` 누락 (`--fix` 지원) |
|
|
688
|
+
| `sellmate/require-locale-number` | §1-4 숫자 컬럼의 `toLocaleString()` 누락 |
|
|
689
|
+
| `sellmate/component-group-gap` | §2-2 컴포넌트 그룹 간격 (체크박스 가로 24 / 세로 8 등) |
|
|
690
|
+
| `sellmate/require-locale-number` | §1-4 금액·수량 등의 `toLocaleString()` 누락 |
|
|
691
|
+
| `sellmate/prefer-typo-preset` | §1-3 낱개 폰트 조합 (`text-14 font-bold`) |
|
|
692
|
+
|
|
693
|
+
`gap-13` 처럼 스케일 밖 값은 Tailwind v4 에서 **에러 없이 조용히 무시된다**. "간격이 왜 안 먹지" 로만 보이므로 반드시 스케일 값을 쓴다.
|
|
694
|
+
|
|
695
|
+
린트가 잡지 못하는 것(§3 컴포넌트 선택, §4 페이지 골격, 버튼 배치)은 §5 체크리스트로 직접 확인한다.
|