sellmate-design-system-react 3.1.0 → 3.3.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/dist/styles.css CHANGED
@@ -1308,6 +1308,9 @@
1308
1308
  .flex-col {
1309
1309
  flex-direction: column;
1310
1310
  }
1311
+ .flex-col-reverse {
1312
+ flex-direction: column-reverse;
1313
+ }
1311
1314
  .flex-row {
1312
1315
  flex-direction: row;
1313
1316
  }
@@ -1329,6 +1332,9 @@
1329
1332
  .items-stretch {
1330
1333
  align-items: stretch;
1331
1334
  }
1335
+ .justify-around {
1336
+ justify-content: space-around;
1337
+ }
1332
1338
  .justify-between {
1333
1339
  justify-content: space-between;
1334
1340
  }
@@ -1419,6 +1425,9 @@
1419
1425
  .gap-\[40px\] {
1420
1426
  gap: 40px;
1421
1427
  }
1428
+ .gap-\[var\(--\.\.\.\)\] {
1429
+ gap: var(--...);
1430
+ }
1422
1431
  .gap-\[var\(--cmp-callout-body-gap\)\] {
1423
1432
  gap: var(--cmp-callout-body-gap);
1424
1433
  }
@@ -1428,6 +1437,9 @@
1428
1437
  .gap-\[var\(--cmp-checkbox-gap\)\] {
1429
1438
  gap: var(--cmp-checkbox-gap);
1430
1439
  }
1440
+ .gap-\[var\(--cmp-checkbox-group-gap-horizontal\)\] {
1441
+ gap: var(--cmp-checkbox-group-gap-horizontal);
1442
+ }
1431
1443
  .gap-\[var\(--cmp-chip-gap\)\] {
1432
1444
  gap: var(--cmp-chip-gap);
1433
1445
  }
package/eslint/index.mjs CHANGED
@@ -10,6 +10,7 @@ import noOffScaleSpacing from "./rules/no-off-scale-spacing.mjs";
10
10
  import preferTypoPreset from "./rules/prefer-typo-preset.mjs";
11
11
  import tableNumericAlign from "./rules/table-numeric-align.mjs";
12
12
  import requireLocaleNumber from "./rules/require-locale-number.mjs";
13
+ import componentGroupGap from "./rules/component-group-gap.mjs";
13
14
 
14
15
  const PLUGIN_NAME = "sellmate";
15
16
 
@@ -22,6 +23,7 @@ const plugin = {
22
23
  "prefer-typo-preset": preferTypoPreset,
23
24
  "table-numeric-align": tableNumericAlign,
24
25
  "require-locale-number": requireLocaleNumber,
26
+ "component-group-gap": componentGroupGap,
25
27
  },
26
28
  };
27
29
 
@@ -38,6 +40,7 @@ const recommended = [
38
40
  [`${PLUGIN_NAME}/no-off-scale-spacing`]: "error",
39
41
  [`${PLUGIN_NAME}/table-numeric-align`]: "error",
40
42
  [`${PLUGIN_NAME}/require-locale-number`]: "error",
43
+ [`${PLUGIN_NAME}/component-group-gap`]: "error",
41
44
  // 대체 프리셋 판단에 여지가 있어 warn
42
45
  [`${PLUGIN_NAME}/prefer-typo-preset`]: "warn",
43
46
  },
@@ -0,0 +1,186 @@
1
+ /**
2
+ * 같은 컴포넌트를 여러 개 나열할 때 컴포넌트별 그룹 간격을 지키게 한다.
3
+ *
4
+ * 일반 요소 사이의 기본값(gap-8)과 달리, 체크박스·라디오는 가로 24 / 세로 8 처럼
5
+ * **배열 방향에 따라 값이 다르다.** 가로 배열에 8 을 쓰면 항목이 붙어 보인다.
6
+ *
7
+ * 오탐을 줄이기 위해 다음은 검사하지 않는다.
8
+ * - `justify-between` 등으로 양끝 분리한 경우 (하단 버튼 배치 규칙 §3-5)
9
+ * - gap 을 토큰 var() 로 직접 참조한 경우
10
+ * - 자식이 2개 미만이거나 여러 종류가 섞인 경우
11
+ *
12
+ * AGENTS.md §2-2.
13
+ */
14
+ import { collectClassTokens, stripVariants, stripModifiers } from "../lib/class-names.mjs";
15
+
16
+ /**
17
+ * 컴포넌트별 기대 간격 (px).
18
+ * horizontal/vertical 이 다르면 각각, 같으면 both 로 둔다.
19
+ * sizes 가 있으면 자식의 size prop 에 따라 값이 갈린다.
20
+ */
21
+ const GROUP_GAP = {
22
+ SCheckbox: { horizontal: 24, vertical: 8 },
23
+ SRadio: { horizontal: 24, vertical: 8 },
24
+ STextLink: {
25
+ horizontal: { default: 16, bySize: { md: 24, lg: 24 } },
26
+ vertical: 4,
27
+ },
28
+ SGhostButton: { both: 4 },
29
+ SButton: { both: { default: 8, bySize: { lg: 12 } } },
30
+ STag: { both: 8 },
31
+ SToggle: { both: 8 },
32
+ };
33
+
34
+ /** 이 컴포넌트들은 전용 그룹 컴포넌트가 따로 있다 */
35
+ const HAS_GROUP_COMPONENT = {
36
+ SRadio: "SRadioGroup (direction 으로 간격을 맞춥니다)",
37
+ SRadioButton: "SRadioGroup — 그룹 간격이 -1px(테두리 겹침)이라 직접 만들지 않습니다",
38
+ };
39
+
40
+ /** gap 유틸리티에서 px 값을 뽑는다. 토큰 var() 참조면 null(검사 제외) */
41
+ function readGap(tokens) {
42
+ for (const { token } of tokens) {
43
+ const util = stripModifiers(stripVariants(token));
44
+ const m = util.match(/^gap-(.+)$/);
45
+ if (!m) continue;
46
+ if (m[1].startsWith("[")) return { skip: true }; // gap-[var(--...)] 등
47
+ if (/^\d+$/.test(m[1])) return { value: Number(m[1]) };
48
+ }
49
+ return { value: null }; // gap 자체가 없음
50
+ }
51
+
52
+ /** JSX 자식 중 컴포넌트 엘리먼트만 (공백 텍스트 제외) */
53
+ function elementChildren(node) {
54
+ return (node.children ?? []).filter(
55
+ (ch) =>
56
+ ch.type === "JSXElement" &&
57
+ ch.openingElement.name.type === "JSXIdentifier",
58
+ );
59
+ }
60
+
61
+ const nameOf = (el) => el.openingElement.name.name;
62
+
63
+ /** 자식들의 size prop 중 가장 먼저 나오는 리터럴 값 */
64
+ function sizeOf(children) {
65
+ for (const ch of children) {
66
+ const attr = ch.openingElement.attributes.find(
67
+ (a) => a.type === "JSXAttribute" && a.name?.name === "size",
68
+ );
69
+ if (attr?.value?.type === "Literal" && typeof attr.value.value === "string") {
70
+ return attr.value.value;
71
+ }
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ /** spec(number | {default, bySize}) 을 size 로 해석 */
77
+ function resolve(spec, size) {
78
+ if (typeof spec === "number") return spec;
79
+ if (!spec) return undefined;
80
+ return (size && spec.bySize?.[size]) ?? spec.default;
81
+ }
82
+
83
+ export default {
84
+ meta: {
85
+ type: "problem",
86
+ docs: {
87
+ description: "같은 컴포넌트를 나열할 때 컴포넌트별 그룹 간격 사용",
88
+ },
89
+ schema: [
90
+ {
91
+ type: "object",
92
+ properties: {
93
+ ignore: { type: "array", items: { type: "string" }, uniqueItems: true },
94
+ },
95
+ additionalProperties: false,
96
+ },
97
+ ],
98
+ messages: {
99
+ wrongGap:
100
+ "{{component}} 를 {{dirLabel}} 배열할 때 간격은 `gap-{{expected}}` 입니다{{actualLabel}}. (AGENTS.md §2-2)",
101
+ useGroupComponent:
102
+ "{{component}} 를 직접 flex 로 감싸지 마세요 — {{suggestion}}. (AGENTS.md §2-2)",
103
+ },
104
+ fixable: "code",
105
+ },
106
+
107
+ create(context) {
108
+ const ignore = new Set(context.options[0]?.ignore ?? []);
109
+
110
+ return {
111
+ JSXElement(node) {
112
+ const classAttr = node.openingElement.attributes.find(
113
+ (a) => a.type === "JSXAttribute" && (a.name?.name === "className" || a.name?.name === "class"),
114
+ );
115
+ if (!classAttr) return;
116
+
117
+ const tokens = collectClassTokens(classAttr);
118
+ const utils = tokens.map(({ token }) => stripModifiers(stripVariants(token)));
119
+
120
+ // flex 컨테이너가 아니면 대상이 아니다
121
+ if (!utils.includes("flex") && !utils.includes("inline-flex")) return;
122
+ // 양끝 분리는 gap 규칙 대상이 아니다 (하단 버튼 배치 등)
123
+ if (utils.some((u) => u.startsWith("justify-between") || u.startsWith("justify-around"))) return;
124
+
125
+ const children = elementChildren(node);
126
+ if (children.length < 2) return;
127
+
128
+ // 같은 컴포넌트로만 이루어진 그룹만 판단한다
129
+ const first = nameOf(children[0]);
130
+ if (!children.every((ch) => nameOf(ch) === first)) return;
131
+ if (ignore.has(first)) return;
132
+
133
+ // 전용 그룹 컴포넌트가 있으면 그쪽을 권한다
134
+ if (HAS_GROUP_COMPONENT[first]) {
135
+ context.report({
136
+ node: node.openingElement,
137
+ messageId: "useGroupComponent",
138
+ data: { component: first, suggestion: HAS_GROUP_COMPONENT[first] },
139
+ });
140
+ return;
141
+ }
142
+
143
+ const spec = GROUP_GAP[first];
144
+ if (!spec) return;
145
+
146
+ const vertical = utils.includes("flex-col") || utils.includes("flex-col-reverse");
147
+ const dirSpec = spec.both ?? (vertical ? spec.vertical : spec.horizontal);
148
+ const expected = resolve(dirSpec, sizeOf(children));
149
+ if (expected == null) return;
150
+
151
+ const gap = readGap(tokens);
152
+ if (gap.skip) return;
153
+ if (gap.value === expected) return;
154
+
155
+ context.report({
156
+ node: node.openingElement,
157
+ messageId: "wrongGap",
158
+ data: {
159
+ component: first,
160
+ dirLabel: spec.both ? "" : vertical ? "세로로" : "가로로",
161
+ expected: String(expected),
162
+ actualLabel: gap.value == null ? " (지정되지 않았습니다)" : ` — 현재 gap-${gap.value}`,
163
+ },
164
+ fix(fixer) {
165
+ // 기존 gap 유틸리티가 리터럴 문자열일 때만 안전하게 교체한다
166
+ if (gap.value == null) return null;
167
+ const literal = classAttr.value;
168
+ const raw =
169
+ literal?.type === "Literal"
170
+ ? literal
171
+ : literal?.type === "JSXExpressionContainer" && literal.expression.type === "Literal"
172
+ ? literal.expression
173
+ : null;
174
+ if (!raw || typeof raw.value !== "string") return null;
175
+ const next = raw.value.replace(
176
+ new RegExp(`(^|\\s)gap-${gap.value}(?=\\s|$)`),
177
+ `$1gap-${expected}`,
178
+ );
179
+ if (next === raw.value) return null;
180
+ return fixer.replaceText(raw, `"${next}"`);
181
+ },
182
+ });
183
+ },
184
+ };
185
+ },
186
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sellmate-design-system-react",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "Sellmate Design System — React (TypeScript + Tailwind v4) port",
5
5
  "license": "UNLICENSED",
6
6
  "keywords": [
@@ -19,8 +19,13 @@
19
19
  "dist",
20
20
  "eslint",
21
21
  "!eslint/__tests__",
22
+ "bin",
23
+ "!bin/__tests__",
22
24
  "AGENTS.md"
23
25
  ],
26
+ "bin": {
27
+ "sellmate-ds": "./bin/sellmate-ds.mjs"
28
+ },
24
29
  "exports": {
25
30
  ".": {
26
31
  "types": "./dist/index.d.ts",
@@ -51,6 +56,7 @@
51
56
  "build-storybook": "storybook build",
52
57
  "test:spec": "npm run build && node --test ./src/components/**/*.test.mjs",
53
58
  "test:eslint": "node --test ./eslint/__tests__/*.test.mjs",
59
+ "test:cli": "node --test ./bin/__tests__/*.test.mjs",
54
60
  "typecheck": "tsc --noEmit",
55
61
  "lint": "eslint \"src/**/*.{ts,tsx}\"",
56
62
  "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",