sellmate-design-system-react 9.0.0-beta.15 → 9.0.0-beta.16

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 (53) hide show
  1. package/AGENTS.md +228 -47
  2. package/README.md +45 -12
  3. package/dist/components/SBarcodeInput/README.md +1 -1
  4. package/dist/components/SBarcodeInput/SBarcodeInput.d.ts +7 -1
  5. package/dist/components/SChipInput/README.md +1 -1
  6. package/dist/components/SChipInput/SChipInput.d.ts +7 -1
  7. package/dist/components/SDatePicker/README.md +1 -1
  8. package/dist/components/SDatePicker/SDatePicker.d.ts +7 -1
  9. package/dist/components/SDateRangePicker/README.md +1 -1
  10. package/dist/components/SDateRangePicker/SDateRangePicker.d.ts +7 -1
  11. package/dist/components/SField/README.md +3 -3
  12. package/dist/components/SField/SField.d.ts +12 -6
  13. package/dist/components/SFilePicker/README.md +1 -1
  14. package/dist/components/SFilePicker/SFilePicker.d.ts +7 -1
  15. package/dist/components/SGhostButton/README.md +2 -0
  16. package/dist/components/SInput/README.md +1 -1
  17. package/dist/components/SInput/SInput.d.ts +7 -1
  18. package/dist/components/SKeyValueTable/README.md +4 -1
  19. package/dist/components/SKeyValueTable/SKeyValueTable.d.ts +6 -2
  20. package/dist/components/SNumberInput/README.md +1 -1
  21. package/dist/components/SNumberInput/SNumberInput.d.ts +7 -1
  22. package/dist/components/SPage/README.md +1 -1
  23. package/dist/components/SPage/SPage.d.ts +7 -1
  24. package/dist/components/SSearchInput/README.md +1 -1
  25. package/dist/components/SSearchInput/SSearchInput.d.ts +8 -2
  26. package/dist/components/SSelect/README.md +1 -1
  27. package/dist/components/SSelect/SSelect.d.ts +8 -1
  28. package/dist/components/SSwitch/README.md +13 -0
  29. package/dist/components/STable/README.md +26 -74
  30. package/dist/components/STable/STable.d.ts +63 -6
  31. package/dist/components/STable/index.d.ts +1 -1
  32. package/dist/components/STextarea/README.md +1 -1
  33. package/dist/components/STextarea/STextarea.d.ts +7 -1
  34. package/dist/components/STimePicker/README.md +1 -1
  35. package/dist/components/STimePicker/STimePicker.d.ts +7 -1
  36. package/dist/components/STimePicker/timepicker.config.d.ts +4 -2
  37. package/dist/components/STimeRangePicker/README.md +1 -1
  38. package/dist/components/STimeRangePicker/STimeRangePicker.d.ts +7 -1
  39. package/dist/index.cjs +143 -111
  40. package/dist/index.cjs.map +1 -1
  41. package/dist/index.d.ts +1 -0
  42. package/dist/index.js +141 -112
  43. package/dist/index.js.map +1 -1
  44. package/dist/lib/field-width.d.ts +31 -0
  45. package/dist/llms-full.txt +283 -140
  46. package/dist/llms.txt +228 -47
  47. package/dist/styles.css +10 -6
  48. package/eslint/index.mjs +5 -0
  49. package/eslint/lib/table-column.mjs +17 -0
  50. package/eslint/rules/field-width-grade.mjs +27 -10
  51. package/eslint/rules/table-column-width.mjs +109 -0
  52. package/eslint/scale.gen.mjs +3 -0
  53. package/package.json +1 -1
package/AGENTS.md CHANGED
@@ -110,7 +110,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
110
110
 
111
111
  `text-14 font-bold` 같은 조합을 즉흥으로 만들지 않는다. §2-1의 `typo-*` 프리셋 클래스를 쓴다.
112
112
 
113
- ### 1-4. 숫자는 무조건 `toLocaleString()`
113
+ ### 1-4. 숫자·날짜 표기
114
114
 
115
115
  **숫자를 화면에 표시할 때는 예외 없이 `toLocaleString()` 을 거쳐 세 자리마다 콤마를 넣는다.**
116
116
  금액·수량·건수·재고 무엇이든, 테이블·상세·요약 문구 어디에 놓이든 같다.
@@ -125,6 +125,18 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
125
125
 
126
126
  **번호·코드는 제외한다.** 전화번호·사업자번호·송장번호·상품코드처럼 대상을 가리키는 값은 크기를 비교하는 숫자가 아니라 **서식이 정해진 문자열**이다. 여기에 콤마를 넣으면 송장번호 `123456789` 가 `123,456,789` 로 보여 값 자체가 달라진다.
127
127
 
128
+ **날짜는 `YYYY-MM-DD` 로 쓴다.** 자릿수를 채우고 하이픈으로 구분한다 — `2026-08-13`.
129
+ `2026. 8. 13.` 처럼 점으로 구분하거나 한 자리로 줄이지 않는다. 자릿수가 고정돼야 세로줄이 맞고,
130
+ 컬럼 폭을 형식으로 계산할 수 있다(§3-4). 일시가 필요하면 `YYYY-MM-DD HH:mm`.
131
+
132
+ ```tsx
133
+ ❌ {new Date(v).toLocaleDateString()} ❌ {`${y}. ${m}. ${d}.`}
134
+ ✅ {v} // 서버가 이미 YYYY-MM-DD 로 준 값
135
+ ✅ format: (v: string) => v.slice(0, 10)
136
+ ```
137
+
138
+ **`toLocaleDateString()` 은 쓰지 않는다** — 로케일에 따라 결과가 바뀌어 표기를 지킬 수 없다.
139
+
128
140
  ---
129
141
 
130
142
  ## 2. 조합 규칙 — 화면을 어떻게 쌓는가
@@ -369,6 +381,26 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
369
381
 
370
382
  바탕을 깐 경우, 표 사이 구분선(`SDivider`)은 대개 불필요해진다 — 색이 이미 경계를 만든다.
371
383
 
384
+ #### 페이지 높이 — 화면을 꽉 채우고, 스크롤은 각 영역 안에서
385
+
386
+ **대부분의 화면은 본문이 창을 꽉 채우고, 스크롤은 각 영역 안에서 일어난다.** 표는 자기 안에서 스크롤하고, 좌측 목록은 목록 안에서 스크롤하고, 페이지네이션·하단 액션은 자리에 고정된다. 이것이 표준이다 — 목록 페이지만의 예외가 아니다.
387
+
388
+ `SPage` 의 `contentHeight="fill"` 이 그 모드다. 프레임 컴포넌트에서 넘긴다(§4-1).
389
+
390
+ ```tsx
391
+ <SPage contentHeight="fill">
392
+ <div className="flex h-full min-h-0 flex-col gap-sd-12">
393
+ <STableBar … />
394
+ <STable className="min-h-0 flex-1" pagination={…} />
395
+ </div>
396
+ </SPage>
397
+ ```
398
+
399
+ - **`min-h-0 flex-1` 사슬이 페이지의 기본 골격이다.** `fill` 은 본문 래퍼에 `h-full` 을 주고, 거기서부터 스크롤될 자리까지 `min-h-0 flex-1` 이 이어져야 자식이 남은 높이를 잡는다.
400
+ - **사슬이 한 군데만 끊겨도 자식이 높이를 못 잡는데, 그 실패가 조용하다** — 화면은 그려지고 스크롤만 엉뚱한 데서 일어난다. 체크리스트(§5)로 확인한다.
401
+ - **페이지 스크롤은 예외다.** 블록의 높이가 정해져 있고 그 높이가 창보다 클 때만 페이지가 스크롤한다. 그때만 `contentHeight="auto"` 와 `scrollEndSpacing` 을 켠다.
402
+ - **`SScrollArea` 로 페이지 본문 전체를 감싸지 않는다.** 스크롤은 `SPage` 의 `<main>` 몫이고, 감싸면 스크롤바가 본문 패딩 안쪽으로 들어와 가장자리에서 뜬다. `SScrollArea` 는 페이지 안의 특정 영역에만 쓴다(§3-0 D).
403
+
372
404
  #### 스크롤 영역의 하단 여백
373
405
 
374
406
  스크롤을 끝까지 내렸을 때 마지막 항목이 화면 경계에 붙으면 **목록이 끝난 것인지 더 있는 것인지** 읽히지 않는다. 그래서 스크롤 영역은 **하단만** 넓게 둔다. 나머지 세 방향은 위 16 / 24 규칙 그대로다.
@@ -376,9 +408,9 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
376
408
  | 스크롤 종류 | 어떻게 |
377
409
  | --- | --- |
378
410
  | **패널 자체 스크롤** (좌측 목록, 중앙 대화 등) | 그 패널 안쪽 하단에 `pb-[var(--cmp-pageBody-padding-scrollEnd)]` — `SPage` 와 같은 토큰이라 값이 바뀌어도 함께 따라간다 |
379
- | **페이지 단위 스크롤** | **`SPage` 넣는다. 직접 주지 않는다** |
411
+ | **페이지 단위 스크롤** | **`SPage` `scrollEndSpacing` 으로 켠다. 직접 패딩을 주지 않는다** |
380
412
 
381
- `SPage` 기본으로 넣으므로 **아무것도 하지 않으면 맞다.** 직접 일은 없다 **페이지네이션이 붙은 테이블**은 `contentHeight="fill"` 쓰고, 모드에서는 페이지가 스크롤하지 않아 스크롤 여백이 **자동으로 무시**된다 (§4-2 목록 페이지).
413
+ **`scrollEndSpacing` 기본이 꺼져 있다.** 페이지가 실제로 스크롤될 때만 필요한 값이라, 조건 없이 붙이면 내용이 화면에 거의 딱 맞는 페이지까지 그 여백 때문에 스크롤되게 만든다. 페이지 스크롤을 쓰는 화면(`contentHeight="auto"` + 내용이 창보다 김)에서만 켠다. 페이지네이션처럼 끝을 알려주는 것이 이미 있으면 켜지 않는다.
382
414
 
383
415
  ### 2-3. 색상
384
416
 
@@ -504,6 +536,8 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
504
536
  | 사용자가 영역 크기를 조절하게 한다 | `SSplitter` | §3-6 |
505
537
  | 특정 영역 안에서만 스크롤시킨다 | `SScrollArea` | |
506
538
 
539
+ **`SScrollArea` 로 페이지 본문 전체를 감싸지 않는다.** 페이지 스크롤은 `SPage` 의 `<main>` 몫이다 — 감싸면 스크롤바가 본문 패딩 안쪽으로 들어와 페이지 가장자리에서 떨어져 그려진다 (§2-2).
540
+
507
541
  #### E. 다른 곳으로 이동시킨다
508
542
 
509
543
  | 하려는 일 | 컴포넌트 | 갈림 |
@@ -744,36 +778,51 @@ SModal.create({ component: OrderModal, componentProps: { orderId } })
744
778
 
745
779
  작성 중인 내용이 있을 때 닫기를 시도하면 이탈 안내를 띄우는 것은 **소비 앱 몫**이다. 디자인 시스템은 dirty 상태를 알지 못하므로 백드롭·ESC 를 일괄 차단할 뿐이다. 안내가 필요하면 앱이 자체 dirty 판정 후 `SModal.confirm` 으로 띄운다.
746
780
 
747
- ### 3-4. 테이블 컬럼 — 정렬과 너비
781
+ ### 3-4. 테이블 컬럼 — 정렬·너비·헤더
748
782
 
749
- #### 정렬
783
+ #### 정렬과 너비는 같은 표에서 정한다
750
784
 
751
- **값의 크기를 비교하는 숫자 컬럼은 예외 없이 오른쪽 정렬한다** (`align: 'right'`).
752
- 자릿수가 세로로 맞아야 값의 크기를 눈으로 비교할 수 있기 때문이다.
785
+ 컬럼을 정의할 정렬과 너비는 따로 판단하는 것이 아니다. 다 **값의 성격**에서 나온다.
753
786
 
754
- **판별 기준은 "숫자인가"가 아니라 "크기를 비교하는가"다.** 자릿수 차이가 거의 없고 값끼리 대소를 견줄 일이 없으면 숫자로만 이루어져 있어도 우측 정렬하지 않는다.
787
+ 원칙 줄: **길이를 형식이 정하면 고정, 사용자가 정하면 가변.**
755
788
 
756
- | 값 성격 | 정렬 | 예 |
757
- | --- | --- | --- |
758
- | **금액·수량·개수·비율 양을 나타내는 값** | **`'right'`** | `39,000원` · `12개` · `3건` · `15%` |
759
- | 코드·식별자 (주문번호, 상품코드, 순번) | **`'center'`** | `RV20250728-000010` · `1024` |
760
- | 전화번호·사업자번호 | **`'center'`** | `010-1234-5678` |
761
- | 일자·일시 | **`'center'`** | `2024-10-23` |
762
- | 텍스트 | 생략(기본 `left`) | 상품명, 카테고리 |
763
- | 상태 태그·아이콘·체크박스 등 고정폭 요소 | `'center'` | `STag`, `SIcon` |
789
+ | 값 성격 | 정렬 | 너비 | 예 |
790
+ | --- | --- | --- | --- |
791
+ | **금액·수량·개수·비율** (양을 나타내는 값) | **`'right'`** | 고정 | `39,000원` · `12개` · `3건` · `15%` |
792
+ | 코드·식별자, 전화번호, 일자·일시 | **`'center'`** | 고정 | `RV20250728-000010` · `010-1234-5678` · `2026-08-13` |
793
+ | **닫힌 값 집합** (enum · 마스터 목록에서 고르는 값) | **`'center'`** | 고정 | 상태 · 직급 · 공개 범위 · 고용 형태 · 요일 |
794
+ | 상태 태그·아이콘·버튼·체크박스 | `'center'` | 고정 (`contentType: 'control'`) | `STag` · `SIcon` · `SGhostButton` |
795
+ | **텍스트** (사용자가 자유 입력) | 생략(기본 `left`) | 기준 + `resizable` | 이름 · 목표명 · 이메일 · 메모 |
764
796
 
765
797
  **중앙 정렬은 `align: 'center'` 를 명시한다.** 기본값이 좌측이라 생략하면 중앙이 되지 않는다.
766
798
 
799
+ ##### 판별 — 값의 크기를 비교하는가
800
+
801
+ 우측 정렬의 근거는 "자릿수를 세로로 맞춰 크기를 읽는다"다. 자릿수 차이가 거의 없고 값끼리 대소를 견줄 일이 없으면 숫자로만 이루어져 있어도 우측 정렬하지 않는다 — 송장번호 `123456789` 는 크기를 비교하는 값이 아니다.
802
+
803
+ ##### 판별 — 값 집합이 닫혀 있는가
804
+
805
+ **닫힌 값 집합이면 태그로 그리든 맨 텍스트로 그리든 `center` 다.** 판별 질문 하나 — *사용자가 그 칸을 직접 치는 값인가?* 아니면 닫힌 집합이다.
806
+
807
+ | | 값의 출처 | 정렬 | 예 |
808
+ | --- | --- | --- | --- |
809
+ | **닫힘** | enum · 마스터 목록에서 선택 (`SSelect` 의 `options` 에서 오는 값) | `center` | 직급 · 상태 · 공개 범위 · 최종 등급 · 고용 형태 · 요일 |
810
+ | **열림** | 사용자가 자유 입력 (자유 입력 필드에서 오는 값) | `left` | 이름 · 목표명 · 이메일 · 문항 그룹명 |
811
+
812
+ - **무엇으로 그렸는지로 가르지 않는다.** 같은 성격의 값이 `STag` 면 `center`, 맨 텍스트면 `left` 가 되면 한 테이블 안에서 기준이 어긋난다.
813
+ - **길이로도 가르지 않는다.** "짧은 라벨이면 center" 같은 단서를 붙이면 `프로덕트디자인팀`(8자)처럼 경계에 걸리는 값에서 매번 판단이 갈린다.
814
+ - 한 열에 텍스트와 태그가 함께 오면 태그 기준(`center`)에 맞춘다.
815
+
767
816
  ```tsx
768
817
  const columns: STableColumn[] = [
769
- { name: 'orderNo', label: '주문번호', field: 'orderNo', width: '140px', align: 'center' },
770
- { name: 'orderedAt', label: '주문일자', field: 'orderedAt', width: '100px', align: 'center' },
771
- { name: 'name', label: '상품명', field: 'name' }, // 텍스트 → 생략
772
- { name: 'qty', label: '수량', field: 'qty', width: '80px', align: 'right',
818
+ { name: 'orderNo', label: '주문번호', field: 'orderNo', width: 140, align: 'center' },
819
+ { name: 'orderedAt', label: '주문일자', field: 'orderedAt', width: 100, align: 'center' },
820
+ { name: 'name', label: '상품명', field: 'name', width: 240 }, // 자유 입력 → 생략
821
+ { name: 'qty', label: '수량', field: 'qty', width: 80, align: 'right',
773
822
  format: (v: number) => `${Number(v).toLocaleString()}개` },
774
- { name: 'price', label: '판매가', field: 'price', width: '120px', align: 'right',
823
+ { name: 'price', label: '판매가', field: 'price', width: 120, align: 'right',
775
824
  format: (v: number) => `${Number(v).toLocaleString()}원` },
776
- { name: 'status', label: '상태', field: 'status', width: '100px', align: 'center',
825
+ { name: 'status', label: '상태', field: 'status', width: 100, align: 'center',
777
826
  render: () => <STag size="sm" color="green" label="판매중" /> },
778
827
  ];
779
828
  ```
@@ -781,29 +830,55 @@ const columns: STableColumn[] = [
781
830
  - `format` 으로 단위를 붙이더라도 **양을 나타내면 오른쪽 정렬**이다. 단위 때문에 문자열이 되는 것은 정렬 판단과 무관하다.
782
831
  - 양을 나타내는 숫자는 §1-4 대로 **`toLocaleString()` 이 필수**다. 세 자리 콤마 없이 출력하지 않는다.
783
832
  - **번호·코드에는 세 자리 콤마를 넣지 않는다.** 송장번호 `123456789` 를 `123,456,789` 로 표시하면 값 자체가 달라 보인다.
833
+ - 날짜는 §1-4 대로 `YYYY-MM-DD` 로 적는다. 자릿수가 고정이라 폭을 형식으로 계산할 수 있다.
784
834
  - **헤더는 가운데, 셀만 우측**으로 두려면 `align` 이 아니라 `tdClass` 를 쓴다. `align` 은 `<th>` 와 `<td>` 에 함께 적용된다.
785
835
 
786
836
  ```tsx
787
- { name: 'views', label: '조회수', field: 'views', align: 'center', tdClass: 'text-right!',
837
+ { name: 'views', label: '조회수', field: 'views', align: 'center', width: 100, tdClass: 'text-right!',
788
838
  format: (v: number) => Number(v).toLocaleString() },
789
839
  ```
790
840
 
791
841
  - `SKeyValueTable` 의 값 셀도 같은 기준을 따른다.
792
842
 
793
- #### 컨트롤이 들어가는 컬럼은 너비를 명시한다
843
+ #### 너비는 px 로만 준다
844
+
845
+ **컬럼 폭은 px 이다.** 숫자를 주면 px 로 읽고, 문자열은 `'120px'` 형태만 받는다. `%` · `clamp()` · `min()` 은 쓰지 않는다.
846
+
847
+ 컬럼 폭은 `<colgroup>` 의 `<col width>` 로 들어가고 테이블이 `table-fixed` 라, 함수형 값은 계산되지 않고 통째로 무시된 뒤 auto 폭으로 떨어진다. `'30%'` 는 더 나쁘게 `30`(px)으로 읽힌다. **둘 다 에러 없이 화면만 틀어진다.**
848
+
849
+ - **내용이 들어가는 열은 전부 폭을 명시한다.** 생략하면 기본 120px 이 조용히 들어가고, "짧은 열이라 그대로 둔 것"과 "판단을 빠뜨린 것"이 구분되지 않는다. 120px 이 맞더라도 `width: 120` 을 적는다.
850
+ - **`autoWidth` 는 남은 폭을 흡수하는 스페이서 열 하나에만 쓴다.** 내용이 들어가는 열에는 쓰지 않는다 — 폭이 다른 열에 좌우돼 화면마다 달라진다. 스페이서 열은 값을 그리지 않으므로 `field` 도 생략한다.
851
+
852
+ ```tsx
853
+ { name: 'spacer', label: '', autoWidth: true },
854
+ ```
794
855
 
795
- 컬럼 폭은 `width` 로 **고정**되고, `<td>` 는 그 폭을 넘는 내용을 잘라낸다(`overflow: hidden`). 텍스트라면 말줄임으로 끝나지만, 셀에 `STag` · `SButton` · `SGhostButton` · `SSelect` · `SInput` · `SNumberInput` 처럼 **자기 폭을 가진 요소**를 넣으면 요소 자체가 잘려 **누르거나 읽거나 입력할 수 없게 된다.** `width` 를 생략해도 내용에 맞춰 늘어나지 않고 `STable` 의 기본 폭이 될 뿐이므로, 컨트롤이 들어가는 컬럼은 폭을 직접 판단해서 준다.
856
+ - **`minWidth` · `maxWidth` `resizable` 손잡이의 이동 범위일 뿐, 레이아웃에는 관여하지 않는다.** 폭을 주지 않은 열이 안에서 잡히는 것이 아니다.
857
+ - **고정폭 합이 최소 창 폭을 넘으면 가로 스크롤이 된다.** `STable` 이 자기 안에서 가로로 스크롤하고 헤더·바디가 함께 움직이므로 별도 조치는 필요 없다 — 폭을 줄여 맞추지 말고, 열이 정말 그만큼 필요한지를 본다.
858
+
859
+ ##### 고정폭을 어떻게 정하는가
860
+
861
+ ```text
862
+ 폭 = ceil( ( max(값 폭 + 값 기준 패딩, 헤더 폭 + 32) + 여유 ) / 8 ) × 8
863
+ ```
864
+
865
+ - **값과 헤더를 따로 계산해 큰 쪽을 쓴다.** `contentType: 'control'` 은 `<td>` 에만 적용되고 `<th>` 는 항상 텍스트 패딩이라, 짧은 컨트롤 + 긴 헤더 조합에서 헤더가 잘린다.
866
+ - 값에 따라 폭이 달라지는 요소(`STag` 라벨, 라벨 있는 버튼)는 **가장 긴 값** 기준으로 잡는다. `판매중` 에 맞춰두면 `판매중지 요청` 에서 잘린다.
867
+ - **정렬 가능한 헤더(`sortable`)는 아이콘 버튼 + 간격만큼 `+20px` 더 든다.** `helpText` 를 함께 달면 그만큼 또 더한다.
868
+ - **계산값은 픽셀 단위까지 맞추면 어긋난다** — 서브픽셀 반올림 때문이다. 여유 8px 을 얹고 8 단위로 올림한다.
869
+ - 좌우 패딩은 `STable` 이 토큰으로 넣으므로 직접 주지 않는다. 그만큼을 뺀 나머지가 요소 몫이라는 점만 계산에 넣는다.
870
+
871
+ #### 컨트롤이 들어가는 컬럼
872
+
873
+ `<td>` 는 폭을 넘는 내용을 잘라낸다(`overflow: hidden`). 텍스트라면 말줄임으로 끝나지만, 셀에 `STag` · `SButton` · `SGhostButton` · `SSelect` · `SInput` · `SNumberInput` 처럼 **자기 폭을 가진 요소**를 넣으면 요소 자체가 잘려 **누르거나 읽거나 입력할 수 없게 된다.**
796
874
 
797
875
  - **컨트롤이 들어가는 컬럼에는 `contentType: 'control'` 을 함께 준다.** 좌우 패딩이 텍스트용(넓게)에서 컨트롤용(좁게)으로 바뀌어, 같은 컬럼 폭에서도 요소가 쓸 폭이 넓어진다. 기본값은 `text` 다.
798
- - 기준은 **요소가 온전히 보이는 폭 + 셀 좌우 패딩**이다. 좌우 패딩은 `STable` 이 토큰으로 넣으므로(직접 주지 않는다) 그만큼을 뺀 나머지가 요소 몫이라는 점을 계산에 넣는다.
799
876
  - 요소가 둘 이상이면 요소 폭의 합에 **`gap` 까지** 더한다 (행 내부 인라인 액션 간격은 `gap-sd-4` 고정, §3-5-5).
800
- - 값에 따라 폭이 달라지는 요소(`STag` 라벨, 라벨 있는 버튼)는 **가장 긴 값** 기준으로 잡는다. `판매중` 에 맞춰두면 `판매중지 요청` 에서 잘린다.
801
877
  - `SSelect` · `SInput` 처럼 셀 폭을 채우는 컨트롤은 **컬럼 폭이 곧 컨트롤 폭**이다. 실제 선택값·입력값이 말줄임 없이 읽히는 폭인지 확인한다.
802
878
  - 폭을 넉넉히 줄 수 없는 자리는 폭을 줄이는 게 아니라 **요소를 바꾼다** — 라벨 버튼 대신 아이콘만 있는 `SGhostButton`, `size="xs"` (§3-5-2, §3-5-5).
803
- - **`autoWidth` 는 해법이 아니다.** 내용에 맞춰 늘어나는 게 아니라 고정폭 컬럼들이 가져가고 **남은 폭을 나눠 갖는 것**이라, 테이블이 좁으면 역시 잘린다. 컨트롤 컬럼은 `width` 로 직접 확보한다.
804
879
 
805
880
  ```tsx
806
- { name: 'normal', label: '정상', field: 'normal', width: '96px',
881
+ { name: 'normal', label: '정상', field: 'normal', width: 96,
807
882
  align: 'center', contentType: 'control', render: row => <SNumberInput … /> },
808
883
  ```
809
884
 
@@ -814,13 +889,13 @@ const columns: STableColumn[] = [
814
889
  ```tsx
815
890
  const columns: STableColumn[] = [
816
891
  // 태그 — 가장 긴 라벨 기준
817
- { name: 'status', label: '상태', field: 'status', width: '120px', minWidth: 120, align: 'center',
892
+ { name: 'status', label: '상태', field: 'status', width: 120, minWidth: 120, align: 'center',
818
893
  render: (row: SRow) => <STag size="sm" color="green" label={row.statusLabel} /> },
819
894
  // 셀 안 입력 — 컬럼 폭이 곧 입력 폭
820
- { name: 'qty', label: '수량', field: 'qty', width: '100px', minWidth: 100, align: 'right',
895
+ { name: 'qty', label: '수량', field: 'qty', width: 100, minWidth: 100, align: 'right',
821
896
  render: (row: SRow) => <SNumberInput value={row.qty} onValueChange={v => setQty(row, v)} /> },
822
897
  // 인라인 액션 둘 — 폭 = xs 버튼 2개 + gap-sd-4 + 셀 좌우 패딩
823
- { name: 'actions', label: '', field: 'id', width: '84px', minWidth: 84, align: 'center',
898
+ { name: 'actions', label: '', field: 'id', width: 84, minWidth: 84, align: 'center',
824
899
  render: (row: SRow) => (
825
900
  <div className="flex items-center justify-center gap-sd-4">
826
901
  <SGhostButton size="xs" intent="action" icon="edit" ariaLabel="수정" onClick={() => editRow(row)} />
@@ -828,11 +903,34 @@ const columns: STableColumn[] = [
828
903
  </div>
829
904
  ) },
830
905
 
831
- // ❌ 컨트롤 컬럼에 width 생략 — 기본 폭에 맡기면 버튼이 잘린다
906
+ // ❌ 컨트롤 컬럼에 width 생략 — 기본 폭(120px)에 맡기면 버튼이 잘린다
832
907
  { name: 'move', label: '', field: 'id', render: () => <SButton label="재고 이동" size="xs" /> },
833
908
  ];
834
909
  ```
835
910
 
911
+ #### 정렬 가능한 컬럼
912
+
913
+ **정렬 상태는 `STable` 이 갖지 않는다.** 컬럼에 `sortable: true` 를 주고, 페이지가 `sort` · `onSortChange` 로 상태를 들고 있는다.
914
+
915
+ ```tsx
916
+ const [sort, setSort] = useState<STableSort | null>({ name: 'orderedAt', dir: 'desc' });
917
+
918
+ <STable
919
+ columns={columns}
920
+ rows={rows}
921
+ sort={sort}
922
+ onSortChange={setSort}
923
+ />
924
+ ```
925
+
926
+ - **정렬은 조회 조건이다.** 서버 정렬이면 `?sort=createdAt&dir=desc` 가 곧 요청이고, 뒤로가기·새로고침·링크 공유로 복원돼야 한다. 컴포넌트가 사본을 들면 URL 과 화면이 어긋난다 — `SExpansionList` 의 선택을 앱이 드는 것과 같은 이유다 (§3-7-7).
927
+ - **행을 실제로 정렬하는 것도 페이지 몫이다.** `STable` 은 받은 순서대로 그린다.
928
+ - **동작** — 헤더 클릭 시 `asc → desc → 해제` 3단. 다른 열을 누르면 그 열의 `asc` 로 시작한다. 해제되면 `onSortChange(null)`.
929
+ - **아이콘** — 미정렬 `updown`, 오름 `arrowUp`, 내림 `arrowDown`. 정렬 중인 열만 `action` 색으로 올라온다. `SGhostButton size="xxs"` 로 그려지므로 직접 만들지 않는다.
930
+ - **클릭 영역은 정렬 버튼뿐이다.** 헤더 셀 전체를 누르게 하지 않는다 — 라벨을 드래그해 고르거나 `helpText` 아이콘에 hover 하는 것과 뒤섞인다.
931
+ - **다중 정렬은 지원하지 않는다.** 한 번에 한 열이다.
932
+ - `renderHeader` 로 헤더를 통째로 교체하면 정렬 아이콘도 클릭도 그리지 않는다 — 헤더 전체가 소비 앱 책임이 된다.
933
+
836
934
  #### 값이 없는 셀은 회색 하이픈
837
935
 
838
936
  셀을 **빈칸으로 두지 않는다.** 값이 `null` · `undefined` · 빈 문자열이면 `-` 를 `text-fg-tertiary`(`grey_65`)로 표시한다.
@@ -844,9 +942,9 @@ const emptyCell = <span className="text-fg-tertiary">-</span>;
844
942
  const hasValue = (v: unknown) => v !== null && v !== undefined && v !== '';
845
943
 
846
944
  const columns: STableColumn[] = [
847
- { name: 'memo', label: '메모', field: 'memo',
945
+ { name: 'memo', label: '메모', field: 'memo', width: 240,
848
946
  render: (row: SRow) => (hasValue(row.memo) ? row.memo : emptyCell) },
849
- { name: 'price', label: '판매가', field: 'price', width: '120px', align: 'right',
947
+ { name: 'price', label: '판매가', field: 'price', width: 120, align: 'right',
850
948
  render: (row: SRow) =>
851
949
  hasValue(row.price) ? `${Number(row.price).toLocaleString()}원` : emptyCell },
852
950
  ];
@@ -858,12 +956,14 @@ const columns: STableColumn[] = [
858
956
 
859
957
  헤더 라벨은 컬럼 폭 안에 들어가야 해서 짧아진다. **산출 기준·단위·상태 값의 뜻처럼 라벨에 담기지 않는 설명은 `column.helpText` 로 준다** — 라벨 뒤에 도움말 아이콘이 붙고 hover 하면 툴팁이 뜬다. 배열의 각 항목이 한 줄이다. `SKeyValueTable` 의 `field.helpText`, `SSectionHeaderCard` 의 `helpText` 와 같은 것이다.
860
958
 
959
+ 판단 기준 한 줄: *컬럼 제목이 줄임말·사내 용어·계산식이거나, 값이 아니라 열 자체의 설명이 필요할 때 헤더에 단다.*
960
+
861
961
  ```tsx
862
962
  const columns: STableColumn[] = [
863
- { name: 'orderCount', label: '주문 수', field: 'orderCount', width: '120px', align: 'right',
963
+ { name: 'orderCount', label: '주문 수', field: 'orderCount', width: 120, align: 'right',
864
964
  helpText: ['취소·반품을 제외한 확정 주문 수입니다.'],
865
965
  format: (v: number) => `${Number(v).toLocaleString()}건` },
866
- { name: 'status', label: '상태', field: 'status', width: '100px', align: 'center',
966
+ { name: 'status', label: '상태', field: 'status', width: 100, align: 'center',
867
967
  helpText: ['활성: 최근 30일 내 주문 있음', '보관됨: 거래 종료'] },
868
968
  ];
869
969
  ```
@@ -871,6 +971,7 @@ const columns: STableColumn[] = [
871
971
  - **`renderHeader` 로 헤더를 직접 만들어 `STooltip` 을 붙이지 않는다.** `renderHeader` 는 헤더 전체를 교체하므로 `helpText` 가 무시되고, 아이콘·크기·색·간격을 손으로 맞추게 된다.
872
972
  - **모든 컬럼에 달지 않는다.** 라벨로 뜻이 통하는 컬럼(`주문번호`·`상품명`)까지 붙이면 헤더가 아이콘으로 뒤덮여 정작 설명이 필요한 컬럼이 묻힌다.
873
973
  - 긴 문장을 넣는 자리가 아니다. 한 줄에 한 가지 사실만 담고, 그 이상은 페이지 상단 안내(`SCallout`)로 뺀다.
974
+ - 아이콘도 폭을 먹는다 — 헤더 폭 계산에 넣는다.
874
975
 
875
976
  ### 3-5. 버튼류
876
977
 
@@ -895,6 +996,19 @@ const columns: STableColumn[] = [
895
996
 
896
997
  `SDropdownButton` 도 같은 규칙을 따르며, **페이지당 `primary` 채움 1개 계산에 포함**된다.
897
998
 
999
+ ##### 무엇이 어느 위계인가
1000
+
1001
+ 개수만으로는 후보가 여럿일 때 어느 것을 올릴지 갈리지 않는다. 기준은 **그 조작이 무엇에 미치는가**다.
1002
+
1003
+ | 위계 | 무엇에 쓰나 |
1004
+ | --- | --- |
1005
+ | `primary` 채움 | **페이지 전체에 해당하는 데이터를 확정**하는 실행 (폼 저장, 상세 수정 확정, 일괄 반영) |
1006
+ | `secondary` 채움 | **페이지 안 중심 데이터에 대한 처리** (선택 항목 상태 변경, 발송, 승인) |
1007
+ | `outline` (`neutral` · `primary`) | 단순 등록, 설정 변경, 이동·취소 |
1008
+
1009
+ - **`primary` 채움은 페이지 전체를 대표하는 실행 하나에만 쓴다. 그런 조작이 없으면 페이지에 `primary` 가 없어도 된다.** 개수 제한이 "반드시 하나 있어야 한다"는 뜻은 아니다.
1010
+ - **`secondary` 연속 배치 금지는 섹션이 다르면 적용되지 않는다.** 섹션마다 독립 인라인 폼이 있는 상세 페이지(§4-4)가 그렇다 — 나란히 놓인 두 버튼이 같은 판단 단위 안에 있을 때의 규칙이다.
1011
+
898
1012
  #### 3-5-2. `size` 는 놓이는 위치가 정한다
899
1013
 
900
1014
  | 위치 | size |
@@ -1123,10 +1237,10 @@ const columns: STableColumn[] = [
1123
1237
  | **순서 자체가 데이터**라 사용자가 끌어서 바꾼다 | `SDraggableList` + `SDraggableItem` |
1124
1238
 
1125
1239
  - **항목 하나의 속성을 나열하는 것은 목록이 아니다.** `라벨: 값` 이 세로로 쌓이는 것은 `SKeyValueTable` 이다 (§4-4).
1126
- - `SList` 는 레이아웃만 담당한다. 펼침·단일 선택 동작이 필요하면 `SExpansionList` 다 (§3-7-7).
1127
- - **`SList` 의 자식은 `SListItem` 을 권장한다.** 다른 자식도 그대로 렌더되지만, 펼치는 항목은 `SExpansionList` + `SExpansionItem` 이, 끌어서 순서를 바꾸는 항목은 `SDraggableList` + `SDraggableItem` 이 여닫힘·선택·정렬 동작까지 함께 관리하므로 그쪽을 쓴다 (§3-7-7).
1240
+ - `SList` 는 레이아웃만 담당한다. depth 단일 펼침이 필요하면 `SExpansionList` 다 (§3-7-7).
1241
+ - **`SList` 의 자식은 `SListItem` 을 권장한다.** 다른 자식도 그대로 렌더되지만, 펼치는 항목은 `SExpansionList` + `SExpansionItem` 이, 끌어서 순서를 바꾸는 항목은 `SDraggableList` + `SDraggableItem` 이 여닫힘·정렬 동작까지 함께 관리하므로 그쪽을 쓴다 (§3-7-7).
1128
1242
  - **항목 사이 구분선은 리스트가 알아서 그린다.** `SListItem`·`SExpansionItem`·`SDraggableItem` 셋 다 스스로 구분선을 그리지 않는다. `SList`·`SExpansionList`·`SDraggableList` 가 자식 **사이에** 구분선을 넣으므로 아이템에 `border-b` 를 직접 붙이지 않고, 켜는 prop 도 따로 없다. 마지막 항목 아래에는 선이 남지 않는다. 테두리형(`bordered`)은 테두리가 구분 역할을 하므로 리스트가 구분선을 빼고, `useGap` 으로 띄운다 — `useGap` 을 준 목록에도 구분선은 들어가지 않는다.
1129
- - **`SListItem` 은 기본이 표시 전용이다.** 눌러서 이동·선택하게 하려면 `clickable` 을 준다 — hover·`selected`·`interaction="chevron"` 표현이 전부 여기에 딸려 있어서, `clickable` 없이 `selected` 만 주면 선택 표시가 나오지 않는다. `SExpansionList` 는 선택을 자기가 관리하므로 자식 아이템을 알아서 클릭 가능하게 만든다.
1243
+ - **`SListItem` 은 기본이 표시 전용이다.** 눌러서 이동·선택하게 하려면 `clickable` 을 준다 — hover·`selected`·`interaction="chevron"` 표현이 전부 여기에 딸려 있어서, `clickable` 없이 `selected` 만 주면 선택 표시가 나오지 않는다. `SExpansionList` 는 자식 아이템을 알아서 클릭 가능하게 만들어 이 함정을 막아 준다 — 선택 상태 자체는 앱이 든다 (§3-7-7).
1130
1244
 
1131
1245
  ```tsx
1132
1246
  ✅ <SList><SListItem title="일반 문의" />…</SList> {/* 줄로 구분되는 목록 — 구분선은 자동 */}
@@ -1145,7 +1259,30 @@ const columns: STableColumn[] = [
1145
1259
  | **한 번에 하나만** 열려야 한다 (아코디언) | `SExpansionList` + `SExpansionItem` |
1146
1260
  | **부모-자식 계층 자체**를 보여줘야 한다 (2단 이상, 연결선) | `STree` |
1147
1261
 
1148
- `SExpansionList` 는 depth 별 단일 확장·선택을 관리하는 wrapper 다. 직접 `useState` 로 "열린 항목 하나"를 들고 있지 않는다. 항목 사이 구분선은 여기서도 리스트가 알아서 그린다 — 따로 줄 prop 이 없다 (§3-7-6).
1262
+ `SExpansionList` 는 depth 별 **단일 확장**을 관리하는 wrapper 다. 직접 `useState` 로 "열린 항목 하나"를 들고 있지 않는다. 항목 사이 구분선은 여기서도 리스트가 알아서 그린다 — 따로 줄 prop 이 없다 (§3-7-6).
1263
+
1264
+ ##### 펼침 ≠ 선택
1265
+
1266
+ **펼침은 리스트가 관리하고, 선택은 앱이 관리한다.**
1267
+
1268
+ 펼침은 화면 밖에 진실이 없는 순간 UI 상태다. 각 항목이 자기 `expanded` 를 들고 있으면 "하나만 열림"을 만들 수 없어 누군가 나머지를 닫아야 하고, 그것이 이 wrapper 다.
1269
+
1270
+ 선택은 다르다. 앱이 `selectedId` 스칼라 하나를 들면 상호배제가 구조적으로 보장되고, 그 값은 URL·store 로 복원돼야 한다. 리스트가 사본을 들면 그 순간 진실이 둘이 되어 어긋난다.
1271
+
1272
+ ```tsx
1273
+ const [selectedId, setSelectedId] = useState<string>();
1274
+
1275
+ <SExpansionList>
1276
+ {/* 하위가 없는 항목(전체·미분류)은 SExpansionItem 이 아니라 SListItem 이다 */}
1277
+ <SListItem title="전체" selected={selectedId === 'all'} onClick={() => setSelectedId('all')} />
1278
+ <SExpansionItem title="조직">
1279
+ <SListItem title="영업팀" selected={selectedId === 'sales'} onClick={() => setSelectedId('sales')} />
1280
+ </SExpansionItem>
1281
+ </SExpansionList>
1282
+ ```
1283
+
1284
+ - `clickable` 은 리스트가 자식 `SListItem` 에 기본으로 켜 준다 — `clickable` 없이 `selected` 만 주면 표시가 안 나오는 함정(§3-7-6)을 막는 값이다.
1285
+ - **하위를 가지지 않는 항목은 `SExpansionItem` 이 아니라 `SListItem`** 으로 둔다. 펼칠 것이 없는데 펼침 항목으로 만들면 화살표만 남는다.
1149
1286
 
1150
1287
  #### 3-7-8. SCard vs SSectionHeaderCard
1151
1288
 
@@ -1240,8 +1377,8 @@ export default function AppShell({
1240
1377
  {/* type/header/folded 는 SLayout 에만 준다 — SGnb 는 context 에서 읽는다 */}
1241
1378
  <SGnb items={MENU} value={current} onValueChange={navigate} logo={<Logo />} />
1242
1379
  {/* 콘텐츠 패딩은 SPage 가 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다 */}
1243
- {/* 스크롤 여백도 SPage 넣는다. 끄는 페이지네이션 있는 목록뿐이라 페이지가 정한다 */}
1244
- {/* 본문이 남은 높이를 채우는 목록 페이지는 contentHeight="fill" 넘긴다 (§4-2) */}
1380
+ {/* 높이 모드는 페이지가 정한다 대부분 contentHeight="fill" 이다 (§2-2) */}
1381
+ {/* 스크롤 여백도 SPage 넣는다. 페이지가 실제로 스크롤되는 화면에서만 켠다 */}
1245
1382
  {/* header 는 페이지마다 달라 AppShell 이 그대로 받아 넘긴다 — 페이지 제목은 여기서 만들지 않는다 */}
1246
1383
  <SPage
1247
1384
  background="frame"
@@ -1290,7 +1427,9 @@ import { SModalOutlet } from 'sellmate-design-system-react';
1290
1427
 
1291
1428
  **최소 너비는 `SLayout` 이 보장한다 — 앱이 `min-w-*` 를 직접 주지 않는다.** 창이 최소 너비(`SLAYOUT_MIN_WIDTH`, GNB 포함한 전체 기준)보다 좁아지면 GNB·상단바는 제자리에 남고 **`SPage` 안에만 가로 스크롤이 생긴다.** 문서(브라우저 창)에는 가로 스크롤이 생기지 않는다. 그래서 셸을 감싸는 요소에 `min-width` 나 `overflow-x` 를 걸지 않는다 — 걸면 창 전체가 스크롤되어 GNB 가 화면 밖으로 밀린다.
1292
1429
 
1293
- **셸의 `SPage` 는 모든 페이지가 공유하므로, 페이지마다 달라지는 것은 페이지가 셸에 알려야 한다.** 위처럼 프레임 컴포넌트가 `scrollEndSpacing` · `contentHeight` 받아 그대로 넘기고, 페이지네이션이 있는 목록 페이지만 `contentHeight="fill"` 을 준다 (§4-2). 나머지 페이지는 넘기지 않으면 기본값(`auto`, 스크롤 끝 여백 켬)이 적용된다.
1430
+ **셸의 `SPage` 는 모든 페이지가 공유하므로, 페이지마다 달라지는 것은 페이지가 셸에 알려야 한다.** 위처럼 프레임 컴포넌트가 `contentHeight` · `scrollEndSpacing` 받아 그대로 넘긴다.
1431
+
1432
+ **대부분의 페이지는 `contentHeight="fill"` 이다** — 본문이 창을 꽉 채우고 스크롤은 각 영역 안에서 일어나는 것이 표준이다(§2-2). 블록의 높이가 정해져 있고 그 높이가 창보다 커서 페이지 자체가 스크롤돼야 하는 화면에서만 `contentHeight="auto"`(기본값) + `scrollEndSpacing` 을 켠다.
1294
1433
 
1295
1434
  **상단바 배치는 `header` 가 정한다.** 요소 순서가 달라지므로 슬롯을 채우기 전에 어느 쪽인지부터 정한다.
1296
1435
 
@@ -1348,9 +1487,10 @@ import { SModalOutlet } from 'sellmate-design-system-react';
1348
1487
  - **페이지 제목 줄에는 이 페이지의 주요 액션을 두지 않는다.** 부가적인 것만 `header.slot` 에 `SButton size="sm"` 으로 온다 (§4-1 "페이지 헤더 사용 규칙").
1349
1488
  - **주요 액션(등록 등)은 `STableBar` 의 `rightActions`** 에 둔다.
1350
1489
  - **선택 상태 액션(선택 삭제 등)은 `STableBar` 의 `actions`** 에 둔다. `actions` 슬롯은 건수 요약이 있으면 앞에 구분선(`SDivider`)을 **자동으로** 넣으므로 직접 구분선을 만들지 않는다.
1351
- - **페이지네이션이 있으면 본문이 남은 높이를 채우게 한다** — `AppShell` 에 `contentHeight="fill"` 을 넘긴다. 페이지가 통째로 스크롤되면 페이지네이션이 화면 밖으로 밀려 "여기서 끝"이 읽히지 않는다. `fill` 이면 **표만 자기 안에서 스크롤하고 페이지네이션은 하단에 고정**된다.
1490
+ - **본문이 남은 높이를 채우게 한다** — `AppShell` 에 `contentHeight="fill"` 을 넘긴다(§2-2 표준). 페이지가 통째로 스크롤되면 페이지네이션이 화면 밖으로 밀려 "여기서 끝"이 읽히지 않는다. `fill` 이면 **표만 자기 안에서 스크롤하고 페이지네이션은 하단에 고정**된다.
1352
1491
  - 본문 루트에 `h-full min-h-0` 으로 세로 축을 잇고, 남은 높이를 먹을 `STable` 에 `min-h-0 flex-1` 을 준다. 이 사슬이 하나라도 끊기면 표가 높이를 못 잡는다.
1353
1492
  - `fill` 에서는 페이지가 스크롤하지 않으므로 **`scrollEndSpacing` 은 무시된다** — 따로 끄지 않는다 (§2-2).
1493
+ - **정렬 가능한 컬럼은 `sortable` 로 준다.** 정렬 상태(`sort`)는 이 페이지가 들고 `onSortChange` 로 받는다 — 조회 조건이라 URL 에 실려야 한다 (§3-4).
1354
1494
 
1355
1495
  ```tsx
1356
1496
  import {
@@ -1451,6 +1591,19 @@ export default function ProductListPage() {
1451
1591
  - 필드를 `div` 로 나열하지 않고 **`SKeyValueTable` 의 행으로 구성**한다.
1452
1592
  - 검증 규칙은 각 field 의 `options.rules` 로 넘긴다. `SForm` 이 하위 컨트롤을 자동 수집해 submit 시 일괄 검증하고, 실패 시 첫 실패 필드로 포커스를 옮긴다.
1453
1593
  - **버튼 순서: 취소·닫기가 왼쪽, 저장·등록·수정·삭제가 오른쪽.** 이 순서는 모든 화면에서 동일하다.
1594
+ - **폼 페이지도 `contentHeight="fill"` 이 기본이다**(§2-2). 폼이 길어 페이지가 실제로 스크롤되는 화면에서만 `auto` + `scrollEndSpacing` 을 켠다.
1595
+ - **필드 폭은 등급으로 준다** — `width="md"` 처럼 `'xs' | 'sm' | 'md' | 'lg' | 'xl'` 중 하나다. px 를 직접 적지 않는다. 등급은 `maxLength`(= 스키마 상한)로 고르고, 상한이 `xl` 을 넘거나 상한이 없으면 `width="100%"` 로 행 전체를 쓴다 (§6 `field-width-grade`).
1596
+
1597
+ **`SKeyValueTable` 의 전체 열 수는 가장 긴 행이 정한다.** 어떤 행이 그보다 짧으면 남는 자리에 셀이 없어 그 구간의 행 구분선이 끊긴다. 마지막 필드에 `tdColSpan` 을 주어 채운다.
1598
+
1599
+ ```tsx
1600
+ [
1601
+ [{ name: 'category', … }, { name: 'price', … }], // 필드 2개 → 4칸
1602
+ [{ name: 'memo', …, tdColSpan: 3 }], // th(1) + td(3) = 4칸
1603
+ ]
1604
+ ```
1605
+
1606
+ **한 행에 필드를 추가하면 다른 행들의 `tdColSpan` 도 함께 봐야 한다.** 전체 열 수가 늘면 나머지 행들이 조용히 짧아진다 — 화면에서만 드러나는 컴포넌트 고유 동작이라 자동으로 채워 주지 않는다.
1454
1607
 
1455
1608
  ```tsx
1456
1609
  import {
@@ -1516,6 +1669,8 @@ export default function ProductCreatePage() {
1516
1669
  - 행이 많아지면 **유형별로 섹션을 나누고, 각 섹션을 `SSectionHeaderCard` 로 감싼다.**
1517
1670
  섹션 제목은 `title` prop 으로, 바디 여백은 `padding` prop 으로 준다.
1518
1671
  - **수정·삭제 버튼은 하단에 둔다.** 내용이 짧아 우측 상단에 두는 변형도 있으나 기본은 하단이다.
1672
+ - **상세 페이지도 `contentHeight="fill"` 이 기본이다**(§2-2). 섹션이 많아 페이지가 실제로 스크롤되는 화면에서만 `auto` + `scrollEndSpacing` 을 켠다.
1673
+ - **섹션마다 독립 인라인 폼이 있는 형태**도 상세 페이지의 변형이다. 섹션 안에서 바로 수정·저장하게 하는 화면인데, 이때 버튼 강조는 **섹션 단위가 아니라 페이지 단위로 판단한다** — §3-5-1 의 "`secondary` 연속 배치 금지"는 섹션이 다르면 적용되지 않는다.
1519
1674
 
1520
1675
  ```tsx
1521
1676
  import {
@@ -1593,6 +1748,21 @@ export default function ProductDetailPage() {
1593
1748
  | --- | --- |
1594
1749
  | `padding` | 안쪽 여백 — `'default'`(기본) / `'wide'` / `'none'`. 판정은 §2-2 "섹션·패널 안쪽 여백". `p-sd-*` 를 직접 주지 않는다 |
1595
1750
 
1751
+ **한 페이지에 섹션 카드가 둘 이상이면 `marker` 를 켠다.** 점은 섹션을 서로 구분할 대상이 여럿일 때만 의미가 있어, 카드가 하나뿐인 페이지에서는 켜지 않는다. 한 페이지 안에서는 켜거나 끄거나 전부 같게 간다.
1752
+
1753
+ **섹션 본문이 자기 안에서 스크롤해야 하면 루트 `className` 으로 마지막 자식에 세로 축을 잇는다.**
1754
+
1755
+ ```tsx
1756
+ <SSectionHeaderCard
1757
+ title="…"
1758
+ className="[&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
1759
+ >
1760
+ <STable className="min-h-0 flex-1" … />
1761
+ </SSectionHeaderCard>
1762
+ ```
1763
+
1764
+ 본문 래퍼는 `className` 을 받지 않으므로(여백은 `padding` prop 으로만 받는다) 루트에서 내려 준다. 흔한 구성은 아니다 — 대부분은 `STable` 이 자기 안에서 스크롤하므로 여기까지 갈 일이 없다.
1765
+
1596
1766
  ---
1597
1767
 
1598
1768
  ## 5. 자가 점검 체크리스트
@@ -1608,7 +1778,10 @@ export default function ProductDetailPage() {
1608
1778
  - [ ] `SPage`·`SPopup` 의 기본 패딩을 `p-sd-*` 로 덮어쓰지 않았는가, 블록·섹션 **간격**이 `gap-sd-12` 인가 (`gap-sd-16`/`gap-sd-24` ❌ — 24 는 안쪽 여백에만 열린다)
1609
1779
  - [ ] 섹션·패널의 안쪽 **여백**이 §2-2 판정과 맞는가 (덩어리 두 종류 → 16 / 세 종류 이상 → 24, 서면 16)
1610
1780
  - [ ] `SSectionHeaderCard` 의 여백을 `p-sd-*` 가 아니라 `padding` prop 으로 줬는가
1611
- - [ ] 자체 스크롤하는 패널의 하단에 `pb-[var(--cmp-pageBody-padding-scrollEnd)]` 이 있는가, 페이지네이션 있는 목록에서 `contentHeight="fill"` 을 넘기고 `h-full min-h-0` `STable` `min-h-0 flex-1` 사슬을 이었는가
1781
+ - [ ] 페이지에 `contentHeight="fill"` 을 넘겼는가 (§2-2 표준 페이지 스크롤을 쓰는 화면에서만 `auto` + `scrollEndSpacing`)
1782
+ - [ ] `fill` 을 쓴 블록에서 **자식까지 `min-h-0 flex-1` 이 끊기지 않았는가** (한 군데만 끊겨도 자식이 높이를 못 잡는데 실패가 조용하다)
1783
+ - [ ] 자체 스크롤하는 패널의 하단에 `pb-[var(--cmp-pageBody-padding-scrollEnd)]` 이 있는가
1784
+ - [ ] 한 페이지에 섹션 카드가 둘 이상이면 `marker` 를 켰는가, 하나뿐이면 껐는가 (§4-5)
1612
1785
  - [ ] 같은 컴포넌트를 나열할 때 §2-2 그룹 간격을 썼는가 (체크박스 가로 `gap-sd-24` 등)
1613
1786
  - [ ] 페이지가 §4의 표준 골격에서 시작했는가
1614
1787
  - [ ] `header.fix` 가 프로젝트 전체와 같은 값인가 (다른 페이지와 다르게 섞어 쓰지 않았는가, §4-1)
@@ -1620,8 +1793,14 @@ export default function ProductDetailPage() {
1620
1793
  - [ ] 상태 표시에 `STag size="sm"` 을 썼는가
1621
1794
  - [ ] 테이블에서 양을 나타내는 컬럼(금액·수량·개수 등)이 전부 `align: 'right'` 인가
1622
1795
  - [ ] 번호·코드·전화번호·일자 컬럼에 `align: 'center'` 를 **명시**했는가 (생략하면 좌측이 된다)
1623
- - [ ] 컨트롤(`STag`·`SButton`·`SSelect`·`SInput` ) 들어가는 컬럼에 `width` 를 명시했는가, `resizable` 이면 `minWidth` 줬는가 (§3-4 — 폭이 모자라면 요소가 잘려 못 쓴다)
1796
+ - [ ] 닫힌 값 집합(enum·마스터 목록에서 고르는 값) 컬럼에 `align: 'center'` 를 줬는가 태그로 그렸든 텍스트로 그렸든 같다 (§3-4)
1797
+ - [ ] **모든 컬럼에 폭을 명시**했는가, px 로만 줬는가 (`%`·`clamp()` ❌), `autoWidth` 는 스페이서 열 하나뿐인가 (§3-4)
1798
+ - [ ] 컨트롤(`STag`·`SButton`·`SSelect`·`SInput` …)이 들어가는 컬럼이 `resizable` 이면 `minWidth` 도 줬는가 (§3-4 — 폭이 모자라면 요소가 잘려 못 쓴다)
1799
+ - [ ] 정렬 가능한 열에 `sortable` 을 줬는가 (`renderHeader` 로 직접 만들지 않았는가), 정렬 상태를 페이지가 들고 있는가 (§3-4)
1624
1800
  - [ ] 금액·수량 등 양을 나타내는 숫자에 빠짐없이 `toLocaleString()` 을 썼는가 (번호·코드는 제외)
1801
+ - [ ] 날짜를 `YYYY-MM-DD` 로 표기했는가 (`toLocaleDateString()` ❌, §1-4)
1802
+ - [ ] 필드 폭을 등급(`'xs' | 'sm' | 'md' | 'lg' | 'xl'`)으로 줬는가 — px 직접 지정 ❌ (§4-3)
1803
+ - [ ] `SKeyValueTable` 의 짧은 행에 `tdColSpan` 을 주어 전체 열 수를 채웠는가 (§4-3 — 안 채우면 그 구간의 행 구분선이 끊긴다)
1625
1804
  - [ ] 하단 버튼이 양끝 분리(`justify-between`)이고, 되돌리기가 왼쪽 끝 · 실행이 오른쪽 끝인가
1626
1805
  - [ ] 페이지에 `color="primary"` 채움 버튼이 **1개뿐**인가 (`danger` 채움도 1개, `SDropdownButton` 포함)
1627
1806
  - [ ] 버튼 `size` 가 위치 규칙과 맞는가 (행 내부 `xs` / 화면 액션 `sm` / 모달 푸터 `md`)
@@ -1658,6 +1837,8 @@ export default function ProductDetailPage() {
1658
1837
  | `sellmate/component-group-gap` | warn | §2-2 컴포넌트 그룹 간격 (체크박스 가로 24 / 세로 8 등) |
1659
1838
  | `sellmate/table-numeric-align` | warn | §3-4 숫자 컬럼의 `align: 'right'` 누락 (`--fix` 지원) |
1660
1839
  | `sellmate/require-locale-number` | warn | §1-4 금액·수량 등 수량 컬럼의 `toLocaleString()` 누락 |
1840
+ | `sellmate/field-width-grade` | warn | §4-3 필드 폭이 `maxLength` 상한과 맞는 등급인가, px 를 직접 적지 않았는가 (px → 등급 `--fix` 지원) |
1841
+ | `sellmate/table-column-width` | warn | §3-4 컬럼 폭 미지정(기본 120px)·px 아닌 값(`%`·`clamp()`)·`autoWidth` 오용 |
1661
1842
  | `sellmate/no-arbitrary-class` | off | §1-2 토큰 있는 속성의 임의 값 (`text-[14px]`, `bg-[#eee]`) — 팀이 켤 때만 |
1662
1843
 
1663
1844
  `configs.strict` 를 쓰는 프로젝트는 전부 error 이고 간격 `sd-` 접두까지 강제된다.
package/README.md CHANGED
@@ -186,7 +186,8 @@ export default [
186
186
  | `sellmate/component-group-gap` | warn | 같은 컴포넌트를 나열할 때의 그룹 간격 — 배열 방향에 따라 값이 다르다(체크박스 가로 24 / 세로 8) |
187
187
  | `sellmate/table-numeric-align` | warn | 수량 컬럼(금액·수량 등)에 `align: 'right'` 누락 — **`--fix` 로 자동 교정** |
188
188
  | `sellmate/require-locale-number` | warn | 수량 컬럼의 `toLocaleString()` 누락 — 세 자리 콤마 |
189
- | `sellmate/field-width-grade` | warn | 필드 폭이 `maxLength`(스키마 상한)와 어긋남 — 등급 미지정 · 등급 밖 폭 · 상한 대비 과부족 |
189
+ | `sellmate/field-width-grade` | warn | 필드 폭이 `maxLength`(스키마 상한)와 어긋남 — 등급 미지정 · 등급 밖 폭 · 상한 대비 과부족 · 등급 px 직접 지정(**`--fix` 로 등급 이름 치환**) |
190
+ | `sellmate/table-column-width` | warn | 컬럼 폭 미지정(기본 120px 이 조용히 들어감) · px 아닌 폭(`%` `clamp()`) · 값을 그리는 열의 `autoWidth` 오용 |
190
191
  | `sellmate/no-arbitrary-class` | **off** | 토큰이 있는 속성(색·타이포·간격·모서리)의 임의 값 — `text-[14px]`, `bg-[#eee]`. 앱 고유 화면에는 정당한 사용이 많아 기본값은 끕니다 |
191
192
 
192
193
  `className` 뿐 아니라 `cn()`/`clsx()` 인자, 템플릿 리터럴, 객체 키 안까지 검사합니다.
@@ -243,23 +244,29 @@ export default [
243
244
  필드 너비는 **`maxLength`(= 스키마 상한)로 정합니다.** 상한이 등급 안에 들어오면 그 등급으로 고정하고,
244
245
  등급 상한(`xl`)을 넘거나 상한이 아예 없으면 행 전체(`width="100%"` 또는 생략)로 둡니다.
245
246
 
246
- | 등급 | 폭 | |
247
- | ---- | ----- | ------------------------ |
248
- | `xs` | 80px | 숫자 필드 전용 |
249
- | `sm` | 120px | |
250
- | `md` | 160px | |
251
- | `lg` | 240px | |
252
- | `xl` | 480px | 정책상 상한 |
247
+ **폭은 등급 이름으로 줍니다** — `width="md"` 처럼 씁니다. 등급은 `--cmp-field-width-*` 토큰으로
248
+ 풀리므로 토큰이 바뀌면 화면이 따라갑니다. 같은 값을 px 로 적으면 그 화면만 옛 값에 남습니다.
249
+
250
+ | 등급 | 쓰는 |
251
+ | ---- | ------------- |
252
+ | `xs` | 숫자 필드 전용 |
253
+ | `sm` | |
254
+ | `md` | |
255
+ | `lg` | |
256
+ | `xl` | 정책상 상한 |
253
257
 
254
258
  ```tsx
255
259
  // 상한이 등급 안에 들어온다 → 그 등급으로 고정
256
- <SInput name="code" label="코드" maxLength={10} width={160} />
260
+ <SInput name="code" label="코드" maxLength={10} width="md" />
257
261
 
258
262
  // 상한이 xl 로도 안 담긴다 → 행 전체
259
263
  <SInput name="desc" label="설명" maxLength={100} width="100%" />
260
264
 
261
265
  // 숫자 필드의 상한은 maxLength 가 아니라 max 다 (천단위 콤마도 폭을 먹는다)
262
- <SNumberInput name="qty" label="수량" max={99} width={80} />
266
+ <SNumberInput name="qty" label="수량" max={99} width="xs" />
267
+
268
+ // 등급 값과 같은 px 를 적으면 등급 이름으로 자동 수정됩니다 (--fix)
269
+ <SInput name="code" label="코드" maxLength={10} width={160} /> // → width="md"
263
270
  ```
264
271
 
265
272
  대상은 `SInput` · `SNumberInput` · `SBarcodeInput` 입니다. `STextarea` 는 여러 줄로 접혀 상한이 폭을
@@ -291,15 +298,41 @@ export default [
291
298
  두 배가 되고(기본값), 있으면 버튼 두 개와 간격이 폭을 먹습니다. 규칙은 JSX 의 `useButton` 을 읽어
292
299
  둘을 구분합니다.
293
300
 
294
- 값이 기준과 다르면 옵션으로 덮습니다.
301
+ 등급 폭은 `--cmp-field-width-*` 토큰에서 읽으므로 규칙과 컴포넌트가 같은 값을 봅니다.
302
+ 글자수 환산 비율이 팀 기준과 다르면 옵션으로 덮습니다(등급 폭도 덮을 수 있지만, 토큰과 어긋나게 됩니다).
295
303
 
296
304
  ```js
297
305
  'sellmate/field-width-grade': ['warn', {
298
- grades: { sm: 120, md: 160, lg: 240, xl: 480 },
299
306
  charRatio: { narrow: 0.55, wide: 1 },
300
307
  }],
301
308
  ```
302
309
 
310
+ ### 컬럼 폭 (`table-column-width`)
311
+
312
+ `STable` 의 컬럼 폭은 `<colgroup>` 의 `<col width>` 로 들어가고 테이블이 `table-fixed` 라,
313
+ **px 이 아닌 값은 에러 없이 화면만 틀어집니다** — `'30%'` 는 `30`(px)으로 읽히고, `clamp()` 같은
314
+ 함수형 값은 폭이 통째로 무효가 된 뒤 auto 로 떨어집니다.
315
+
316
+ ```tsx
317
+ // 내용이 들어가는 열은 전부 폭을 명시한다 (생략하면 기본 120px 이 조용히 들어간다)
318
+ { name: 'name', label: '이름', field: 'name', width: 160 }
319
+ { name: 'code', label: '코드', field: 'code', width: '120px' }
320
+
321
+ // autoWidth 는 남은 폭을 흡수하는 스페이서 열 하나에만 (값을 그리지 않으므로 field 도 없다)
322
+ { name: 'spacer', label: '', autoWidth: true }
323
+
324
+ // ❌ 조용히 깨진다
325
+ { name: 'ratio', label: '비율', field: 'ratio', width: '30%' }
326
+ // ❌ 값을 그리는 열의 autoWidth — 폭이 다른 열에 좌우돼 화면마다 달라진다
327
+ { name: 'title', label: '상품명', field: 'title', autoWidth: true, render: r => r.title }
328
+ ```
329
+
330
+ 예외는 컬럼 `name` 을 `allow` 로 지정해 뺍니다.
331
+
332
+ ```js
333
+ 'sellmate/table-column-width': ['warn', { allow: ['spacer'] }],
334
+ ```
335
+
303
336
  ### 더 엄격하게 / 더 느슨하게
304
337
 
305
338
  `configs.strict` 는 전 규칙을 error 로 올리고, `<ul>` `<ol>` `<li>` `<svg>` `<label>` 까지 검사하며, 간격 유틸리티에 `sd-` 접두를 강제합니다(`requirePrefix`). 디자인 시스템 규칙을 처음부터 전면 적용하는 신규 프로젝트용입니다.
@@ -35,7 +35,7 @@
35
35
  | `hint?` | `string` | — | |
36
36
  | `error?` | `boolean` | — | |
37
37
  | `errorMessage?` | `string` | — | |
38
- | `width?` | `number \| string` | — | |
38
+ | `width?` | `SFieldWidth` | | 컨트롤 너비 폭 등급(`'xs' | 'sm' | 'md' | 'lg' | 'xl'`) · 숫자=px · CSS 길이. 등급은 `--cmp-field-width-*` 토큰으로 풀리며 `maxLength`(= 스키마 상한)로 고른다. 상한이 `xl` 을 넘거나 상한이 없으면 `"100%"`. (`sellmate/field-width-grade` 가 검사한다) |
39
39
  | `className?` | `string` | — | |
40
40
  | `style?` | `CSSProperties` | — | |
41
41