sellmate-design-system-react 9.0.0-beta.3 → 9.0.0-beta.30

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 (164) hide show
  1. package/AGENTS.md +599 -94
  2. package/README.md +111 -9
  3. package/dist/components/SBadge/README.md +24 -0
  4. package/dist/components/SBadge/SBadge.d.ts +1 -1
  5. package/dist/components/SBarcodeInput/README.md +9 -1
  6. package/dist/components/SBarcodeInput/SBarcodeInput.d.ts +7 -1
  7. package/dist/components/SButton/README.md +36 -0
  8. package/dist/components/SCalendar/README.md +13 -0
  9. package/dist/components/SCallout/README.md +15 -0
  10. package/dist/components/SCard/README.md +16 -0
  11. package/dist/components/SCard/SCard.d.ts +17 -2
  12. package/dist/components/SCheckbox/README.md +8 -0
  13. package/dist/components/SChipFilter/README.md +326 -13
  14. package/dist/components/SChipFilter/SChipFilter.d.ts +137 -58
  15. package/dist/components/SChipFilter/index.d.ts +1 -1
  16. package/dist/components/SChipInput/README.md +15 -1
  17. package/dist/components/SChipInput/SChipInput.d.ts +7 -1
  18. package/dist/components/SChipInput/index.d.ts +1 -1
  19. package/dist/components/SCircleProgress/README.md +8 -0
  20. package/dist/components/SConfirmModal/README.md +17 -1
  21. package/dist/components/SConfirmModal/SConfirmModal.d.ts +2 -2
  22. package/dist/components/SConfirmModal/index.d.ts +1 -1
  23. package/dist/components/SDatePicker/README.md +57 -2
  24. package/dist/components/SDatePicker/SDatePicker.d.ts +57 -4
  25. package/dist/components/SDatePicker/index.d.ts +1 -1
  26. package/dist/components/SDateRangePicker/README.md +17 -2
  27. package/dist/components/SDateRangePicker/SDateRangePicker.d.ts +16 -3
  28. package/dist/components/SDivider/README.md +5 -0
  29. package/dist/components/SDivider/SDivider.d.ts +11 -0
  30. package/dist/components/SDraggableItem/README.md +37 -0
  31. package/dist/components/SDraggableItem/SDraggableItem.d.ts +2 -0
  32. package/dist/components/SDraggableItem/index.d.ts +1 -1
  33. package/dist/components/SDraggableList/README.md +30 -0
  34. package/dist/components/SDraggableList/SDraggableList.d.ts +11 -0
  35. package/dist/components/SDraggableList/index.d.ts +1 -1
  36. package/dist/components/SDrawer/README.md +8 -0
  37. package/dist/components/SDropdownButton/README.md +19 -0
  38. package/dist/components/SEditor/EditorBody.d.ts +36 -0
  39. package/dist/components/SEditor/EditorToolbar.d.ts +93 -0
  40. package/dist/components/SEditor/README.md +203 -0
  41. package/dist/components/SEditor/SEditor.d.ts +114 -0
  42. package/dist/components/SEditor/editor-icons.d.ts +63 -0
  43. package/dist/components/SEditor/editor.config.d.ts +119 -0
  44. package/dist/components/SEditor/index.d.ts +8 -0
  45. package/dist/components/SEditor/tiptap-api.d.ts +29 -0
  46. package/dist/components/SEditor/use-is-mobile.d.ts +14 -0
  47. package/dist/components/SExpansionItem/README.md +36 -0
  48. package/dist/components/SExpansionItem/index.d.ts +1 -1
  49. package/dist/components/SField/README.md +27 -2
  50. package/dist/components/SField/SField.d.ts +22 -4
  51. package/dist/components/SFilePicker/README.md +15 -1
  52. package/dist/components/SFilePicker/SFilePicker.d.ts +7 -1
  53. package/dist/components/SFooter/README.md +25 -0
  54. package/dist/components/SFooter/SFooter.d.ts +4 -3
  55. package/dist/components/SForm/README.md +11 -0
  56. package/dist/components/SGhostButton/README.md +20 -4
  57. package/dist/components/SGnb/README.md +51 -0
  58. package/dist/components/SGnb/SGnb.d.ts +22 -0
  59. package/dist/components/SGnb/gnb.config.d.ts +7 -0
  60. package/dist/components/SGnb/index.d.ts +1 -1
  61. package/dist/components/SGnbSystem/README.md +121 -0
  62. package/dist/components/SGnbSystem/SGnbSystem.d.ts +93 -0
  63. package/dist/components/SGnbSystem/gnbSystem.config.d.ts +89 -0
  64. package/dist/components/SGnbSystem/index.d.ts +2 -0
  65. package/dist/components/SGuide/README.md +15 -0
  66. package/dist/components/SIcon/README.md +8 -0
  67. package/dist/components/SIcon/SIcon.d.ts +1 -1
  68. package/dist/components/SIcon/icons.gen.d.ts +2 -0
  69. package/dist/components/SImage/README.md +14 -0
  70. package/dist/components/SInput/README.md +3 -1
  71. package/dist/components/SInput/SInput.d.ts +7 -1
  72. package/dist/components/SKeyValueTable/README.md +87 -0
  73. package/dist/components/SKeyValueTable/SKeyValueTable.d.ts +14 -3
  74. package/dist/components/SKeyValueTable/index.d.ts +1 -1
  75. package/dist/components/SLayout/README.md +16 -0
  76. package/dist/components/SLayout/index.d.ts +1 -1
  77. package/dist/components/SLinearProgress/README.md +15 -0
  78. package/dist/components/SLinearProgress/SLinearProgress.d.ts +3 -0
  79. package/dist/components/SLinearProgress/index.d.ts +1 -1
  80. package/dist/components/SList/README.md +5 -1
  81. package/dist/components/SList/SList.d.ts +0 -2
  82. package/dist/components/SListItem/README.md +41 -0
  83. package/dist/components/SListItem/index.d.ts +1 -1
  84. package/dist/components/SLoadingModal/README.md +9 -1
  85. package/dist/components/SLoadingModal/SLoadingModal.d.ts +2 -2
  86. package/dist/components/SLoadingModal/index.d.ts +1 -1
  87. package/dist/components/SNumberInput/README.md +9 -1
  88. package/dist/components/SNumberInput/SNumberInput.d.ts +7 -1
  89. package/dist/components/SPage/README.md +41 -1
  90. package/dist/components/SPage/SPage.d.ts +24 -2
  91. package/dist/components/SPage/index.d.ts +1 -1
  92. package/dist/components/SPage/page.config.d.ts +8 -0
  93. package/dist/components/SPopover/README.md +15 -0
  94. package/dist/components/SPopup/README.md +19 -0
  95. package/dist/components/SPortal/README.md +14 -0
  96. package/dist/components/SRadio/README.md +20 -0
  97. package/dist/components/SRadioButton/README.md +18 -0
  98. package/dist/components/SRadioButton/SRadioButton.d.ts +1 -1
  99. package/dist/components/SRadioCard/README.md +78 -0
  100. package/dist/components/SRadioCard/SRadioCard.d.ts +29 -0
  101. package/dist/components/SRadioCard/SRadioCardGroup.d.ts +36 -0
  102. package/dist/components/SRadioCard/index.d.ts +2 -0
  103. package/dist/components/SScrollArea/README.md +14 -0
  104. package/dist/components/SSearchInput/README.md +63 -0
  105. package/dist/components/SSearchInput/SSearchInput.d.ts +52 -0
  106. package/dist/components/SSearchInput/index.d.ts +1 -0
  107. package/dist/components/SSectionHeaderCard/README.md +44 -20
  108. package/dist/components/SSectionHeaderCard/SSectionHeaderCard.d.ts +31 -14
  109. package/dist/components/SSectionHeaderCard/index.d.ts +1 -1
  110. package/dist/components/SSelect/README.md +24 -4
  111. package/dist/components/SSelect/SSelect.d.ts +14 -2
  112. package/dist/components/SSplitter/README.md +15 -0
  113. package/dist/components/SStepper/README.md +26 -0
  114. package/dist/components/SSwitch/README.md +13 -0
  115. package/dist/components/SSystemActionButton/README.md +134 -0
  116. package/dist/components/SSystemActionButton/SSystemActionButton.d.ts +79 -0
  117. package/dist/components/SSystemActionButton/index.d.ts +2 -0
  118. package/dist/components/SSystemActionButton/systemActionButton.config.d.ts +87 -0
  119. package/dist/components/STable/README.md +87 -1
  120. package/dist/components/STable/STable.d.ts +141 -11
  121. package/dist/components/STable/index.d.ts +1 -1
  122. package/dist/components/STabs/README.md +12 -1
  123. package/dist/components/STabs/STabs.d.ts +2 -4
  124. package/dist/components/STabs/index.d.ts +1 -1
  125. package/dist/components/STabs/tabs.config.d.ts +3 -4
  126. package/dist/components/STag/README.md +52 -0
  127. package/dist/components/STag/STag.d.ts +3 -1
  128. package/dist/components/STextLink/README.md +17 -0
  129. package/dist/components/STextLink/STextLink.d.ts +2 -0
  130. package/dist/components/STextarea/README.md +1 -1
  131. package/dist/components/STextarea/STextarea.d.ts +7 -1
  132. package/dist/components/STimePicker/README.md +15 -1
  133. package/dist/components/STimePicker/STimePicker.d.ts +7 -1
  134. package/dist/components/STimePicker/timepicker.config.d.ts +7 -0
  135. package/dist/components/STimeRangePicker/README.md +27 -1
  136. package/dist/components/STimeRangePicker/STimeRangePicker.d.ts +7 -1
  137. package/dist/components/SToast/README.md +24 -0
  138. package/dist/components/SToggle/README.md +8 -0
  139. package/dist/components/SToggle/index.d.ts +1 -1
  140. package/dist/components/STooltip/README.md +22 -0
  141. package/dist/components/STree/README.md +41 -0
  142. package/dist/components/STree/STree.d.ts +8 -0
  143. package/dist/components/STree/index.d.ts +1 -1
  144. package/dist/index.cjs +5483 -1150
  145. package/dist/index.cjs.map +1 -1
  146. package/dist/index.d.ts +6 -0
  147. package/dist/index.js +5458 -1149
  148. package/dist/index.js.map +1 -1
  149. package/dist/lib/field-width.d.ts +31 -0
  150. package/dist/lib/floating-height.d.ts +15 -0
  151. package/dist/lib/story-docs.d.ts +19 -3
  152. package/dist/lib/truncated-value-tooltip.d.ts +18 -0
  153. package/dist/llms-full.txt +2813 -193
  154. package/dist/llms.txt +604 -96
  155. package/dist/styles.css +1249 -135
  156. package/dist/theme.css +142 -16
  157. package/eslint/index.mjs +14 -0
  158. package/eslint/lib/table-column.mjs +26 -0
  159. package/eslint/rules/divider-vertical-height.mjs +137 -0
  160. package/eslint/rules/field-width-grade.d.mts +41 -0
  161. package/eslint/rules/field-width-grade.mjs +311 -0
  162. package/eslint/rules/table-column-width.mjs +110 -0
  163. package/eslint/scale.gen.mjs +5 -2
  164. package/package.json +11 -1
package/dist/llms.txt CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  > **대상**: 이 패키지로 화면을 만드는 소비 앱의 개발자와 AI 코딩 에이전트(Claude 등).
13
13
  > 이 문서는 "무엇을 언제 쓰고, 무엇을 쓰면 안 되는지"의 단일 기준이다.
14
- > 개별 컴포넌트의 상세 Props/Events는 `node_modules/sellmate-design-system-react/dist/components/<이름>/README.md` 를 참조한다.
14
+ > 개별 컴포넌트의 상세 Props/Events와 그 Props 가 쓰는 타입 정의(Types)는 `node_modules/sellmate-design-system-react/dist/components/<이름>/README.md` 를 참조한다.
15
15
 
16
16
  ## 0. 최우선 원칙 — 디자인 시스템 컴포넌트가 먼저다
17
17
 
@@ -29,17 +29,17 @@
29
29
 
30
30
  ### 0-1. 전체 컴포넌트 인덱스
31
31
 
32
- 무엇을 만들지 정했으면 **이 표에서 먼저 찾는다.** 상세 Props 는 `dist/components/<이름>/README.md` 참조.
32
+ 무엇을 만들지 정했으면 **이 표에서 먼저 찾는다.** 상세 Props·Types 는 `dist/components/<이름>/README.md` 참조.
33
33
 
34
34
  **이 표에서 어느 것을 골라야 할지 모르겠으면 §3-0 "의도 → 컴포넌트 라우팅" 으로 간다.** 하려는 일을 문장으로 찾으면 답이 하나 나온다 — 여기 인덱스는 "무엇이 있는지", §3-0 은 "언제 그걸 쓰는지" 를 담당한다.
35
35
 
36
36
  | 분류 | 컴포넌트 |
37
37
  | --- | --- |
38
- | **버튼·링크** | `SButton` `SGhostButton` `SDropdownButton` `STextLink` `SSwitch` `SToggle` |
39
- | **입력 (폼)** | `SForm` `SField` `SInput` `SNumberInput` `STextarea` `SSelect` `SCheckbox` `SRadio` `SRadioGroup` `SRadioButton` `SChip` `SChipInput` `SBarcodeInput` `SFilePicker` |
40
- | **날짜·시간** | `SCalendar` `SDatePicker` `SDateRangePicker` `STimePicker` `STimeRangePicker` |
38
+ | **버튼·링크** | `SButton` `SGhostButton` `SDropdownButton` `SSystemActionButton` `STextLink` `SSwitch` `SToggle` |
39
+ | **입력 (폼)** | `SForm` `SField` `SInput` `SSearchInput` `SNumberInput` `STextarea` `SEditor` `SSelect` `SCheckbox` `SRadio` `SRadioGroup` `SRadioButton` `SRadioCard` `SRadioCardGroup` `SChip` `SChipInput` `SBarcodeInput` `SFilePicker` |
40
+ | **날짜·시간** | `SCalendar` `SDatePicker` `SDatePickerYearListbox` `SDatePickerMonthListbox` `SDateRangePicker` `STimePicker` `STimeRangePicker` |
41
41
  | **표·목록** | `STable` `STableBar` `SChipFilter` `SKeyValueTable` `SList` `SListItem` `SExpansionList` `SDraggableList` `SDraggableItem` `STree` |
42
- | **레이아웃** | `SLayout` `SGnb` `SPage`(제목 영역은 `header` prop) `SSectionHeaderCard` `SCard` `SDivider` `SSplitter` `SScrollArea` `SExpansionItem` |
42
+ | **레이아웃** | `SLayout` `SGnb` `SGnbSystem` `SPage`(제목 영역은 `header` prop) `SSectionHeaderCard` `SCard` `SDivider` `SSplitter` `SScrollArea` `SExpansionItem` |
43
43
  | **내비게이션** | `STabs` `SPagination` `SStepper` |
44
44
  | **표시·상태** | `STag` `SBadge` `SIcon` `SImage` `SCallout` `SGuide` |
45
45
  | **진행·로딩** | `SLinearProgress` `SCircleProgress` `SLoadingContainer` `SLoadingModal` |
@@ -69,19 +69,21 @@ AI 에이전트는 코드를 생성하기 전에 이 목록을 반드시 지킨
69
69
  | --- | --- |
70
70
  | `<button>` | `SButton`, `SGhostButton`, `SDropdownButton`, `STextLink` |
71
71
  | `<input type="text/password/...">` | `SInput` |
72
+ | `<input type="search">` | `SSearchInput` |
72
73
  | `<input type="number">` | `SNumberInput` |
73
74
  | `<input type="checkbox">` | `SCheckbox`, `SToggle`, `SSwitch` |
74
75
  | `<input type="radio">` | `SRadio`, `SRadioButton` |
75
76
  | `<input type="file">` | `SFilePicker` |
76
77
  | `<select>` | `SSelect` |
77
78
  | `<textarea>` | `STextarea` |
79
+ | `contenteditable`, 직접 붙인 에디터 라이브러리 | `SEditor` |
78
80
  | `<table>` | `STable`, `SKeyValueTable` |
79
81
  | `<form>` | `SForm` |
80
82
  | `<dialog>`, 직접 만든 오버레이 | `SModal.confirm(...)`, `SModal.create(...)`, `SPopup` |
81
83
  | `alert()`, `confirm()` | `SToast`, `SModal.confirm(...)` |
82
84
  | 직접 만든 탭/페이지네이션/스텝퍼 | `STabs`, `SPagination`, `SStepper` |
83
85
  | `<ul>`/`<li>` 로 만든 목록 UI | `SList` + `SListItem` (드래그 정렬은 `SDraggableItem`) |
84
- | 직접 만든 섹션 카드(제목 바 + 본문 박스) | `SSectionHeaderCard` + `.Header` / `.Body` |
86
+ | 직접 만든 섹션 카드(제목 바 + 본문 박스) | `SSectionHeaderCard` 의 `title` / `padding` props |
85
87
  | `<svg>` 직접 삽입, 이모지 아이콘 | `SIcon` |
86
88
  | `<hr>` | `SDivider` |
87
89
  | `<details>` / `<summary>` | `SExpansionItem` |
@@ -118,7 +120,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
118
120
 
119
121
  `text-14 font-bold` 같은 조합을 즉흥으로 만들지 않는다. §2-1의 `typo-*` 프리셋 클래스를 쓴다.
120
122
 
121
- ### 1-4. 숫자는 무조건 `toLocaleString()`
123
+ ### 1-4. 숫자·날짜 표기
122
124
 
123
125
  **숫자를 화면에 표시할 때는 예외 없이 `toLocaleString()` 을 거쳐 세 자리마다 콤마를 넣는다.**
124
126
  금액·수량·건수·재고 무엇이든, 테이블·상세·요약 문구 어디에 놓이든 같다.
@@ -133,6 +135,18 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
133
135
 
134
136
  **번호·코드는 제외한다.** 전화번호·사업자번호·송장번호·상품코드처럼 대상을 가리키는 값은 크기를 비교하는 숫자가 아니라 **서식이 정해진 문자열**이다. 여기에 콤마를 넣으면 송장번호 `123456789` 가 `123,456,789` 로 보여 값 자체가 달라진다.
135
137
 
138
+ **날짜는 `YYYY-MM-DD` 로 쓴다.** 자릿수를 채우고 하이픈으로 구분한다 — `2026-08-13`.
139
+ `2026. 8. 13.` 처럼 점으로 구분하거나 한 자리로 줄이지 않는다. 자릿수가 고정돼야 세로줄이 맞고,
140
+ 컬럼 폭을 형식으로 계산할 수 있다(§3-4). 일시가 필요하면 `YYYY-MM-DD HH:mm`.
141
+
142
+ ```tsx
143
+ ❌ {new Date(v).toLocaleDateString()} ❌ {`${y}. ${m}. ${d}.`}
144
+ ✅ {v} // 서버가 이미 YYYY-MM-DD 로 준 값
145
+ ✅ format: (v: string) => v.slice(0, 10)
146
+ ```
147
+
148
+ **`toLocaleDateString()` 은 쓰지 않는다** — 로케일에 따라 결과가 바뀌어 표기를 지킬 수 없다.
149
+
136
150
  ---
137
151
 
138
152
  ## 2. 조합 규칙 — 화면을 어떻게 쌓는가
@@ -145,9 +159,9 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
145
159
 
146
160
  | 층 | 무엇인가 | 컴포넌트 |
147
161
  | --- | --- | --- |
148
- | **셸** | 앱 전체 뼈대. 페이지가 바뀌어도 남는다 | `SLayout` `SGnb` `SPage`(제목 영역은 `header` prop) |
162
+ | **셸** | 앱 전체 뼈대. 페이지가 바뀌어도 남는다 | `SLayout` `SGnb` `SGnbSystem`(GNB 맨 아래 패널) `SPage`(제목 영역은 `header` prop) |
149
163
  | **블록** | `SPage` 의 직계 자식. 페이지를 세로로 쌓는 단위 | `SSectionHeaderCard` `SCard` `SForm` `SSplitter` `SScrollArea` `STable` `STableBar` `SChipFilter` `SKeyValueTable` `SList` `SExpansionList` `SDraggableList` `STree` `SCallout` `STabs` `SStepper` `SPagination` `SDivider` |
150
- | **요소** | 블록 **안에** 놓이는 컨트롤. 혼자 페이지에 서지 않는다 | `SButton` `SGhostButton` `SDropdownButton` `SField` `SInput` `SNumberInput` `STextarea` `SSelect` `SCheckbox` `SRadio` `SRadioGroup` `SRadioButton` `SSwitch` `SToggle` `SChipInput` `SBarcodeInput` `SFilePicker` `SDatePicker` `SDateRangePicker` `STimePicker` `STimeRangePicker` `SCalendar` `SListItem` `SExpansionItem` `SDraggableItem` `SImage` `SLinearProgress` `SCircleProgress` |
164
+ | **요소** | 블록 **안에** 놓이는 컨트롤. 혼자 페이지에 서지 않는다 | `SButton` `SGhostButton` `SDropdownButton` `SSystemActionButton` `SField` `SInput` `SSearchInput` `SNumberInput` `STextarea` `SEditor` `SSelect` `SCheckbox` `SRadio` `SRadioGroup` `SRadioButton` `SRadioCard` `SRadioCardGroup` `SSwitch` `SToggle` `SChipInput` `SBarcodeInput` `SFilePicker` `SDatePicker` `SDatePickerYearListbox` `SDatePickerMonthListbox` `SDateRangePicker` `STimePicker` `STimeRangePicker` `SCalendar` `SListItem` `SExpansionItem` `SDraggableItem` `SImage` `SLinearProgress` `SCircleProgress` |
151
165
  | **인라인** | 텍스트 흐름·셀·라벨 안에 섞인다. 혼자 블록이 되지 않는다 | `STag` `SBadge` `SIcon` `STextLink` `SChip` |
152
166
  | **레이어** | 문서 흐름 **밖**에 떠서 그려진다. 어느 층에서 띄우든 레이아웃에 영향이 없다 | `SModal` `SActionModal` `SConfirmModal` `SPopup` `SDrawer` `SPopover` `STooltip` `SPortal` `SToast` `SLoadingModal` `SLoadingContainer` `SGuide` |
153
167
 
@@ -167,7 +181,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
167
181
  | 담는 것 | 올 수 있는 것 | 오면 안 되는 것 |
168
182
  | --- | --- | --- |
169
183
  | `SPage` | **블록만** | **요소를 직접** — 버튼 하나도 블록에 담아 놓는다 |
170
- | `SSectionHeaderCard.Body` | 그리는 블록 · 요소 | `SSectionHeaderCard` · `SCard` (카드 겹침, §3-7-8) |
184
+ | `SSectionHeaderCard` 의 `children` | 그리는 블록 · 요소 | `SSectionHeaderCard` · `SCard` (카드 겹침, §3-7-8) |
171
185
  | `SCard` | 그리는 블록 · 요소 | `SCard` · `SSectionHeaderCard` |
172
186
  | `SForm` | 블록 (보통 `SKeyValueTable` + 하단 액션) | — |
173
187
  | `SSplitter.Before` / `.After` | 블록 | — |
@@ -231,7 +245,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
231
245
 
232
246
  페이지 제목만 18px 로 크게 두고 그 아래는 14 / 12 로 촘촘하게 간다. 중간 크기(16px)는 기본 골격에서 쓰지 않는다.
233
247
 
234
- - **페이지·섹션 제목의 타이포를 직접 주지 않는다.** `SPage` 의 `header.title`, `SSectionHeaderCard.Header` 의 `title` 이 이미 넣는다 — 그 위에 `typo-heading-lg`/`typo-heading-sm` 을 또 씌우지 않는다.
248
+ - **페이지·섹션 제목의 타이포를 직접 주지 않는다.** `SPage` 의 `header.title`, `SSectionHeaderCard` 의 `header.title` 이 이미 넣는다 — 그 위에 `typo-heading-lg`/`typo-heading-sm` 을 또 씌우지 않는다.
235
249
  - **하위 제목이 필요하면 먼저 섹션을 나눌 수 없는지 본다.** 한 섹션 안에서 제목이 두 단으로 갈린다는 것은 대개 섹션이 둘이라는 뜻이다 (§3-7-8).
236
250
  - 본문 안에서 한 단어를 강조할 때는 `typo-body-sm-medium` 을 쓴다. `typo-body-sm-bold` 는 제목 성격의 짧은 라벨에만 쓴다. <!-- TODO(디자인): 강조 굵기 기준 확정 -->
237
251
 
@@ -295,13 +309,14 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
295
309
 
296
310
  - **라디오는 `SRadioGroup` 을 쓴다.** `direction="horizontal" | "vertical"` 만 주면 간격을 알아서 맞춘다 — 직접 `flex` 로 감싸지 않는다.
297
311
  - `SRadioButton` 그룹의 간격은 `-1px`(테두리 겹침 처리)이라 손으로 만들지 않는다.
312
+ - **카드형 라디오는 `SRadioCardGroup` 을 쓴다.** 카드 사이 간격은 수평·수직이 같고 `SRadioGroup` 과 다르므로, 손으로 `flex gap-…` 을 맞추지 말고 `direction` 만 준다.
298
313
  - 정확한 값이 필요하면 토큰을 직접 참조해도 된다: `gap-[var(--cmp-checkbox-group-gap-horizontal)]`
299
314
 
300
315
  #### 섹션·패널 안쪽 여백
301
316
 
302
317
  **페이지 프레임은 예외 없이 `SPage` 가 넣는다.** 아래 규칙은 그 안의 **섹션·패널 레벨에만** 적용된다.
303
318
 
304
- **컴포넌트가 자체 여백을 가지면 컴포넌트 기준이 우선한다.** `SKeyValueTable`·`STable` 처럼 자기 여백을 토큰으로 갖고 있는 컴포넌트에는 이 판정을 적용하지 않는다 — 손댈 것이 없다. 아래 판정이 필요한 자리는 **직접 만든 컨테이너**와 **`SSectionHeaderCard.Body`** 두 곳뿐이다.
319
+ **컴포넌트가 자체 여백을 가지면 컴포넌트 기준이 우선한다.** `SKeyValueTable`·`STable` 처럼 자기 여백을 토큰으로 갖고 있는 컴포넌트에는 이 판정을 적용하지 않는다 — 손댈 것이 없다. 아래 판정이 필요한 자리는 **직접 만든 컨테이너**와 **`SSectionHeaderCard` 의 `padding`** 두 곳뿐이다.
305
320
 
306
321
  판정은 **그 영역이 담고 있는 콘텐츠 덩어리의 종류 수**로 한다.
307
322
 
@@ -336,14 +351,67 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
336
351
 
337
352
  **중첩되면 안쪽 여백을 주지 않는다.** 24 영역 안에 또 여백을 주면 가장자리가 40 으로 벌어져 한 면적처럼 읽힌다. 안쪽 카드·목록이 **배경색이 다르거나 테두리가 있어** 경계가 스스로 보이는 경우에만 자기 여백을 유지한다.
338
353
 
339
- `SSectionHeaderCard.Body` 는 이 규칙을 **prop 으로 받는다** — 직접 `p-sd-*` 를 주지 않는다.
354
+ `SSectionHeaderCard` 는 이 규칙을 **`padding` prop 으로 받는다** — 직접 `p-sd-*` 를 주지 않는다.
355
+
356
+ ```tsx
357
+ <SSectionHeaderCard title="기본 정보">…</SSectionHeaderCard> {/* 기본 = 16 */}
358
+ <SSectionHeaderCard title="기본 정보" padding="wide">…</SSectionHeaderCard> {/* 3종류 이상 */}
359
+ <SSectionHeaderCard title="기본 정보" padding="none">…</SSectionHeaderCard> {/* 표를 가장자리까지 */}
360
+ ```
361
+
362
+ ##### 카드 가장자리까지 채우는 표는 자기 테두리를 끈다
363
+
364
+ 여기서 "표"는 **`STable` 과 `SKeyValueTable` 둘 다**다. 두 컴포넌트 모두 자기 바깥 테두리를 그리는데, 카드도 바깥 테두리를 그린다. `padding="none"` 으로 붙이면 **1px 두 개가 나란히 놓여 그 변만 2px** 로 보인다(카드의 다른 변은 1px 그대로라 굵기가 어긋난다).
365
+
366
+ ```tsx
367
+ <SSectionHeaderCard title="발주 내역" padding="none">
368
+ <SKeyValueTable fields={…} bordered={false} radius="useTop" />
369
+ <STable columns={…} rows={…} bordered={false} radius="useTop" />
370
+ </SSectionHeaderCard>
371
+ ```
372
+
373
+ - **`bordered={false}`** 로 표의 테두리를 끈다. 카드가 이미 그린다.
374
+ - **`radius="useTop"`** 으로 위쪽 모서리를 죽인다. 아래쪽 라운드는 카드가 처리한다.
375
+ - `STable` 은 **페이지네이션 바의 테두리와 `-mt-px` 겹침도 함께 꺼진다** — 본문 테두리가 없으면 겹칠 대상이 없어, 그대로 두면 페이지네이션만 테두리를 갖고 1px 어긋난다.
376
+
377
+ #### 본문 바탕 눌러앉히기 (선택)
378
+
379
+ `SSectionHeaderCard` 는 **`background="neutral"`** 로 본문 바탕을 한 단계 눌러앉힐 수 있다. 흰 면 덩어리(표·리스트)가 여럿일 때 그 덩어리들이 **"면 위에 놓인 객체"로 읽혀 묶음이 더 강하게 보인다.**
340
380
 
341
381
  ```tsx
342
- <SSectionHeaderCard.Body>…</SSectionHeaderCard.Body> {/* 기본 = 16 */}
343
- <SSectionHeaderCard.Body padding="wide">…</SSectionHeaderCard.Body> {/* 3종류 이상 */}
344
- <SSectionHeaderCard.Body padding="none">…</SSectionHeaderCard.Body> {/* 표를 가장자리까지 */}
382
+ <SSectionHeaderCard title="발주 상세" background="neutral">…흰 면 표 여럿…</SSectionHeaderCard>
345
383
  ```
346
384
 
385
+ **기본값(`frame`, 흰 면)이 틀린 것이 아니다.** 이 저장소의 표·리스트는 테두리·라운드·헤더 줄과 `gap-sd-12` 를 이미 갖고 있어 흰 바탕에서도 경계가 읽힌다. 위계를 한 단계 더 주고 싶을 때 고르는 수단이지, 덩어리가 둘 이상이면 반드시 깔아야 하는 규칙이 아니다.
386
+
387
+ 깔아도 **효과가 없는** 자리는 있다.
388
+
389
+ - **덩어리가 가장자리까지 차는 경우** — `padding="none"` 으로 표를 채우면 깐 바탕이 표에 완전히 가려 보이지 않는다.
390
+ - **덩어리에 회색 면이 섞인 경우** — 그 덩어리가 바탕과 같은 색이 되어 묻힌다.
391
+ - **맨 텍스트·폼 컨트롤만 있는 본문** — 떠오를 흰 면이 없다.
392
+
393
+ 바탕을 깐 경우, 표 사이 구분선(`SDivider`)은 대개 불필요해진다 — 색이 이미 경계를 만든다.
394
+
395
+ #### 페이지 높이 — 화면을 꽉 채우고, 스크롤은 각 영역 안에서
396
+
397
+ **대부분의 화면은 본문이 창을 꽉 채우고, 스크롤은 각 영역 안에서 일어난다.** 표는 자기 안에서 스크롤하고, 좌측 목록은 목록 안에서 스크롤하고, 페이지네이션·하단 액션은 자리에 고정된다. 이것이 표준이다 — 목록 페이지만의 예외가 아니다.
398
+
399
+ `SPage` 의 `contentHeight="fill"` 이 그 모드다. 프레임 컴포넌트에서 넘긴다(§4-1).
400
+
401
+ ```tsx
402
+ <SPage contentHeight="fill">
403
+ <div className="flex h-full min-h-0 flex-col gap-sd-12">
404
+ <STableBar … />
405
+ <STable className="min-h-0 flex-1" pagination={…} />
406
+ </div>
407
+ </SPage>
408
+ ```
409
+
410
+ - **`min-h-0 flex-1` 사슬이 페이지의 기본 골격이다.** `fill` 은 본문 래퍼에 `h-full` 을 주고, 거기서부터 스크롤될 자리까지 `min-h-0 flex-1` 이 이어져야 자식이 남은 높이를 잡는다.
411
+ - **사슬이 한 군데만 끊겨도 자식이 높이를 못 잡는데, 그 실패가 조용하다** — 화면은 그려지고 스크롤만 엉뚱한 데서 일어난다. 체크리스트(§5)로 확인한다.
412
+ - **페이지 스크롤은 예외다.** 블록의 높이가 정해져 있고 그 높이가 창보다 클 때만 페이지가 스크롤한다. 그때만 `contentHeight="auto"` 와 `scrollEndSpacing` 을 켠다.
413
+ - **`SScrollArea` 로 페이지 본문 전체를 감싸지 않는다.** 스크롤은 `SPage` 의 `<main>` 몫이고, 감싸면 스크롤바가 본문 패딩 안쪽으로 들어와 가장자리에서 뜬다. `SScrollArea` 는 페이지 안의 특정 영역에만 쓴다(§3-0 D).
414
+
347
415
  #### 스크롤 영역의 하단 여백
348
416
 
349
417
  스크롤을 끝까지 내렸을 때 마지막 항목이 화면 경계에 붙으면 **목록이 끝난 것인지 더 있는 것인지** 읽히지 않는다. 그래서 스크롤 영역은 **하단만** 넓게 둔다. 나머지 세 방향은 위 16 / 24 규칙 그대로다.
@@ -351,9 +419,9 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
351
419
  | 스크롤 종류 | 어떻게 |
352
420
  | --- | --- |
353
421
  | **패널 자체 스크롤** (좌측 목록, 중앙 대화 등) | 그 패널 안쪽 하단에 `pb-[var(--cmp-pageBody-padding-scrollEnd)]` — `SPage` 와 같은 토큰이라 값이 바뀌어도 함께 따라간다 |
354
- | **페이지 단위 스크롤** | **`SPage` 가 넣는다. 직접 주지 않는다** |
422
+ | **페이지 단위 스크롤** | **`SPage` 의 `scrollEndSpacing` 으로 켠다. 직접 패딩을 주지 않는다** |
355
423
 
356
- `SPage` 는 기본으로 넣으므로 **아무것도 하지 않으면 맞다.** 끄는 경우는 하나뿐이다 — **페이지네이션이 붙은 테이블.** 페이지네이션이 이미 "여기서 끝"을 알려주므로 `scrollEndSpacing={false}` 로 끈다 (§4-2 목록 페이지).
424
+ **`scrollEndSpacing` 은 기본이 꺼져 있다.** 페이지가 실제로 스크롤될 때만 필요한 값이라, 조건 없이 붙이면 내용이 화면에 거의 딱 맞는 페이지까지 그 여백 때문에 스크롤되게 만든다. 페이지 스크롤을 쓰는 화면(`contentHeight="auto"` + 내용이 창보다 김)에서만 켠다. 페이지네이션처럼 끝을 알려주는 것이 이미 있으면 켜지 않는다.
357
425
 
358
426
  ### 2-3. 색상
359
427
 
@@ -415,11 +483,15 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
415
483
  | --- | --- | --- |
416
484
  | 한 줄 텍스트를 받는다 | `SInput` | §3-7-1 |
417
485
  | 여러 줄 텍스트를 받는다 | `STextarea` | §3-7-1 |
486
+ | 제목·굵게·목록·색 같은 **서식이 남아야 하는** 글을 받는다 | `SEditor` | §3-7-1 |
487
+ | 목록·결과를 검색어로 좁힌다 | `SSearchInput` | §3-7-1 |
418
488
  | 숫자(수량·금액)를 받는다 | `SNumberInput` | |
419
489
  | 바코드를 스캔해 받는다 | `SBarcodeInput` | |
420
490
  | 목록에서 하나 고르게 한다 | `SSelect` | §3-7-2 |
421
491
  | 선택지를 항상 펼쳐 두고 하나 고르게 한다 | `SRadioGroup` | §3-7-2 |
422
492
  | 버튼 모양으로 모드를 하나 고르게 한다 | `SRadioButton` | §3-7-2 |
493
+ | 선택지마다 설명이 붙어 한 줄로는 안 된다 | `SRadioCardGroup` | §3-7-2 |
494
+ | 설명이 붙은 선택지 카드 하나를 직접 배치한다 | `SRadioCard` | §3-7-2 |
423
495
  | 라디오 하나를 표 셀 등에 직접 배치한다 | `SRadio` | §3-7-2 |
424
496
  | 여러 개를 고르게 한다 / 동의를 받는다 | `SCheckbox` | §3-7-3 |
425
497
  | 켜는 즉시 반영되는 설정을 준다 | `SSwitch` | §3-7-3 |
@@ -428,6 +500,8 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
428
500
  | 입력된 값 하나를 지우거나 고치게 한다 | `SChip` | §3-1 |
429
501
  | 파일을 받는다 | `SFilePicker` | |
430
502
  | 날짜 하나를 받는다 | `SDatePicker` | §3-7-4 |
503
+ | 연도 선택 리스트만 커스텀 조합에 넣는다 | `SDatePickerYearListbox` | §3-7-4 |
504
+ | 연도+월 선택 리스트만 커스텀 조합에 넣는다 | `SDatePickerMonthListbox` | §3-7-4 |
431
505
  | 날짜 기간을 받는다 | `SDateRangePicker` | §3-7-4 |
432
506
  | 시각 하나를 받는다 | `STimePicker` | |
433
507
  | 시각 범위를 받는다 | `STimeRangePicker` | |
@@ -461,6 +535,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
461
535
  | 라벨이 있는 일반 액션을 준다 | `SButton` | §3-5 |
462
536
  | 아이콘 하나로 뜻이 통하는 부가 조작을 준다 | `SGhostButton` | §3-5-5 |
463
537
  | 한 버튼에 여러 선택지를 매단다 | `SDropdownButton` | §3-5-4 |
538
+ | GNB system 패널에서 한 줄짜리 행 액션을 준다 | `SSystemActionButton` | §3-5-7 |
464
539
 
465
540
  #### D. 화면을 담고 나눈다
466
541
 
@@ -468,6 +543,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
468
543
  | --- | --- | --- |
469
544
  | 앱 셸(상단바 + 내비 + 본문)을 세운다 | `SLayout` | §4-1 |
470
545
  | 좌측 내비게이션을 만든다 | `SGnb` | §4-1 |
546
+ | 좌측 내비게이션 맨 아래에 서비스·도메인·알림·설정·계정 묶음을 붙인다 | `SGnbSystem` | §4-1 |
471
547
  | 페이지 본문을 담는다 (패딩·스크롤) | `SPage` | §4-1 |
472
548
  | 페이지 제목(+ 서브 텍스트·뒤로가기·우측 슬롯)을 만든다 | `SPage` 의 `header` prop | §4-1 |
473
549
  | 제목 있는 섹션으로 묶는다 | `SSectionHeaderCard` | §3-7-8 |
@@ -476,6 +552,8 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
476
552
  | 사용자가 영역 크기를 조절하게 한다 | `SSplitter` | §3-6 |
477
553
  | 특정 영역 안에서만 스크롤시킨다 | `SScrollArea` | |
478
554
 
555
+ **`SScrollArea` 로 페이지 본문 전체를 감싸지 않는다.** 페이지 스크롤은 `SPage` 의 `<main>` 몫이다 — 감싸면 스크롤바가 본문 패딩 안쪽으로 들어와 페이지 가장자리에서 떨어져 그려진다 (§2-2).
556
+
479
557
  #### E. 다른 곳으로 이동시킨다
480
558
 
481
559
  | 하려는 일 | 컴포넌트 | 갈림 |
@@ -559,9 +637,26 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
559
637
  | | 무엇인가 | 크기 |
560
638
  | --- | --- | --- |
561
639
  | **SPopup** | **별도 브라우저 창** (`window.open` 으로 여는 전용 라우트) | 창 크기 = 콘텐츠 크기 |
562
- | **SActionModal** | 같은 창 위 오버레이 카드 | `width` / `height` prop |
640
+ | **SActionModal** | 같은 창 위 오버레이 카드 | `width` prop. **높이는 주지 않는다** (아래) |
563
641
  | **SModal.confirm** (`SConfirmModal`) | 같은 창 위 확인창 | 고정 |
564
642
 
643
+ ##### 모달 높이는 내용이 정하고, 상한은 시스템이 건다
644
+
645
+ **`height` 를 주지 않는다.** 높이는 내용이 정하고, 카드는 **뷰포트의 85%** 에서 멈춘다(시스템이 모든 모달에 건다). 데이터가 적으면 내용만큼 작아지고, 많으면 85% 에서 멈춘다.
646
+
647
+ 가로는 좌우 24px 씩을 뺀 값으로 클램핑하는데 **세로만 비율**인 이유는, 모달이 화면을 거의 다 덮으면 뒤 맥락이 사라져 "떠 있는 것"으로 읽히지 않기 때문이다.
648
+
649
+ **상한에 닿았을 때 스크롤되어야 하는 것은 모달 본문이 아니라 표다.**
650
+
651
+ ```tsx
652
+ <SActionModal modalTitle="발주 검토" button={{ label: '확정', onClick: submit }}>
653
+ {/* 표가 남은 높이를 먹고 자기 안에서 스크롤한다 — 헤더·합계·푸터는 늘 보인다 */}
654
+ <STable className="min-h-0 flex-1" columns={columns} rows={rows} />
655
+ </SActionModal>
656
+ ```
657
+
658
+ 본문(`overflow-auto` 영역)이 통째로 스크롤되면 표 헤더와 합계 줄이 위로 밀려 사라진다. `SActionModal` 의 본문은 이미 `min-h-0 flex-1` 이므로, 표에 `min-h-0 flex-1` 을 주면 세로 축이 이어져 표만 스크롤한다 (§4-2 목록 페이지와 같은 사슬이다).
659
+
565
660
  **성격이 먼저 둘로 갈린다.**
566
661
 
567
662
  | 성격 | 정의 | 컴포넌트 |
@@ -699,36 +794,51 @@ SModal.create({ component: OrderModal, componentProps: { orderId } })
699
794
 
700
795
  작성 중인 내용이 있을 때 닫기를 시도하면 이탈 안내를 띄우는 것은 **소비 앱 몫**이다. 디자인 시스템은 dirty 상태를 알지 못하므로 백드롭·ESC 를 일괄 차단할 뿐이다. 안내가 필요하면 앱이 자체 dirty 판정 후 `SModal.confirm` 으로 띄운다.
701
796
 
702
- ### 3-4. 테이블 컬럼 — 정렬과 너비
797
+ ### 3-4. 테이블 컬럼 — 정렬·너비·헤더
703
798
 
704
- #### 정렬
799
+ #### 정렬과 너비는 같은 표에서 정한다
705
800
 
706
- **값의 크기를 비교하는 숫자 컬럼은 예외 없이 오른쪽 정렬한다** (`align: 'right'`).
707
- 자릿수가 세로로 맞아야 값의 크기를 눈으로 비교할 수 있기 때문이다.
801
+ 컬럼을 정의할 때 정렬과 너비는 따로 판단하는 것이 아니다. 둘 다 **값의 성격**에서 나온다.
708
802
 
709
- **판별 기준은 "숫자인가"가 아니라 "크기를 비교하는가"다.** 자릿수 차이가 거의 없고 값끼리 대소를 견줄 일이 없으면 숫자로만 이루어져 있어도 우측 정렬하지 않는다.
803
+ 원칙 한 줄: **길이를 형식이 정하면 고정, 사용자가 정하면 가변.**
710
804
 
711
- | 값 성격 | 정렬 | 예 |
712
- | --- | --- | --- |
713
- | **금액·수량·개수·비율 등 양을 나타내는 값** | **`'right'`** | `39,000원` · `12개` · `3건` · `15%` |
714
- | 코드·식별자 (주문번호, 상품코드, 순번) | **`'center'`** | `RV20250728-000010` · `1024` |
715
- | 전화번호·사업자번호 | **`'center'`** | `010-1234-5678` |
716
- | 일자·일시 | **`'center'`** | `2024-10-23` |
717
- | 텍스트 | 생략(기본 `left`) | 상품명, 카테고리 |
718
- | 상태 태그·아이콘·체크박스 등 고정폭 요소 | `'center'` | `STag`, `SIcon` |
805
+ | 값 성격 | 정렬 | 너비 | 예 |
806
+ | --- | --- | --- | --- |
807
+ | **금액·수량·개수·비율** (양을 나타내는 값) | **`'right'`** | 고정 | `39,000원` · `12개` · `3건` · `15%` |
808
+ | 코드·식별자, 전화번호, 일자·일시 | **`'center'`** | 고정 | `RV20250728-000010` · `010-1234-5678` · `2026-08-13` |
809
+ | **닫힌 값 집합** (enum · 마스터 목록에서 고르는 값) | **`'center'`** | 고정 | 상태 · 직급 · 공개 범위 · 고용 형태 · 요일 |
810
+ | 상태 태그·아이콘·버튼·체크박스 | `'center'` | 고정 (`contentType: 'control'`) | `STag` · `SIcon` · `SGhostButton` |
811
+ | **텍스트** (사용자가 자유 입력) | 생략(기본 `left`) | 기준 폭 + `resizable` | 이름 · 목표명 · 이메일 · 메모 |
719
812
 
720
813
  **중앙 정렬은 `align: 'center'` 를 명시한다.** 기본값이 좌측이라 생략하면 중앙이 되지 않는다.
721
814
 
815
+ ##### 판별 — 값의 크기를 비교하는가
816
+
817
+ 우측 정렬의 근거는 "자릿수를 세로로 맞춰 크기를 읽는다"다. 자릿수 차이가 거의 없고 값끼리 대소를 견줄 일이 없으면 숫자로만 이루어져 있어도 우측 정렬하지 않는다 — 송장번호 `123456789` 는 크기를 비교하는 값이 아니다.
818
+
819
+ ##### 판별 — 값 집합이 닫혀 있는가
820
+
821
+ **닫힌 값 집합이면 태그로 그리든 맨 텍스트로 그리든 `center` 다.** 판별 질문 하나 — *사용자가 그 칸을 직접 치는 값인가?* 아니면 닫힌 집합이다.
822
+
823
+ | | 값의 출처 | 정렬 | 예 |
824
+ | --- | --- | --- | --- |
825
+ | **닫힘** | enum · 마스터 목록에서 선택 (`SSelect` 의 `options` 에서 오는 값) | `center` | 직급 · 상태 · 공개 범위 · 최종 등급 · 고용 형태 · 요일 |
826
+ | **열림** | 사용자가 자유 입력 (자유 입력 필드에서 오는 값) | `left` | 이름 · 목표명 · 이메일 · 문항 그룹명 |
827
+
828
+ - **무엇으로 그렸는지로 가르지 않는다.** 같은 성격의 값이 `STag` 면 `center`, 맨 텍스트면 `left` 가 되면 한 테이블 안에서 기준이 어긋난다.
829
+ - **길이로도 가르지 않는다.** "짧은 라벨이면 center" 같은 단서를 붙이면 `프로덕트디자인팀`(8자)처럼 경계에 걸리는 값에서 매번 판단이 갈린다.
830
+ - 한 열에 텍스트와 태그가 함께 오면 태그 기준(`center`)에 맞춘다.
831
+
722
832
  ```tsx
723
833
  const columns: STableColumn[] = [
724
- { name: 'orderNo', label: '주문번호', field: 'orderNo', width: '140px', align: 'center' },
725
- { name: 'orderedAt', label: '주문일자', field: 'orderedAt', width: '100px', align: 'center' },
726
- { name: 'name', label: '상품명', field: 'name' }, // 텍스트 → 생략
727
- { name: 'qty', label: '수량', field: 'qty', width: '80px', align: 'right',
834
+ { name: 'orderNo', label: '주문번호', field: 'orderNo', width: 140, align: 'center' },
835
+ { name: 'orderedAt', label: '주문일자', field: 'orderedAt', width: 100, align: 'center' },
836
+ { name: 'name', label: '상품명', field: 'name', width: 240 }, // 자유 입력 → 생략
837
+ { name: 'qty', label: '수량', field: 'qty', width: 80, align: 'right',
728
838
  format: (v: number) => `${Number(v).toLocaleString()}개` },
729
- { name: 'price', label: '판매가', field: 'price', width: '120px', align: 'right',
839
+ { name: 'price', label: '판매가', field: 'price', width: 120, align: 'right',
730
840
  format: (v: number) => `${Number(v).toLocaleString()}원` },
731
- { name: 'status', label: '상태', field: 'status', width: '100px', align: 'center',
841
+ { name: 'status', label: '상태', field: 'status', width: 100, align: 'center',
732
842
  render: () => <STag size="sm" color="green" label="판매중" /> },
733
843
  ];
734
844
  ```
@@ -736,38 +846,73 @@ const columns: STableColumn[] = [
736
846
  - `format` 으로 단위를 붙이더라도 **양을 나타내면 오른쪽 정렬**이다. 단위 때문에 문자열이 되는 것은 정렬 판단과 무관하다.
737
847
  - 양을 나타내는 숫자는 §1-4 대로 **`toLocaleString()` 이 필수**다. 세 자리 콤마 없이 출력하지 않는다.
738
848
  - **번호·코드에는 세 자리 콤마를 넣지 않는다.** 송장번호 `123456789` 를 `123,456,789` 로 표시하면 값 자체가 달라 보인다.
849
+ - 날짜는 §1-4 대로 `YYYY-MM-DD` 로 적는다. 자릿수가 고정이라 폭을 형식으로 계산할 수 있다.
739
850
  - **헤더는 가운데, 셀만 우측**으로 두려면 `align` 이 아니라 `tdClass` 를 쓴다. `align` 은 `<th>` 와 `<td>` 에 함께 적용된다.
740
851
 
741
852
  ```tsx
742
- { name: 'views', label: '조회수', field: 'views', align: 'center', tdClass: 'text-right!',
853
+ { name: 'views', label: '조회수', field: 'views', align: 'center', width: 100, tdClass: 'text-right!',
743
854
  format: (v: number) => Number(v).toLocaleString() },
744
855
  ```
745
856
 
746
857
  - `SKeyValueTable` 의 값 셀도 같은 기준을 따른다.
747
858
 
748
- #### 컨트롤이 들어가는 컬럼은 너비를 명시한다
859
+ #### 너비는 px 로만 준다
749
860
 
750
- 컬럼 폭은 `width` 로 **고정**되고, `<td>` 는 그 폭을 넘는 내용을 잘라낸다(`overflow: hidden`). 텍스트라면 말줄임으로 끝나지만, 셀에 `STag` · `SButton` · `SGhostButton` · `SSelect` · `SInput` · `SNumberInput` 처럼 **자기 폭을 가진 요소**를 넣으면 요소 자체가 잘려 **누르거나 읽거나 입력할 수 없게 된다.** `width` 를 생략해도 내용에 맞춰 늘어나지 않고 `STable` 의 기본 폭이 될 뿐이므로, 컨트롤이 들어가는 컬럼은 폭을 직접 판단해서 준다.
861
+ **컬럼 폭은 px 이다.** 숫자를 주면 px 로 읽고, 문자열은 `'120px'` 형태만 받는다. `%` · `clamp()` · `min()` 은 쓰지 않는다.
751
862
 
752
- - 기준은 **요소가 온전히 보이는 폭 + 셀 좌우 패딩**이다. 좌우 패딩은 `STable` 이 토큰으로 넣으므로(직접 주지 않는다) 그만큼을 뺀 나머지가 요소 몫이라는 점을 계산에 넣는다.
753
- - 요소가 둘 이상이면 요소 폭의 합에 **`gap` 까지** 더한다 (행 내부 인라인 액션 간격은 `gap-sd-4` 고정, §3-5-5).
863
+ 컬럼 폭은 `<colgroup>` 의 `<col width>` 로 들어가고 테이블이 `table-fixed` 라, 함수형 값은 계산되지 않고 통째로 무시된 뒤 auto 폭으로 떨어진다. `'30%'` 는 더 나쁘게 `30`(px)으로 읽힌다. **둘 다 에러 없이 화면만 틀어진다.**
864
+
865
+ - **내용이 들어가는 열은 전부 폭을 명시한다.** 생략하면 기본 120px 이 조용히 들어가고, "짧은 열이라 그대로 둔 것"과 "판단을 빠뜨린 것"이 구분되지 않는다. 120px 이 맞더라도 `width: 120` 을 적는다.
866
+ - **`autoWidth` 는 남은 폭을 흡수하는 스페이서 열 하나에만 쓴다.** 내용이 들어가는 열에는 쓰지 않는다 — 폭이 다른 열에 좌우돼 화면마다 달라진다. 스페이서 열은 값을 그리지 않으므로 `field` 도 생략한다.
867
+
868
+ ```tsx
869
+ { name: 'spacer', label: '', autoWidth: true },
870
+ ```
871
+
872
+ - **`minWidth` · `maxWidth` 는 `resizable` 손잡이의 이동 범위일 뿐, 레이아웃에는 관여하지 않는다.** 폭을 주지 않은 열이 이 값 안에서 잡히는 것이 아니다.
873
+ - **고정폭 합이 최소 창 폭을 넘으면 가로 스크롤이 된다.** `STable` 이 자기 안에서 가로로 스크롤하고 헤더·바디가 함께 움직이므로 별도 조치는 필요 없다 — 폭을 줄여 맞추지 말고, 열이 정말 그만큼 필요한지를 본다.
874
+
875
+ ##### 고정폭을 어떻게 정하는가
876
+
877
+ ```text
878
+ 폭 = ceil( ( max(값 폭 + 값 기준 패딩, 헤더 폭 + 32) + 여유 ) / 8 ) × 8
879
+ ```
880
+
881
+ - **값과 헤더를 따로 계산해 큰 쪽을 쓴다.** `contentType: 'control'` 은 `<td>` 에만 적용되고 `<th>` 는 항상 텍스트 패딩이라, 짧은 컨트롤 + 긴 헤더 조합에서 헤더가 잘린다.
754
882
  - 값에 따라 폭이 달라지는 요소(`STag` 라벨, 라벨 있는 버튼)는 **가장 긴 값** 기준으로 잡는다. `판매중` 에 맞춰두면 `판매중지 요청` 에서 잘린다.
883
+ - **정렬 가능한 헤더(`sortable`)는 아이콘 버튼 + 간격만큼 `+20px` 더 든다.** `helpText` 를 함께 달면 그만큼 또 더한다.
884
+ - **`editable` · `navigable` 표식과 `required` 표시(`*`) 도 각각 폭을 먹는다.** 헤더에 붙는 것이 늘수록 **라벨이 먼저 잘리므로**, 붙인 열은 폭을 함께 넓힌다.
885
+ - **계산값은 픽셀 단위까지 맞추면 어긋난다** — 서브픽셀 반올림 때문이다. 여유 8px 을 얹고 8 단위로 올림한다.
886
+ - 좌우 패딩은 `STable` 이 토큰으로 넣으므로 직접 주지 않는다. 그만큼을 뺀 나머지가 요소 몫이라는 점만 계산에 넣는다.
887
+
888
+ #### 컨트롤이 들어가는 컬럼
889
+
890
+ `<td>` 는 폭을 넘는 내용을 잘라낸다(`overflow: hidden`). 텍스트라면 말줄임으로 끝나지만, 셀에 `STag` · `SButton` · `SGhostButton` · `SSelect` · `SInput` · `SNumberInput` 처럼 **자기 폭을 가진 요소**를 넣으면 요소 자체가 잘려 **누르거나 읽거나 입력할 수 없게 된다.**
891
+
892
+ - **컨트롤이 들어가는 컬럼에는 `contentType: 'control'` 을 함께 준다.** 좌우 패딩이 텍스트용(넓게)에서 컨트롤용(좁게)으로 바뀌어, 같은 컬럼 폭에서도 요소가 쓸 폭이 넓어진다. 기본값은 `text` 다.
893
+ - 요소가 둘 이상이면 요소 폭의 합에 **`gap` 까지** 더한다 (행 내부 인라인 액션 간격은 `gap-sd-4` 고정, §3-5-5).
755
894
  - `SSelect` · `SInput` 처럼 셀 폭을 채우는 컨트롤은 **컬럼 폭이 곧 컨트롤 폭**이다. 실제 선택값·입력값이 말줄임 없이 읽히는 폭인지 확인한다.
756
895
  - 폭을 넉넉히 줄 수 없는 자리는 폭을 줄이는 게 아니라 **요소를 바꾼다** — 라벨 버튼 대신 아이콘만 있는 `SGhostButton`, `size="xs"` (§3-5-2, §3-5-5).
757
- - **`autoWidth` 는 해법이 아니다.** 내용에 맞춰 늘어나는 게 아니라 고정폭 컬럼들이 가져가고 **남은 폭을 나눠 갖는 것**이라, 테이블이 좁으면 역시 잘린다. 컨트롤 컬럼은 `width` 로 직접 확보한다.
896
+
897
+ ```tsx
898
+ { name: 'normal', label: '정상', field: 'normal', width: 96,
899
+ align: 'center', contentType: 'control', render: row => <SNumberInput … /> },
900
+ ```
901
+
902
+ **셀 좌우 여백은 내용이 정한다.** 텍스트는 넓게, 컨트롤은 좁게다 — `SKeyValueTable` 은 `field.type` 으로 이 판정을 스스로 하지만, `STable` 의 셀은 소비 앱이 넘긴 임의의 `render` 결과라 컴포넌트가 알 수 없다. 그래서 `contentType` 으로 알려준다. 여백 값 자체는 토큰이 정하므로 `tdClass` 로 패딩을 직접 덮어쓰지 않는다.
758
903
 
759
904
  **`resizable` 테이블이면 `minWidth` 를 함께 준다.** resize 하한 기본값은 어떤 컨트롤도 담지 못할 만큼 작아, 사용자가 끝까지 끌면 그대로 잘린다. `width` 를 정한 근거와 같은 값을 하한으로 둔다 — 텍스트 컬럼과 달리 여기서는 더 줄일 여지가 없다.
760
905
 
761
906
  ```tsx
762
907
  const columns: STableColumn[] = [
763
908
  // 태그 — 가장 긴 라벨 기준
764
- { name: 'status', label: '상태', field: 'status', width: '120px', minWidth: 120, align: 'center',
909
+ { name: 'status', label: '상태', field: 'status', width: 120, minWidth: 120, align: 'center',
765
910
  render: (row: SRow) => <STag size="sm" color="green" label={row.statusLabel} /> },
766
911
  // 셀 안 입력 — 컬럼 폭이 곧 입력 폭
767
- { name: 'qty', label: '수량', field: 'qty', width: '100px', minWidth: 100, align: 'right',
912
+ { name: 'qty', label: '수량', field: 'qty', width: 100, minWidth: 100, align: 'right',
768
913
  render: (row: SRow) => <SNumberInput value={row.qty} onValueChange={v => setQty(row, v)} /> },
769
914
  // 인라인 액션 둘 — 폭 = xs 버튼 2개 + gap-sd-4 + 셀 좌우 패딩
770
- { name: 'actions', label: '', field: 'id', width: '84px', minWidth: 84, align: 'center',
915
+ { name: 'actions', label: '', field: 'id', width: 84, minWidth: 84, align: 'center',
771
916
  render: (row: SRow) => (
772
917
  <div className="flex items-center justify-center gap-sd-4">
773
918
  <SGhostButton size="xs" intent="action" icon="edit" ariaLabel="수정" onClick={() => editRow(row)} />
@@ -775,11 +920,34 @@ const columns: STableColumn[] = [
775
920
  </div>
776
921
  ) },
777
922
 
778
- // ❌ 컨트롤 컬럼에 width 생략 — 기본 폭에 맡기면 버튼이 잘린다
923
+ // ❌ 컨트롤 컬럼에 width 생략 — 기본 폭(120px)에 맡기면 버튼이 잘린다
779
924
  { name: 'move', label: '', field: 'id', render: () => <SButton label="재고 이동" size="xs" /> },
780
925
  ];
781
926
  ```
782
927
 
928
+ #### 정렬 가능한 컬럼
929
+
930
+ **정렬 상태는 `STable` 이 갖지 않는다.** 컬럼에 `sortable: true` 를 주고, 페이지가 `sort` · `onSortChange` 로 상태를 들고 있는다.
931
+
932
+ ```tsx
933
+ const [sort, setSort] = useState<STableSort | null>({ name: 'orderedAt', dir: 'desc' });
934
+
935
+ <STable
936
+ columns={columns}
937
+ rows={rows}
938
+ sort={sort}
939
+ onSortChange={setSort}
940
+ />
941
+ ```
942
+
943
+ - **정렬은 조회 조건이다.** 서버 정렬이면 `?sort=createdAt&dir=desc` 가 곧 요청이고, 뒤로가기·새로고침·링크 공유로 복원돼야 한다. 컴포넌트가 사본을 들면 URL 과 화면이 어긋난다 — `SExpansionList` 의 선택을 앱이 드는 것과 같은 이유다 (§3-7-7).
944
+ - **행을 실제로 정렬하는 것도 페이지 몫이다.** `STable` 은 받은 순서대로 그린다.
945
+ - **동작** — 헤더 클릭 시 `asc → desc → 해제` 3단. 다른 열을 누르면 그 열의 `asc` 로 시작한다. 해제되면 `onSortChange(null)`.
946
+ - **아이콘** — 미정렬 `updown`, 오름 `arrowUp`, 내림 `arrowDown`. 정렬 중인 열만 `action` 색으로 올라온다. `SGhostButton size="xxs"` 로 그려지므로 직접 만들지 않는다.
947
+ - **클릭 영역은 정렬 버튼뿐이다.** 헤더 셀 전체를 누르게 하지 않는다 — 라벨을 드래그해 고르거나 `helpText` 아이콘에 hover 하는 것과 뒤섞인다.
948
+ - **다중 정렬은 지원하지 않는다.** 한 번에 한 열이다.
949
+ - `renderHeader` 로 헤더를 통째로 교체하면 정렬 아이콘도 클릭도 그리지 않는다 — 헤더 전체가 소비 앱 책임이 된다.
950
+
783
951
  #### 값이 없는 셀은 회색 하이픈
784
952
 
785
953
  셀을 **빈칸으로 두지 않는다.** 값이 `null` · `undefined` · 빈 문자열이면 `-` 를 `text-fg-tertiary`(`grey_65`)로 표시한다.
@@ -791,9 +959,9 @@ const emptyCell = <span className="text-fg-tertiary">-</span>;
791
959
  const hasValue = (v: unknown) => v !== null && v !== undefined && v !== '';
792
960
 
793
961
  const columns: STableColumn[] = [
794
- { name: 'memo', label: '메모', field: 'memo',
962
+ { name: 'memo', label: '메모', field: 'memo', width: 240,
795
963
  render: (row: SRow) => (hasValue(row.memo) ? row.memo : emptyCell) },
796
- { name: 'price', label: '판매가', field: 'price', width: '120px', align: 'right',
964
+ { name: 'price', label: '판매가', field: 'price', width: 120, align: 'right',
797
965
  render: (row: SRow) =>
798
966
  hasValue(row.price) ? `${Number(row.price).toLocaleString()}원` : emptyCell },
799
967
  ];
@@ -801,6 +969,54 @@ const columns: STableColumn[] = [
801
969
 
802
970
  `0` 은 값이 있는 것이므로 하이픈으로 바꾸지 않는다 — `0원` 그대로 표시한다.
803
971
 
972
+ #### 라벨만으로 뜻이 안 통하는 컬럼은 `helpText`
973
+
974
+ 헤더 라벨은 컬럼 폭 안에 들어가야 해서 짧아진다. **산출 기준·단위·상태 값의 뜻처럼 라벨에 담기지 않는 설명은 `column.helpText` 로 준다** — 라벨 뒤에 도움말 아이콘이 붙고 hover 하면 툴팁이 뜬다. 배열의 각 항목이 한 줄이다. `SKeyValueTable` 의 `field.helpText`, `SSectionHeaderCard` 의 `helpText` 와 같은 것이다.
975
+
976
+ 판단 기준 한 줄: *컬럼 제목이 줄임말·사내 용어·계산식이거나, 값이 아니라 열 자체의 설명이 필요할 때 헤더에 단다.*
977
+
978
+ ```tsx
979
+ const columns: STableColumn[] = [
980
+ { name: 'orderCount', label: '주문 수', field: 'orderCount', width: 120, align: 'right',
981
+ helpText: ['취소·반품을 제외한 확정 주문 수입니다.'],
982
+ format: (v: number) => `${Number(v).toLocaleString()}건` },
983
+ { name: 'status', label: '상태', field: 'status', width: 100, align: 'center',
984
+ helpText: ['활성: 최근 30일 내 주문 있음', '보관됨: 거래 종료'] },
985
+ ];
986
+ ```
987
+
988
+ - **`renderHeader` 로 헤더를 직접 만들어 `STooltip` 을 붙이지 않는다.** `renderHeader` 는 헤더 전체를 교체하므로 `helpText` 가 무시되고, 아이콘·크기·색·간격을 손으로 맞추게 된다.
989
+ - **모든 컬럼에 달지 않는다.** 라벨로 뜻이 통하는 컬럼(`주문번호`·`상품명`)까지 붙이면 헤더가 아이콘으로 뒤덮여 정작 설명이 필요한 컬럼이 묻힌다.
990
+ - 긴 문장을 넣는 자리가 아니다. 한 줄에 한 가지 사실만 담고, 그 이상은 페이지 상단 안내(`SCallout`)로 뺀다.
991
+ - 아이콘도 폭을 먹는다 — 헤더 폭 계산에 넣는다.
992
+
993
+ #### 헤더에 붙는 것들의 순서 · 열을 어떻게 다루는지 알리는 표식
994
+
995
+ 헤더 라벨 뒤에 붙는 것은 네 가지고, **순서는 `STable` 이 고정한다.** 소비 앱이 바꾸는 것이 아니다.
996
+
997
+ ```text
998
+ 라벨 [helpText ?] [editable] [navigable] [required *] [sortable 정렬버튼]
999
+ ```
1000
+
1001
+ **`editable` 은 값을 직접 고칠 수 있는 열, `navigable` 은 눌러서 다른 화면으로 넘어가는 열에 준다.** 둘 다 **표식일 뿐 버튼이 아니다** — 아이콘·크기·색은 컴포넌트가 고정하고 클릭은 받지 않는다.
1002
+
1003
+ ```tsx
1004
+ const columns: STableColumn[] = [
1005
+ { name: 'name', label: '상품명', field: 'name', width: 200,
1006
+ navigable: true,
1007
+ render: (row: SRow) => <STextLink label={row.name} onClick={() => goDetail(row.id)} /> },
1008
+ { name: 'stock', label: '재고', field: 'stock', width: 160, contentType: 'control',
1009
+ editable: true, required: true,
1010
+ render: (row: SRow) => <SNumberInput value={row.stock} width="100%" /> },
1011
+ ];
1012
+ ```
1013
+
1014
+ - **실제로 그렇게 동작하는 열에만 켠다.** 표식만 켜고 셀은 텍스트 그대로 두면, 고칠 수 있다고 해 놓고 고칠 방법이 없고 넘어갈 수 있다고 해 놓고 누를 것이 없다. `editable` 이면 셀에 입력 컨트롤이, `navigable` 이면 셀에 링크·클릭이 있어야 한다.
1015
+ - **표식으로 동작을 대신하지 않는다.** 고치는 UI 도 넘어가는 동작도 셀(`column.render`) 몫이다.
1016
+ - **`renderHeader` 로 헤더를 통째로 교체하면 표식도 `required` 도 그려지지 않는다** — 헤더 전체가 소비 앱 책임이 되므로 순서·크기·색을 손으로 맞추게 된다.
1017
+
1018
+ **값을 반드시 채워야 하는 열에는 `column.required`** 를 준다 — 라벨 뒤에 `*` 가 붙는다. `SKeyValueTable` 의 `field.required` 와 같은 것이다. **읽기 전용 열에 붙이지 않는다** — 표시만 있고 채울 방법이 없어 사용자가 막힌다.
1019
+
804
1020
  ### 3-5. 버튼류
805
1021
 
806
1022
  | 상황 | 사용 |
@@ -810,6 +1026,7 @@ const columns: STableColumn[] = [
810
1026
  | **아이콘 하나로 뜻이 통하는 부가 조작** | **`SGhostButton`** — 라벨이 없는 아이콘 전용 버튼 (아래 3-5-5) |
811
1027
  | 본문 속 이동 링크 | `STextLink` |
812
1028
  | 메인 액션 + 부가 메뉴 | `SDropdownButton` |
1029
+ | **GNB system 패널의 한 줄 액션** | **`SSystemActionButton`** — 행 전체가 버튼인 표면형 (아래 3-5-7) |
813
1030
 
814
1031
  #### 3-5-1. 채움 버튼은 페이지당 개수 제한이 있다
815
1032
 
@@ -824,6 +1041,19 @@ const columns: STableColumn[] = [
824
1041
 
825
1042
  `SDropdownButton` 도 같은 규칙을 따르며, **페이지당 `primary` 채움 1개 계산에 포함**된다.
826
1043
 
1044
+ ##### 무엇이 어느 위계인가
1045
+
1046
+ 개수만으로는 후보가 여럿일 때 어느 것을 올릴지 갈리지 않는다. 기준은 **그 조작이 무엇에 미치는가**다.
1047
+
1048
+ | 위계 | 무엇에 쓰나 |
1049
+ | --- | --- |
1050
+ | `primary` 채움 | **페이지 전체에 해당하는 데이터를 확정**하는 실행 (폼 저장, 상세 수정 확정, 일괄 반영) |
1051
+ | `secondary` 채움 | **페이지 안 중심 데이터에 대한 처리** (선택 항목 상태 변경, 발송, 승인) |
1052
+ | `outline` (`neutral` · `primary`) | 단순 등록, 설정 변경, 이동·취소 |
1053
+
1054
+ - **`primary` 채움은 페이지 전체를 대표하는 실행 하나에만 쓴다. 그런 조작이 없으면 페이지에 `primary` 가 없어도 된다.** 개수 제한이 "반드시 하나 있어야 한다"는 뜻은 아니다.
1055
+ - **`secondary` 연속 배치 금지는 섹션이 다르면 적용되지 않는다.** 섹션마다 독립 인라인 폼이 있는 상세 페이지(§4-4)가 그렇다 — 나란히 놓인 두 버튼이 같은 판단 단위 안에 있을 때의 규칙이다.
1056
+
827
1057
  #### 3-5-2. `size` 는 놓이는 위치가 정한다
828
1058
 
829
1059
  | 위치 | size |
@@ -929,6 +1159,35 @@ const columns: STableColumn[] = [
929
1159
  </div>
930
1160
  ```
931
1161
 
1162
+ #### 3-5-7. `SSystemActionButton` — GNB system 패널의 한 줄 액션
1163
+
1164
+ **행 전체가 하나의 버튼**인 표면형 컨트롤이다. 아이콘 프레임 + 라벨 + 우측 액션이 한 줄에 붙고, hover 하면 표면색이 한 단계 바뀐다.
1165
+
1166
+ | 상황 | 사용 |
1167
+ | --- | --- |
1168
+ | 페이지·모달의 일반 액션 | `SButton` — 라벨 폭만큼만 차지한다 |
1169
+ | 목록에서 항목 하나를 고르게 한다 | `SListItem` · `STree` (§3-7-6·§3-7-7) |
1170
+ | 좌측 내비게이션의 메뉴 | `SGnb` 의 `menus` (§4-1) |
1171
+ | **GNB system 패널에서 설정·연결 상태를 한 줄씩 쌓고, 그 줄을 눌러 들어가게 한다** | **`SSystemActionButton`** |
1172
+
1173
+ **무엇이 그려지는지는 `type` 이 정한다.** 타입마다 다른 값만 `option` 으로 받으므로, 타입에 맞지 않는 `option` 은 타입 에러가 된다.
1174
+
1175
+ | `type` | 선행 아이콘 | 라벨 | 우측 | 언제 |
1176
+ | --- | --- | --- | --- | --- |
1177
+ | `account` | `user` 고정 | `label` | 없음 | 로그인한 계정을 보여주고 계정 화면으로 보낼 때 |
1178
+ | `select` | `option.icon` (필수) | `label` | 화살표 | 눌러서 하위 화면으로 들어갈 때 |
1179
+ | `switch` | `option.icon` (필수) | `label` | `option.text` + 새로고침 | 지금 붙어 있는 대상을 갈아 끼울 때 |
1180
+ | `subSelect` | `option.icon` (필수) | `label` | `option.text` + 화살표 | 지금 선택돼 있는 값을 함께 보여줄 때 |
1181
+ | `status` | 없음 | 상태 태그(`option.color`·`option.label`) | 화살표 | 라벨 대신 상태 자체가 제목일 때 |
1182
+
1183
+ - **`SPage` 본문에 직접 놓지 않는다.** 폭을 가득 채우는 행이라 페이지에 바로 놓으면 표처럼 보인다. GNB system 패널·`SDrawer`·`SPopover` 처럼 **좁은 폭이 정해진 자리**에 쌓는다. 행 사이 간격은 `--cmp-gnb-system-panel-gap` 이다.
1184
+ - **GNB 맨 아래에 쌓는 것이라면 이 버튼을 직접 배치하지 말고 `SGnbSystem` 의 `actions` 에 넘긴다** (§4-1). 패널의 배경·구분선·도메인 태그·알림·계정까지 한 벌로 그려주므로, 조각을 손으로 조립할 이유가 없다.
1185
+ - **`color` 는 놓이는 표면을 따라간다** — 흰 면이면 `light`, 오션블루 면이면 `dark`, 네이비 면이면 `darker`. 배경과 다른 색을 고르면 버튼만 떠 보인다.
1186
+ - **상태는 default·hover 둘뿐이다.** 토큰에 selected 가 없으므로 "지금 열려 있는 항목"을 이 버튼으로 표시하지 않는다. 선택 상태가 필요한 자리면 `SGnb` 메뉴나 `SListItem`(§3-7-6) 이다.
1187
+ - **선행 아이콘이 비는 행은 없다.** `account` 는 `user` 고정, `status` 는 태그가 자리를 차지하고, 나머지는 `option.icon` 이 필수다.
1188
+ - **우측 아이콘은 `type` 이 정하므로 밖에서 바꾸지 않는다.** 화살표가 필요한데 문구가 없으면 `select`, 문구가 함께 필요하면 `subSelect` 다.
1189
+ - 높이·라운드·아이콘 크기·좌우 여백은 전부 토큰이 넣는다. 직접 주지 않는다.
1190
+
932
1191
  ### 3-6. 영역 나누기 — SDivider vs SSplitter
933
1192
 
934
1193
  | 상황 | 사용 |
@@ -938,6 +1197,25 @@ const columns: STableColumn[] = [
938
1197
 
939
1198
  `SSplitter` 의 구분선은 평소 자리만 잡고 칠해지지 않다가, 경계에 커서를 올리거나 포커스를 주면 그때 드러난다 — 조절 가능한 자리라는 신호다. **항상 보이는 선이 필요하면 `SDivider` 를 쓴다.** 선 색·두께·주변 여백은 토큰이 정하므로 직접 주지 않는다.
940
1199
 
1200
+ **세로 구분선의 길이는 `height` prop 으로 준다 — `className` 의 `h-*` 로 주지 않는다.**
1201
+
1202
+ ```tsx
1203
+ // 줄 높이를 그대로 채운다 (기본)
1204
+ <div className="flex items-center gap-sd-8">
1205
+ <span>총 주문 3건</span>
1206
+ <SDivider vertical />
1207
+ <span>총 품목 12건</span>
1208
+ </div>
1209
+
1210
+ // 양옆 글자보다 짧은 선이 필요할 때만 길이를 정한다
1211
+ <SDivider vertical height={20} />
1212
+
1213
+ // 하지 말 것 — 선이 줄 맨 위에 붙는다
1214
+ <SDivider vertical className="h-sd-20" />
1215
+ ```
1216
+
1217
+ 길이를 주지 않으면 `align-self: stretch` 로 부모 줄 높이를 채우는데, `stretch` 는 높이가 `auto` 일 때만 늘린다. `className` 으로 높이를 정하면 `stretch` 가 조용히 무효가 되어 선이 위로 솟는다. `height` 는 길이와 교차축 가운데 정렬을 함께 적용하므로 이 함정이 없다 — 위·아래 정렬이 필요하면 `className="self-start"` 처럼 명시한다. (`sellmate/divider-vertical-height` 규칙이 잡는다.)
1218
+
941
1219
  ```tsx
942
1220
  <SSplitter defaultValue={30} limits={[20, 60]}>
943
1221
  <SSplitter.Before>내비게이션</SSplitter.Before>
@@ -968,18 +1246,42 @@ const columns: STableColumn[] = [
968
1246
 
969
1247
  > §3-0 라우팅에서 이 절을 가리키는 자리들이다. <!-- TODO(디자인): 전체 검수·확정 -->
970
1248
 
971
- #### 3-7-1. SInput vs STextarea
1249
+ #### 3-7-1. SInput vs STextarea vs SEditor vs SSearchInput
972
1250
 
973
- **줄 수가 아니라 값의 성격으로 고른다.** 값의 길이를 미리 알 수 있으면 `SInput`, 없으면 `STextarea` 다.
1251
+ **먼저 "그 값이 저장되는가"를 본다.** 저장되면 폼 필드(`SInput`·`STextarea`), 화면을 좁히기만 하고 사라지면 `SSearchInput` 이다.
974
1252
 
975
1253
  | 값 | 사용 |
976
1254
  | --- | --- |
977
1255
  | 이름·코드·전화번호·URL 처럼 형식이 정해진 값 | `SInput` |
978
1256
  | 메모·사유·설명처럼 길이가 예측되지 않는 문장 | `STextarea` |
1257
+ | 서식(제목·굵게·목록·정렬·색·링크·이미지)이 값의 일부로 저장되어야 하는 글 | `SEditor` |
1258
+ | 지금 보이는 목록·결과를 좁히는 검색어 | `SSearchInput` |
1259
+
1260
+ 폼 필드 둘은 **줄 수가 아니라 값의 성격으로** 갈린다. 값이 길어질 수 있는데 `SInput` 을 쓰면 사용자가 자기가 쓴 것을 다시 읽지 못한다 — 한 줄 안에서 좌우로 스크롤해야 하기 때문이다. 반대로 짧은 값에 `STextarea` 를 쓰면 빈 공간이 남아 입력량을 잘못 기대하게 한다.
1261
+
1262
+ `SEditor` 는 **서식이 값의 일부일 때만** 쓴다. 값을 HTML 문자열로 주고받으므로 저장·검색·비교가 평문보다 비싸고, 화면에 다시 보여줄 때도 HTML 로 렌더해야 한다. 서식이 필요 없는 메모·사유는 `STextarea` 다 — "입력창이 커 보여서" 고르는 컴포넌트가 아니다. 반대로 공지·안내문·상품 상세처럼 **작성자가 정한 강조와 목록이 그대로 보여야 하는 글**이면 `STextarea` 로는 표현할 수 없다.
979
1263
 
980
- 값이 길어질 수 있는데 `SInput` 을 쓰면 사용자가 자기가 쓴 것을 다시 읽지 못한다 — 한 줄 안에서 좌우로 스크롤해야 하기 때문이다. 반대로 짧은 값에 `STextarea` 를 쓰면 빈 공간이 남아 입력량을 잘못 기대하게 한다.
1264
+ `SEditor` 도 `SInput`·`STextarea` 와 같은 폼 필드다 — `label`·`hint`·`rules`·`errorMessage` 를 자기 prop 으로 받고 `SForm` 제출 검증에도 들어간다. 빈 문서는 빈 문자열로 나오므로 `required` 규칙이 그대로 걸린다. **툴바를 끄는 길은 없다** — 서식 입력이 필요 없는 자리라면 서식 없는 `SEditor` 가 아니라 `STextarea` 를 고른다.
981
1265
 
982
- #### 3-7-2. 하나를 고르게 하는 다섯 — SSelect vs SRadioGroup vs SRadioButton vs STabs vs SRadio
1266
+ 툴바는 **프리셋 둘 중 하나뿐이다.** 기본은 쓸 수 있는 것을 모두 보이고, **`simple` 을 켜면 글자에 거는 서식만 남는다** — 목록·정렬·인용·코드·링크·이미지·구분선이 빠지고 선택했을 때 뜨는 판도 같은 범위로 줄어든다. 받은 글의 **문단 구조까지 작성자를 따라가면 곤란한 자리**(좁은 칸의 메모·사유·짧은 안내문)가 `simple` 이다. 항목을 직접 조합하는 prop 은 없다 — 화면마다 다른 툴바가 서면 그 자체가 학습 비용이 된다.
1267
+
1268
+ 본문에서 따옴표·하이픈·화살표는 **자동으로 치환된다**(`"` → `“”`, `--` → `—`, `->` → `→`). 끌 수 없으므로, 상품 코드·규격 문자열처럼 **입력한 그대로 남아야 하는 값**은 `SEditor` 본문이 아니라 `SInput` 으로 따로 받는다.
1269
+
1270
+ 툴바는 **자리를 지킨다**(고정). 긴 글을 쓰는 동안 막대가 화면 밖으로 나가지 않으므로, 툴바를 따로 감싸거나 위치를 주지 않는다.
1271
+
1272
+ 툴바에는 **글자 크기 드롭다운**과 **글자색 팔레트**가 들어 있다 — 작성자가 문단마다 크기·색을 직접 지정할 수 있고, 크기 목록 맨 위 `기본` 은 지정을 떼는 자리다. 크기 눈금은 화면 타이포(§2-1)가 아니라 워드프로세서의 눈금이라 본문보다 훨씬 큰 단계까지 있다. **눈금도 팔레트도 좁히는 prop 이 없다** — 화면마다 고를 수 있는 것이 다르면 같은 글이 어디에 붙느냐에 따라 다르게 보이기 때문이다. 그래서 **작성자가 화면 리듬을 벗어나면 곤란한 자리(상품 상세 설명·반복 노출되는 안내문 등)라면 `SEditor` 가 맞는 자리인지 먼저 본다** — 서식이 값의 일부가 아니라면 `STextarea` 다. 고른 크기·색은 저장되는 HTML 에 그대로 남아 나중에 되돌릴 수 없다.
1273
+
1274
+ 본문에는 **표**도 들어간다 — 툴바의 `표` 드롭다운에서 격자를 끌어 크기를 고르고(최대 8행 × 10열), 행·열을 늘리고, 칸을 병합한다. **열 너비는 균등 고정이고 바꿀 수 없다**: 너비를 저장하면 그 값이 px 로 박혀 작성한 화면보다 좁은 곳에서 표가 넘친다. 대신 어떤 폭에서도 표가 상자 안에 들어오도록 열을 고르게 나눈다 — 열이 많은 표는 좁은 칸에서 글자가 잘게 접히므로, **열이 넷을 넘어가는 표라면 `SEditor` 본문이 아니라 `STable` 이 맞는 자리인지 본다.** 표는 작성자가 쓰는 글의 일부일 때만 여기에 있고, 데이터를 줄 세워 보여 주는 것은 `STable` 이다.
1275
+
1276
+ 글을 선택하면 **그 위에 서식 판이 뜬다**(버블 메뉴). 툴바까지 커서를 옮기지 않고 바로 굵게·색·링크를 거는 길이다. **끄고 켜는 prop 은 없다** — 편집할 수 있으면 언제나 뜨고, 읽기 전용·비활성일 때는 뜨지 않는다. 화면마다 있고 없고가 달라지면 그 자체가 학습 비용이 되기 때문이다. 판의 구성은 `simple` 이 막대와 함께 정한다. 뜨는 자리는 DS 가 잡는다, 직접 감싸거나 위치를 주지 않는다.
1277
+
1278
+ `SEditor` 는 화면에 처음 놓일 때 **에디터 엔진을 따로 불러온다** — 앱 초기 번들에는 들어가지 않는다. 그동안은 같은 크기의 빈 편집 영역이 자리를 지키므로 레이아웃은 흔들리지 않지만, **마운트하자마자 `ref.current.getHTML()` 로 값을 읽거나 툴바를 누를 수는 없다.** 열자마자 커서를 놓고 싶으면 `ref.current.focus()` 를 그냥 부르면 된다 — 준비되는 순간 대신 실행된다.
1279
+
1280
+ **이미지를 넣으려면 `onImageUpload` 를 준다** — 고른 파일을 저장하고 표시할 URL 을 돌려주는 훅이다. 저장 위치는 앱마다 다르므로 DS 가 정하지 않고, 훅이 없으면 툴바에서 이미지 항목이 빠진다. 본문에 base64 를 박는 길은 막아 두었다 — 저장 HTML 이 수 MB 로 부풀어 그대로 DB·API 에 실리기 때문이다.
1281
+
1282
+ `SSearchInput` 은 폼 필드가 아니다 — 라벨·힌트·유효성 규칙·에러 메시지를 받지 않고, `SForm` 의 제출 검증 대상에도 들어가지 않는다. 돋보기 아이콘이 항상 앞에 붙어 "여기는 검색"임을 스스로 밝히므로 라벨을 따로 붙이지 않는다. 검색 실행은 `onSearch`(Enter) 로 받고, 값이 바뀔 때마다 좁히는 실시간 필터라면 `onValueChange` 만 쓴다. 반대로 검색어를 **저장하거나 검증해야 한다면** 그것은 폼 값이므로 `SInput` 이다.
1283
+
1284
+ #### 3-7-2. 하나를 고르게 하는 여섯 — SSelect vs SRadioGroup vs SRadioCardGroup vs SRadioButton vs STabs vs SRadio
983
1285
 
984
1286
  **먼저 "고르면 무엇이 바뀌는가"를 본다.**
985
1287
 
@@ -990,10 +1292,17 @@ const columns: STableColumn[] = [
990
1292
  | --- | --- |
991
1293
  | 6개 이상, 또는 서버에서 오는 동적 목록 | `SSelect` |
992
1294
  | 2~5개 고정 + 선택지를 항상 보여야 함 | `SRadioGroup` |
1295
+ | 2~5개 고정 + **선택지마다 설명 한 줄이 필요함** | `SRadioCardGroup` |
993
1296
  | 2~4개 + 짧은 라벨의 배타적 모드 전환 (세그먼트) | `SRadioButton` |
994
1297
 
995
1298
  - **`SRadio` 를 직접 나열하지 않는다.** 그룹 간격은 `SRadioGroup` 이 맞춘다 (§2-2). `SRadio` 단독은 `SRadioGroup` 이 만들 수 없는 배치 — 표 셀 안에 행마다 하나씩 놓는 경우 — 에만 쓴다.
996
1299
  - `SRadioButton` 은 `options` 를 통째로 받는 세그먼트 컨트롤이라 `SRadio` 를 여러 개 넣는 게 아니다.
1300
+ - **`SRadioGroup` 과 `SRadioCardGroup` 의 갈림길은 "설명이 필요한가" 하나다.** 라벨만으로 뜻이 통하면 `SRadioGroup`, 고르기 전에 한 줄 설명을 읽어야 하면 `SRadioCardGroup` 이다. 라벨 옆에 설명을 괄호로 덧붙여 늘이지 않는다.
1301
+ - **`SRadioCardGroup` 은 `SRadioGroup` 의 대체가 아니라 격상판이다.** 라벨이 한 단계 크고 그 아래 설명 줄까지 들어가 카드 한 장이 자리를 넉넉히 쓴다 — 선택지가 조밀하게 반복되는 자리에는 과하다.
1302
+ - **쓰는 자리는 설정 화면·마법사(스텝)의 소수 결정이다.** 고르고 나면 뒤 화면이 통째로 바뀌거나 되돌리기 어려운 선택 — 평가 대상자 범위(전체/특정 구성원), 상담 자동 배정 방식 같은 것. 목록 필터·표 안 조건처럼 같은 선택지가 화면마다 반복 노출되는 자리에는 `SRadioGroup` 을 쓴다.
1303
+ - `SRadioCard` 단독은 `SRadioCardGroup` 이 만들 수 없는 배치에만 쓴다 — 나열은 그룹이 맡는다 (§2-2).
1304
+ - 카드 안에 상태 배지를 넣으려면 `tag` 슬롯에 `STag` 를 준다. 라벨 문자열에 "(추천)" 처럼 섞어 쓰지 않는다.
1305
+ - **카드처럼 생겼다고 `SCard`/`SSectionHeaderCard` 로 감싸지 않는다.** `SRadioCard` 자체가 완결된 요소이고, 나열 간격은 `SRadioCardGroup` 의 `direction` 이 맞춘다 (§2-2).
997
1306
 
998
1307
  #### 3-7-3. 켜고 끄는 셋 — SCheckbox vs SSwitch vs SToggle
999
1308
 
@@ -1014,15 +1323,45 @@ const columns: STableColumn[] = [
1014
1323
  | 판별 | 사용 |
1015
1324
  | --- | --- |
1016
1325
  | 날짜 **하나**를 값으로 받는다 | `SDatePicker` |
1326
+ | 연도 선택 리스트만 필요하다 (트리거·팝오버는 직접 조합) | `SDatePickerYearListbox` |
1327
+ | 연도+월 선택 리스트만 필요하다 (트리거·팝오버는 직접 조합) | `SDatePickerMonthListbox` |
1017
1328
  | **시작~종료** 를 값으로 받는다 | `SDateRangePicker` |
1018
1329
  | 달력 격자 **자체가 화면 콘텐츠** 다 (일정·이벤트 보기) | `SCalendar` |
1019
1330
 
1020
1331
  - **기간을 `SDatePicker` 두 개로 만들지 않는다.** 시작이 종료보다 뒤인 입력을 막는 검증과 한쪽만 고른 중간 상태 처리가 `SDateRangePicker` 안에 이미 있다. 두 개로 쪼개면 그게 전부 앱 몫이 된다.
1021
1332
  - `SDatePicker`·`SDateRangePicker` 는 내부적으로 `SCalendar` 를 팝오버로 띄운다. 값을 받는 자리에 `SCalendar` 를 직접 쓰지 않는다.
1333
+ - `SDatePickerYearListbox`·`SDatePickerMonthListbox` 는 `SDatePicker` 의 mode listbox 조각만 떼어낸 컴포넌트다. 일반 폼 입력에는 `SDatePicker mode="year" | "month"` 를 우선 쓰고, 다른 트리거·팝오버 안에 리스트만 끼워 넣을 때만 직접 쓴다.
1334
+
1335
+ **날짜·시간 피커는 폭 상한을 스스로 갖는다 — `width` 를 주지 않는다.** 값 길이가 `YYYY-MM-DD` 처럼 정해져 있어 컴포넌트가 사이즈별 상한을 안다. `SKeyValueTable` 이 모든 컨트롤에 `width="100%"` 를 넘기지만, 이 상한 덕분에 행 전체로 늘어나지 않고 제 폭에서 멈춘다.
1336
+
1337
+ | 컴포넌트 | `size="sm"` | `size="md"` |
1338
+ | --- | --- | --- |
1339
+ | `SDatePicker` | md | lg |
1340
+ | `SDateRangePicker` | lg | xl |
1341
+ | `STimePicker` | md | lg |
1342
+ | `STimeRangePicker` | md | lg (오전/오후 표시는 두 사이즈 모두 lg) |
1343
+
1344
+ `SDateRangePicker` 가 한 등급씩 위인 것은 값이 `YYYY-MM-DD ~ YYYY-MM-DD` 로 두 배가 넘기 때문이다. 같은 이유로 `STimeRangePicker` 의 오전/오후 모드도 sm 에서 한 등급 위를 쓴다 — 그 모드의 최소 폭이 md 등급을 이미 넘어, 그대로 두면 하한이 상한을 넘어 상한이 무력해진다.
1345
+
1346
+ `SDatePicker` 만 이 상한을 `maxWidth` 로 덮을 수 있다 — 등급을 주면 그 등급이 상한이 되고, `width="100%" maxWidth="100%"` 면 부모 폭을 그대로 채운다. **폭이 이미 좁게 정해진 자리(팝오버·좁은 카드)에서만 쓴다.** 폼·표 행에서는 쓰지 않는다 — 거기서 상한을 풀면 4~10글자짜리 값이 행 전체를 차지한다.
1347
+
1348
+ ##### 값을 지울 수 있게 하려면 `clearable`
1349
+
1350
+ `SSelect` · `SDatePicker` · `SDateRangePicker` · `STimePicker` · `STimeRangePicker` 가 같은 규칙으로 갖는다. 값이 있을 때만 지우기 버튼이 나타나고, 누르면 **`onValueChange` 로 `null` 이 온다** (빈 문자열이 아니다). 받는 쪽 상태도 `null` 을 담을 수 있어야 한다.
1351
+
1352
+ ```tsx
1353
+ const [from, setFrom] = useState<string | null>(null);
1354
+
1355
+ <SDatePicker label="시작일" clearable value={from} onValueChange={setFrom} />;
1356
+ ```
1357
+
1358
+ - **조회 조건(필터)에는 켠다.** 한 번 고른 날짜를 되돌릴 방법이 없으면 전체 조회로 돌아가려고 새로고침하게 된다.
1359
+ - **필수 입력 필드에는 켜지 않는다.** 지우면 다시 고르기 전까지 폼이 통과하지 못한다 — 지울 수 있어야 하는 값이면 애초에 필수가 아니다.
1360
+ - `disabled` 이면 지우기 버튼도 함께 사라진다. 끈 필드를 지울 수 있으면 안 되기 때문이다.
1022
1361
 
1023
1362
  #### 3-7-5. SField 를 직접 쓰는 경우
1024
1363
 
1025
- **거의 없다.** `SInput`·`SNumberInput`·`STextarea`·`SSelect`·날짜/시간 피커는 이미 내부에서 `SField` 를 쓰고 있어서 `label`·`required`·에러 표시를 자기 prop 으로 받는다. 그 위에 `SField` 를 한 겹 더 감싸면 라벨이 두 번 나온다.
1364
+ **거의 없다.** `SInput`·`SNumberInput`·`STextarea`·`SEditor`·`SSelect`·날짜/시간 피커는 이미 내부에서 `SField` 를 쓰고 있어서 `label`·`required`·에러 표시를 자기 prop 으로 받는다. 그 위에 `SField` 를 한 겹 더 감싸면 라벨이 두 번 나온다.
1026
1365
 
1027
1366
  직접 쓰는 경우는 하나뿐이다 — **디자인 시스템에 없는 컨트롤**에 다른 필드와 똑같은 라벨·필수·에러 모양을 붙일 때.
1028
1367
 
@@ -1035,15 +1374,17 @@ const columns: STableColumn[] = [
1035
1374
  | **순서 자체가 데이터**라 사용자가 끌어서 바꾼다 | `SDraggableList` + `SDraggableItem` |
1036
1375
 
1037
1376
  - **항목 하나의 속성을 나열하는 것은 목록이 아니다.** `라벨: 값` 이 세로로 쌓이는 것은 `SKeyValueTable` 이다 (§4-4).
1038
- - `SList` 는 레이아웃만 담당한다. 펼침·단일 선택 동작이 필요하면 `SExpansionList` 다 (§3-7-7).
1039
- - **항목 사이 구분선은 리스트가 그린다.** `SListItem`·`SExpansionItem`·`SDraggableItem` 셋 다 스스로 구분선을 그리지 않으므로, 목록을 감싸는 `SList`·`SExpansionList`·`SDraggableList` 에 `separator` 를 준다 — 아이템에 `border-b` 를 직접 붙이지 않는다. 테두리형(`bordered`)은 테두리가 구분 역할을 하므로 `separator` 대신 `useGap` 으로 띄운다.
1040
- - **`SListItem` 은 기본이 표시 전용이다.** 눌러서 이동·선택하게 하려면 `clickable` 을 준다 — hover·`selected`·`interaction="chevron"` 표현이 전부 여기에 딸려 있어서, `clickable` 없이 `selected` 만 주면 선택 표시가 나오지 않는다. `SExpansionList` 는 선택을 자기가 관리하므로 자식 아이템을 알아서 클릭 가능하게 만든다.
1377
+ - `SList` 는 레이아웃만 담당한다. depth 별 단일 펼침이 필요하면 `SExpansionList` 다 (§3-7-7).
1378
+ - **`SList` 의 자식은 `SListItem` 을 권장한다.** 다른 자식도 그대로 렌더되지만, 펼치는 항목은 `SExpansionList` + `SExpansionItem` 이, 끌어서 순서를 바꾸는 항목은 `SDraggableList` + `SDraggableItem` 이 여닫힘·정렬 동작까지 함께 관리하므로 그쪽을 쓴다 (§3-7-7).
1379
+ - **항목 사이 구분선은 리스트가 알아서 그린다.** `SListItem`·`SExpansionItem`·`SDraggableItem` 셋 다 스스로 구분선을 그리지 않는다. `SList`·`SExpansionList`·`SDraggableList` 가 자식 **사이에** 구분선을 넣으므로 아이템에 `border-b` 를 직접 붙이지 않고, 켜는 prop 도 따로 없다. 마지막 항목 아래에는 선이 남지 않는다. 테두리형(`bordered`)은 테두리가 구분 역할을 하므로 리스트가 구분선을 빼고, `useGap` 으로 띄운다 — `useGap` 을 준 목록에도 구분선은 들어가지 않는다.
1380
+ - **`SListItem` 은 기본이 표시 전용이다.** 눌러서 이동·선택하게 하려면 `clickable` 을 준다 — hover·`selected`·`interaction="chevron"` 표현이 전부 여기에 딸려 있어서, `clickable` 없이 `selected` 만 주면 선택 표시가 나오지 않는다. `SExpansionList` 는 자식 아이템을 알아서 클릭 가능하게 만들어 이 함정을 막아 준다 — 선택 상태 자체는 앱이 든다 (§3-7-7).
1041
1381
 
1042
1382
  ```tsx
1043
- ✅ <SList separator><SListItem title="일반 문의" />…</SList> {/* 줄로 구분되는 목록 */}
1383
+ ✅ <SList><SListItem title="일반 문의" />…</SList> {/* 줄로 구분되는 목록 — 구분선은 자동 */}
1044
1384
  ✅ <SList useGap><SListItem title="일반 문의" bordered />…</SList> {/* 카드처럼 떨어진 목록 */}
1045
1385
  ✅ <SListItem title="일반 문의" clickable selected onClick={…} /> {/* 눌러서 고르는 목록 */}
1046
- ❌ <SList><SListItem title="일반 문의" className="border-b" />…</SList>
1386
+ ❌ <SList><SListItem title="일반 문의" className="border-b" />…</SList> {/* 구분선을 직접 붙이지 않는다 */}
1387
+ ❌ <SList><><SListItem title="일반 문의" /><SListItem title="결제 문의" /></></SList> {/* Fragment 로 묶으면 그 안쪽은 구분되지 않는다 */}
1047
1388
  ❌ <SListItem title="일반 문의" selected /> {/* clickable 없으면 선택 표시가 안 나온다 */}
1048
1389
  ```
1049
1390
 
@@ -1055,7 +1396,30 @@ const columns: STableColumn[] = [
1055
1396
  | **한 번에 하나만** 열려야 한다 (아코디언) | `SExpansionList` + `SExpansionItem` |
1056
1397
  | **부모-자식 계층 자체**를 보여줘야 한다 (2단 이상, 연결선) | `STree` |
1057
1398
 
1058
- `SExpansionList` 는 depth 별 단일 확장·선택을 관리하는 wrapper 다. 직접 `useState` 로 "열린 항목 하나"를 들고 있지 않는다. 항목 사이 구분선은 여기서도 리스트가 그린다 — `separator` 를 준다 (§3-7-6).
1399
+ `SExpansionList` 는 depth 별 **단일 확장**을 관리하는 wrapper 다. 직접 `useState` 로 "열린 항목 하나"를 들고 있지 않는다. 항목 사이 구분선은 여기서도 리스트가 알아서 그린다 — 따로 줄 prop 이 없다 (§3-7-6).
1400
+
1401
+ ##### 펼침 ≠ 선택
1402
+
1403
+ **펼침은 리스트가 관리하고, 선택은 앱이 관리한다.**
1404
+
1405
+ 펼침은 화면 밖에 진실이 없는 순간 UI 상태다. 각 항목이 자기 `expanded` 를 들고 있으면 "하나만 열림"을 만들 수 없어 누군가 나머지를 닫아야 하고, 그것이 이 wrapper 다.
1406
+
1407
+ 선택은 다르다. 앱이 `selectedId` 스칼라 하나를 들면 상호배제가 구조적으로 보장되고, 그 값은 URL·store 로 복원돼야 한다. 리스트가 사본을 들면 그 순간 진실이 둘이 되어 어긋난다.
1408
+
1409
+ ```tsx
1410
+ const [selectedId, setSelectedId] = useState<string>();
1411
+
1412
+ <SExpansionList>
1413
+ {/* 하위가 없는 항목(전체·미분류)은 SExpansionItem 이 아니라 SListItem 이다 */}
1414
+ <SListItem title="전체" selected={selectedId === 'all'} onClick={() => setSelectedId('all')} />
1415
+ <SExpansionItem title="조직">
1416
+ <SListItem title="영업팀" selected={selectedId === 'sales'} onClick={() => setSelectedId('sales')} />
1417
+ </SExpansionItem>
1418
+ </SExpansionList>
1419
+ ```
1420
+
1421
+ - `clickable` 은 리스트가 자식 `SListItem` 에 기본으로 켜 준다 — `clickable` 없이 `selected` 만 주면 표시가 안 나오는 함정(§3-7-6)을 막는 값이다.
1422
+ - **하위를 가지지 않는 항목은 `SExpansionItem` 이 아니라 `SListItem`** 으로 둔다. 펼칠 것이 없는데 펼침 항목으로 만들면 화살표만 남는다.
1059
1423
 
1060
1424
  #### 3-7-8. SCard vs SSectionHeaderCard
1061
1425
 
@@ -1066,7 +1430,23 @@ const columns: STableColumn[] = [
1066
1430
 
1067
1431
  - 페이지 골격에서 콘텐츠를 묶는 섹션은 **사실상 전부 `SSectionHeaderCard`** 다 (§4-4·§4-5). 제목·필수 표시·도움말·헤더 우측 액션이 전부 여기 붙는다.
1068
1432
  - **카드 안에 카드를 겹치지 않는다.** 섹션 안을 더 나눠야 하면 `SDivider` 로 끊거나(§3-6) 섹션을 둘로 분리한다.
1069
- - 안쪽 여백은 `SSectionHeaderCard.Body` 의 `padding` prop 으로 준다 — `p-sd-*` 를 직접 주지 않는다 (§2-2).
1433
+ - 안쪽 여백은 `SSectionHeaderCard` 의 `padding` prop 으로 준다 — `p-sd-*` 를 직접 주지 않는다 (§2-2).
1434
+
1435
+ **카드가 자기 안에서 확정하는 액션을 가지면 하단 버튼을 `children` 에 직접 두지 않는다.** 두 카드 모두 모달·드로어와 같은 하단 액션 영역을 갖는다 — 주 액션은 `button`, 보조 버튼은 `footerLeft` 로 넘긴다(§3-3-4 와 같은 규칙). 배경·상단 구분선·좌우 여백·양끝 분리가 컴포넌트 규칙대로 잡히고, 좌우 끝이 헤더·본문과 맞는다. `footerLeft` 는 슬롯이라 `SButton` 을 직접 배치하며 `size="md"` 를 명시한다(§3-5-2).
1436
+
1437
+ ```tsx
1438
+ {/* 섹션 안에서 바로 수정·저장하는 인라인 폼 섹션 (§4-4) */}
1439
+ <SSectionHeaderCard
1440
+ title="배송지"
1441
+ marker
1442
+ footerLeft={<SButton color="neutral" outline size="md" label="취소" onClick={reset} />}
1443
+ button={{ label: '저장', onClick: save }}
1444
+ >
1445
+ <SKeyValueTable … />
1446
+ </SSectionHeaderCard>
1447
+ ```
1448
+
1449
+ **페이지 전체를 확정하는 액션은 카드 푸터가 아니라 페이지 하단에 둔다.** 카드 푸터는 **그 카드 안에서 닫히는 액션**의 자리다 — 여러 섹션을 한 번에 저장하는 버튼을 마지막 카드의 푸터에 넣으면 그 카드에만 걸리는 액션으로 읽힌다. 이때는 §4-4 처럼 카드 밖 하단 줄에 둔다.
1070
1450
 
1071
1451
  #### 3-7-9. SLinearProgress vs SCircleProgress
1072
1452
 
@@ -1093,7 +1473,8 @@ const columns: STableColumn[] = [
1093
1473
  - **기본은 `SKeyValueTable` 이다** (§4-2). 조건이 대여섯 개 이하로 고정이면 표로 펼쳐 두는 편이 한눈에 읽힌다.
1094
1474
  - `SChipFilter` 는 조건을 **칩 한 줄**로 접고, "필터 추가" 로 필요한 것만 꺼내 쓰게 한다. 칩을 누르면 편집 팝오버가 열리고, 날짜는 프리셋(오늘·지난 7일·사용자 지정)으로 고른다. 조건 후보가 많은 목록 화면에서 필터가 화면을 세로로 잡아먹는 것을 막는 용도다.
1095
1475
  - 검색 실행 시점이 다르다 — `SKeyValueTable` 필터는 앱이 검색 버튼을 직접 놓지만, `SChipFilter` 는 편집 팝오버가 닫히거나 "검색" 을 누를 때 `onSearch` 가 값 맵과 함께 호출된다. 값이 바뀌지 않았으면 호출되지 않는다.
1096
- - 함께 걸어야 하는 조건(예: 기간 중 하나는 필수)이 있으면 `fields` 를 그룹으로 넘긴다. 규칙을 못 채운 동안 경고 툴팁이 떠 있고 `onSearch` 가 막힌다.
1476
+ - **노출할 칩을 앱이 계산하지 않는다.** 바에 놓이는 것은 `fixed`·`required` 필드와 **`value` 에 값이 들어 있는 필드**다. 노출 목록을 밖에서 넘기는 prop 은 없다. 쿼리스트링·서버 상태에서 조건을 복원하는 목록 화면도 **`value` 만 넘기면 칩이 함께 살아나고**, 그 조건을 빼면 칩도 함께 빠진다 — 칩과 조회 조건이 어긋날 자리가 없다. "필터 추가" 로 꺼낸 필터는 값을 넣기 전에도 자리를 지키지만, "검색 초기화" 를 누르거나 화면을 다시 그리면 사라진다. 걸린 조건이 없으니 문제되지 않는다.
1477
+ - **`fields` 는 항상 그룹 배열이다.** 묶을 것이 없어도 `[{ fields: [...] }]` 로 한 겹 감싼다. 함께 걸어야 하는 조건(예: 기간 중 하나는 필수)이 있으면 그 필드들만 별도 그룹으로 떼어 `rule` 을 준다 — 규칙을 못 채운 동안 경고 툴팁이 떠 있고 `onSearch` 가 막힌다. 그룹 앞 구분선은 `divider` 로 켠다. 검증 단위와 구분선은 별개라, 묶어서 검증만 하고 싶으면 `divider` 를 주지 않는다.
1097
1478
 
1098
1479
  #### 3-7-12. 이미지 — SImage
1099
1480
 
@@ -1138,15 +1519,27 @@ export default function AppShell({
1138
1519
  children,
1139
1520
  header,
1140
1521
  scrollEndSpacing,
1141
- }: { children: React.ReactNode; header?: SPageHeaderProps; scrollEndSpacing?: boolean }) {
1522
+ contentHeight,
1523
+ }: {
1524
+ children: React.ReactNode;
1525
+ header?: SPageHeaderProps;
1526
+ scrollEndSpacing?: boolean;
1527
+ contentHeight?: SPageContentHeight;
1528
+ }) {
1142
1529
  return (
1143
1530
  <SLayout type="box" header="fix">
1144
1531
  {/* type/header/folded 는 SLayout 에만 준다 — SGnb 는 context 에서 읽는다 */}
1145
1532
  <SGnb items={MENU} value={current} onValueChange={navigate} logo={<Logo />} />
1146
1533
  {/* 콘텐츠 패딩은 SPage 가 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다 */}
1147
- {/* 스크롤 끝 여백도 SPage 가 넣는다. 끄는 건 페이지네이션 있는 목록뿐이라 페이지가 정한다 */}
1534
+ {/* 높이 모드는 페이지가 정한다 — 대부분 contentHeight="fill" 이다 (§2-2) */}
1535
+ {/* 스크롤 끝 여백도 SPage 가 넣는다. 페이지가 실제로 스크롤되는 화면에서만 켠다 */}
1148
1536
  {/* header 는 페이지마다 달라 AppShell 이 그대로 받아 넘긴다 — 페이지 제목은 여기서 만들지 않는다 */}
1149
- <SPage background="frame" scrollEndSpacing={scrollEndSpacing} header={header}>
1537
+ <SPage
1538
+ background="frame"
1539
+ scrollEndSpacing={scrollEndSpacing}
1540
+ contentHeight={contentHeight}
1541
+ header={header}
1542
+ >
1150
1543
  {children}
1151
1544
  </SPage>
1152
1545
  </SLayout>
@@ -1188,7 +1581,9 @@ import { SModalOutlet } from 'sellmate-design-system-react';
1188
1581
 
1189
1582
  **최소 너비는 `SLayout` 이 보장한다 — 앱이 `min-w-*` 를 직접 주지 않는다.** 창이 최소 너비(`SLAYOUT_MIN_WIDTH`, GNB 포함한 전체 기준)보다 좁아지면 GNB·상단바는 제자리에 남고 **`SPage` 안에만 가로 스크롤이 생긴다.** 문서(브라우저 창)에는 가로 스크롤이 생기지 않는다. 그래서 셸을 감싸는 요소에 `min-width` 나 `overflow-x` 를 걸지 않는다 — 걸면 창 전체가 스크롤되어 GNB 가 화면 밖으로 밀린다.
1190
1583
 
1191
- **셸의 `SPage` 는 모든 페이지가 공유하므로, 스크롤 끝 여백을 끄려면 페이지가 셸에 알려야 한다.** 위처럼 프레임 컴포넌트가 `scrollEndSpacing` 을 받아 그대로 넘기고, 페이지네이션이 있는 목록 페이지만 `false` 를 준다 (§4-2). 나머지 페이지는 넘기지 않으면 기본값(켬)이 적용된다.
1584
+ **셸의 `SPage` 는 모든 페이지가 공유하므로, 페이지마다 달라지는 것은 페이지가 셸에 알려야 한다.** 위처럼 프레임 컴포넌트가 `contentHeight` · `scrollEndSpacing` 을 받아 그대로 넘긴다.
1585
+
1586
+ **대부분의 페이지는 `contentHeight="fill"` 이다** — 본문이 창을 꽉 채우고 스크롤은 각 영역 안에서 일어나는 것이 표준이다(§2-2). 블록의 높이가 정해져 있고 그 높이가 창보다 커서 페이지 자체가 스크롤돼야 하는 화면에서만 `contentHeight="auto"`(기본값) + `scrollEndSpacing` 을 켠다.
1192
1587
 
1193
1588
  **상단바 배치는 `header` 가 정한다.** 요소 순서가 달라지므로 슬롯을 채우기 전에 어느 쪽인지부터 정한다.
1194
1589
 
@@ -1210,7 +1605,7 @@ import { SModalOutlet } from 'sellmate-design-system-react';
1210
1605
  topContent={
1211
1606
  /* 남는 폭 전체를 받는다 — 왼쪽은 그대로, 오른쪽 끝은 ml-auto */
1212
1607
  <div className="flex w-full items-center gap-sd-8">
1213
- <SInput value={keyword} onValueChange={setKeyword} placeholder="통합 검색" />
1608
+ <SSearchInput value={keyword} onValueChange={setKeyword} onSearch={runSearch} placeholder="통합 검색" />
1214
1609
  <SButton size="sm" color="neutral" outline label="내 계정" className="ml-auto" onClick={openAccount} />
1215
1610
  </div>
1216
1611
  }
@@ -1227,7 +1622,7 @@ import { SModalOutlet } from 'sellmate-design-system-react';
1227
1622
  {/* 접히면 menuTop·menuFooter 가 함께 빠지므로, 폴드 레일에 남길 것만 foldedTop 으로 따로 준다 */}
1228
1623
  <SGnb
1229
1624
  items={MENU} value={current} onValueChange={navigate} useRail
1230
- menuTop={<SInput value={keyword} onValueChange={setKeyword} placeholder="메뉴 검색" />}
1625
+ menuTop={<SSearchInput value={keyword} onValueChange={setKeyword} placeholder="메뉴 검색" />}
1231
1626
  menuFooter={<AccountRow />}
1232
1627
  foldedTop={<SGhostButton icon="search" size="sm" ariaLabel="메뉴 검색" onClick={openSearch} />}
1233
1628
  />
@@ -1235,6 +1630,37 @@ import { SModalOutlet } from 'sellmate-design-system-react';
1235
1630
 
1236
1631
  슬롯 안쪽 여백은 **슬롯 내용이 직접 갖는다** — 컴포넌트는 자리만 잡는다(폴드 슬롯만 좁은 폭에 맞춰 가운데 정렬한다). 메뉴 폭은 `resizable` 로 바뀔 수 있으므로 슬롯 내용은 고정 폭 대신 `w-full` 로 따라가게 둔다.
1237
1632
 
1633
+ #### GNB 맨 아래의 system 패널
1634
+
1635
+ **서비스 전환·도메인·알림·설정·계정처럼 "지금 어떤 자격으로 보고 있는가" 를 다루는 묶음은 `SGnbSystem` 을 `SGnb` 의 `system` 슬롯에 넣는다.** 이 조각들을 `menuFooter` 에 손으로 조립하지 않는다 — 패널의 배경·위쪽 경계·구분선·행 간격이 전부 토큰으로 정해져 있어 직접 만들면 어긋난다.
1636
+
1637
+ `system` 은 다른 슬롯과 달리 **펼침·접힘 양쪽을 혼자 맡는다** — 펼치면 메뉴 컬럼 바닥에, 접히면(`header="fix"`) `foldedFooter` 아래 레일 바닥에 여백 없이 붙는다. 그래서 `menuFooter`/`foldedFooter` 로 나눠 줄 필요가 없다.
1638
+
1639
+ ```tsx
1640
+ <SGnb
1641
+ items={MENU} value={current} onValueChange={navigate} logo={<Logo />}
1642
+ system={
1643
+ <SGnbSystem
1644
+ /* 상단에 쌓는 행 — SSystemActionButton 의 props 배열이다 (§3-5). color 는 넘기지 않는다 */
1645
+ actions={[{ type: 'select', label: '서비스 전환', option: { icon: 'robot' }, onClick: openServices }]}
1646
+ domain={domainName}
1647
+ alert={{ count: unreadCount, onClick: openAlerts }}
1648
+ setting={{ onClick: openSettings }}
1649
+ account={{ label: userName, onClick: openAccount }}
1650
+ />
1651
+ }
1652
+ />
1653
+ ```
1654
+
1655
+ - **`color` 와 `folded` 를 주지 않는다.** `system` 슬롯에 있으면 GNB 가 자기 색과 접힘 형태를 내려준다. 두 prop 은 GNB 밖에서 이 패널을 단독으로 쓸 때만 준다.
1656
+ - **각 조각은 해당 prop 을 줄 때만 나타난다 — 어떤 조합이어도 그것만으로 판이 성립한다.** 계정만, 알림만, 설정만, 알림+계정 … 서비스가 쓰는 것만 넘긴다. 조각을 빼려고 빈 문자열이나 빈 객체를 넣지 않는다(`setting={{}}` 은 "설정 버튼을 쓴다" 는 뜻이다).
1657
+ - 구분선은 **위아래 양쪽에 내용이 있을 때만** 그어진다 — `actions` 만 넘겨도 바닥에 뜻 없는 선이 남지 않는다.
1658
+ - 알림·설정은 **혼자 서면 폭을 다 먹고**, 둘이 나란히 서면 알림이 남는 폭을·설정이 제 폭을 갖는다. 어느 쪽이 빠져도 왼쪽 끝은 위아래 행과 맞으므로 앱이 정렬을 맞출 일이 없다.
1659
+ - **켜진 조각이 하나도 없으면 아무것도 렌더되지 않는다.** 조건부로 조각이 다 빠지는 화면에서도 GNB 바닥에 빈 판이 남지 않는다.
1660
+ - **접히면 알림·계정 아이콘만 남는다** — `actions`·`domain`·`setting` 은 48px 폭에 놓을 자리가 없어 렌더되지 않고, 알림 개수는 점 배지가 된다. **알림도 계정도 안 쓰는 조합이면 접힌 패널은 아예 렌더되지 않는다** — 접힌 상태에서도 반드시 눌러야 하는 것이 그 둘 밖에 있으면 `foldedTop`/`foldedFooter` 로 따로 준다.
1661
+ - **알림 개수는 `alert.count` 하나로 표현한다.** 1 이상이면 벨이 울리는 아이콘과 강조색으로 바뀐다 — 색은 GNB 색이 정하므로 앱이 직접 칠하지 않는다.
1662
+ - **`useRail` 에서 하위 메뉴가 없는 레일 아이템이 활성이면 메뉴 컬럼째 사라지므로 이 패널도 함께 빠진다.** 항상 보여야 하는 계정·알림이라면 그런 레일 리프를 두지 않는다.
1663
+
1238
1664
  ### 4-2. 목록 페이지 (필터 + 테이블)
1239
1665
 
1240
1666
  구조: **페이지 헤더(`AppShell` 의 `header` prop, 가이드 등 부가 액션은 slot) → 필터(`SKeyValueTable`) → `STableBar` → `STable`**
@@ -1246,7 +1672,10 @@ import { SModalOutlet } from 'sellmate-design-system-react';
1246
1672
  - **페이지 제목 줄에는 이 페이지의 주요 액션을 두지 않는다.** 부가적인 것만 `header.slot` 에 `SButton size="sm"` 으로 온다 (§4-1 "페이지 헤더 사용 규칙").
1247
1673
  - **주요 액션(등록 등)은 `STableBar` 의 `rightActions`** 에 둔다.
1248
1674
  - **선택 상태 액션(선택 삭제 등)은 `STableBar` 의 `actions`** 에 둔다. `actions` 슬롯은 건수 요약이 있으면 앞에 구분선(`SDivider`)을 **자동으로** 넣으므로 직접 구분선을 만들지 않는다.
1249
- - **페이지네이션이 있으면 스크롤 끝 여백을 끈다** — `AppShell` 에 `scrollEndSpacing={false}` 를 넘긴다 (§2-2). 페이지네이션이 이미 "여기서 끝"을 알려준다.
1675
+ - **본문이 남은 높이를 채우게 한다** — `AppShell` 에 `contentHeight="fill"` 을 넘긴다(§2-2 표준). 페이지가 통째로 스크롤되면 페이지네이션이 화면 밖으로 밀려 "여기서 끝"이 읽히지 않는다. `fill` 이면 **표만 자기 안에서 스크롤하고 페이지네이션은 하단에 고정**된다.
1676
+ - 본문 루트에 `h-full min-h-0` 으로 세로 축을 잇고, 남은 높이를 먹을 `STable` 에 `min-h-0 flex-1` 을 준다. 이 사슬이 하나라도 끊기면 표가 높이를 못 잡는다.
1677
+ - `fill` 에서는 페이지가 스크롤하지 않으므로 **`scrollEndSpacing` 은 무시된다** — 따로 끄지 않는다 (§2-2).
1678
+ - **정렬 가능한 컬럼은 `sortable` 로 준다.** 정렬 상태(`sort`)는 이 페이지가 들고 `onSortChange` 로 받는다 — 조회 조건이라 URL 에 실려야 한다 (§3-4).
1250
1679
 
1251
1680
  ```tsx
1252
1681
  import {
@@ -1294,9 +1723,10 @@ export default function ProductListPage() {
1294
1723
  // 이 페이지의 주요 액션이 아니라 부가 액션 — slot 은 sm 버튼으로만 채운다
1295
1724
  slot: <SButton size="sm" color="neutral" outline label="이용 가이드" onClick={openGuide} />,
1296
1725
  }}
1297
- scrollEndSpacing={false} // 페이지네이션이 있으므로 끈다
1726
+ contentHeight="fill" // 표가 남은 높이를 채우고 페이지네이션이 하단에 고정된다
1298
1727
  >
1299
- <div className="flex flex-col gap-sd-12">
1728
+ {/* h-full min-h-0 → STable 의 min-h-0 flex-1 로 세로 축이 이어진다 */}
1729
+ <div className="flex h-full min-h-0 flex-col gap-sd-12">
1300
1730
  {/* 필터 — search 를 켜면 우측에 검색 패널이 붙는다 */}
1301
1731
  <SKeyValueTable
1302
1732
  fields={filterFields}
@@ -1321,7 +1751,9 @@ export default function ProductListPage() {
1321
1751
  }
1322
1752
  />
1323
1753
 
1754
+ {/* 남은 높이를 채우고 본문만 스크롤한다 — 페이지네이션 바는 표 안에서 하단 고정 */}
1324
1755
  <STable
1756
+ className="min-h-0 flex-1"
1325
1757
  columns={columns}
1326
1758
  rows={rows}
1327
1759
  rowKey="id"
@@ -1337,6 +1769,33 @@ export default function ProductListPage() {
1337
1769
  }
1338
1770
  ```
1339
1771
 
1772
+ #### 한 화면에 더 많은 행을 — `dense` 와 밀도 토글
1773
+
1774
+ 행 높이를 줄이는 것은 `dense` 다. 세로 여백만 줄고 좌우 패딩은 그대로라, 값이 잘리지 않으면서 한 화면에 들어가는 행 수가 늘어난다.
1775
+
1776
+ **어느 쪽이 편한지는 화면이 아니라 사용자가 안다.** 그래서 `dense` 는 밀도를 고정하는 스위치가 아니라 **시작 밀도이자 밀도 토글의 스위치**다 — 켜면 하단 바 우측(rows per page 셀렉트 왼쪽)에 `좁게 보기` · `넓게 보기` 링크가 붙는다. 켜고 끄는 별도 prop 은 없다.
1777
+
1778
+ ```tsx
1779
+ // 좁게 시작하고, 사용자가 바꾸는 밀도는 표가 알아서 들고 간다
1780
+ <STable dense useRowsPerPageSelect pagination={{ currentPage, lastPage }} />;
1781
+
1782
+ // 사용자가 고른 밀도를 다음 방문에도 남기려는 화면만 받아서 저장한다.
1783
+ // 저장한 값은 시작 밀도로만 돌려준다 — 되돌려 넣지 않는다
1784
+ const [initialDense] = useState(() => loadPref('list.dense', true));
1785
+
1786
+ <STable
1787
+ dense={initialDense}
1788
+ onDenseChange={next => savePref('list.dense', next)}
1789
+ useRowsPerPageSelect
1790
+ pagination={{ currentPage, lastPage }}
1791
+ />;
1792
+ ```
1793
+
1794
+ - **누른 뒤의 밀도는 `STable` 이 내부 상태로 들고 간다.** `onDenseChange` 없이도 토글은 동작한다. 저장이 필요한 화면만 받아서 저장하면 된다.
1795
+ - **`dense` 에 `onDenseChange` 값을 되돌려 넣지 않는다.** 토글을 붙일지는 이 prop 이 정하므로, 넓게 본 순간 `dense` 가 `false` 가 되면 토글이 사라져 다시 좁힐 길이 없다. prop 값을 바꿔 넘기는 것은 외부 버튼 등으로 밀도를 **되돌릴 때**만 쓴다 — 내부 밀도가 그 값으로 맞춰진다.
1796
+ - **`dense` 면 페이지네이션이 없어도 토글이 나온다** — 하단 바를 토글만 담아 그린다. `dense` 가 아니면 토글도 없다.
1797
+ - 라벨과 아이콘은 현재 상태가 아니라 **누르면 되는 상태**를 가리킨다. 좁게 보고 있으면 `넓게 보기` 다.
1798
+
1340
1799
  ### 4-3. 폼 페이지 (등록/수정)
1341
1800
 
1342
1801
  구조: **페이지 제목(`AppShell` 의 `header` prop) → `SForm` + `SKeyValueTable` → 하단 버튼**
@@ -1344,6 +1803,19 @@ export default function ProductListPage() {
1344
1803
  - 필드를 `div` 로 나열하지 않고 **`SKeyValueTable` 의 행으로 구성**한다.
1345
1804
  - 검증 규칙은 각 field 의 `options.rules` 로 넘긴다. `SForm` 이 하위 컨트롤을 자동 수집해 submit 시 일괄 검증하고, 실패 시 첫 실패 필드로 포커스를 옮긴다.
1346
1805
  - **버튼 순서: 취소·닫기가 왼쪽, 저장·등록·수정·삭제가 오른쪽.** 이 순서는 모든 화면에서 동일하다.
1806
+ - **폼 페이지도 `contentHeight="fill"` 이 기본이다**(§2-2). 폼이 길어 페이지가 실제로 스크롤되는 화면에서만 `auto` + `scrollEndSpacing` 을 켠다.
1807
+ - **필드 폭은 등급으로 준다** — `width="md"` 처럼 `'xs' | 'sm' | 'md' | 'lg' | 'xl'` 중 하나다. px 를 직접 적지 않는다. 등급은 `maxLength`(= 스키마 상한)로 고르고, 상한이 `xl` 을 넘거나 상한이 없으면 `width="100%"` 로 행 전체를 쓴다 (§6 `field-width-grade`).
1808
+
1809
+ **`SKeyValueTable` 의 전체 열 수는 가장 긴 행이 정한다.** 어떤 행이 그보다 짧으면 남는 자리에 셀이 없어 그 구간의 행 구분선이 끊긴다. 마지막 필드에 `tdColSpan` 을 주어 채운다.
1810
+
1811
+ ```tsx
1812
+ [
1813
+ [{ name: 'category', … }, { name: 'price', … }], // 필드 2개 → 4칸
1814
+ [{ name: 'memo', …, tdColSpan: 3 }], // th(1) + td(3) = 4칸
1815
+ ]
1816
+ ```
1817
+
1818
+ **한 행에 필드를 추가하면 다른 행들의 `tdColSpan` 도 함께 봐야 한다.** 전체 열 수가 늘면 나머지 행들이 조용히 짧아진다 — 화면에서만 드러나는 컴포넌트 고유 동작이라 자동으로 채워 주지 않는다.
1347
1819
 
1348
1820
  ```tsx
1349
1821
  import {
@@ -1407,8 +1879,10 @@ export default function ProductCreatePage() {
1407
1879
 
1408
1880
  - 조회 값은 `type: 'text'` 행으로 표시한다. **상태·분류 태그도 별도 영역이 아니라 표의 한 행**으로 넣는다 (`render` 에 `STag`).
1409
1881
  - 행이 많아지면 **유형별로 섹션을 나누고, 각 섹션을 `SSectionHeaderCard` 로 감싼다.**
1410
- 합성 컴포넌트라 `SSectionHeaderCard.Header` / `SSectionHeaderCard.Body` 를 자식으로 쓴다.
1882
+ 섹션 제목은 `title` prop 으로, 바디 여백은 `padding` prop 으로 준다.
1411
1883
  - **수정·삭제 버튼은 하단에 둔다.** 내용이 짧아 우측 상단에 두는 변형도 있으나 기본은 하단이다.
1884
+ - **상세 페이지도 `contentHeight="fill"` 이 기본이다**(§2-2). 섹션이 많아 페이지가 실제로 스크롤되는 화면에서만 `auto` + `scrollEndSpacing` 을 켠다.
1885
+ - **섹션마다 독립 인라인 폼이 있는 형태**도 상세 페이지의 변형이다. 섹션 안에서 바로 수정·저장하게 하는 화면인데, 이때 버튼 강조는 **섹션 단위가 아니라 페이지 단위로 판단한다** — §3-5-1 의 "`secondary` 연속 배치 금지"는 섹션이 다르면 적용되지 않는다. 그 섹션 안에서 닫히는 저장·취소는 `SSectionHeaderCard` 의 `button`·`footerLeft` 로 넘긴다 (§3-7-8). 아래 예처럼 **페이지 전체를 확정하는 버튼은 카드 밖 하단 줄**에 둔다 — 둘을 섞지 않는다.
1412
1886
 
1413
1887
  ```tsx
1414
1888
  import {
@@ -1440,24 +1914,18 @@ export default function ProductDetailPage() {
1440
1914
  // 목록에서 들어온 상세 페이지 — onBack 으로 뒤로가기를 준다
1441
1915
  <AppShell header={{ fix: true, title: '클래식 셔츠', onBack: goList }}>
1442
1916
  <div className="flex flex-col gap-sd-12">
1443
- <SSectionHeaderCard>
1444
- <SSectionHeaderCard.Header title="기본 정보" marker thickness="accent" />
1445
- <SSectionHeaderCard.Body>
1446
- <SKeyValueTable fields={basicFields} values={product} />
1447
- </SSectionHeaderCard.Body>
1917
+ <SSectionHeaderCard title="기본 정보" marker thickness="accent">
1918
+ <SKeyValueTable fields={basicFields} values={product} />
1448
1919
  </SSectionHeaderCard>
1449
1920
 
1450
- <SSectionHeaderCard>
1451
- {/* 헤더 우측에 액션이 필요하면 slot 을 쓴다 */}
1452
- <SSectionHeaderCard.Header
1453
- title="가격 정보"
1454
- marker
1455
- helpText={['부가세 포함 금액입니다.']}
1456
- slot={<SButton size="sm" color="secondary" label="이력" onClick={openHistory} />}
1457
- />
1458
- <SSectionHeaderCard.Body>
1459
- <SKeyValueTable fields={priceFields} values={product} />
1460
- </SSectionHeaderCard.Body>
1921
+ {/* 헤더 우측에 액션이 필요하면 slot 을 쓴다 */}
1922
+ <SSectionHeaderCard
1923
+ title="가격 정보"
1924
+ marker
1925
+ helpText={['부가세 포함 금액입니다.']}
1926
+ slot={<SButton size="sm" color="secondary" label="이력" onClick={openHistory} />}
1927
+ >
1928
+ <SKeyValueTable fields={priceFields} values={product} />
1461
1929
  </SSectionHeaderCard>
1462
1930
 
1463
1931
  {/* 액션은 하단 — 목록(되돌리기)은 왼쪽 끝, 실행 액션은 오른쪽 끝 */}
@@ -1491,6 +1959,29 @@ export default function ProductDetailPage() {
1491
1959
  | Prop (Body) | 용도 |
1492
1960
  | --- | --- |
1493
1961
  | `padding` | 안쪽 여백 — `'default'`(기본) / `'wide'` / `'none'`. 판정은 §2-2 "섹션·패널 안쪽 여백". `p-sd-*` 를 직접 주지 않는다 |
1962
+ | `background` | 본문 바탕 — `'frame'`(기본) / `'neutral'`. 판정은 §2-2 "본문 바탕 눌러앉히기" |
1963
+
1964
+ | Prop (Footer) | 용도 |
1965
+ | --- | --- |
1966
+ | `button` | 하단 액션 영역 우측 주 액션 (§3-7-8) |
1967
+ | `footerLeft` | 하단 액션 영역 좌측 슬롯 — 보조 버튼. `SButton` 에 `size="md"` 를 명시한다 |
1968
+
1969
+ 둘 중 하나라도 주면 하단 액션 영역이 렌더된다. 회색 바탕 + 상단 구분선이며 좌우 끝은 헤더에 맞는다 — 배경·여백을 직접 주지 않는다.
1970
+
1971
+ **한 페이지에 섹션 카드가 둘 이상이면 `marker` 를 켠다.** 점은 섹션을 서로 구분할 대상이 여럿일 때만 의미가 있어, 카드가 하나뿐인 페이지에서는 켜지 않는다. 한 페이지 안에서는 켜거나 끄거나 전부 같게 간다.
1972
+
1973
+ **섹션 본문이 자기 안에서 스크롤해야 하면 루트 `className` 으로 마지막 자식에 세로 축을 잇는다.**
1974
+
1975
+ ```tsx
1976
+ <SSectionHeaderCard
1977
+ title="…"
1978
+ className="[&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
1979
+ >
1980
+ <STable className="min-h-0 flex-1" … />
1981
+ </SSectionHeaderCard>
1982
+ ```
1983
+
1984
+ 본문 래퍼는 `className` 을 받지 않으므로(여백은 `padding` prop 으로만 받는다) 루트에서 내려 준다. **하단 액션 영역이 있으면 본문이 더 이상 마지막 자식이 아니다** — 그때는 `[&>div:nth-last-child(2)]` 로 겨눈다. 흔한 구성은 아니다 — 대부분은 `STable` 이 자기 안에서 스크롤하므로 여기까지 갈 일이 없다.
1494
1985
 
1495
1986
  ---
1496
1987
 
@@ -1506,8 +1997,11 @@ export default function ProductDetailPage() {
1506
1997
  - [ ] 텍스트 회색 위계를 순차 적용했는가 (기본 → `text-fg-secondary` → `text-fg-tertiary`, 단계 건너뛰기 ❌)
1507
1998
  - [ ] `SPage`·`SPopup` 의 기본 패딩을 `p-sd-*` 로 덮어쓰지 않았는가, 블록·섹션 **간격**이 `gap-sd-12` 인가 (`gap-sd-16`/`gap-sd-24` ❌ — 24 는 안쪽 여백에만 열린다)
1508
1999
  - [ ] 섹션·패널의 안쪽 **여백**이 §2-2 판정과 맞는가 (덩어리 두 종류 → 16 / 세 종류 이상 → 24, 서면 16)
1509
- - [ ] `SSectionHeaderCard.Body` 의 여백을 `p-sd-*` 가 아니라 `padding` prop 으로 줬는가
1510
- - [ ] 자체 스크롤하는 패널의 하단에 `pb-[var(--cmp-pageBody-padding-scrollEnd)]` 이 있는가, 페이지네이션 있는 목록에서 `scrollEndSpacing={false}` 를 넘겼는가
2000
+ - [ ] `SSectionHeaderCard` 의 여백을 `p-sd-*` 가 아니라 `padding` prop 으로 줬는가
2001
+ - [ ] 페이지에 `contentHeight="fill"` 을 넘겼는가 (§2-2 표준 — 페이지 스크롤을 쓰는 화면에서만 `auto` + `scrollEndSpacing`)
2002
+ - [ ] `fill` 을 쓴 블록에서 **자식까지 `min-h-0 flex-1` 이 끊기지 않았는가** (한 군데만 끊겨도 자식이 높이를 못 잡는데 실패가 조용하다)
2003
+ - [ ] 자체 스크롤하는 패널의 하단에 `pb-[var(--cmp-pageBody-padding-scrollEnd)]` 이 있는가
2004
+ - [ ] 한 페이지에 섹션 카드가 둘 이상이면 `marker` 를 켰는가, 하나뿐이면 껐는가 (§4-5)
1511
2005
  - [ ] 같은 컴포넌트를 나열할 때 §2-2 그룹 간격을 썼는가 (체크박스 가로 `gap-sd-24` 등)
1512
2006
  - [ ] 페이지가 §4의 표준 골격에서 시작했는가
1513
2007
  - [ ] `header.fix` 가 프로젝트 전체와 같은 값인가 (다른 페이지와 다르게 섞어 쓰지 않았는가, §4-1)
@@ -1516,11 +2010,20 @@ export default function ProductDetailPage() {
1516
2010
  - [ ] 필터·폼·상세 정보를 `SKeyValueTable` 로 만들었는가 (컨트롤을 `div` 로 나열하지 않았는가). 필터를 `SChipFilter` 로 했다면 §3-7-11 의 판정을 거쳤는가
1517
2011
  - [ ] 섹션 구분에 `SSectionHeaderCard` 를 썼는가 (직접 만든 카드가 아니라)
1518
2012
  - [ ] 목록의 주요 액션이 `STableBar` 의 `rightActions` 에 있는가 (페이지 제목 줄이 아니라)
2013
+ - [ ] 목록 페이지 표에 `dense` 로 밀도 토글을 띄웠는가, 그 값을 `onDenseChange` 결과로 되돌려 넣지는 않았는가 (§4-2 — 되돌려 넣으면 넓게 본 순간 토글이 사라진다)
1519
2014
  - [ ] 상태 표시에 `STag size="sm"` 을 썼는가
1520
2015
  - [ ] 테이블에서 양을 나타내는 컬럼(금액·수량·개수 등)이 전부 `align: 'right'` 인가
1521
2016
  - [ ] 번호·코드·전화번호·일자 컬럼에 `align: 'center'` 를 **명시**했는가 (생략하면 좌측이 된다)
1522
- - [ ] 컨트롤(`STag`·`SButton`·`SSelect`·`SInput` …)이 들어가는 컬럼에 `width` 를 명시했는가, `resizable` 이면 `minWidth` 도 줬는가 (§3-4 — 폭이 모자라면 요소가 잘려 못 쓴다)
2017
+ - [ ] 닫힌 값 집합(enum·마스터 목록에서 고르는 값) 컬럼에 `align: 'center'` 를 줬는가 — 태그로 그렸든 맨 텍스트로 그렸든 같다 (§3-4)
2018
+ - [ ] **모든 컬럼에 폭을 명시**했는가, px 로만 줬는가 (`%`·`clamp()` ❌), `autoWidth` 는 스페이서 열 하나뿐인가 (§3-4)
2019
+ - [ ] 컨트롤(`STag`·`SButton`·`SSelect`·`SInput` …)이 들어가는 컬럼이 `resizable` 이면 `minWidth` 도 줬는가 (§3-4 — 폭이 모자라면 요소가 잘려 못 쓴다)
2020
+ - [ ] 정렬 가능한 열에 `sortable` 을 줬는가 (`renderHeader` 로 직접 만들지 않았는가), 정렬 상태를 페이지가 들고 있는가 (§3-4)
2021
+ - [ ] `editable` · `navigable` 표식을 켠 열이 **셀에서도 실제로 그렇게 동작하는가** (입력 컨트롤 · 링크가 있는가), 표식을 붙인 열의 폭을 함께 넓혔는가 (§3-4)
1523
2022
  - [ ] 금액·수량 등 양을 나타내는 숫자에 빠짐없이 `toLocaleString()` 을 썼는가 (번호·코드는 제외)
2023
+ - [ ] 날짜를 `YYYY-MM-DD` 로 표기했는가 (`toLocaleDateString()` ❌, §1-4)
2024
+ - [ ] 필드 폭을 등급(`'xs' | 'sm' | 'md' | 'lg' | 'xl'`)으로 줬는가 — px 직접 지정 ❌ (§4-3)
2025
+ - [ ] 조회 조건으로 쓰는 셀렉트·날짜·시간 피커에 `clearable` 을 줬는가, 그 상태가 `null` 을 담을 수 있는가 (§3-7-4 — 필수 입력 필드에는 켜지 않는다)
2026
+ - [ ] `SKeyValueTable` 의 짧은 행에 `tdColSpan` 을 주어 전체 열 수를 채웠는가 (§4-3 — 안 채우면 그 구간의 행 구분선이 끊긴다)
1524
2027
  - [ ] 하단 버튼이 양끝 분리(`justify-between`)이고, 되돌리기가 왼쪽 끝 · 실행이 오른쪽 끝인가
1525
2028
  - [ ] 페이지에 `color="primary"` 채움 버튼이 **1개뿐**인가 (`danger` 채움도 1개, `SDropdownButton` 포함)
1526
2029
  - [ ] 버튼 `size` 가 위치 규칙과 맞는가 (행 내부 `xs` / 화면 액션 `sm` / 모달 푸터 `md`)
@@ -1529,7 +2032,7 @@ export default function ProductDetailPage() {
1529
2032
  - [ ] `SGhostButton` 의 `intent` 가 조작 성격과 맞는가 (되돌릴 수 없는 삭제만 `danger`, 진입·추가는 `action`, 나머지는 `default`)
1530
2033
  - [ ] 창을 띄울 때 §3-3-1 판별 순서를 따랐는가 (그 자체가 화면 → `SPopup` / 실행 여부만 확정 → `SModal.confirm` / 모달 안에서 작성 → `SActionModal`)
1531
2034
  - [ ] 작업용 모달을 `SActionModal` + `SModal.create` 로 만들었는가 (직접 오버레이 ❌)
1532
- - [ ] 모달·드로어의 하단 버튼을 본문이 아니라 `button` · `footerLeft` prop 으로 넘겼는가 (§3-3-4)
2035
+ - [ ] 모달·드로어의 하단 버튼을 본문이 아니라 `button` · `footerLeft` prop 으로 넘겼는가 (§3-3-4), 카드 안에서 닫히는 액션도 같은 prop 으로 넘겼는가 (§3-7-8)
1533
2036
  - [ ] 앱 부트스트랩의 Provider 안쪽에 `<SModalOutlet />` 이 한 번 렌더되어 있는가 (§4-1 — 없으면 모달 안에서 앱 훅이 죽는다), 그 대신으로 모달 컴포넌트를 Provider 로 다시 감싸지 않았는가
1534
2037
  - [ ] 고른 컴포넌트를 §2-0 의 제 층에 놓았는가 (요소를 `SPage` 에 직접 놓지 않았는가, 블록을 `div` 로 감싸지 않았는가)
1535
2038
  - [ ] §2-0 포함 규칙을 지켰는가 (카드 안 카드 ❌, 표 셀 안 블록 ❌)
@@ -1557,6 +2060,8 @@ export default function ProductDetailPage() {
1557
2060
  | `sellmate/component-group-gap` | warn | §2-2 컴포넌트 그룹 간격 (체크박스 가로 24 / 세로 8 등) |
1558
2061
  | `sellmate/table-numeric-align` | warn | §3-4 숫자 컬럼의 `align: 'right'` 누락 (`--fix` 지원) |
1559
2062
  | `sellmate/require-locale-number` | warn | §1-4 금액·수량 등 수량 컬럼의 `toLocaleString()` 누락 |
2063
+ | `sellmate/field-width-grade` | warn | §4-3 필드 폭이 `maxLength` 상한과 맞는 등급인가, px 를 직접 적지 않았는가 (px → 등급 `--fix` 지원) |
2064
+ | `sellmate/table-column-width` | warn | §3-4 컬럼 폭 미지정(기본 120px)·px 아닌 값(`%`·`clamp()`)·`autoWidth` 오용 |
1560
2065
  | `sellmate/no-arbitrary-class` | off | §1-2 토큰 있는 속성의 임의 값 (`text-[14px]`, `bg-[#eee]`) — 팀이 켤 때만 |
1561
2066
 
1562
2067
  `configs.strict` 를 쓰는 프로젝트는 전부 error 이고 간격 `sd-` 접두까지 강제된다.
@@ -1619,9 +2124,11 @@ Tailwind 유틸리티는 아래 스케일에 있는 값만 사용한다. 리터
1619
2124
  | typo-body-xs-default | 11px | 400 | 18px |
1620
2125
  | typo-body-sm-default | 12px | 400 | 20px |
1621
2126
  | typo-body-sm-medium | 12px | 500 | 20px |
2127
+ | typo-body-sm-semibold | 12px | 600 | 20px |
1622
2128
  | typo-body-sm-bold | 12px | 700 | 20px |
1623
2129
  | typo-body-md-default | 14px | 400 | 24px |
1624
2130
  | typo-body-md-medium | 14px | 500 | 24px |
2131
+ | typo-body-md-semibold | 14px | 600 | 24px |
1625
2132
  | typo-body-md-bold | 14px | 700 | 24px |
1626
2133
  | typo-body-lg-default | 16px | 400 | 26px |
1627
2134
  | typo-body-lg-medium | 16px | 500 | 26px |
@@ -1741,8 +2248,9 @@ Tailwind 유틸리티는 아래 스케일에 있는 값만 사용한다. 리터
1741
2248
  --sys-color-lightblue-subtle
1742
2249
  --sys-color-link-accent
1743
2250
  --sys-color-navigation-gnb-bg-dark
2251
+ --sys-color-navigation-gnb-bg-system
1744
2252
 
1745
- ## 3. 컴포넌트 인덱스 (65종)
2253
+ ## 3. 컴포넌트 인덱스 (70종)
1746
2254
 
1747
2255
  **Props 는 이 문서에 없다.** 쓸 컴포넌트를 정했으면 그 컴포넌트의 README 를 읽는다.
1748
2256
 
@@ -1751,4 +2259,4 @@ Tailwind 유틸리티는 아래 스케일에 있는 값만 사용한다. 리터
1751
2259
  예) SButton 을 쓴다면 `dist/components/SButton/README.md`.
1752
2260
  prop 을 추측해서 쓰지 말고 반드시 README 로 확인한다 (분류별 용도는 §1 의 "전체 컴포넌트 인덱스" 참조).
1753
2261
 
1754
- SActionModal · SBadge · SBarcodeInput · SButton · SCalendar · SCallout · SCard · SCheckbox · SChip · SChipFilter · SChipInput · SCircleProgress · SConfirmModal · SDatePicker · SDateRangePicker · SDivider · SDraggableItem · SDraggableList · SDrawer · SDropdownButton · SExpansionItem · SExpansionList · SField · SFilePicker · SForm · SGhostButton · SGnb · SGuide · SIcon · SImage · SInput · SKeyValueTable · SLayout · SLinearProgress · SList · SListItem · SLoadingContainer · SLoadingModal · SModal · SNumberInput · SPage · SPagination · SPopover · SPopup · SPortal · SRadio · SRadioButton · SScrollArea · SSectionHeaderCard · SSelect · SSplitter · SStepper · SSwitch · STable · STableBar · STabs · STag · STextLink · STextarea · STimePicker · STimeRangePicker · SToast · SToggle · STooltip · STree
2262
+ SActionModal · SBadge · SBarcodeInput · SButton · SCalendar · SCallout · SCard · SCheckbox · SChip · SChipFilter · SChipInput · SCircleProgress · SConfirmModal · SDatePicker · SDateRangePicker · SDivider · SDraggableItem · SDraggableList · SDrawer · SDropdownButton · SEditor · SExpansionItem · SExpansionList · SField · SFilePicker · SForm · SGhostButton · SGnb · SGnbSystem · SGuide · SIcon · SImage · SInput · SKeyValueTable · SLayout · SLinearProgress · SList · SListItem · SLoadingContainer · SLoadingModal · SModal · SNumberInput · SPage · SPagination · SPopover · SPopup · SPortal · SRadio · SRadioButton · SRadioCard · SScrollArea · SSearchInput · SSectionHeaderCard · SSelect · SSplitter · SStepper · SSwitch · SSystemActionButton · STable · STableBar · STabs · STag · STextLink · STextarea · STimePicker · STimeRangePicker · SToast · SToggle · STooltip · STree