sellmate-design-system-react 9.0.0-beta.75 → 9.0.0-beta.76

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.
Files changed (36) hide show
  1. package/AGENTS.md +35 -0
  2. package/README.md +5 -5
  3. package/bin/sellmate-ds.mjs +91 -59
  4. package/dist/components/SCircleProgress/README.md +6 -3
  5. package/dist/components/SCircleProgress/SCircleProgress.d.ts +11 -3
  6. package/dist/components/SLinearProgress/README.md +2 -2
  7. package/dist/components/SLinearProgress/SLinearProgress.d.ts +8 -2
  8. package/dist/components/SSelect/README.md +18 -2
  9. package/dist/components/SSelect/SSelect.d.ts +28 -5
  10. package/dist/components/SSelect/index.d.ts +1 -1
  11. package/dist/components/STable/README.md +29 -20
  12. package/dist/components/STable/STable.d.ts +47 -31
  13. package/dist/components/STable/index.d.ts +1 -1
  14. package/dist/index.cjs +107 -50
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.js +107 -50
  17. package/dist/index.js.map +1 -1
  18. package/dist/llms-full.txt +90 -28
  19. package/dist/llms.txt +37 -1
  20. package/dist/styles.css +16 -0
  21. package/dist/theme.css +3 -0
  22. package/eslint/index.mjs +42 -42
  23. package/eslint/lib/class-names.mjs +23 -25
  24. package/eslint/lib/table-column.mjs +11 -11
  25. package/eslint/rules/component-group-gap.mjs +32 -30
  26. package/eslint/rules/divider-vertical-height.mjs +22 -22
  27. package/eslint/rules/field-width-grade.mjs +50 -50
  28. package/eslint/rules/no-arbitrary-class.mjs +63 -20
  29. package/eslint/rules/no-off-scale-spacing.mjs +61 -41
  30. package/eslint/rules/no-raw-html-control.mjs +38 -36
  31. package/eslint/rules/prefer-typo-preset.mjs +23 -17
  32. package/eslint/rules/require-locale-number.mjs +14 -19
  33. package/eslint/rules/table-column-width.mjs +21 -17
  34. package/eslint/rules/table-numeric-align.mjs +14 -14
  35. package/eslint/scale.gen.mjs +1 -1
  36. package/package.json +3 -3
package/AGENTS.md CHANGED
@@ -1053,6 +1053,25 @@ const columns: STableColumn[] = [
1053
1053
  - **계산값은 픽셀 단위까지 맞추면 어긋난다** — 서브픽셀 반올림 때문이다. 여유 8px 을 얹고 8 단위로 올림한다.
1054
1054
  - 좌우 패딩은 `STable` 이 토큰으로 넣으므로 직접 주지 않는다. 그만큼을 뺀 나머지가 요소 몫이라는 점만 계산에 넣는다.
1055
1055
 
1056
+ #### 행 타입을 알면 넘긴다
1057
+
1058
+ `STable`·`STableColumn` 은 행 타입을 받는다. 넘기면 `render`·`format`·`onRowClick`·`selected` 가 그 타입으로 좁혀져, 없는 키나 오타가 컴파일에서 잡힌다. 넘기지 않으면 행은 `SRow`(키만 아는 느슨한 레코드)다 — 기존 코드는 그대로 동작한다.
1059
+
1060
+ ```tsx
1061
+ interface Order { id: number; orderNo: string; qty: number }
1062
+
1063
+ const columns: STableColumn<Order>[] = [
1064
+ { name: 'orderNo', label: '주문번호', field: 'orderNo', width: 140, align: 'center' },
1065
+ { name: 'qty', label: '수량', field: 'qty', width: 80, align: 'right',
1066
+ format: v => `${Number(v).toLocaleString()}개` },
1067
+ ];
1068
+
1069
+ <STable<Order> columns={columns} rows={orders} onRowClick={order => open(order.id)} />
1070
+ ```
1071
+
1072
+ - 행 타입을 넘기면 `format`·`render` 의 **셀 값은 `unknown`** 이다. `field` 가 접근 함수일 수도 있어 행 타입만으로는 값의 타입이 정해지지 않으므로, `Number(v)`·`String(v)` 로 받는 쪽에서 확인한다. 행이 필요하면 두 번째 인자(`format`)·첫 번째 인자(`render`)의 행을 쓴다.
1073
+ - `SSelect` 도 같은 방식으로 `onValueChange` 의 값 타입을 받는다 — `<SSelect<string> valueAsPrimitive … />`. 실제 모양은 `type`·`valueAsPrimitive` 조합이 정하므로 그 조합에 맞는 타입을 넘긴다.
1074
+
1056
1075
  #### 컨트롤이 들어가는 컬럼
1057
1076
 
1058
1077
  `<td>` 는 폭을 넘는 내용을 잘라낸다(`overflow: hidden`). 텍스트라면 말줄임으로 끝나지만, 셀에 `STag` · `SButton` · `SGhostButton` · `SSelect` · `SInput` · `SNumberInput` 처럼 **자기 폭을 가진 요소**를 넣으면 요소 자체가 잘려 **누르거나 읽거나 입력할 수 없게 된다.**
@@ -1733,6 +1752,11 @@ tableRef.current.scrollToRow(row); // 복원
1733
1752
  - 카드 안에 상태 배지를 넣으려면 `tag` 슬롯에 `STag` 를 준다. 라벨 문자열에 "(추천)" 처럼 섞어 쓰지 않는다.
1734
1753
  - **카드처럼 생겼다고 `SCard`/`SSectionHeaderCard` 로 감싸지 않는다.** `SRadioCard` 자체가 완결된 요소이고, 나열 간격은 `SRadioCardGroup` 의 `direction` 이 맞춘다 (§2-2).
1735
1754
 
1755
+ ##### 옵션을 서버에서 받는 동안 — `loading`
1756
+
1757
+ - **받는 동안 `loading` 을 켠다.** 트리거의 펼침 아이콘 자리에 스피너가, 드롭다운에 로딩 행이 뜬다. 첫 조회든 다음 페이지든 같은 prop 이다.
1758
+ - **셀렉트 옆에 따로 스피너를 두거나 `disabled` 로 잠가 로딩을 표현하지 않는다.** 로딩 중에도 열 수 있고, 열면 "표시할 항목이 없습니다." 대신 로딩 행이 나온다. `disabled` 와 `loading` 을 함께 주지 않는다 — 비활성이면 `loading` 을 무시해 스피너가 뜨지 않는다. 잠긴 셀렉트의 옵션은 잠금이 풀릴 때 받는다.
1759
+
1736
1760
  ##### 옵션이 수백~수천 개면 — `onReachEnd`
1737
1761
 
1738
1762
  **렌더는 걱정하지 않아도 된다.** `SSelect` 는 언제나 보이는 범위의 행만 그린다 — 켜고 끄는 prop 이 없고, 옵션이 5개든 5,000개든 여는 비용이 같다. 행 높이가 균일하다고 가정하지도 않으므로 계층 목록이나 큰 글씨가 섞인 라벨도 그대로 넘기면 된다.
@@ -1993,6 +2017,17 @@ const [selectedId, setSelectedId] = useState<string>();
1993
2017
 
1994
2018
  `SCircleProgress` 는 `indeterminate` 로 두면 스피너가 된다. **다만 화면이나 영역을 막아야 하는 상황이면 progress 가 아니라 `SLoadingModal`·`SLoadingContainer` 다** (§3-2) — 진행 표시와 입력 차단은 다른 일이고, 막지 않으면 사용자가 로딩 중에 또 누른다.
1995
2019
 
2020
+ `type` 은 진행 **상태**로 고른다. 색이 아니라 상태가 기준이다.
2021
+
2022
+ | 상태 | `type` |
2023
+ | --- | --- |
2024
+ | 정상 진행 중 | `primary` |
2025
+ | 진행은 계속되지만 제품이 정한 기준에 미달해 주의가 필요하다 | `warning` |
2026
+ | 실패·중단 등 더 이상 정상적으로 진행되지 않는다 | `error` |
2027
+ | 완료 | `complete` |
2028
+
2029
+ **`warning` 과 `error` 는 "아직 진행 중인가" 로 가른다.** 기준 미달이어도 진행이 계속되면 `warning` 이다 — `error` 로 칠하면 멈춘 작업으로 읽힌다. 미달 기준은 디자인 시스템이 정하지 않으므로 제품 코드에서 판정해 `type` 을 넘긴다.
2030
+
1996
2031
  #### 3-7-10. SPortal — 직접 쓸 일이 거의 없다
1997
2032
 
1998
2033
  `STooltip`·`SPopover`·`SSelect`·날짜 피커가 내부에서 쓰는 저수준 레이어다. 앵커에 붙여 띄우는 동작이 필요하면 **먼저 §3-3 에서 대응 컴포넌트를 찾는다.** `SPortal` 을 직접 쓰는 것은 그 넷 중 어느 것도 아닌 새로운 부착형 레이어를 만들 때뿐이고, 그때도 모달 안에서 열릴 수 있다면 소속 컨테이너를 맞춰야 한다.
package/README.md CHANGED
@@ -261,12 +261,12 @@ export default [
261
261
  **폭은 등급 이름으로 줍니다** — `width="md"` 처럼 씁니다. 등급은 `--cmp-field-width-*` 토큰으로
262
262
  풀리므로 토큰이 바뀌면 화면이 따라갑니다. 같은 값을 px 로 적으면 그 화면만 옛 값에 남습니다.
263
263
 
264
- | 등급 | 쓰는 곳 |
265
- | ---- | ------------- |
264
+ | 등급 | 쓰는 곳 |
265
+ | ---- | -------------- |
266
266
  | `xs` | 숫자 필드 전용 |
267
- | `sm` | |
268
- | `md` | |
269
- | `lg` | |
267
+ | `sm` | |
268
+ | `md` | |
269
+ | `lg` | |
270
270
  | `xl` | 정책상 상한 |
271
271
 
272
272
  ```tsx
@@ -12,23 +12,28 @@
12
12
  * - 안전하게 고칠 수 없는 파일은 손대지 않고 붙여넣을 스니펫을 출력한다.
13
13
  * - 무엇을 바꿨는지 전부 보고한다.
14
14
  */
15
- import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
16
- import { join, relative, dirname, sep } from "node:path";
15
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
16
+ import { join, relative, dirname, sep } from 'node:path';
17
17
 
18
- const PKG = "sellmate-design-system-react";
18
+ const PKG = 'sellmate-design-system-react';
19
19
  const LLMS_PATH = `node_modules/${PKG}/dist/llms.txt`;
20
20
 
21
21
  const args = process.argv.slice(2);
22
- const command = args.find((a) => !a.startsWith("-")) ?? "init";
23
- const dryRun = args.includes("--dry-run") || args.includes("-n");
22
+ const command = args.find(a => !a.startsWith('-')) ?? 'init';
23
+ const dryRun = args.includes('--dry-run') || args.includes('-n');
24
24
 
25
25
  const cwd = process.cwd();
26
26
 
27
27
  /* ─────────────────────────── 출력 ─────────────────────────── */
28
28
 
29
29
  const c = {
30
- reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
31
- green: "\x1b[32m", yellow: "\x1b[33m", cyan: "\x1b[36m", red: "\x1b[31m",
30
+ reset: '\x1b[0m',
31
+ bold: '\x1b[1m',
32
+ dim: '\x1b[2m',
33
+ green: '\x1b[32m',
34
+ yellow: '\x1b[33m',
35
+ cyan: '\x1b[36m',
36
+ red: '\x1b[31m',
32
37
  };
33
38
  const paint = (color, s) => (process.stdout.isTTY ? `${c[color]}${s}${c.reset}` : s);
34
39
 
@@ -38,7 +43,7 @@ const add = (status, file, detail, snippet) => results.push({ status, file, deta
38
43
 
39
44
  /* ─────────────────────────── 유틸 ─────────────────────────── */
40
45
 
41
- const read = (p) => readFileSync(p, "utf8");
46
+ const read = p => readFileSync(p, 'utf8');
42
47
 
43
48
  function write(path, content) {
44
49
  if (dryRun) return;
@@ -47,14 +52,14 @@ function write(path, content) {
47
52
 
48
53
  /** node_modules 가 있는 프로젝트 루트인지 확인 */
49
54
  function assertProjectRoot() {
50
- if (!existsSync(join(cwd, "package.json"))) {
51
- console.error(paint("red", "package.json 이 없습니다. 프로젝트 루트에서 실행하세요."));
55
+ if (!existsSync(join(cwd, 'package.json'))) {
56
+ console.error(paint('red', 'package.json 이 없습니다. 프로젝트 루트에서 실행하세요.'));
52
57
  process.exit(1);
53
58
  }
54
59
  }
55
60
 
56
61
  /** 후보 경로 중 처음 존재하는 것 */
57
- const firstExisting = (candidates) => candidates.find((p) => existsSync(join(cwd, p)));
62
+ const firstExisting = candidates => candidates.find(p => existsSync(join(cwd, p)));
58
63
 
59
64
  /* ────────────────── 1. 에이전트 지침 파일 ────────────────── */
60
65
 
@@ -73,24 +78,24 @@ UI 작업 전 \`${LLMS_PATH}\` 를 **반드시 먼저 읽는다.**
73
78
  `;
74
79
 
75
80
  function stepAgentInstructions() {
76
- const existing = ["CLAUDE.md", "AGENTS.md"].filter((f) => existsSync(join(cwd, f)));
77
- const targets = existing.length ? existing : ["CLAUDE.md"];
81
+ const existing = ['CLAUDE.md', 'AGENTS.md'].filter(f => existsSync(join(cwd, f)));
82
+ const targets = existing.length ? existing : ['CLAUDE.md'];
78
83
 
79
84
  for (const file of targets) {
80
85
  const path = join(cwd, file);
81
- const current = existsSync(path) ? read(path) : "";
86
+ const current = existsSync(path) ? read(path) : '';
82
87
 
83
88
  if (current.includes(LLMS_PATH)) {
84
- add("skipped", file, "이미 llms.txt 참조가 있습니다");
89
+ add('skipped', file, '이미 llms.txt 참조가 있습니다');
85
90
  continue;
86
91
  }
87
92
 
88
93
  const next = current
89
- ? `${current.replace(/\s*$/, "")}\n${AGENT_SECTION}`
94
+ ? `${current.replace(/\s*$/, '')}\n${AGENT_SECTION}`
90
95
  : `# 프로젝트 지침\n${AGENT_SECTION}`;
91
96
 
92
97
  write(path, next);
93
- add("added", file, existsSync(path) ? "디자인 시스템 지침 섹션 추가" : "생성 후 지침 추가");
98
+ add('added', file, existsSync(path) ? '디자인 시스템 지침 섹션 추가' : '생성 후 지침 추가');
94
99
  }
95
100
  }
96
101
 
@@ -105,8 +110,8 @@ function matchingBracket(src, openIndex) {
105
110
  let depth = 0;
106
111
  for (let i = openIndex; i < src.length; i++) {
107
112
  const ch = src[i];
108
- if (ch === "[") depth++;
109
- else if (ch === "]") {
113
+ if (ch === '[') depth++;
114
+ else if (ch === ']') {
110
115
  depth--;
111
116
  if (depth === 0) return i;
112
117
  }
@@ -116,11 +121,19 @@ function matchingBracket(src, openIndex) {
116
121
 
117
122
  function stepEslint() {
118
123
  const file = firstExisting([
119
- "eslint.config.mjs", "eslint.config.js", "eslint.config.ts", "eslint.config.cjs",
124
+ 'eslint.config.mjs',
125
+ 'eslint.config.js',
126
+ 'eslint.config.ts',
127
+ 'eslint.config.cjs',
120
128
  ]);
121
129
 
122
130
  if (!file) {
123
- add("manual", "eslint.config.mjs", "설정 파일이 없습니다 — 아래 내용으로 만드세요", ESLINT_SNIPPET);
131
+ add(
132
+ 'manual',
133
+ 'eslint.config.mjs',
134
+ '설정 파일이 없습니다 — 아래 내용으로 만드세요',
135
+ ESLINT_SNIPPET,
136
+ );
124
137
  return;
125
138
  }
126
139
 
@@ -128,7 +141,7 @@ function stepEslint() {
128
141
  const src = read(path);
129
142
 
130
143
  if (src.includes(`${PKG}/eslint`)) {
131
- add("skipped", file, "이미 ESLint 프리셋이 연결되어 있습니다");
144
+ add('skipped', file, '이미 ESLint 프리셋이 연결되어 있습니다');
132
145
  return;
133
146
  }
134
147
 
@@ -136,14 +149,14 @@ function stepEslint() {
136
149
  // 배열 경계를 확신할 수 없으므로 손대지 않고 스니펫을 안내한다.
137
150
  const exportMatch = src.match(/export\s+default\s*\[/);
138
151
  if (!exportMatch) {
139
- add("manual", file, "자동 삽입이 어려운 형태입니다 — 아래를 직접 추가하세요", ESLINT_SNIPPET);
152
+ add('manual', file, '자동 삽입이 어려운 형태입니다 — 아래를 직접 추가하세요', ESLINT_SNIPPET);
140
153
  return;
141
154
  }
142
155
 
143
- const openIndex = src.indexOf("[", exportMatch.index);
156
+ const openIndex = src.indexOf('[', exportMatch.index);
144
157
  const closeIndex = matchingBracket(src, openIndex);
145
158
  if (closeIndex === -1) {
146
- add("manual", file, "배열 끝을 찾지 못했습니다 — 아래를 직접 추가하세요", ESLINT_SNIPPET);
159
+ add('manual', file, '배열 끝을 찾지 못했습니다 — 아래를 직접 추가하세요', ESLINT_SNIPPET);
147
160
  return;
148
161
  }
149
162
 
@@ -164,11 +177,13 @@ function stepEslint() {
164
177
  const before = withImport.slice(0, close);
165
178
  const needsComma = /[^[\s,]\s*$/.test(before);
166
179
  const next =
167
- before.replace(/\s*$/, "") + (needsComma ? "," : "") + `\n${ESLINT_SPREAD}\n` +
180
+ before.replace(/\s*$/, '') +
181
+ (needsComma ? ',' : '') +
182
+ `\n${ESLINT_SPREAD}\n` +
168
183
  withImport.slice(close);
169
184
 
170
185
  write(path, next);
171
- add("added", file, "프리셋(configs.recommended) 연결");
186
+ add('added', file, '프리셋(configs.recommended) 연결');
172
187
  }
173
188
 
174
189
  /* ────────────────── 3. 전역 CSS ────────────────── */
@@ -176,14 +191,19 @@ function stepEslint() {
176
191
  /** 앱의 전역 CSS 를 찾는다 */
177
192
  function findGlobalCss() {
178
193
  const candidates = [
179
- "app/globals.css", "src/app/globals.css", "src/styles/globals.css",
180
- "styles/globals.css", "src/index.css", "src/main.css", "src/global.css",
194
+ 'app/globals.css',
195
+ 'src/app/globals.css',
196
+ 'src/styles/globals.css',
197
+ 'styles/globals.css',
198
+ 'src/index.css',
199
+ 'src/main.css',
200
+ 'src/global.css',
181
201
  ];
182
202
  const found = firstExisting(candidates);
183
203
  if (found) return found;
184
204
 
185
205
  // 후보에 없으면 @import "tailwindcss" 가 있는 css 를 얕게 탐색
186
- const roots = ["src", "app", "styles"].filter((d) => existsSync(join(cwd, d)));
206
+ const roots = ['src', 'app', 'styles'].filter(d => existsSync(join(cwd, d)));
187
207
  for (const root of roots) {
188
208
  const hit = walkCss(join(cwd, root), 3);
189
209
  if (hit) return relative(cwd, hit);
@@ -200,7 +220,7 @@ function walkCss(dir, depth) {
200
220
  return null;
201
221
  }
202
222
  for (const name of entries) {
203
- if (name === "node_modules" || name.startsWith(".")) continue;
223
+ if (name === 'node_modules' || name.startsWith('.')) continue;
204
224
  const p = join(dir, name);
205
225
  let st;
206
226
  try {
@@ -211,9 +231,9 @@ function walkCss(dir, depth) {
211
231
  if (st.isDirectory()) {
212
232
  const hit = walkCss(p, depth - 1);
213
233
  if (hit) return hit;
214
- } else if (name.endsWith(".css")) {
234
+ } else if (name.endsWith('.css')) {
215
235
  try {
216
- if (read(p).includes("tailwindcss")) return p;
236
+ if (read(p).includes('tailwindcss')) return p;
217
237
  } catch {
218
238
  /* 읽기 실패는 무시 */
219
239
  }
@@ -227,9 +247,9 @@ function stepGlobalCss() {
227
247
 
228
248
  if (!file) {
229
249
  add(
230
- "manual",
231
- "전역 CSS",
232
- "전역 CSS 를 찾지 못했습니다 — 앱의 전역 CSS 에 아래를 추가하세요",
250
+ 'manual',
251
+ '전역 CSS',
252
+ '전역 CSS 를 찾지 못했습니다 — 앱의 전역 CSS 에 아래를 추가하세요',
233
253
  `@import 'tailwindcss';\n@import '${PKG}/theme.css';\n@source "<상대경로>/node_modules/${PKG}/dist";`,
234
254
  );
235
255
  return;
@@ -239,14 +259,14 @@ function stepGlobalCss() {
239
259
  const src = read(path);
240
260
 
241
261
  // CSS 파일 기준 상대경로로 @source 를 계산한다 (README 주의사항)
242
- const toNodeModules = relative(dirname(path), join(cwd, "node_modules", PKG, "dist"));
243
- const sourcePath = toNodeModules.split(sep).join("/");
262
+ const toNodeModules = relative(dirname(path), join(cwd, 'node_modules', PKG, 'dist'));
263
+ const sourcePath = toNodeModules.split(sep).join('/');
244
264
 
245
265
  const hasTheme = src.includes(`${PKG}/theme.css`);
246
266
  const hasSource = src.includes(`node_modules/${PKG}/dist`);
247
267
 
248
268
  if (hasTheme && hasSource) {
249
- add("skipped", file, "theme.css · @source 가 이미 설정되어 있습니다");
269
+ add('skipped', file, 'theme.css · @source 가 이미 설정되어 있습니다');
250
270
  return;
251
271
  }
252
272
 
@@ -258,28 +278,28 @@ function stepGlobalCss() {
258
278
  const tw = src.match(/@import\s+["']tailwindcss["'];?/);
259
279
  if (!tw) {
260
280
  add(
261
- "manual",
281
+ 'manual',
262
282
  file,
263
283
  "@import 'tailwindcss' 를 찾지 못했습니다 — 아래를 직접 추가하세요",
264
- lines.join("\n"),
284
+ lines.join('\n'),
265
285
  );
266
286
  return;
267
287
  }
268
288
 
269
289
  const insertAt = tw.index + tw[0].length;
270
- const next = src.slice(0, insertAt) + "\n" + lines.join("\n") + src.slice(insertAt);
290
+ const next = src.slice(0, insertAt) + '\n' + lines.join('\n') + src.slice(insertAt);
271
291
  write(path, next);
272
- add("added", file, lines.length === 2 ? "theme.css import · @source 추가" : "누락분 추가");
292
+ add('added', file, lines.length === 2 ? 'theme.css import · @source 추가' : '누락분 추가');
273
293
  }
274
294
 
275
295
  /* ─────────────────────────── 실행 ─────────────────────────── */
276
296
 
277
297
  function printHelp() {
278
298
  console.log(`
279
- ${paint("bold", "sellmate-ds")} — ${PKG} 소비 앱 설정
299
+ ${paint('bold', 'sellmate-ds')} — ${PKG} 소비 앱 설정
280
300
 
281
- ${paint("cyan", "npx sellmate-ds init")} 설정을 자동으로 연결합니다
282
- ${paint("cyan", "npx sellmate-ds init --dry-run")} 무엇이 바뀔지만 보여줍니다
301
+ ${paint('cyan', 'npx sellmate-ds init')} 설정을 자동으로 연결합니다
302
+ ${paint('cyan', 'npx sellmate-ds init --dry-run')} 무엇이 바뀔지만 보여줍니다
283
303
 
284
304
  연결하는 것
285
305
  · CLAUDE.md / AGENTS.md AI 에이전트가 규칙(llms.txt)을 읽도록 지침 추가
@@ -290,13 +310,13 @@ ${paint("bold", "sellmate-ds")} — ${PKG} 소비 앱 설정
290
310
  `);
291
311
  }
292
312
 
293
- if (command === "help" || args.includes("--help") || args.includes("-h")) {
313
+ if (command === 'help' || args.includes('--help') || args.includes('-h')) {
294
314
  printHelp();
295
315
  process.exit(0);
296
316
  }
297
317
 
298
- if (command !== "init") {
299
- console.error(paint("red", `알 수 없는 명령: ${command}`));
318
+ if (command !== 'init') {
319
+ console.error(paint('red', `알 수 없는 명령: ${command}`));
300
320
  printHelp();
301
321
  process.exit(1);
302
322
  }
@@ -304,34 +324,46 @@ if (command !== "init") {
304
324
  assertProjectRoot();
305
325
 
306
326
  console.log(
307
- `\n${paint("bold", `${PKG} 설정`)}${dryRun ? paint("yellow", " (dry-run — 파일을 바꾸지 않습니다)") : ""}\n`,
327
+ `\n${paint('bold', `${PKG} 설정`)}${dryRun ? paint('yellow', ' (dry-run — 파일을 바꾸지 않습니다)') : ''}\n`,
308
328
  );
309
329
 
310
330
  stepAgentInstructions();
311
331
  stepEslint();
312
332
  stepGlobalCss();
313
333
 
314
- const icon = { added: paint("green", "✓"), skipped: paint("dim", "·"), manual: paint("yellow", "!"), error: paint("red", "✗") };
334
+ const icon = {
335
+ added: paint('green', '✓'),
336
+ skipped: paint('dim', '·'),
337
+ manual: paint('yellow', '!'),
338
+ error: paint('red', '✗'),
339
+ };
315
340
 
316
341
  for (const r of results) {
317
- console.log(` ${icon[r.status]} ${paint("bold", r.file)} ${paint("dim", r.detail)}`);
342
+ console.log(` ${icon[r.status]} ${paint('bold', r.file)} ${paint('dim', r.detail)}`);
318
343
  if (r.snippet) {
319
- console.log(r.snippet.split("\n").map((l) => ` ${paint("cyan", l)}`).join("\n"));
344
+ console.log(
345
+ r.snippet
346
+ .split('\n')
347
+ .map(l => ` ${paint('cyan', l)}`)
348
+ .join('\n'),
349
+ );
320
350
  }
321
351
  }
322
352
 
323
- const added = results.filter((r) => r.status === "added").length;
324
- const manual = results.filter((r) => r.status === "manual").length;
353
+ const added = results.filter(r => r.status === 'added').length;
354
+ const manual = results.filter(r => r.status === 'manual').length;
325
355
 
326
356
  console.log();
327
357
  if (dryRun) {
328
- console.log(paint("yellow", ` ${added}개 항목이 변경됩니다. --dry-run 을 빼고 다시 실행하세요.`));
358
+ console.log(
359
+ paint('yellow', ` ${added}개 항목이 변경됩니다. --dry-run 을 빼고 다시 실행하세요.`),
360
+ );
329
361
  } else if (added) {
330
- console.log(paint("green", ` ${added}개 항목을 설정했습니다.`));
362
+ console.log(paint('green', ` ${added}개 항목을 설정했습니다.`));
331
363
  } else if (!manual) {
332
- console.log(paint("dim", " 이미 모두 설정되어 있습니다."));
364
+ console.log(paint('dim', ' 이미 모두 설정되어 있습니다.'));
333
365
  }
334
366
  if (manual) {
335
- console.log(paint("yellow", ` ${manual}개 항목은 위 내용을 직접 추가해야 합니다.`));
367
+ console.log(paint('yellow', ` ${manual}개 항목은 위 내용을 직접 추가해야 합니다.`));
336
368
  }
337
369
  console.log();
@@ -9,9 +9,9 @@
9
9
  | Prop | Type | Default | Description |
10
10
  |------|------|---------|-------------|
11
11
  | `value?` | `number` | `0` | 진행률 (0–100) |
12
- | `type?` | `SCircleProgressType` | `'primary'` | 색상 테마 |
12
+ | `type?` | `SCircleProgressType` | `'primary'` | 진행 상태 - `primary`: 정상 진행 중 - `warning`: 진행 중이지만 설정된 기준에 미달해 주의가 필요하다 (기준은 제품이 정한다) - `error`: 실패·중단 등 더 이상 정상적으로 진행되지 않는다 - `complete`: 완료 - `inverse`: 어두운 배경 위에 놓을 때 - `neutral`: 콘텐츠가 아직 없는 자리를 채울 때 (대기) |
13
13
  | `indeterminate?` | `boolean` | `false` | 불확정(스피너) 모드 — value 무시 |
14
- | `size?` | `number \| string` | `DEFAULT_SIZE` | 링의 지름. 숫자는 px 로 해석한다. 부모 크기에 비례시키려면 컨테이너 쿼리 단위(`'45cqh'`)를 쓴다 — 퍼센트는 부모가 inline-flex 라 기준 폭이 정해지지 않아 해석되지 않는다. |
14
+ | `size?` | `number \| string` | — | 링의 지름. 숫자는 px 로 해석한다. 주지 않으면 `--cmp-progress-circular-size` 를 쓴다. 부모 크기에 비례시키려면 컨테이너 쿼리 단위(`'45cqh'`)를 쓴다 — 퍼센트는 부모가 inline-flex 라 기준 폭이 정해지지 않아 해석되지 않는다. |
15
15
  | `label?` | `string` | — | 하단 레이블 |
16
16
  | `innerValue?` | `boolean` | `false` | true면 퍼센트를 원 아래가 아닌 원 가운데에 표시 |
17
17
  | `className?` | `string` | — | |
@@ -22,7 +22,8 @@
22
22
  ### SCircleProgressType
23
23
 
24
24
  ```ts
25
- export type SCircleProgressType = 'primary' | 'inverse' | 'error' | 'complete' | 'neutral';
25
+ export type SCircleProgressType =
26
+ 'primary' | 'inverse' | 'warning' | 'error' | 'complete' | 'neutral';
26
27
  ```
27
28
 
28
29
  ## Dependencies
@@ -32,6 +33,7 @@ export type SCircleProgressType = 'primary' | 'inverse' | 'error' | 'complete' |
32
33
  - [SImage](../SImage)
33
34
  - [SLoadingContainer](../SLoadingContainer)
34
35
  - [SLoadingModal](../SLoadingModal)
36
+ - [SSelect](../SSelect)
35
37
  - [STable](../STable)
36
38
 
37
39
  ### Graph
@@ -41,6 +43,7 @@ graph TD;
41
43
  SImage --> SCircleProgress
42
44
  SLoadingContainer --> SCircleProgress
43
45
  SLoadingModal --> SCircleProgress
46
+ SSelect --> SCircleProgress
44
47
  STable --> SCircleProgress
45
48
  style SCircleProgress fill:#f9f,stroke:#333,stroke-width:4px
46
49
  ```
@@ -1,14 +1,22 @@
1
1
  import { type CSSProperties } from 'react';
2
- export type SCircleProgressType = 'primary' | 'inverse' | 'error' | 'complete' | 'neutral';
2
+ export type SCircleProgressType = 'primary' | 'inverse' | 'warning' | 'error' | 'complete' | 'neutral';
3
3
  export interface SCircleProgressProps {
4
4
  /** 진행률 (0–100) */
5
5
  value?: number;
6
- /** 색상 테마 */
6
+ /**
7
+ * 진행 상태
8
+ * - `primary`: 정상 진행 중
9
+ * - `warning`: 진행 중이지만 설정된 기준에 미달해 주의가 필요하다 (기준은 제품이 정한다)
10
+ * - `error`: 실패·중단 등 더 이상 정상적으로 진행되지 않는다
11
+ * - `complete`: 완료
12
+ * - `inverse`: 어두운 배경 위에 놓을 때
13
+ * - `neutral`: 콘텐츠가 아직 없는 자리를 채울 때 (대기)
14
+ */
7
15
  type?: SCircleProgressType;
8
16
  /** 불확정(스피너) 모드 — value 무시 */
9
17
  indeterminate?: boolean;
10
18
  /**
11
- * 링의 지름. 숫자는 px 로 해석한다.
19
+ * 링의 지름. 숫자는 px 로 해석한다. 주지 않으면 `--cmp-progress-circular-size` 를 쓴다.
12
20
  * 부모 크기에 비례시키려면 컨테이너 쿼리 단위(`'45cqh'`)를 쓴다 — 퍼센트는 부모가
13
21
  * inline-flex 라 기준 폭이 정해지지 않아 해석되지 않는다.
14
22
  */
@@ -9,7 +9,7 @@
9
9
  | Prop | Type | Default | Description |
10
10
  |------|------|---------|-------------|
11
11
  | `value?` | `number` | `0` | 진행률 (0–100) |
12
- | `type?` | `SLinearProgressType` | `'primary'` | 색상 타입 |
12
+ | `type?` | `SLinearProgressType` | `'primary'` | 진행 상태 - `primary`: 정상 진행 중 - `warning`: 진행 중이지만 설정된 기준에 미달해 주의가 필요하다 (기준은 제품이 정한다) - `error`: 실패·중단 등 더 이상 정상적으로 진행되지 않는다 - `complete`: 완료 |
13
13
  | `size?` | `SLinearProgressSize` | `'sm'` | 바 높이. xs 는 바 안에 퍼센트 텍스트를 넣지 않는다 |
14
14
  | `indeterminate?` | `boolean` | `false` | 진행률 없이 무한 애니메이션 |
15
15
  | `label?` | `string` | — | 하단 레이블 |
@@ -21,7 +21,7 @@
21
21
  ### SLinearProgressType
22
22
 
23
23
  ```ts
24
- export type SLinearProgressType = 'primary' | 'error' | 'complete';
24
+ export type SLinearProgressType = 'primary' | 'warning' | 'error' | 'complete';
25
25
  ```
26
26
 
27
27
  ### SLinearProgressSize
@@ -1,10 +1,16 @@
1
1
  import { type CSSProperties } from 'react';
2
- export type SLinearProgressType = 'primary' | 'error' | 'complete';
2
+ export type SLinearProgressType = 'primary' | 'warning' | 'error' | 'complete';
3
3
  export type SLinearProgressSize = 'xs' | 'sm' | 'md';
4
4
  export interface SLinearProgressProps {
5
5
  /** 진행률 (0–100) */
6
6
  value?: number;
7
- /** 색상 타입 */
7
+ /**
8
+ * 진행 상태
9
+ * - `primary`: 정상 진행 중
10
+ * - `warning`: 진행 중이지만 설정된 기준에 미달해 주의가 필요하다 (기준은 제품이 정한다)
11
+ * - `error`: 실패·중단 등 더 이상 정상적으로 진행되지 않는다
12
+ * - `complete`: 완료
13
+ */
8
14
  type?: SLinearProgressType;
9
15
  /** 바 높이. xs 는 바 안에 퍼센트 텍스트를 넣지 않는다 */
10
16
  size?: SLinearProgressSize;
@@ -28,7 +28,7 @@
28
28
  | `virtualBuffer?` | `number` | `DEFAULT_VIRTUAL_BUFFER` | 화면 위·아래로 더 그려둘 여유 행 수 — 빠르게 스크롤할 때 빈 칸이 보이지 않게 한다. 목록은 **언제나 보이는 범위만 렌더한다**(끄는 prop 은 없다). 그래서 여는 비용이 옵션 수와 무관하고, 이 값만이 DOM 에 남는 행 수를 정한다 — 한 화면 분량 + 앞뒤로 이만큼. |
29
29
  | `reachEndThreshold?` | `number` | `DEFAULT_REACH_END_THRESHOLD` | 목록 끝에서 이만큼 행이 남았을 때 `onReachEnd` 를 부른다 — 스크롤이 바닥에 닿기 전에 미리 받아 둔다. **한 페이지 크기보다 충분히 작게 잡는다.** 한 페이지가 드롭다운을 채우고도 이 문턱만큼 남기지 못하면, 페이지가 도착하는 족족 다음 페이지를 다시 청하게 된다 — 사용자가 스크롤을 하지 않아도 목록 전체를 받아 오므로 페이징을 한 의미가 사라진다. (한 페이지 50개 · 드롭다운에 20행이 보인다면 남는 것은 30행이므로, 문턱은 그보다 작아야 한다) |
30
30
  | `hasMore?` | `boolean` | `false` | 더 받아올 페이지가 남았는가. `false` 면 `onReachEnd` 를 더 부르지 않는다 |
31
- | `loading?` | `boolean` | `false` | 다음 페이지를 받는 중 — 목록 하단에 로딩 표시를 내고, 그동안 `onReachEnd` 를 다시 부르지 않는다 |
31
+ | `loading?` | `boolean` | `false` | 옵션을 받는 중 — 트리거의 펼침 아이콘 자리에 스피너를, 드롭다운 하단에 로딩 행을 낸다. 첫 조회(옵션이 아직 없을 때)든 다음 페이지든 같은 prop 이고, 그동안 `onReachEnd` 를 다시 부르지 않는다. 로딩 중에도 열 수 있다 — 서버 검색·페이징은 열린 채로 로딩을 오가므로, 열기를 막으면 닫았다 다시 연 사용자에게 클릭이 먹지 않는 고장으로 보인다. 받는 중에는 "표시할 항목이 없습니다."를 내지 않는다. 비활성이면 트리거 스피너를 내지 않는다 — 열 수 없는데 스피너가 돌면 "기다리면 열린다"로 읽힌다. |
32
32
  | `serverSearch?` | `boolean` | `false` | 검색을 서버로 넘긴다 — 내부 필터링을 하지 않고 `options` 를 받은 그대로 보여준다. `onSearchChange` 로 온 검색어에 맞는 목록을 소비 앱이 다시 내려줘야 한다. 켜면 검색바가 옵션 수와 무관하게 항상 나온다 — 검색 결과가 줄었다고 검색바가 사라지면 검색어를 지울 수단이 없어지기 때문이다. |
33
33
  | `searchDebounce?` | `number` | `DEFAULT_SEARCH_DEBOUNCE` | `serverSearch` 에서 검색어를 서버로 넘기기 전 기다리는 시간(ms) |
34
34
  | `label?` | `string` | — | |
@@ -50,7 +50,7 @@
50
50
 
51
51
  | Event | Type | Description |
52
52
  |-------|------|-------------|
53
- | `onValueChange` | `(value: any) => void` | 값 변경. 기본은 SSelectOption(들)이 오고, `valueAsPrimitive` 면 원시값이 온다 |
53
+ | `onValueChange` | `(value: TValue) => void` | 값 변경. 기본은 SSelectOption(들)이 오고, `valueAsPrimitive` 면 원시값이 온다 |
54
54
  | `onOpenChange` | `(open: boolean) => void` | 열림/닫힘 변경 (sdDropDownShow) |
55
55
  | `onReachEnd` | `() => void` | 목록 끝이 가까워지면 부른다 — 다음 페이지를 받아 `options` 뒤에 이어붙이라는 신호다. `hasMore` 가 `false` 이거나 `loading` 중이면 부르지 않고, 같은 목록 길이로 두 번 부르지 않는다. **`options` 는 갈아끼우지 말고 이어붙인다.** depth 타입이면 이미 있는 그룹의 `children` 에 이어야 한다 — 같은 그룹을 새 항목으로 또 밀어 넣으면 목록에 같은 헤더가 두 번 뜬다. 아직 받지 않은 옵션은 라벨을 알 수 없다. 그래서 이 prop 을 쓰는 화면은 `valueAsPrimitive` 를 켜지 않는 편이 안전하다 — 기본값(옵션 객체)이면 선택값이 라벨을 함께 들고 다녀서, 그 옵션이 목록에서 사라져도 트리거에 이름이 그대로 남는다. |
56
56
  | `onSearchChange` | `(query: string) => void` | 검색어 변경. `serverSearch` 면 `searchDebounce` 만큼 묶어서 온다 |
@@ -64,6 +64,20 @@
64
64
 
65
65
  ## Types
66
66
 
67
+ ### SSelectChangeValue
68
+
69
+ ```ts
70
+ /**
71
+ * `onValueChange` 로 오는 값의 기본 타입.
72
+ *
73
+ * 실제 모양은 `type`(단일/multi)과 `valueAsPrimitive`(옵션 객체/원시값)의 조합으로 정해져 한 타입으로
74
+ * 적을 수 없다. 화면이 안다면 `<SSelect<string> valueAsPrimitive … />` 처럼 넘겨 좁힌다 — 넘기지 않은
75
+ * 기존 코드는 이 기본값으로 예전처럼 동작한다.
76
+ */
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 값 타입을 넘기지 않은 화면의 의도된 탈출구
78
+ export type SSelectChangeValue = any;
79
+ ```
80
+
67
81
  ### SSelectOption
68
82
 
69
83
  ```ts
@@ -90,6 +104,7 @@ export type SSelectType = 'default' | 'multi' | 'default_depth' | 'multi_depth';
90
104
 
91
105
  ### Depends on
92
106
 
107
+ - [SCircleProgress](../SCircleProgress)
93
108
  - [SField](../SField)
94
109
  - [SIcon](../SIcon)
95
110
  - [SPortal](../SPortal)
@@ -99,6 +114,7 @@ export type SSelectType = 'default' | 'multi' | 'default_depth' | 'multi_depth';
99
114
 
100
115
  ```mermaid
101
116
  graph TD;
117
+ SSelect --> SCircleProgress
102
118
  SSelect --> SField
103
119
  SSelect --> SIcon
104
120
  SSelect --> SPortal
@@ -1,4 +1,4 @@
1
- import { type CSSProperties } from 'react';
1
+ import { type CSSProperties, type ForwardedRef, type ReactElement } from 'react';
2
2
  import { type Rule } from '../../lib/form';
3
3
  import { type SFieldAddonAlign, type SFieldLabelPosition } from '../SField';
4
4
  import { type SIconName } from '../SIcon';
@@ -13,11 +13,19 @@ export interface SSelectHandle {
13
13
  /** 드롭다운 열기 (sdOpen) */
14
14
  open: () => void;
15
15
  }
16
- export interface SSelectProps {
16
+ /**
17
+ * `onValueChange` 로 오는 값의 기본 타입.
18
+ *
19
+ * 실제 모양은 `type`(단일/multi)과 `valueAsPrimitive`(옵션 객체/원시값)의 조합으로 정해져 한 타입으로
20
+ * 적을 수 없다. 화면이 안다면 `<SSelect<string> valueAsPrimitive … />` 처럼 넘겨 좁힌다 — 넘기지 않은
21
+ * 기존 코드는 이 기본값으로 예전처럼 동작한다.
22
+ */
23
+ export type SSelectChangeValue = any;
24
+ export interface SSelectProps<TValue extends SSelectChangeValue = SSelectChangeValue> {
17
25
  /** 선택 값. multi 타입이면 배열. 기본(valueAsPrimitive=false) 라운드트립을 위해 SSelectOption 객체도 허용 */
18
26
  value?: (string | number | SSelectOption) | (string | number | SSelectOption)[] | null;
19
27
  /** 값 변경. 기본은 SSelectOption(들)이 오고, `valueAsPrimitive` 면 원시값이 온다 */
20
- onValueChange?: (value: any) => void;
28
+ onValueChange?: (value: TValue) => void;
21
29
  /** 열림/닫힘 변경 (sdDropDownShow) */
22
30
  onOpenChange?: (open: boolean) => void;
23
31
  /**
@@ -93,7 +101,16 @@ export interface SSelectProps {
93
101
  reachEndThreshold?: number;
94
102
  /** 더 받아올 페이지가 남았는가. `false` 면 `onReachEnd` 를 더 부르지 않는다 */
95
103
  hasMore?: boolean;
96
- /** 다음 페이지를 받는 중 — 목록 하단에 로딩 표시를 내고, 그동안 `onReachEnd` 를 다시 부르지 않는다 */
104
+ /**
105
+ * 옵션을 받는 중 — 트리거의 펼침 아이콘 자리에 스피너를, 드롭다운 하단에 로딩 행을 낸다.
106
+ * 첫 조회(옵션이 아직 없을 때)든 다음 페이지든 같은 prop 이고, 그동안 `onReachEnd` 를 다시 부르지 않는다.
107
+ *
108
+ * 로딩 중에도 열 수 있다 — 서버 검색·페이징은 열린 채로 로딩을 오가므로, 열기를 막으면
109
+ * 닫았다 다시 연 사용자에게 클릭이 먹지 않는 고장으로 보인다. 받는 중에는 "표시할 항목이
110
+ * 없습니다."를 내지 않는다.
111
+ *
112
+ * 비활성이면 트리거 스피너를 내지 않는다 — 열 수 없는데 스피너가 돌면 "기다리면 열린다"로 읽힌다.
113
+ */
97
114
  loading?: boolean;
98
115
  /**
99
116
  * 검색을 서버로 넘긴다 — 내부 필터링을 하지 않고 `options` 를 받은 그대로 보여준다.
@@ -144,4 +161,10 @@ export interface SSelectProps {
144
161
  className?: string;
145
162
  style?: CSSProperties;
146
163
  }
147
- export declare const SSelect: import("react").ForwardRefExoticComponent<SSelectProps & import("react").RefAttributes<SSelectHandle>>;
164
+ /**
165
+ * 값 타입을 받는 제네릭 컴포넌트로 내보낸다 — `forwardRef` 는 타입 파라미터를 지워 버리므로
166
+ * 바깥 시그니처만 다시 씌운다(STable·SDraggableList 와 같은 방식).
167
+ */
168
+ export declare const SSelect: <TValue extends SSelectChangeValue = SSelectChangeValue>(props: SSelectProps<TValue> & {
169
+ ref?: ForwardedRef<SSelectHandle>;
170
+ }) => ReactElement | null;