sellmate-design-system-react 9.0.0-beta.6 → 9.0.0-beta.8

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.
@@ -0,0 +1,18 @@
1
+ import { type Ref, type ReactNode } from 'react';
2
+ /** 여러 ref 에 같은 노드를 흘려보낸다 — 소비자용 ref 와 측정용 ref 가 같은 `<input>` 을 가리켜야 한다 */
3
+ export declare function mergeRefs<T>(...refs: (Ref<T> | undefined)[]): (node: T | null) => void;
4
+ export interface TruncatedValueTooltip {
5
+ /** 측정 대상 `<input>` 에 붙일 ref. 소비자 ref 와는 `mergeRefs` 로 합친다 */
6
+ valueRef: Ref<HTMLInputElement>;
7
+ /** 측정 대상에 붙일 호버 핸들러 */
8
+ onMouseEnter: () => void;
9
+ onMouseLeave: () => void;
10
+ /** 필드 어디든 함께 렌더한다 (Portal 이라 레이아웃을 차지하지 않는다) */
11
+ tooltip: ReactNode;
12
+ }
13
+ export declare function useTruncatedValueTooltip({ enabled, text, }: {
14
+ /** `readOnly` 이고 비활성이 아닐 때만 켠다 */
15
+ enabled: boolean;
16
+ /** 툴팁에 표시할 전문. 화면에 보이는 문자열이어야 한다(숫자 필드의 천단위 콤마 포함) */
17
+ text: string;
18
+ }): TruncatedValueTooltip;
@@ -3442,7 +3442,6 @@ Tailwind 유틸리티는 아래 스케일에 있는 값만 사용한다. 리터
3442
3442
  | `children?` | `ReactNode` | — | 리스트 컨테이너 내부에 렌더링할 내용 |
3443
3443
  | `useGap?` | `boolean` | `false` | 리스트 아이템 사이 gap 토큰 적용 여부 |
3444
3444
  | `usePadding?` | `boolean` | `false` | 리스트 컨테이너 padding 토큰 적용 여부 |
3445
- | `separator?` | `boolean` | `false` | 아이템 하단 구분선 표시 여부. 테두리를 가진 아이템(`bordered`)에는 쓰지 않는다 |
3446
3445
 
3447
3446
  ## Dependencies
3448
3447
 
@@ -3977,6 +3976,7 @@ function OrderModal({ open, onOpenChange, onClose, modalRef, orderId }: OrderMod
3977
3976
  |------|------|---------|-------------|
3978
3977
  | `background?` | `SPageBackground` | `'frame'` | 페이지 배경 표면. frame=흰 콘텐츠 면, neutral=옅은 회색 면, screen=앱 바탕. 스크롤바 처리도 여기 묶여 있다 — 셋 다 구분선+트랙 배경이고, 트랙 색만 neutral 에서 흰색이 된다. |
3979
3978
  | `scrollEndSpacing?` | `boolean` | `true` | 스크롤 끝 여백. 마지막 항목이 창 하단에 붙어 "여기서 끝"이 안 읽히는 것을 막는다. 기본으로 켜져 있고, 페이지네이션처럼 끝을 알려주는 것이 이미 있으면 끈다(목록 페이지). |
3979
+ | `overlayScrollbar?` | `boolean` | `false` | true면 네이티브 스크롤바를 숨기고 SPage 위에 오버레이 스크롤바를 얹는다. 스크롤바가 레이아웃 폭을 차지하지 않아 내부 콘텐츠 폭이 줄어들지 않는다. |
3980
3980
  | `contentHeight?` | `SPageContentHeight` | `'auto'` | 본문 높이 모드. 기본은 `auto` — 콘텐츠가 흐르고 넘치면 페이지가 스크롤한다. `fill` 은 본문이 남은 높이를 정확히 채우고 페이지는 스크롤하지 않는다. 표가 자기 안에서 스크롤하고 페이지네이션이 하단에 고정되는 목록 화면용이다. 본문 루트에 `h-full` 이 먹으므로 `<div className="flex h-full min-h-0 flex-col">` + `<STable className="min-h-0 flex-1" />` 구성이 성립한다. `fill` 에서는 페이지가 스크롤하지 않으므로 `scrollEndSpacing` 이 무시된다. |
3981
3981
  | `header?` | `SPageHeaderProps` | — | 페이지 타이틀 영역(pageHeader 포팅). 주면 스크롤·페이지 패딩 밖으로 빼내 상단에 고정 배치한다. `children` 은 항상 본문이다 — header 유무와 무관하게 같은 자리에 같은 뜻으로 들어간다. |
3982
3982
 
@@ -4583,7 +4583,6 @@ function OrderModal({ open, onOpenChange, onClose, modalRef, orderId }: OrderMod
4583
4583
  |------|------|---------|-------------|
4584
4584
  | `value` | `string` | — | 현재 선택된 탭 value |
4585
4585
  | `tabs` | `STabOption[]` | — | 탭 옵션 목록 |
4586
- | `size?` | `STabSize` | `'md'` | 탭 크기 (main 전용) |
4587
4586
  | `isSub?` | `boolean` | `false` | 서브 탭(밑줄형) 스타일 |
4588
4587
  | `vertical?` | `boolean` | `false` | 세로 배치 (sub 전용 — main 폴더형은 항상 가로) |
4589
4588
  | `className?` | `string` | — | |
@@ -5028,6 +5027,7 @@ function OrderModal({ open, onOpenChange, onClose, modalRef, orderId }: OrderMod
5028
5027
  | `border?` | `boolean` | `true` | 아이템 하단 border 표시 여부 |
5029
5028
  | `indeterminate?` | `boolean` | `false` | 일부 선택 상태 |
5030
5029
  | `selectable?` | `boolean` | `true` | 체크박스 표시 여부 |
5030
+ | `size?` | `STreeSize` | `'sm'` | 타이포그래피 크기 |
5031
5031
  | `leading?` | `ReactNode \| ((state: STreeItemRenderState) => ReactNode)` | — | 사용자 지정 leading 콘텐츠 |
5032
5032
  | `trailing?` | `ReactNode \| ((state: STreeItemRenderState) => ReactNode)` | — | 사용자 지정 trailing 콘텐츠 |
5033
5033
 
@@ -5050,6 +5050,7 @@ function OrderModal({ open, onOpenChange, onClose, modalRef, orderId }: OrderMod
5050
5050
  | `expandedIds?` | `string[]` | — | 펼쳐진 노드 ID 목록 |
5051
5051
  | `defaultExpandedIds?` | `string[]` | — | 비제어 펼침 초기값 |
5052
5052
  | `selectable?` | `boolean` | `true` | 체크박스 표시 여부 |
5053
+ | `size?` | `STreeSize` | `'sm'` | 타이포그래피 크기 |
5053
5054
  | `useAll?` | `boolean` | `false` | 체크박스 사용 시 최상단 전체 선택 아이템 표시 여부 |
5054
5055
  | `guideline?` | `boolean` | `true` | depth 연결선 표시 여부 |
5055
5056
  | `border?` | `boolean` | `true` | 아이템 하단 border 표시 여부 |
package/dist/styles.css CHANGED
@@ -590,6 +590,9 @@
590
590
  .inset-y-0 {
591
591
  inset-block: 0;
592
592
  }
593
+ .-top-\[9999px\] {
594
+ top: calc(9999px * -1);
595
+ }
593
596
  .top-0 {
594
597
  top: 0;
595
598
  }
@@ -602,6 +605,9 @@
602
605
  .top-\[12px\] {
603
606
  top: 12px;
604
607
  }
608
+ .top-\[13px\] {
609
+ top: 13px;
610
+ }
605
611
  .top-\[15px\] {
606
612
  top: 15px;
607
613
  }
@@ -632,6 +638,9 @@
632
638
  .right-1\/2 {
633
639
  right: calc(1 / 2 * 100%);
634
640
  }
641
+ .right-\[2px\] {
642
+ right: 2px;
643
+ }
635
644
  .right-\[10px\] {
636
645
  right: 10px;
637
646
  }
@@ -650,6 +659,9 @@
650
659
  .bottom-\[-1px\] {
651
660
  bottom: -1px;
652
661
  }
662
+ .bottom-\[2px\] {
663
+ bottom: 2px;
664
+ }
653
665
  .left-0 {
654
666
  left: 0;
655
667
  }
@@ -782,6 +794,9 @@
782
794
  .mt-sd-2 {
783
795
  margin-top: var(--spacing-sd-2);
784
796
  }
797
+ .mt-sd-4 {
798
+ margin-top: var(--spacing-sd-4);
799
+ }
785
800
  .mt-sd-8 {
786
801
  margin-top: var(--spacing-sd-8);
787
802
  }
@@ -894,9 +909,18 @@
894
909
  .h-\[2px\] {
895
910
  height: 2px;
896
911
  }
912
+ .h-\[8px\] {
913
+ height: 8px;
914
+ }
897
915
  .h-\[10px\] {
898
916
  height: 10px;
899
917
  }
918
+ .h-\[12px\] {
919
+ height: 12px;
920
+ }
921
+ .h-\[15px\] {
922
+ height: 15px;
923
+ }
900
924
  .h-\[16px\] {
901
925
  height: 16px;
902
926
  }
@@ -927,6 +951,9 @@
927
951
  .h-\[56px\] {
928
952
  height: 56px;
929
953
  }
954
+ .h-\[72px\] {
955
+ height: 72px;
956
+ }
930
957
  .h-\[80vh\] {
931
958
  height: 80vh;
932
959
  }
@@ -954,6 +981,9 @@
954
981
  .h-\[364px\] {
955
982
  height: 364px;
956
983
  }
984
+ .h-\[380px\] {
985
+ height: 380px;
986
+ }
957
987
  .h-\[420px\] {
958
988
  height: 420px;
959
989
  }
@@ -1101,12 +1131,18 @@
1101
1131
  .w-\[4px\] {
1102
1132
  width: 4px;
1103
1133
  }
1134
+ .w-\[8px\] {
1135
+ width: 8px;
1136
+ }
1104
1137
  .w-\[9px\] {
1105
1138
  width: 9px;
1106
1139
  }
1107
1140
  .w-\[10px\] {
1108
1141
  width: 10px;
1109
1142
  }
1143
+ .w-\[12px\] {
1144
+ width: 12px;
1145
+ }
1110
1146
  .w-\[14px\] {
1111
1147
  width: 14px;
1112
1148
  }
@@ -1137,6 +1173,9 @@
1137
1173
  .w-\[220px\] {
1138
1174
  width: 220px;
1139
1175
  }
1176
+ .w-\[240px\] {
1177
+ width: 240px;
1178
+ }
1140
1179
  .w-\[280px\] {
1141
1180
  width: 280px;
1142
1181
  }
@@ -1419,6 +1458,9 @@
1419
1458
  .grid-cols-\[200px_1fr\] {
1420
1459
  grid-template-columns: 200px 1fr;
1421
1460
  }
1461
+ .grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1fr\)\] {
1462
+ grid-template-columns: minmax(0,1fr) minmax(0,1fr);
1463
+ }
1422
1464
  .grid-cols-\[repeat\(7\,var\(--cmp-datepicker-calendar-day-width\)\)\] {
1423
1465
  grid-template-columns: repeat(7,var(--cmp-datepicker-calendar-day-width));
1424
1466
  }
@@ -1446,6 +1488,9 @@
1446
1488
  .items-center {
1447
1489
  align-items: center;
1448
1490
  }
1491
+ .items-end {
1492
+ align-items: flex-end;
1493
+ }
1449
1494
  .items-start {
1450
1495
  align-items: flex-start;
1451
1496
  }
@@ -2221,6 +2266,9 @@
2221
2266
  .bg-\[\#FFE3F7\] {
2222
2267
  background-color: #FFE3F7;
2223
2268
  }
2269
+ .bg-\[\#FFF4F4\] {
2270
+ background-color: #FFF4F4;
2271
+ }
2224
2272
  .bg-\[\#eee\] {
2225
2273
  background-color: #eee;
2226
2274
  }
@@ -2323,6 +2371,9 @@
2323
2371
  .bg-\[var\(--color-grey-15\)\] {
2324
2372
  background-color: var(--color-grey-15);
2325
2373
  }
2374
+ .bg-\[var\(--color-grey-45\)\] {
2375
+ background-color: var(--color-grey-45);
2376
+ }
2326
2377
  .bg-\[var\(--color-white\)\] {
2327
2378
  background-color: var(--color-white);
2328
2379
  }
@@ -2776,6 +2827,9 @@
2776
2827
  .pb-sd-16 {
2777
2828
  padding-bottom: var(--spacing-sd-16);
2778
2829
  }
2830
+ .pb-sd-48 {
2831
+ padding-bottom: var(--spacing-sd-48);
2832
+ }
2779
2833
  .pl-\(--cmp-guide-contents-depth-paddingLeft\) {
2780
2834
  padding-left: var(--cmp-guide-contents-depth-paddingLeft);
2781
2835
  }
@@ -3050,9 +3104,6 @@
3050
3104
  .text-\(--cmp-tree-item-text-default\) {
3051
3105
  color: var(--cmp-tree-item-text-default);
3052
3106
  }
3053
- .text-\(--cmp-tree-item-text-selected\) {
3054
- color: var(--cmp-tree-item-text-selected);
3055
- }
3056
3107
  .text-\(--color-grey-65\) {
3057
3108
  color: var(--color-grey-65);
3058
3109
  }
@@ -4936,6 +4987,11 @@
4936
4987
  --cmp-field-errorMessage-color: #E30000;
4937
4988
  --cmp-field-contents-gap: 4px;
4938
4989
  --cmp-field-message-paddingX: 8px;
4990
+ --cmp-field-width-xs: 80px;
4991
+ --cmp-field-width-sm: 120px;
4992
+ --cmp-field-width-md: 160px;
4993
+ --cmp-field-width-lg: 240px;
4994
+ --cmp-field-width-xl: 480px;
4939
4995
  --cmp-textinput-sm-height: 28px;
4940
4996
  --cmp-textinput-sm-paddingX: 12px;
4941
4997
  --cmp-textinput-sm-paddingY: 4px;
@@ -5428,10 +5484,8 @@
5428
5484
  --cmp-stepper-vertical-title-paddingTop: 12px;
5429
5485
  --cmp-stepper-vertical-title-paddingBottom: 6px;
5430
5486
  --cmp-stepper-vertical-title-text: #004290;
5431
- --cmp-tab-main-lg-height: 44px;
5432
- --cmp-tab-main-lg-paddingX: 32px;
5433
- --cmp-tab-main-md-height: 36px;
5434
- --cmp-tab-main-md-paddingX: 24px;
5487
+ --cmp-tab-main-height: 36px;
5488
+ --cmp-tab-main-paddingX: 24px;
5435
5489
  --cmp-tab-main-gap: 8px;
5436
5490
  --cmp-tab-main-container-gap: 4px;
5437
5491
  --cmp-tab-main-radius: 6px;
@@ -5587,9 +5641,8 @@
5587
5641
  --cmp-tree-item-rowGap: 8px;
5588
5642
  --cmp-tree-item-contentGap: 12px;
5589
5643
  --cmp-tree-item-text-default: #222222;
5590
- --cmp-tree-item-text-selected: #0075FF;
5591
5644
  --cmp-tree-item-arrow-icon: 16px;
5592
- --cmp-tree-connector-line-color: #AAAAAA;
5645
+ --cmp-tree-connector-line-color: #E1E1E1;
5593
5646
  --cmp-pageHeader-paddingX: 16px;
5594
5647
  --cmp-pageHeader-gap: 12px;
5595
5648
  --cmp-pageHeader-title-paddingLeft: 4px;
package/dist/theme.css CHANGED
@@ -746,6 +746,11 @@
746
746
  --cmp-field-errorMessage-color: #E30000;
747
747
  --cmp-field-contents-gap: 4px;
748
748
  --cmp-field-message-paddingX: 8px;
749
+ --cmp-field-width-xs: 80px;
750
+ --cmp-field-width-sm: 120px;
751
+ --cmp-field-width-md: 160px;
752
+ --cmp-field-width-lg: 240px;
753
+ --cmp-field-width-xl: 480px;
749
754
  --cmp-textinput-sm-height: 28px;
750
755
  --cmp-textinput-sm-paddingX: 12px;
751
756
  --cmp-textinput-sm-paddingY: 4px;
@@ -1238,10 +1243,8 @@
1238
1243
  --cmp-stepper-vertical-title-paddingTop: 12px;
1239
1244
  --cmp-stepper-vertical-title-paddingBottom: 6px;
1240
1245
  --cmp-stepper-vertical-title-text: #004290;
1241
- --cmp-tab-main-lg-height: 44px;
1242
- --cmp-tab-main-lg-paddingX: 32px;
1243
- --cmp-tab-main-md-height: 36px;
1244
- --cmp-tab-main-md-paddingX: 24px;
1246
+ --cmp-tab-main-height: 36px;
1247
+ --cmp-tab-main-paddingX: 24px;
1245
1248
  --cmp-tab-main-gap: 8px;
1246
1249
  --cmp-tab-main-container-gap: 4px;
1247
1250
  --cmp-tab-main-radius: 6px;
@@ -1397,9 +1400,8 @@
1397
1400
  --cmp-tree-item-rowGap: 8px;
1398
1401
  --cmp-tree-item-contentGap: 12px;
1399
1402
  --cmp-tree-item-text-default: #222222;
1400
- --cmp-tree-item-text-selected: #0075FF;
1401
1403
  --cmp-tree-item-arrow-icon: 16px;
1402
- --cmp-tree-connector-line-color: #AAAAAA;
1404
+ --cmp-tree-connector-line-color: #E1E1E1;
1403
1405
  --cmp-pageHeader-paddingX: 16px;
1404
1406
  --cmp-pageHeader-gap: 12px;
1405
1407
  --cmp-pageHeader-title-paddingLeft: 4px;
package/eslint/index.mjs CHANGED
@@ -11,6 +11,7 @@ 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
13
  import componentGroupGap from "./rules/component-group-gap.mjs";
14
+ import fieldWidthGrade from "./rules/field-width-grade.mjs";
14
15
 
15
16
  const PLUGIN_NAME = "sellmate";
16
17
 
@@ -24,6 +25,7 @@ const plugin = {
24
25
  "table-numeric-align": tableNumericAlign,
25
26
  "require-locale-number": requireLocaleNumber,
26
27
  "component-group-gap": componentGroupGap,
28
+ "field-width-grade": fieldWidthGrade,
27
29
  },
28
30
  };
29
31
 
@@ -54,6 +56,8 @@ const recommended = [
54
56
  [`${PLUGIN_NAME}/component-group-gap`]: "warn",
55
57
  [`${PLUGIN_NAME}/table-numeric-align`]: "warn",
56
58
  [`${PLUGIN_NAME}/require-locale-number`]: "warn",
59
+ // 폭 등급 환산이 아직 잠정치라 권고에 둔다 (디자인 확정표가 오면 error 승격 검토)
60
+ [`${PLUGIN_NAME}/field-width-grade`]: "warn",
57
61
 
58
62
  // 앱 고유 치수(w-[280px])·일회성 값에는 임의 값이 정당하다. 필요한 팀만 켠다.
59
63
  [`${PLUGIN_NAME}/no-arbitrary-class`]: "off",
@@ -79,6 +83,7 @@ const strict = [
79
83
  [`${PLUGIN_NAME}/component-group-gap`]: "error",
80
84
  [`${PLUGIN_NAME}/table-numeric-align`]: "error",
81
85
  [`${PLUGIN_NAME}/require-locale-number`]: "error",
86
+ [`${PLUGIN_NAME}/field-width-grade`]: "error",
82
87
  },
83
88
  },
84
89
  ];
@@ -0,0 +1,41 @@
1
+ /**
2
+ * playground 의 실측 페이지(FieldWidthGradePage)가 규칙과 같은 계산을 쓰기 위한 타입 선언.
3
+ * 규칙 본체는 소비 앱이 그대로 실행하는 plain ESM 이라 .mjs 로 두고, 타입만 여기에 붙인다.
4
+ */
5
+
6
+ export type FieldGrade = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
7
+ export type FieldSize = 'sm' | 'md';
8
+
9
+ export interface FieldComponentSpec {
10
+ /** 스키마 상한을 담는 prop — 숫자 필드는 maxLength 가 아니라 max 다 */
11
+ lengthProp: 'maxLength' | 'max';
12
+ font: Record<FieldSize, number>;
13
+ /** 글자가 쓸 수 없는 폭의 합 (좌우 패딩 · 테두리 · 스테퍼) */
14
+ chrome: Record<FieldSize, number>;
15
+ /** 한글이 들어갈 수 있는가 */
16
+ korean: boolean;
17
+ grades: FieldGrade[];
18
+ }
19
+
20
+ export declare const GRADES: Record<FieldGrade, number>;
21
+ export declare const CHAR_RATIO: { narrow: number; wide: number };
22
+ export declare const COMPONENTS: Record<string, FieldComponentSpec>;
23
+
24
+ export declare function capacity(
25
+ spec: FieldComponentSpec,
26
+ size: FieldSize,
27
+ grade: FieldGrade,
28
+ ratio: number,
29
+ grades?: Record<FieldGrade, number>,
30
+ ): number;
31
+
32
+ export declare function smallestGradeFor(
33
+ spec: FieldComponentSpec,
34
+ size: FieldSize,
35
+ len: number,
36
+ ratio: number,
37
+ grades?: Record<FieldGrade, number>,
38
+ ): FieldGrade | undefined;
39
+
40
+ declare const rule: unknown;
41
+ export default rule;
@@ -0,0 +1,294 @@
1
+ /**
2
+ * 필드 폭을 `maxLength`(= 스키마 상한) 기준으로 고정했는지 검사한다.
3
+ *
4
+ * 확정 규칙 (design-feedback-final.md §1-2):
5
+ * 필드 너비는 maxLength 로 정한다.
6
+ * - 상한이 등급 안에 들어오면 → 그 등급
7
+ * - 상한이 등급 상한(xl)을 넘거나 상한이 없으면 → 행 전체 + 100%
8
+ * - 셀렉트는 예외 (maxLength 개념이 없어 목록 최장값 기준) → 대상 아님
9
+ *
10
+ * 디자인팀이 요구한 "검수: maxLength ↔ 폭 등급 적합성 필수 확인" 을 자동화한 것이다.
11
+ * 사람이 하는 검수는 스키마가 나중에 확정되는 화면에서 빠지기 쉽다 —
12
+ * 시안 단계의 잠정 등급이 그대로 남아도 화면은 멀쩡해 보이기 때문이다.
13
+ *
14
+ * 대상이 아닌 것과 그 이유:
15
+ * STextarea 여러 줄로 접히므로 maxLength 가 폭을 정하지 않는다
16
+ * SSelect 계열 maxLength 개념이 없다 (§1-2 예외 — 목록 최장값 기준)
17
+ * SDatePicker 계열 컴포넌트가 자체 상한을 갖는다 (B-14)
18
+ *
19
+ * ⚠️ 글자수 ↔ 등급 환산은 아직 디자인 확정본이 없어 토큰 값에서 계산한 잠정치다.
20
+ * (폭 등급 자체도 B-8 토큰 도착 전이라 하드코딩이다 — 도착하면 GRADES 를 교체한다)
21
+ * 확정표가 오면 GRADES · CHAR_RATIO 를 그 값으로 바꾼다. 두 상수 모두 옵션으로 덮을 수 있다.
22
+ */
23
+
24
+ /**
25
+ * 폭 등급 (px). design-feedback-final.md §2-10.
26
+ * B-8 토큰(`--cmp-field-width-*`)이 소스에 추가되면 scale.gen.mjs 를 거쳐 여기로 들어온다.
27
+ */
28
+ export const GRADES = { xs: 80, sm: 120, md: 160, lg: 240, xl: 480 };
29
+
30
+ /**
31
+ * 글자 하나가 차지하는 폭 (font-size 대비 비율).
32
+ * narrow — 영문·숫자. "이 등급에 안 담긴다" 를 판정할 때 쓴다(가장 좁게 잡아도 넘치면 확실히 위반)
33
+ * wide — 한글(전각). "한 등급 작아도 된다" 를 판정할 때 쓴다(가장 넓게 잡아도 남으면 확실히 과하다)
34
+ * 두 판정 사이에는 아무것도 보고하지 않는 구간이 남는다 — 오탐보다 미탐을 택한 것이다.
35
+ */
36
+ export const CHAR_RATIO = { narrow: 0.55, wide: 1 };
37
+
38
+ /**
39
+ * 컴포넌트별 기하. chrome 은 글자가 쓸 수 없는 폭(좌우 패딩 · 테두리 · 스테퍼)의 합이고
40
+ * 전부 theme.css 토큰 값에서 계산했다.
41
+ *
42
+ * SInput/SBarcodeInput paddingX(12·16) × 2 + border 1 × 2
43
+ * SNumberInput 스테퍼가 없으면 패딩이 두 배가 된다(SNumberInput.tsx:388) —
44
+ * 없을 때 paddingX(6·8) × 2 × 2 + border,
45
+ * 있을 때 paddingX × 2 + stepper(20·24) × 2 + gap(= paddingX) × 2 + border
46
+ *
47
+ * lengthProp — 숫자 필드의 스키마 상한은 `maxLength` 가 아니라 `max` 다.
48
+ * korean — 한글이 들어갈 수 있는가. false 면 두 판정 모두 narrow 로 계산한다.
49
+ * chromeStepper — `useButton` 일 때의 chrome. 기본값이 false 라 chrome 쪽이 기본이다.
50
+ */
51
+ export const COMPONENTS = {
52
+ SInput: {
53
+ lengthProp: "maxLength",
54
+ font: { sm: 12, md: 14 },
55
+ chrome: { sm: 26, md: 34 },
56
+ korean: true,
57
+ grades: ["sm", "md", "lg", "xl"],
58
+ },
59
+ SBarcodeInput: {
60
+ lengthProp: "maxLength",
61
+ font: { sm: 12, md: 14 },
62
+ chrome: { sm: 26, md: 34 },
63
+ korean: false, // 바코드는 영숫자다
64
+ grades: ["sm", "md", "lg", "xl"],
65
+ },
66
+ SNumberInput: {
67
+ lengthProp: "max",
68
+ font: { sm: 12, md: 14 },
69
+ chrome: { sm: 26, md: 34 },
70
+ chromeStepper: { sm: 66, md: 82 },
71
+ korean: false,
72
+ grades: ["xs", "sm", "md", "lg", "xl"], // xs 는 숫자 필드 전용 등급이다
73
+ },
74
+ };
75
+
76
+ /**
77
+ * 등급이 담을 수 있는 글자수. playground 의 실측 페이지가 같은 식을 쓰도록 내보낸다
78
+ * (숫자를 두 곳에 적으면 한쪽만 고쳐져 검수가 무의미해진다).
79
+ */
80
+ export function capacity(spec, size, grade, ratio, grades = GRADES) {
81
+ const usable = Math.max(0, grades[grade] - spec.chrome[size]);
82
+ return Math.floor(usable / (spec.font[size] * ratio));
83
+ }
84
+
85
+ /** 그 비율로 len 글자를 담을 수 있는 가장 작은 등급 (없으면 undefined) */
86
+ export function smallestGradeFor(spec, size, len, ratio, grades = GRADES) {
87
+ return spec.grades.find(g => capacity(spec, size, g, ratio, grades) >= len);
88
+ }
89
+
90
+ /** JSX 속성 노드 */
91
+ function attr(node, name) {
92
+ return node.attributes.find(a => a.type === "JSXAttribute" && a.name?.name === name);
93
+ }
94
+
95
+ /**
96
+ * 속성에서 리터럴 값을 꺼낸다.
97
+ * 없음 → undefined
98
+ * 리터럴 → 값
99
+ * 그 밖(변수) → null (판단 불가 — 조용히 넘긴다)
100
+ */
101
+ function literal(node, name) {
102
+ const a = attr(node, name);
103
+ if (!a) return undefined;
104
+ const v = a.value;
105
+ if (!v) return true; // <SNumberInput useButton /> 같은 boolean shorthand 는 true 다
106
+ if (v.type === "Literal") return v.value;
107
+ if (v.type === "JSXExpressionContainer" && v.expression.type === "Literal") {
108
+ return v.expression.value;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ /** `max={1000}` → 자릿수 4. 천단위 콤마도 폭을 먹으므로 함께 센다 */
114
+ function digitsOf(max) {
115
+ const digits = String(Math.abs(Math.trunc(max))).length;
116
+ return digits + Math.floor((digits - 1) / 3);
117
+ }
118
+
119
+ export default {
120
+ meta: {
121
+ type: "problem",
122
+ docs: {
123
+ description: "필드 폭을 maxLength(스키마 상한) 기준 등급으로 고정",
124
+ },
125
+ schema: [
126
+ {
127
+ type: "object",
128
+ properties: {
129
+ /** 폭 등급 px 재정의 — 디자인 확정본이 오면 여기로 덮는다 */
130
+ grades: { type: "object", additionalProperties: { type: "number" } },
131
+ /** 글자폭 비율 재정의 */
132
+ charRatio: {
133
+ type: "object",
134
+ properties: { narrow: { type: "number" }, wide: { type: "number" } },
135
+ additionalProperties: false,
136
+ },
137
+ },
138
+ additionalProperties: false,
139
+ },
140
+ ],
141
+ messages: {
142
+ noGrade:
143
+ "`{{lengthProp}}={{len}}` 로 상한이 정해진 필드인데 `width` 가 없어 행 전체를 차지합니다. `width={{{suggestPx}}}`({{suggest}} 등급)으로 고정하세요. (§1-2)",
144
+ offGrade:
145
+ "`width={{{width}}}` 는 폭 등급이 아닙니다. {{gradeList}} 중 하나이거나, 상한이 없으면 `width=\"100%\"` 여야 합니다. (§1-2)",
146
+ noBound:
147
+ "`width` 로 폭을 고정했는데 `{{lengthProp}}` 이 없습니다. 폭의 근거가 스키마 상한이므로, 상한이 없으면 행 전체(`width=\"100%\"` 또는 생략)로 둡니다. (§1-2)",
148
+ tooNarrow:
149
+ "`{{lengthProp}}={{len}}` 이 {{grade}} 등급({{gradePx}}px)에 담기지 않습니다. `width={{{suggestPx}}}`({{suggest}} 등급) 이상이 필요합니다. (§1-2)",
150
+ tooWide:
151
+ "`{{lengthProp}}={{len}}` 은 {{suggest}} 등급({{suggestPx}}px)으로 충분한데 {{grade}} 등급({{gradePx}}px)을 주었습니다. 폭은 상한에 맞춥니다. (§1-2)",
152
+ overMax:
153
+ "`{{lengthProp}}={{len}}` 은 최대 등급 xl({{xl}}px)로도 담기지 않습니다. 행 전체(`width=\"100%\"`)로 둡니다. (§1-2)",
154
+ fullWidthBounded:
155
+ "`{{lengthProp}}={{len}}` 로 상한이 정해져 있으므로 `width=\"100%\"` 가 아니라 `width={{{suggestPx}}}`({{suggest}} 등급)로 고정합니다. 행 전체는 상한이 없거나 xl 을 넘을 때만 씁니다. (§1-2)",
156
+ },
157
+ },
158
+
159
+ create(context) {
160
+ const grades = { ...GRADES, ...(context.options[0]?.grades ?? {}) };
161
+ const ratio = { ...CHAR_RATIO, ...(context.options[0]?.charRatio ?? {}) };
162
+
163
+ return {
164
+ JSXOpeningElement(node) {
165
+ if (node.name.type !== "JSXIdentifier") return;
166
+ const base = COMPONENTS[node.name.name];
167
+ if (!base) return;
168
+
169
+ // spread 가 있으면 width·maxLength 가 그 안에 있을 수 있어 판단하지 않는다
170
+ if (node.attributes.some(a => a.type === "JSXSpreadAttribute")) return;
171
+
172
+ const size = literal(node, "size") === "md" ? "md" : "sm";
173
+ // 스테퍼가 붙으면 글자가 쓸 수 있는 폭이 크게 줄어든다 (useButton 기본값은 false)
174
+ const spec = literal(node, "useButton") === true && base.chromeStepper
175
+ ? { ...base, chrome: base.chromeStepper }
176
+ : base;
177
+
178
+ const capacityAt = (grade, r) => capacity(spec, size, grade, r, grades);
179
+ const smallestFor = (len, r) => smallestGradeFor(spec, size, len, r, grades);
180
+
181
+ const narrow = ratio.narrow;
182
+ // 한글이 들어갈 수 없는 필드는 두 판정 모두 좁은 글자폭으로 계산한다
183
+ const wide = spec.korean ? ratio.wide : ratio.narrow;
184
+
185
+ const rawLen = literal(node, spec.lengthProp);
186
+ if (rawLen === null) return; // 변수로 넘긴 상한 — 판단 불가
187
+ const len =
188
+ rawLen === undefined
189
+ ? undefined
190
+ : spec.lengthProp === "max"
191
+ ? digitsOf(rawLen)
192
+ : rawLen;
193
+ if (len !== undefined && (!Number.isFinite(len) || len <= 0)) return;
194
+
195
+ const rawWidth = literal(node, "width");
196
+ if (rawWidth === null) return; // 변수로 넘긴 폭 — 판단 불가
197
+
198
+ const reportNode = attr(node, "width") ?? attr(node, spec.lengthProp) ?? node.name;
199
+ const gradeList = spec.grades.map(g => `${g}(${grades[g]})`).join(" · ");
200
+
201
+ /* ── width 가 없다 ── */
202
+ if (rawWidth === undefined) {
203
+ if (len === undefined) return; // 둘 다 없음 = 상한 없는 필드 → 행 전체가 맞다
204
+ const suggest = smallestFor(len, wide);
205
+ if (!suggest) return; // xl 로도 안 담긴다 → 행 전체가 맞다
206
+ context.report({
207
+ node: reportNode,
208
+ messageId: "noGrade",
209
+ data: { lengthProp: spec.lengthProp, len: rawLen, suggest, suggestPx: grades[suggest] },
210
+ });
211
+ return;
212
+ }
213
+
214
+ /* ── width="100%" ── */
215
+ if (rawWidth === "100%") {
216
+ if (len === undefined) return; // 상한 없음 + 행 전체 = 규칙대로다
217
+ const suggest = smallestFor(len, wide);
218
+ if (!suggest) return; // xl 초과 → 행 전체가 맞다
219
+ context.report({
220
+ node: reportNode,
221
+ messageId: "fullWidthBounded",
222
+ data: { lengthProp: spec.lengthProp, len: rawLen, suggest, suggestPx: grades[suggest] },
223
+ });
224
+ return;
225
+ }
226
+
227
+ /* ── width 가 등급 값인가 ── */
228
+ const px = typeof rawWidth === "number" ? rawWidth : /^(\d+)px$/.exec(String(rawWidth))?.[1];
229
+ const grade = px != null && spec.grades.find(g => grades[g] === Number(px));
230
+ if (!grade) {
231
+ context.report({
232
+ node: reportNode,
233
+ messageId: "offGrade",
234
+ data: { width: String(rawWidth), gradeList },
235
+ });
236
+ return;
237
+ }
238
+
239
+ /* ── 등급은 맞다. 상한과 맞는가 ── */
240
+ if (len === undefined) {
241
+ context.report({
242
+ node: reportNode,
243
+ messageId: "noBound",
244
+ data: { lengthProp: spec.lengthProp },
245
+ });
246
+ return;
247
+ }
248
+
249
+ // 영문·숫자로 채워도 넘치면 등급이 확실히 좁다
250
+ if (capacityAt(grade, narrow) < len) {
251
+ const suggest = smallestFor(len, narrow);
252
+ if (!suggest) {
253
+ context.report({
254
+ node: reportNode,
255
+ messageId: "overMax",
256
+ data: { lengthProp: spec.lengthProp, len: rawLen, xl: grades.xl },
257
+ });
258
+ return;
259
+ }
260
+ context.report({
261
+ node: reportNode,
262
+ messageId: "tooNarrow",
263
+ data: {
264
+ lengthProp: spec.lengthProp,
265
+ len: rawLen,
266
+ grade,
267
+ gradePx: grades[grade],
268
+ suggest,
269
+ suggestPx: grades[suggest],
270
+ },
271
+ });
272
+ return;
273
+ }
274
+
275
+ // 한글로 채워도 더 작은 등급에 들어가면 등급이 확실히 과하다
276
+ const enough = smallestFor(len, wide);
277
+ if (enough && grades[enough] < grades[grade]) {
278
+ context.report({
279
+ node: reportNode,
280
+ messageId: "tooWide",
281
+ data: {
282
+ lengthProp: spec.lengthProp,
283
+ len: rawLen,
284
+ grade,
285
+ gradePx: grades[grade],
286
+ suggest: enough,
287
+ suggestPx: grades[enough],
288
+ },
289
+ });
290
+ }
291
+ },
292
+ };
293
+ },
294
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sellmate-design-system-react",
3
- "version": "9.0.0-beta.6",
3
+ "version": "9.0.0-beta.8",
4
4
  "description": "Sellmate Design System — React (TypeScript + Tailwind v4) port",
5
5
  "license": "UNLICENSED",
6
6
  "keywords": [