sellmate-design-system-react 9.0.0-beta.7 → 9.0.0-beta.71

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 (388) hide show
  1. package/AGENTS.md +1873 -250
  2. package/README.md +76 -24
  3. package/dist/components/SAccountListBox/AccountListBoxPortal.d.ts +31 -0
  4. package/dist/components/SAccountListBox/README.md +86 -0
  5. package/dist/components/SAccountListBox/SAccountListBox.d.ts +79 -0
  6. package/dist/components/SAccountListBox/SystemMenuPortal.d.ts +58 -0
  7. package/dist/components/SAccountListBox/accountListBox.config.d.ts +64 -0
  8. package/dist/components/SAccountListBox/index.d.ts +2 -0
  9. package/dist/components/SActionModal/README.md +1 -0
  10. package/dist/components/SActionModal/SActionModal.d.ts +9 -1
  11. package/dist/components/SBadge/README.md +27 -0
  12. package/dist/components/SBadge/SBadge.d.ts +1 -1
  13. package/dist/components/SBarChart/README.md +122 -0
  14. package/dist/components/SBarChart/SBarChart.d.ts +99 -0
  15. package/dist/components/SBarChart/barChart.config.d.ts +85 -0
  16. package/dist/components/SBarChart/index.d.ts +2 -0
  17. package/dist/components/SBarcodeInput/README.md +13 -4
  18. package/dist/components/SBarcodeInput/SBarcodeInput.d.ts +18 -3
  19. package/dist/components/SButton/README.md +40 -0
  20. package/dist/components/SCalendar/README.md +13 -0
  21. package/dist/components/SCalendarBoard/CalendarDayCard.d.ts +53 -0
  22. package/dist/components/SCalendarBoard/CalendarDayEventListBox.d.ts +26 -0
  23. package/dist/components/SCalendarBoard/CalendarEventButton.d.ts +20 -0
  24. package/dist/components/SCalendarBoard/CalendarMoreButton.d.ts +18 -0
  25. package/dist/components/SCalendarBoard/README.md +148 -0
  26. package/dist/components/SCalendarBoard/SCalendarBoard.d.ts +83 -0
  27. package/dist/components/SCalendarBoard/calendar-board-layout.d.ts +59 -0
  28. package/dist/components/SCalendarBoard/calendarBoard.config.d.ts +38 -0
  29. package/dist/components/SCalendarBoard/index.d.ts +3 -0
  30. package/dist/components/SCalendarBoard/types.d.ts +40 -0
  31. package/dist/components/SCallout/README.md +17 -1
  32. package/dist/components/SCallout/SCallout.d.ts +14 -4
  33. package/dist/components/SCard/README.md +17 -0
  34. package/dist/components/SCard/SCard.d.ts +26 -2
  35. package/dist/components/SChatAttachedFile/README.md +33 -0
  36. package/dist/components/SChatAttachedFile/SChatAttachedFile.d.ts +13 -0
  37. package/dist/components/SChatAttachedFile/chatAttachedFile.config.d.ts +23 -0
  38. package/dist/components/SChatAttachedFile/index.d.ts +2 -0
  39. package/dist/components/SChatFile/README.md +33 -0
  40. package/dist/components/SChatFile/SChatFile.d.ts +37 -0
  41. package/dist/components/SChatFile/chatFile.config.d.ts +56 -0
  42. package/dist/components/SChatFile/index.d.ts +2 -0
  43. package/dist/components/SChatInput/README.md +44 -0
  44. package/dist/components/SChatInput/SChatInput.d.ts +60 -0
  45. package/dist/components/SChatInput/index.d.ts +1 -0
  46. package/dist/components/SChatMessage/README.md +86 -0
  47. package/dist/components/SChatMessage/SChatMessage.d.ts +123 -0
  48. package/dist/components/SChatMessage/index.d.ts +1 -0
  49. package/dist/components/SChatSystemMessage/README.md +12 -0
  50. package/dist/components/SChatSystemMessage/SChatSystemMessage.d.ts +18 -0
  51. package/dist/components/SChatSystemMessage/index.d.ts +1 -0
  52. package/dist/components/SCheckbox/README.md +8 -0
  53. package/dist/components/SChip/README.md +25 -0
  54. package/dist/components/SChip/SChip.d.ts +8 -0
  55. package/dist/components/SChip/index.d.ts +1 -1
  56. package/dist/components/SChipFilter/README.md +369 -14
  57. package/dist/components/SChipFilter/SChipFilter.d.ts +169 -60
  58. package/dist/components/SChipFilter/index.d.ts +1 -1
  59. package/dist/components/SChipInput/README.md +17 -2
  60. package/dist/components/SChipInput/SChipInput.d.ts +16 -3
  61. package/dist/components/SChipInput/index.d.ts +1 -1
  62. package/dist/components/SCircleProgress/README.md +8 -0
  63. package/dist/components/SConfirmModal/README.md +13 -4
  64. package/dist/components/SConfirmModal/SConfirmModal.d.ts +46 -9
  65. package/dist/components/SContextAlert/README.md +52 -0
  66. package/dist/components/SContextAlert/SContextAlert.d.ts +51 -0
  67. package/dist/components/SContextAlert/contextAlert.config.d.ts +22 -0
  68. package/dist/components/SContextAlert/index.d.ts +2 -0
  69. package/dist/components/SDatePicker/README.md +21 -2
  70. package/dist/components/SDatePicker/SDatePicker.d.ts +35 -5
  71. package/dist/components/SDateRangePicker/README.md +21 -4
  72. package/dist/components/SDateRangePicker/SDateRangePicker.d.ts +37 -7
  73. package/dist/components/SDivider/README.md +7 -0
  74. package/dist/components/SDivider/SDivider.d.ts +11 -0
  75. package/dist/components/SDonutChart/README.md +105 -0
  76. package/dist/components/SDonutChart/SDonutChart.d.ts +82 -0
  77. package/dist/components/SDonutChart/index.d.ts +1 -0
  78. package/dist/components/SDraggableItem/README.md +37 -0
  79. package/dist/components/SDraggableItem/SDraggableItem.d.ts +2 -0
  80. package/dist/components/SDraggableItem/index.d.ts +1 -1
  81. package/dist/components/SDraggableList/README.md +33 -3
  82. package/dist/components/SDraggableList/SDraggableList.d.ts +18 -3
  83. package/dist/components/SDraggableList/index.d.ts +1 -1
  84. package/dist/components/SDrawer/README.md +8 -0
  85. package/dist/components/SDropdownButton/README.md +19 -0
  86. package/dist/components/SEditor/EditorBody.d.ts +36 -0
  87. package/dist/components/SEditor/EditorToolbar.d.ts +93 -0
  88. package/dist/components/SEditor/README.md +204 -0
  89. package/dist/components/SEditor/SEditor.d.ts +121 -0
  90. package/dist/components/SEditor/editor-icons.d.ts +63 -0
  91. package/dist/components/SEditor/editor.config.d.ts +119 -0
  92. package/dist/components/SEditor/index.d.ts +8 -0
  93. package/dist/components/SEditor/tiptap-api.d.ts +29 -0
  94. package/dist/components/SEditor/use-is-mobile.d.ts +14 -0
  95. package/dist/components/SExpansionItem/README.md +36 -0
  96. package/dist/components/SExpansionItem/index.d.ts +1 -1
  97. package/dist/components/SField/README.md +36 -5
  98. package/dist/components/SField/SField.d.ts +33 -8
  99. package/dist/components/SField/index.d.ts +1 -1
  100. package/dist/components/SFilePicker/README.md +19 -4
  101. package/dist/components/SFilePicker/SFilePicker.d.ts +19 -6
  102. package/dist/components/SFooter/README.md +44 -0
  103. package/dist/components/SFooter/SFooter.d.ts +29 -13
  104. package/dist/components/SFooter/index.d.ts +1 -1
  105. package/dist/components/SForm/README.md +12 -1
  106. package/dist/components/SForm/SForm.d.ts +2 -2
  107. package/dist/components/SGhostButton/README.md +28 -4
  108. package/dist/components/SGhostButton/SGhostButton.d.ts +4 -1
  109. package/dist/components/SGnb/README.md +97 -7
  110. package/dist/components/SGnb/SGnb.d.ts +94 -10
  111. package/dist/components/SGnb/gnb.config.d.ts +18 -5
  112. package/dist/components/SGnb/index.d.ts +1 -1
  113. package/dist/components/SGnbSystem/README.md +186 -0
  114. package/dist/components/SGnbSystem/SGnbSystem.d.ts +179 -0
  115. package/dist/components/SGnbSystem/gnbSystem.config.d.ts +122 -0
  116. package/dist/components/SGnbSystem/index.d.ts +2 -0
  117. package/dist/components/SGuide/README.md +16 -1
  118. package/dist/components/SGuide/SGuide.d.ts +5 -2
  119. package/dist/components/SIcon/README.md +20 -0
  120. package/dist/components/SIcon/SIcon.d.ts +1 -1
  121. package/dist/components/SIcon/icons.gen.d.ts +20 -1
  122. package/dist/components/SImage/README.md +23 -1
  123. package/dist/components/SImage/SImage.d.ts +11 -8
  124. package/dist/components/SInput/README.md +7 -4
  125. package/dist/components/SInput/SInput.d.ts +13 -5
  126. package/dist/components/SKeyValueTable/README.md +88 -0
  127. package/dist/components/SKeyValueTable/SKeyValueTable.d.ts +30 -3
  128. package/dist/components/SKeyValueTable/index.d.ts +1 -1
  129. package/dist/components/SLauncherListBox/README.md +81 -0
  130. package/dist/components/SLauncherListBox/SLauncherListBox.d.ts +67 -0
  131. package/dist/components/SLauncherListBox/index.d.ts +2 -0
  132. package/dist/components/SLauncherListBox/launcherListBox.config.d.ts +118 -0
  133. package/dist/components/SLayout/README.md +22 -1
  134. package/dist/components/SLayout/SLayout.d.ts +6 -2
  135. package/dist/components/SLayout/index.d.ts +1 -1
  136. package/dist/components/SLineChart/README.md +131 -0
  137. package/dist/components/SLineChart/SLineChart.d.ts +110 -0
  138. package/dist/components/SLineChart/index.d.ts +1 -0
  139. package/dist/components/SLineChart/lineChart.config.d.ts +95 -0
  140. package/dist/components/SLinearProgress/README.md +15 -0
  141. package/dist/components/SLinearProgress/SLinearProgress.d.ts +3 -0
  142. package/dist/components/SLinearProgress/index.d.ts +1 -1
  143. package/dist/components/SList/README.md +15 -2
  144. package/dist/components/SList/SList.d.ts +7 -3
  145. package/dist/components/SList/index.d.ts +1 -1
  146. package/dist/components/SListItem/README.md +41 -0
  147. package/dist/components/SListItem/index.d.ts +1 -1
  148. package/dist/components/SLoadingContainer/index.d.ts +1 -1
  149. package/dist/components/SLoadingContainer/loading.d.ts +41 -6
  150. package/dist/components/SLoadingModal/README.md +10 -2
  151. package/dist/components/SLoadingModal/SLoadingModal.d.ts +4 -4
  152. package/dist/components/SLoadingModal/index.d.ts +1 -1
  153. package/dist/components/SLoginCard/README.md +37 -0
  154. package/dist/components/SLoginCard/SLoginCard.d.ts +39 -0
  155. package/dist/components/SLoginCard/index.d.ts +1 -0
  156. package/dist/components/SLogo/README.md +54 -0
  157. package/dist/components/SLogo/SLogo.d.ts +47 -0
  158. package/dist/components/SLogo/index.d.ts +2 -0
  159. package/dist/components/SLogo/logo.config.d.ts +14 -0
  160. package/dist/components/SLogo/sellmate-wordmark.d.ts +20 -0
  161. package/dist/components/SMeatballButton/README.md +67 -0
  162. package/dist/components/SMeatballButton/SMeatballButton.d.ts +36 -0
  163. package/dist/components/SMeatballButton/index.d.ts +1 -0
  164. package/dist/components/SModal/README.md +15 -5
  165. package/dist/components/SModal/SModal.d.ts +1 -1
  166. package/dist/components/SNumberInput/README.md +18 -8
  167. package/dist/components/SNumberInput/SNumberInput.d.ts +42 -7
  168. package/dist/components/SPage/README.md +105 -5
  169. package/dist/components/SPage/SPage.d.ts +34 -10
  170. package/dist/components/SPage/SPageHeader.d.ts +31 -13
  171. package/dist/components/SPage/index.d.ts +2 -2
  172. package/dist/components/SPage/page-column.d.ts +34 -0
  173. package/dist/components/SPage/page-width.d.ts +23 -0
  174. package/dist/components/SPage/page.config.d.ts +26 -0
  175. package/dist/components/SPieChart/README.md +93 -0
  176. package/dist/components/SPieChart/SPieChart.d.ts +73 -0
  177. package/dist/components/SPieChart/index.d.ts +1 -0
  178. package/dist/components/SPopover/README.md +15 -0
  179. package/dist/components/SPopup/README.md +26 -2
  180. package/dist/components/SPopup/SPopup.d.ts +13 -10
  181. package/dist/components/SPopup/index.d.ts +1 -1
  182. package/dist/components/SPortal/README.md +20 -0
  183. package/dist/components/SRadio/README.md +20 -0
  184. package/dist/components/SRadioButton/README.md +18 -0
  185. package/dist/components/SRadioButton/SRadioButton.d.ts +1 -1
  186. package/dist/components/SRadioCard/README.md +78 -0
  187. package/dist/components/SRadioCard/SRadioCard.d.ts +29 -0
  188. package/dist/components/SRadioCard/SRadioCardGroup.d.ts +36 -0
  189. package/dist/components/SRadioCard/index.d.ts +2 -0
  190. package/dist/components/SScrollArea/README.md +28 -0
  191. package/dist/components/SScrollArea/SScrollArea.d.ts +17 -0
  192. package/dist/components/SSearchInput/README.md +63 -0
  193. package/dist/components/SSearchInput/SSearchInput.d.ts +52 -0
  194. package/dist/components/SSearchInput/index.d.ts +1 -0
  195. package/dist/components/SSectionHeaderCard/README.md +38 -0
  196. package/dist/components/SSectionHeaderCard/SSectionHeaderCard.d.ts +19 -0
  197. package/dist/components/SSelect/README.md +38 -11
  198. package/dist/components/SSelect/SSelect.d.ts +80 -9
  199. package/dist/components/SSelect/select.config.d.ts +15 -0
  200. package/dist/components/SSplitter/README.md +17 -2
  201. package/dist/components/SSplitter/SSplitter.d.ts +7 -3
  202. package/dist/components/SStepper/README.md +26 -0
  203. package/dist/components/SSwitch/README.md +13 -1
  204. package/dist/components/SSwitch/SSwitch.d.ts +1 -3
  205. package/dist/components/SSystemActionButton/README.md +246 -0
  206. package/dist/components/SSystemActionButton/SSystemActionButton.d.ts +167 -0
  207. package/dist/components/SSystemActionButton/index.d.ts +2 -0
  208. package/dist/components/SSystemActionButton/systemActionButton.config.d.ts +93 -0
  209. package/dist/components/STable/README.md +116 -11
  210. package/dist/components/STable/STable.d.ts +243 -23
  211. package/dist/components/STable/index.d.ts +1 -1
  212. package/dist/components/STableBar/README.md +1 -0
  213. package/dist/components/STableBar/STableBar.d.ts +8 -1
  214. package/dist/components/STabs/README.md +13 -2
  215. package/dist/components/STabs/STabs.d.ts +3 -5
  216. package/dist/components/STabs/index.d.ts +1 -1
  217. package/dist/components/STabs/tabs.config.d.ts +3 -4
  218. package/dist/components/STag/README.md +55 -0
  219. package/dist/components/STag/STag.d.ts +3 -1
  220. package/dist/components/STag/tag.config.d.ts +1 -1
  221. package/dist/components/STextLink/README.md +19 -1
  222. package/dist/components/STextLink/STextLink.d.ts +5 -1
  223. package/dist/components/STextarea/README.md +11 -5
  224. package/dist/components/STextarea/STextarea.d.ts +42 -6
  225. package/dist/components/STimePicker/README.md +18 -2
  226. package/dist/components/STimePicker/STimePicker.d.ts +29 -6
  227. package/dist/components/STimePicker/timepicker.config.d.ts +4 -2
  228. package/dist/components/STimeRangePicker/README.md +30 -2
  229. package/dist/components/STimeRangePicker/STimeRangePicker.d.ts +28 -6
  230. package/dist/components/SToast/README.md +25 -1
  231. package/dist/components/SToast/SToast.d.ts +2 -2
  232. package/dist/components/SToggle/README.md +8 -0
  233. package/dist/components/SToggle/index.d.ts +1 -1
  234. package/dist/components/STooltip/README.md +25 -1
  235. package/dist/components/STooltip/STooltip.d.ts +1 -1
  236. package/dist/components/STree/README.md +42 -4
  237. package/dist/components/STree/STree.d.ts +18 -6
  238. package/dist/components/STree/STree.examples.d.ts +2 -0
  239. package/dist/docs/Gallery.d.ts +9 -0
  240. package/dist/docs/RecipeGallery.d.ts +13 -0
  241. package/dist/docs/gallery-previews.d.ts +24 -0
  242. package/dist/fonts/LICENSE.txt +94 -0
  243. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.0.woff2 +0 -0
  244. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.1.woff2 +0 -0
  245. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.10.woff2 +0 -0
  246. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.11.woff2 +0 -0
  247. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.12.woff2 +0 -0
  248. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.13.woff2 +0 -0
  249. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.14.woff2 +0 -0
  250. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.15.woff2 +0 -0
  251. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.16.woff2 +0 -0
  252. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.17.woff2 +0 -0
  253. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.18.woff2 +0 -0
  254. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.19.woff2 +0 -0
  255. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.2.woff2 +0 -0
  256. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.20.woff2 +0 -0
  257. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.21.woff2 +0 -0
  258. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.22.woff2 +0 -0
  259. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.23.woff2 +0 -0
  260. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.24.woff2 +0 -0
  261. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.25.woff2 +0 -0
  262. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.26.woff2 +0 -0
  263. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.27.woff2 +0 -0
  264. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.28.woff2 +0 -0
  265. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.29.woff2 +0 -0
  266. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.3.woff2 +0 -0
  267. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.30.woff2 +0 -0
  268. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.31.woff2 +0 -0
  269. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.32.woff2 +0 -0
  270. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.33.woff2 +0 -0
  271. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.34.woff2 +0 -0
  272. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.35.woff2 +0 -0
  273. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.36.woff2 +0 -0
  274. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.37.woff2 +0 -0
  275. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.38.woff2 +0 -0
  276. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.39.woff2 +0 -0
  277. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.4.woff2 +0 -0
  278. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.40.woff2 +0 -0
  279. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.41.woff2 +0 -0
  280. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.42.woff2 +0 -0
  281. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.43.woff2 +0 -0
  282. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.44.woff2 +0 -0
  283. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.45.woff2 +0 -0
  284. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.46.woff2 +0 -0
  285. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.47.woff2 +0 -0
  286. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.48.woff2 +0 -0
  287. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.49.woff2 +0 -0
  288. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.5.woff2 +0 -0
  289. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.50.woff2 +0 -0
  290. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.51.woff2 +0 -0
  291. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.52.woff2 +0 -0
  292. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.53.woff2 +0 -0
  293. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.54.woff2 +0 -0
  294. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.55.woff2 +0 -0
  295. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.56.woff2 +0 -0
  296. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.57.woff2 +0 -0
  297. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.58.woff2 +0 -0
  298. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.59.woff2 +0 -0
  299. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.6.woff2 +0 -0
  300. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.60.woff2 +0 -0
  301. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.61.woff2 +0 -0
  302. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.62.woff2 +0 -0
  303. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.63.woff2 +0 -0
  304. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.64.woff2 +0 -0
  305. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.65.woff2 +0 -0
  306. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.66.woff2 +0 -0
  307. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.67.woff2 +0 -0
  308. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.68.woff2 +0 -0
  309. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.69.woff2 +0 -0
  310. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.7.woff2 +0 -0
  311. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.70.woff2 +0 -0
  312. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.71.woff2 +0 -0
  313. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.72.woff2 +0 -0
  314. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.73.woff2 +0 -0
  315. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.74.woff2 +0 -0
  316. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.75.woff2 +0 -0
  317. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.76.woff2 +0 -0
  318. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.77.woff2 +0 -0
  319. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.78.woff2 +0 -0
  320. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.79.woff2 +0 -0
  321. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.8.woff2 +0 -0
  322. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.80.woff2 +0 -0
  323. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.81.woff2 +0 -0
  324. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.82.woff2 +0 -0
  325. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.83.woff2 +0 -0
  326. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.84.woff2 +0 -0
  327. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.85.woff2 +0 -0
  328. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.86.woff2 +0 -0
  329. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.87.woff2 +0 -0
  330. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.88.woff2 +0 -0
  331. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.89.woff2 +0 -0
  332. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.9.woff2 +0 -0
  333. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.90.woff2 +0 -0
  334. package/dist/fonts/woff2-dynamic-subset/PretendardVariable.subset.91.woff2 +0 -0
  335. package/dist/index.cjs +20593 -10374
  336. package/dist/index.cjs.map +1 -1
  337. package/dist/index.d.ts +22 -0
  338. package/dist/index.js +20517 -10374
  339. package/dist/index.js.map +1 -1
  340. package/dist/lib/chart/cartesian.d.ts +56 -0
  341. package/dist/lib/chart/frame.d.ts +21 -0
  342. package/dist/lib/chart/hover-dim.d.ts +19 -0
  343. package/dist/lib/chart/legend.d.ts +21 -0
  344. package/dist/lib/chart/palette.d.ts +28 -0
  345. package/dist/lib/chart/radial.d.ts +99 -0
  346. package/dist/lib/chart/scale.d.ts +26 -0
  347. package/dist/lib/chart/tooltip.d.ts +60 -0
  348. package/dist/lib/chart/types.d.ts +29 -0
  349. package/dist/lib/chart/use-element-size.d.ts +39 -0
  350. package/dist/lib/date.d.ts +2 -1
  351. package/dist/lib/depth-filter.d.ts +27 -0
  352. package/dist/lib/doc-sections.d.ts +35 -0
  353. package/dist/lib/dropdown-menu.d.ts +19 -0
  354. package/dist/lib/field-width.d.ts +31 -0
  355. package/dist/lib/floating-height.d.ts +15 -0
  356. package/dist/lib/global-overlay.d.ts +5 -0
  357. package/dist/lib/hover-preview.d.ts +32 -0
  358. package/dist/lib/inert.d.ts +11 -0
  359. package/dist/lib/overlay-scrollbar.d.ts +32 -0
  360. package/dist/lib/row-metrics.d.ts +61 -0
  361. package/dist/lib/story-docs.d.ts +19 -3
  362. package/dist/llms-full.txt +5904 -550
  363. package/dist/llms.txt +1918 -267
  364. package/dist/playground/categories.d.ts +19 -0
  365. package/dist/pretendard.css +937 -0
  366. package/dist/recipes/AppShell.d.ts +21 -0
  367. package/dist/recipes/ExcelFilePopupPage.d.ts +5 -0
  368. package/dist/recipes/LoginPage.d.ts +6 -0
  369. package/dist/recipes/ProductCreatePage.d.ts +1 -0
  370. package/dist/recipes/ProductCreateSectionsPage.d.ts +5 -0
  371. package/dist/recipes/ProductDetailInlineEditPage.d.ts +9 -0
  372. package/dist/recipes/ProductDetailPage.d.ts +1 -0
  373. package/dist/recipes/ProductListChipFilterPage.d.ts +1 -0
  374. package/dist/recipes/ProductListPage.d.ts +1 -0
  375. package/dist/recipes/TransferOrderPopupPage.d.ts +2 -0
  376. package/dist/recipes/api.d.ts +91 -0
  377. package/dist/recipes/recipe-story.d.ts +33 -0
  378. package/dist/recipes/router.d.ts +9 -0
  379. package/dist/styles.css +3331 -258
  380. package/dist/theme.css +589 -33
  381. package/eslint/index.mjs +9 -0
  382. package/eslint/lib/table-column.mjs +26 -0
  383. package/eslint/rules/divider-vertical-height.mjs +137 -0
  384. package/eslint/rules/field-width-grade.mjs +27 -10
  385. package/eslint/rules/prefer-typo-preset.mjs +1 -1
  386. package/eslint/rules/table-column-width.mjs +110 -0
  387. package/eslint/scale.gen.mjs +6 -3
  388. package/package.json +17 -3
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
 
@@ -27,21 +27,35 @@
27
27
 
28
28
  "비슷하게 생긴 것을 직접 만드는 것"이 어색함의 가장 큰 원인이다. 대응 컴포넌트를 쓰면 색·간격·상태·접근성이 전부 따라온다.
29
29
 
30
+ ### 0-0. 화면을 짜기 전에 — 세 가지 질문
31
+
32
+ **컴포넌트를 고르기 전에 이 세 가지에 답한다.** 요청에 답이 없으면 추측해서 채우지 말고 **되묻는다.**
33
+
34
+ 1. 이 화면에서 사용자가 하려는 **단 하나의 일**은 무엇인가
35
+ 2. 그 일을 끝내는 데 **없어도 되는 것**은 무엇인가 — 컬럼·버튼·안내 문구 중 빼도 되는 것
36
+ 3. **실패하면** 어디로 돌아가는가 — 오류·빈 목록·권한 없음일 때 무엇을 보여줄지
37
+
38
+ 요청받은 것을 그대로 다 넣지 않는다. **"있을 법한 필드를 빠짐없이 채우는 것"이 화면을 망치는 가장 흔한 방식이다.**
39
+ 목록에 컬럼을, 폼에 입력란을, 툴바에 버튼을 하나 더 넣기는 쉽고 나중에 빼기는 어렵다. 2번은 그래서 있다.
40
+
41
+ 2번에 답이 하나도 없으면 아직 화면을 이해하지 못한 것이다. 그 상태로 코드를 쓰면 미정 상태가 그대로 화면이 된다.
42
+
30
43
  ### 0-1. 전체 컴포넌트 인덱스
31
44
 
32
- 무엇을 만들지 정했으면 **이 표에서 먼저 찾는다.** 상세 Props 는 `dist/components/<이름>/README.md` 참조.
45
+ 무엇을 만들지 정했으면 **이 표에서 먼저 찾는다.** 상세 Props·Types 는 `dist/components/<이름>/README.md` 참조.
33
46
 
34
47
  **이 표에서 어느 것을 골라야 할지 모르겠으면 §3-0 "의도 → 컴포넌트 라우팅" 으로 간다.** 하려는 일을 문장으로 찾으면 답이 하나 나온다 — 여기 인덱스는 "무엇이 있는지", §3-0 은 "언제 그걸 쓰는지" 를 담당한다.
35
48
 
36
49
  | 분류 | 컴포넌트 |
37
50
  | --- | --- |
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` `SDatePickerYearListbox` `SDatePickerMonthListbox` `SDateRangePicker` `STimePicker` `STimeRangePicker` |
41
- | **표·목록** | `STable` `STableBar` `SChipFilter` `SKeyValueTable` `SList` `SListItem` `SExpansionList` `SDraggableList` `SDraggableItem` `STree` |
42
- | **레이아웃** | `SLayout` `SGnb` `SPage`(제목 영역은 `header` prop) `SSectionHeaderCard` `SCard` `SDivider` `SSplitter` `SScrollArea` `SExpansionItem` |
51
+ | **버튼·링크** | `SButton` `SGhostButton` `SMeatballButton`(⋯ 로 '더 보기' 메뉴를 여는 아이콘 버튼) `SDropdownButton` `STextLink` `SSwitch` `SToggle` |
52
+ | **입력 (폼)** | `SForm` `SField` `SInput` `SChatInput`(대화 입력창) `SSearchInput` `SNumberInput` `STextarea` `SEditor` `SSelect` `SCheckbox` `SRadio` `SRadioGroup` `SRadioButton` `SRadioCard` `SRadioCardGroup` `SChip` `SChipInput` `SBarcodeInput` `SFilePicker` |
53
+ | **날짜·시간** | `SCalendar` `SCalendarBoard`(한 달치 일정 판) `SDatePicker` `SDatePickerYearListbox` `SDatePickerMonthListbox` `SDateRangePicker` `STimePicker` `STimeRangePicker` |
54
+ | **표·목록** | `SChatMessage`(대화의 메시지 한 건) `SChatAttachedFile`(입력창 위, 아직 보내지 않은 첨부 한 칸) `SChatFile`(대화 흐름에 선, 보낸 파일 한 칸) `SChatSystemMessage`(대화 흐름 가운데 서는 시스템 안내) `STable` `STableBar` `SChipFilter` `SKeyValueTable` `SList` `SListItem` `SExpansionList` `SDraggableList` `SDraggableItem` `STree` |
55
+ | **레이아웃** | `SLayout` `SGnb` `SGnbSystem` `SSystemActionButton`(GNB system 패널에 한 줄씩 쌓는 액션 행 — 버튼 고르기는 §3-5) `SAccountListBox`(계정 행을 눌러 뜨는 계정 패널) `SLauncherListBox`(런처 버튼을 눌러 뜨는 서비스 목록) `SPage` `SPageHeader`(페이지 제목 영역 — `SLayout` 안에서 `SPage` 앞에 둔다) `SSectionHeaderCard` `SCard` `SLoginCard`(통합 계정 로그인 화면의 카드) `SDivider` `SSplitter` `SScrollArea` `SExpansionItem` |
43
56
  | **내비게이션** | `STabs` `SPagination` `SStepper` |
44
- | **표시·상태** | `STag` `SBadge` `SIcon` `SImage` `SCallout` `SGuide` |
57
+ | **차트** | `SBarChart`(막대 그래프 — 항목끼리 크기를 견준다. `stacked` 로 항목 안의 구성까지) `SLineChart`(꺾은선 그래프 — 순서가 있는 항목의 추이를 본다. `area` 로 크기까지, `stacked` 로 구성까지) `SPieChart`(원그래프 — 한 시점의 전체를 몇 개의 몫으로 나눈다) `SDonutChart`(도넛 그래프 — 가운데가 빈 원그래프. 구멍에 글자를 두지 않는다) |
58
+ | **표시·상태** | `STag` `SBadge` `SIcon` `SLogo`(브랜드 로고를 아이콘처럼 — size 는 높이다) `SImage` `SCallout` `SContextAlert`(한 줄짜리 맥락 알림 — 오른쪽에 후속 동작 버튼) `SGuide` |
45
59
  | **진행·로딩** | `SLinearProgress` `SCircleProgress` `SLoadingContainer` `SLoadingModal` |
46
60
  | **오버레이** | `STooltip` `SPopover` `SPopup` `SDrawer` `SPortal` |
47
61
  | **모달** | `SModal.confirm()` `SModal.create()` + `SActionModal` `SConfirmModal` `SModalOutlet`(앱 루트 1회) |
@@ -67,14 +81,16 @@ AI 에이전트는 코드를 생성하기 전에 이 목록을 반드시 지킨
67
81
 
68
82
  | 금지 | 대신 사용 |
69
83
  | --- | --- |
70
- | `<button>` | `SButton`, `SGhostButton`, `SDropdownButton`, `STextLink` |
84
+ | `<button>` | `SButton`, `SGhostButton`, `SMeatballButton`, `SDropdownButton`, `STextLink` |
71
85
  | `<input type="text/password/...">` | `SInput` |
86
+ | `<input type="search">` | `SSearchInput` |
72
87
  | `<input type="number">` | `SNumberInput` |
73
88
  | `<input type="checkbox">` | `SCheckbox`, `SToggle`, `SSwitch` |
74
89
  | `<input type="radio">` | `SRadio`, `SRadioButton` |
75
90
  | `<input type="file">` | `SFilePicker` |
76
91
  | `<select>` | `SSelect` |
77
92
  | `<textarea>` | `STextarea` |
93
+ | `contenteditable`, 직접 붙인 에디터 라이브러리 | `SEditor` |
78
94
  | `<table>` | `STable`, `SKeyValueTable` |
79
95
  | `<form>` | `SForm` |
80
96
  | `<dialog>`, 직접 만든 오버레이 | `SModal.confirm(...)`, `SModal.create(...)`, `SPopup` |
@@ -82,7 +98,7 @@ AI 에이전트는 코드를 생성하기 전에 이 목록을 반드시 지킨
82
98
  | 직접 만든 탭/페이지네이션/스텝퍼 | `STabs`, `SPagination`, `SStepper` |
83
99
  | `<ul>`/`<li>` 로 만든 목록 UI | `SList` + `SListItem` (드래그 정렬은 `SDraggableItem`) |
84
100
  | 직접 만든 섹션 카드(제목 바 + 본문 박스) | `SSectionHeaderCard` 의 `title` / `padding` props |
85
- | `<svg>` 직접 삽입, 이모지 아이콘 | `SIcon` |
101
+ | `<svg>` 직접 삽입, 이모지 아이콘 | `SIcon` (셀메이트 로고는 `SLogo`) |
86
102
  | `<hr>` | `SDivider` |
87
103
  | `<details>` / `<summary>` | `SExpansionItem` |
88
104
  | `<progress>` | `SLinearProgress`, `SCircleProgress` |
@@ -118,7 +134,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
118
134
 
119
135
  `text-14 font-bold` 같은 조합을 즉흥으로 만들지 않는다. §2-1의 `typo-*` 프리셋 클래스를 쓴다.
120
136
 
121
- ### 1-4. 숫자는 무조건 `toLocaleString()`
137
+ ### 1-4. 숫자·날짜 표기
122
138
 
123
139
  **숫자를 화면에 표시할 때는 예외 없이 `toLocaleString()` 을 거쳐 세 자리마다 콤마를 넣는다.**
124
140
  금액·수량·건수·재고 무엇이든, 테이블·상세·요약 문구 어디에 놓이든 같다.
@@ -133,6 +149,20 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
133
149
 
134
150
  **번호·코드는 제외한다.** 전화번호·사업자번호·송장번호·상품코드처럼 대상을 가리키는 값은 크기를 비교하는 숫자가 아니라 **서식이 정해진 문자열**이다. 여기에 콤마를 넣으면 송장번호 `123456789` 가 `123,456,789` 로 보여 값 자체가 달라진다.
135
151
 
152
+ **날짜는 `YYYY-MM-DD` 로 쓴다.** 자릿수를 채우고 하이픈으로 구분한다 — `2026-08-13`.
153
+ `2026. 8. 13.` 처럼 점으로 구분하거나 한 자리로 줄이지 않는다. 자릿수가 고정돼야 세로줄이 맞고,
154
+ 컬럼 폭을 형식으로 계산할 수 있다(§3-4). 일시가 필요하면 `YYYY-MM-DD HH:mm`.
155
+
156
+ ```tsx
157
+ ❌ {new Date(v).toLocaleDateString()} ❌ {`${y}. ${m}. ${d}.`}
158
+ ✅ {v} // 서버가 이미 YYYY-MM-DD 로 준 값
159
+ ✅ format: (v: string) => v.slice(0, 10)
160
+ ```
161
+
162
+ **`toLocaleDateString()` 은 쓰지 않는다** — 로케일에 따라 결과가 바뀌어 표기를 지킬 수 없다.
163
+
164
+ **대화 화면의 시각은 예외다.** `SChatMessage` 의 `time` 처럼 말풍선에 붙는 시각은 `오후 5:52` 로 쓴다. 여기서 시각은 정렬하거나 크기를 비교하는 값이 아니라 대화의 흐름을 짚는 표시라, 자릿수를 맞춘 `17:52` 보다 말하듯 읽히는 표기가 맞다. **이 예외는 대화 말풍선에만 열린다** — 같은 화면이라도 표·상세·목록에 들어가는 일시는 그대로 `YYYY-MM-DD HH:mm` 이다.
165
+
136
166
  ---
137
167
 
138
168
  ## 2. 조합 규칙 — 화면을 어떻게 쌓는가
@@ -145,11 +175,11 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
145
175
 
146
176
  | 층 | 무엇인가 | 컴포넌트 |
147
177
  | --- | --- | --- |
148
- | **셸** | 앱 전체 뼈대. 페이지가 바뀌어도 남는다 | `SLayout` `SGnb` `SPage`(제목 영역은 `header` prop) |
149
- | **블록** | `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` `SDatePickerYearListbox` `SDatePickerMonthListbox` `SDateRangePicker` `STimePicker` `STimeRangePicker` `SCalendar` `SListItem` `SExpansionItem` `SDraggableItem` `SImage` `SLinearProgress` `SCircleProgress` |
151
- | **인라인** | 텍스트 흐름·셀·라벨 안에 섞인다. 혼자 블록이 되지 않는다 | `STag` `SBadge` `SIcon` `STextLink` `SChip` |
152
- | **레이어** | 문서 흐름 **밖**에 떠서 그려진다. 어느 층에서 띄우든 레이아웃에 영향이 없다 | `SModal` `SActionModal` `SConfirmModal` `SPopup` `SDrawer` `SPopover` `STooltip` `SPortal` `SToast` `SLoadingModal` `SLoadingContainer` `SGuide` |
178
+ | **셸** | 앱 전체 뼈대. 페이지가 바뀌어도 남는다 | `SLayout` `SGnb` `SGnbSystem`(GNB 맨 아래 판 · 전폭 상단바 오른쪽 끝) `SPage` `SPageHeader`(`SLayout` 안에서 `SPage` 앞에 둔다) |
179
+ | **블록** | `SPage` 의 직계 자식. 페이지를 세로로 쌓는 단위 | `SSectionHeaderCard` `SCard` `SChatMessage` `SChatSystemMessage` `SChatInput` `SLoginCard`(유일하게 `SPage` 밖에 선다 — 로그인 화면 자체가 자기 자리다, §4-6) `SForm` `SSplitter` `SScrollArea` `SCalendarBoard` `STable` `STableBar` `SChipFilter` `SKeyValueTable` `SList` `SExpansionList` `SDraggableList` `STree` `SCallout` `SContextAlert` `SBarChart` `SLineChart` `SPieChart` `SDonutChart` `STabs` `SStepper` `SPagination` `SDivider` |
180
+ | **요소** | 블록 **안에** 놓이는 컨트롤. 혼자 페이지에 서지 않는다 | `SButton` `SGhostButton` `SMeatballButton` `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` `SChatAttachedFile` `SChatFile` `SImage` `SLinearProgress` `SCircleProgress` |
181
+ | **인라인** | 텍스트 흐름·셀·라벨 안에 섞인다. 혼자 블록이 되지 않는다 | `STag` `SBadge` `SIcon` `SLogo` `STextLink` `SChip` |
182
+ | **레이어** | 문서 흐름 **밖**에 떠서 그려진다. 어느 층에서 띄우든 레이아웃에 영향이 없다 | `SModal` `SActionModal` `SConfirmModal` `SPopup` `SDrawer` `SPopover` `STooltip` `SPortal` `SAccountListBox`(계정 행에 붙어 뜬다 — 직접 띄우지 않는다) `SLauncherListBox`(런처 버튼에 붙어 뜬다 — 직접 띄우지 않는다) `SToast` `SLoadingModal` `SLoadingContainer` `SGuide` |
153
183
 
154
184
  여기에 화면을 차지하지 않는 **부트스트랩** 이 따로 있다 — `SModalOutlet` `SToastContainer` 는 앱 진입점에 한 번만 렌더한다 (§4-1).
155
185
 
@@ -176,7 +206,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
176
206
  | `SKeyValueTable` 값 셀 | 인라인 · 요소 | 블록 |
177
207
  | `SListItem` | 인라인 | 블록 · 요소 |
178
208
 
179
- - **블록을 `div` 로 감싸지 않는다.** 감싸면 페이지 스택에서 빠져나가 `gap-sd-12` 리듬이 끊긴다. 여러 블록을 묶어야 하면 그건 섹션이므로 `SSectionHeaderCard` 다.
209
+ - **블록을 `div` 로 감싸지 않는다.** 감싸면 페이지 스택에서 빠져나가 `gap-sd-12` 리듬이 끊긴다. 여러 블록을 묶어야 하면 그건 섹션이므로 `SSectionHeaderCard` 다. **예외는 툴바+표 하나뿐이다** — 둘은 한 블록이라 붙여야 하므로 간격 없는 `div` 로 묶는다(바로 아래).
180
210
  - **요소를 페이지에 직접 놓지 않는다.** 하단 액션 버튼들처럼 블록이 없는 자리는 `div` 로 한 줄을 만들어 그 `div` 가 블록이 된다 (§4-3·§4-4).
181
211
  - **레이어는 어디서 띄워도 된다.** `body` 로 portal 되므로 셸 안에 넣을 필요가 없고, 넣어도 레이아웃이 바뀌지 않는다 (§4-1).
182
212
 
@@ -195,10 +225,10 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
195
225
  블록 순서는 화면 종류와 무관하게 같다. **필요한 것만 남기되 순서를 바꾸지 않는다.**
196
226
 
197
227
  ```text
198
- 1. 페이지 제목 `SPage` 의 `header` prop (+ 가이드·매뉴얼 링크는 slot. 액션 버튼은 오지 않는다 §4-2)
228
+ 1. 페이지 제목 `SPageHeader` (SGnb 와 SPage 사이. 가이드·매뉴얼 링크는 slot, 액션 버튼은 오지 않는다 §4-2)
199
229
  2. 상시 안내 SCallout
200
230
  3. 필터 SKeyValueTable · SChipFilter (§3-7-11)
201
- 4. 툴바 STableBar (건수 요약 + 액션)
231
+ 4. 툴바 STableBar (제목 slot + 건수 요약 + 액션)
202
232
  5. 본문 STable · 섹션 카드들 · SList …
203
233
  6. 페이지네이션 SPagination (STable 이 pagination prop 으로 직접 그린다)
204
234
  7. 하단 액션 되돌리기 왼쪽 · 실행 오른쪽 (§4-3)
@@ -206,6 +236,26 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
206
236
 
207
237
  간격은 층마다 다르다 — 블록 ↔ 블록은 `gap-sd-12`, 요소 ↔ 요소는 `gap-sd-8` 이 기본이고, 같은 컴포넌트를 나열할 때는 컴포넌트별 그룹 간격이 따로 있다. 전부 §2-2 에 있다.
208
238
 
239
+ #### 툴바(4)와 본문 표(5)는 한 블록이다 — 붙인다
240
+
241
+ `STableBar` 는 자기 아래 표의 건수와 액션을 말한다. **둘 사이를 벌리면 툴바가 어느 표의 것인지 끊긴다.** 위 순서에서 4·5 만은 따로 선 블록이 아니라 한 덩어리이므로, 간격 없는 `div` 로 묶고 그 `div` 가 블록 스택의 한 칸이 된다.
242
+
243
+ ```tsx
244
+ <div className="flex min-h-0 flex-1 flex-col"> {/* 툴바+표 = 한 블록 */}
245
+ <STableBar className="border-b-0" title="주문 목록" … />
246
+ <STable className="min-h-0 flex-1" … />
247
+ </div>
248
+ ```
249
+
250
+ **표의 이름은 `STableBar` 의 `title` 슬롯에 넣는다.** 이름만 적자고 그 위에 `SSectionHeaderCard`
251
+ 를 따로 세우지 않는다 — 블록이 한 칸 늘어 툴바와 표가 멀어지고, 위에서 막은 "둘 사이를 벌리지
252
+ 않는다" 를 스스로 어기게 된다. 섹션 카드는 표 말고도 여러 블록을 함께 담을 때 쓴다.
253
+
254
+ - **맞닿는 가로선은 표가 그린다 — 툴바에 `border-b-0` 을 준다.** 둘 다 1px 테두리를 가진 상자라, 그냥 붙이면 그 변만 1px+1px=2px 로 두꺼워진다. 표에는 아무것도 주지 않는다.
255
+ - **`gap-sd-12` 는 이 덩어리 바깥에만 걸린다.** 필터 ↔ (툴바+표) 는 여느 블록 사이와 같다.
256
+ - **`min-h-0 flex-1` 사슬은 이 `div` 를 한 칸 더 지난다** — 감싸는 `div` 에도 붙여야 표가 남은 높이를 잡는다(§4-2).
257
+ - 툴바를 표 없이 단독으로 쓰는 자리라면 `border-b-0` 을 주지 않는다. 그때는 사방이 닫힌 제 상자다.
258
+
209
259
  ### 2-1. 타이포그래피 프리셋
210
260
 
211
261
  역할(role) → 크기 → 굵기 순으로 조합된 클래스가 이미 준비되어 있다.
@@ -213,7 +263,8 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
213
263
  | 용도 | 클래스 |
214
264
  | --- | --- |
215
265
  | 페이지/섹션 제목 | `typo-heading-lg`(18px) · `typo-heading-md`(16px) · `typo-heading-sm`(14px) · `typo-heading-xs`(12px) |
216
- | 본문 | `typo-body-lg-*` (16px) · `typo-body-md-*`(14px) · `typo-body-sm-*`(12px) · `typo-body-xs-default`(11px) — `*` = `default`/`medium`/`bold` |
266
+ | 카드·아이템 안의 메인 수치 데이터 | `typo-heading-xl`(24px) — 제목에 쓰지 않는다 |
267
+ | 본문 | `typo-body-lg-*` (16px) · `typo-body-md-*`(14px) · `typo-body-sm-*`(12px) · `typo-body-xs-*`(11px) — `*` = `default`/`medium`/`semibold`/`bold` |
217
268
  | 테이블 | `typo-table-header` · `typo-table-body` · `typo-table-accent` |
218
269
  | 컨트롤·필드·피드백·내비 | `typo-control-*` `typo-field-*` `typo-feedback-*` `typo-navigation-*` (컴포넌트 내부용 — 직접 쓸 일은 드묾) |
219
270
 
@@ -223,7 +274,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
223
274
 
224
275
  | 층 (§2-0) | 역할 | 클래스 | 크기 |
225
276
  | --- | --- | --- | --- |
226
- | 셸 | 페이지 제목 (`SPage` 의 `header.title`) | `typo-heading-lg` | 18px |
277
+ | 셸 | 페이지 제목 (`SPageHeader` 의 `title`) | `typo-heading-lg` | 18px |
227
278
  | 블록 | 섹션 제목 | `typo-heading-sm` | 14px |
228
279
  | 블록 내부 | 하위 제목 (섹션 안을 더 나눌 때) | `typo-heading-xs` | 12px |
229
280
  | — | 본문 | `typo-body-sm-default` | 12px |
@@ -231,12 +282,19 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
231
282
 
232
283
  페이지 제목만 18px 로 크게 두고 그 아래는 14 / 12 로 촘촘하게 간다. 중간 크기(16px)는 기본 골격에서 쓰지 않는다.
233
284
 
234
- - **페이지·섹션 제목의 타이포를 직접 주지 않는다.** `SPage` 의 `header.title`, `SSectionHeaderCard` 의 `header.title` 이 이미 넣는다 — 그 위에 `typo-heading-lg`/`typo-heading-sm` 을 또 씌우지 않는다.
285
+ - **페이지·섹션 제목의 타이포를 직접 주지 않는다.** `SPageHeader` 의 `title`, `SSectionHeaderCard` 의 `header.title` 이 이미 넣는다 — 그 위에 `typo-heading-lg`/`typo-heading-sm` 을 또 씌우지 않는다.
235
286
  - **하위 제목이 필요하면 먼저 섹션을 나눌 수 없는지 본다.** 한 섹션 안에서 제목이 두 단으로 갈린다는 것은 대개 섹션이 둘이라는 뜻이다 (§3-7-8).
236
287
  - 본문 안에서 한 단어를 강조할 때는 `typo-body-sm-medium` 을 쓴다. `typo-body-sm-bold` 는 제목 성격의 짧은 라벨에만 쓴다. <!-- TODO(디자인): 강조 굵기 기준 확정 -->
237
288
 
238
289
  **보조 설명의 색** — 기본은 `text-fg-tertiary`(`grey_65`) 다. 보조 설명 안에서 위계가 한 단계 더 필요할 때만 `text-fg-secondary`(`grey_80`) → `text-fg-tertiary`(`grey_65`) 순으로 내려 쓴다 (§2-3).
239
290
 
291
+ **글꼴은 지정하지 않는다.** `theme.css` 가 문서 전역 글꼴을 `--font-base` 로 깔고 글꼴 파일(Pretendard)까지 함께 싣는다. 앱이 할 일은 없다 — 글꼴 유틸리티(기본 sans 유틸리티, 임의 값 글꼴 클래스)나 `fontFamily` 인라인 스타일을 붙일 이유가 없다. 기본 sans 유틸리티도 같은 스택을 가리킨다.
292
+
293
+ ```tsx
294
+ ✅ <p className="typo-body-sm-default">주문이 접수되었습니다.</p>
295
+ ❌ <p style={{ fontFamily: 'Pretendard' }}>주문이 접수되었습니다.</p> // 폴백 스택이 사라진다
296
+ ```
297
+
240
298
  ### 2-2. 간격 (spacing)
241
299
 
242
300
  - **간격 유틸리티는 `sd-` 접두를 붙인다** — `gap-sd-8`, `p-sd-16`, `mt-sd-12`. 숫자 = px 다.
@@ -259,7 +317,9 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
259
317
  | 상황 | 값 |
260
318
  | --- | --- |
261
319
  | **페이지 콘텐츠 패딩** | **`SPage` 가 `--cmp-pageBody-padding-default` 로 이미 넣는다.** 직접 주지 않는다 (덮어쓰면 토큰이 바뀌어도 안 따라간다). `SPage` 밖에서 같은 패딩이 필요하면 `p-sd-16` |
262
- | **섹션 ↔ 섹션**, **블록 ↔ 블록** (헤더·필터·툴바·테이블 사이) | **`gap-sd-12`** |
320
+ | **가장자리까지 차는 블록이 맨 위에 서는 페이지** (`SChipFilter` 바 등) | `SPage` 에 `padding="none"` 으로 끄고, **그 아래 블록이 `p-[var(--cmp-pageBody-padding-default)]` 로 같은 토큰의 여백을 잡는다.** `className="p-0"` 이나 `p-sd-16` 으로 적지 않는다 (§4-2 칩 필터) |
321
+ | **섹션 ↔ 섹션**, **블록 ↔ 블록** (헤더·필터·본문 사이) | **`gap-sd-12`** |
322
+ | **툴바 ↔ 그 표** (`STableBar` ↔ `STable`) | **간격 없음 — 붙인다.** 둘은 한 블록이다 (§2-0) |
263
323
  | 요소 ↔ 요소 | **기본 `gap-sd-8`** (`--sys-space-stack-gap-element-normal`) · 타이트 `gap-sd-4`(`-tight`) · 여유 `gap-sd-12`(`-relaxed`) / `gap-sd-16`(`-wide`) |
264
324
  | 제목 ↔ 설명 텍스트 | **수직 배치 `gap-sd-4`**(`--sys-space-stack-gap-text-normal`, 타이트 `gap-sd-2`) · **가로 배치 `gap-sd-8`**(`-relaxed`) |
265
325
 
@@ -275,7 +335,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
275
335
  | `SCheckbox` | **`gap-sd-24`** | `gap-sd-8` |
276
336
  | `SRadio` | **`gap-sd-24`** | `gap-sd-8` |
277
337
  | `STextLink` | **`gap-sd-16`**(sm) / **`gap-sd-24`**(md·lg) | `gap-sd-4` |
278
- | `SGhostButton` | `gap-sd-4` | `gap-sd-4` |
338
+ | `SGhostButton` · `SMeatballButton` | `gap-sd-4` | `gap-sd-4` |
279
339
  | `SButton` | `gap-sd-8` (xs·sm·md) / **`gap-sd-12`**(lg) | 〃 |
280
340
  | `STag` | `gap-sd-8` | `gap-sd-8` |
281
341
  | `SToggle` | `gap-sd-8` | `gap-sd-8` |
@@ -295,6 +355,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
295
355
 
296
356
  - **라디오는 `SRadioGroup` 을 쓴다.** `direction="horizontal" | "vertical"` 만 주면 간격을 알아서 맞춘다 — 직접 `flex` 로 감싸지 않는다.
297
357
  - `SRadioButton` 그룹의 간격은 `-1px`(테두리 겹침 처리)이라 손으로 만들지 않는다.
358
+ - **카드형 라디오는 `SRadioCardGroup` 을 쓴다.** 카드 사이 간격은 수평·수직이 같고 `SRadioGroup` 과 다르므로, 손으로 `flex gap-…` 을 맞추지 말고 `direction` 만 준다.
298
359
  - 정확한 값이 필요하면 토큰을 직접 참조해도 된다: `gap-[var(--cmp-checkbox-group-gap-horizontal)]`
299
360
 
300
361
  #### 섹션·패널 안쪽 여백
@@ -356,7 +417,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
356
417
  ```
357
418
 
358
419
  - **`bordered={false}`** 로 표의 테두리를 끈다. 카드가 이미 그린다.
359
- - **`radius="useTop"`** 으로 위쪽 모서리를 죽인다. 아래쪽 라운드는 카드가 처리한다.
420
+ - **`radius="useTop"`** 으로 위쪽 모서리를 죽인다. 아래쪽 라운드는 카드가 처리한다. **표 아래에 카드 푸터(`button`·`footerLeft`)가 오면 `radius="full"`** — 아래 모서리도 카드가 아니라 푸터의 상단 구분선이 이어받으므로 네 모서리를 모두 죽인다.
360
421
  - `STable` 은 **페이지네이션 바의 테두리와 `-mt-px` 겹침도 함께 꺼진다** — 본문 테두리가 없으면 겹칠 대상이 없어, 그대로 두면 페이지네이션만 테두리를 갖고 1px 어긋난다.
361
422
 
362
423
  #### 본문 바탕 눌러앉히기 (선택)
@@ -377,6 +438,29 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
377
438
 
378
439
  바탕을 깐 경우, 표 사이 구분선(`SDivider`)은 대개 불필요해진다 — 색이 이미 경계를 만든다.
379
440
 
441
+ #### 페이지 높이 — 화면을 꽉 채우고, 스크롤은 각 영역 안에서
442
+
443
+ **대부분의 화면은 본문이 창을 꽉 채우고, 스크롤은 각 영역 안에서 일어난다.** 표는 자기 안에서 스크롤하고, 좌측 목록은 목록 안에서 스크롤하고, 페이지네이션·하단 액션은 자리에 고정된다. 이것이 표준이다 — 목록 페이지만의 예외가 아니다.
444
+
445
+ `SPage` 의 `contentHeight="fill"` 이 그 모드다. 프레임 컴포넌트에서 넘긴다(§4-1).
446
+
447
+ ```tsx
448
+ <SPage contentHeight="fill">
449
+ <div className="flex h-full min-h-0 flex-col gap-sd-12">
450
+ {/* 툴바+표 = 한 블록. 사이를 벌리지 않는다 (§2-0) */}
451
+ <div className="flex min-h-0 flex-1 flex-col">
452
+ <STableBar className="border-b-0" … />
453
+ <STable className="min-h-0 flex-1" pagination={…} />
454
+ </div>
455
+ </div>
456
+ </SPage>
457
+ ```
458
+
459
+ - **`min-h-0 flex-1` 사슬이 페이지의 기본 골격이다.** `fill` 은 본문 래퍼에 `h-full` 을 주고, 거기서부터 스크롤될 자리까지 `min-h-0 flex-1` 이 이어져야 자식이 남은 높이를 잡는다.
460
+ - **사슬이 한 군데만 끊겨도 자식이 높이를 못 잡는데, 그 실패가 조용하다** — 화면은 그려지고 스크롤만 엉뚱한 데서 일어난다. 체크리스트(§5)로 확인한다.
461
+ - **페이지 스크롤은 예외다.** 블록의 높이가 정해져 있고 그 높이가 창보다 클 때만 페이지가 스크롤한다. 그때만 `contentHeight="auto"` 와 `scrollEndSpacing` 을 켠다.
462
+ - **`SScrollArea` 로 페이지 본문 전체를 감싸지 않는다.** 스크롤은 `SPage` 의 `<main>` 몫이고, 감싸면 스크롤바가 본문 패딩 안쪽으로 들어와 가장자리에서 뜬다. `SScrollArea` 는 페이지 안의 특정 영역에만 쓴다(§3-0 D).
463
+
380
464
  #### 스크롤 영역의 하단 여백
381
465
 
382
466
  스크롤을 끝까지 내렸을 때 마지막 항목이 화면 경계에 붙으면 **목록이 끝난 것인지 더 있는 것인지** 읽히지 않는다. 그래서 스크롤 영역은 **하단만** 넓게 둔다. 나머지 세 방향은 위 16 / 24 규칙 그대로다.
@@ -384,9 +468,9 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
384
468
  | 스크롤 종류 | 어떻게 |
385
469
  | --- | --- |
386
470
  | **패널 자체 스크롤** (좌측 목록, 중앙 대화 등) | 그 패널 안쪽 하단에 `pb-[var(--cmp-pageBody-padding-scrollEnd)]` — `SPage` 와 같은 토큰이라 값이 바뀌어도 함께 따라간다 |
387
- | **페이지 단위 스크롤** | **`SPage` 가 넣는다. 직접 주지 않는다** |
471
+ | **페이지 단위 스크롤** | **`SPage` 의 `scrollEndSpacing` 으로 켠다. 직접 패딩을 주지 않는다** |
388
472
 
389
- `SPage` 는 기본으로 넣으므로 **아무것도 하지 않으면 맞다.** 직접 끌 일은 없다 — **페이지네이션이 붙은 테이블**은 `contentHeight="fill"` 을 쓰고, 이 모드에서는 페이지가 스크롤하지 않아 스크롤 끝 여백이 **자동으로 무시**된다 (§4-2 목록 페이지).
473
+ **`scrollEndSpacing` 은 기본이 꺼져 있다.** 페이지가 실제로 스크롤될 때만 필요한 값이라, 조건 없이 붙이면 내용이 화면에 거의 딱 맞는 페이지까지 그 여백 때문에 스크롤되게 만든다. 페이지 스크롤을 쓰는 화면(`contentHeight="auto"` + 내용이 창보다 김)에서만 켠다. 페이지네이션처럼 끝을 알려주는 것이 이미 있으면 켜지 않는다.
390
474
 
391
475
  ### 2-3. 색상
392
476
 
@@ -448,18 +532,25 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
448
532
  | --- | --- | --- |
449
533
  | 한 줄 텍스트를 받는다 | `SInput` | §3-7-1 |
450
534
  | 여러 줄 텍스트를 받는다 | `STextarea` | §3-7-1 |
451
- | 숫자(수량·금액)를 받는다 | `SNumberInput` | |
535
+ | 대화 화면 아래에서 메시지를 입력받는다 | `SChatInput` | |
536
+ | 제목·굵게·목록·색 같은 **서식이 남아야 하는** 글을 받는다 | `SEditor` | §3-7-1 |
537
+ | 목록·결과를 검색어로 좁힌다 | `SSearchInput` | §3-7-1 |
538
+ | 숫자(수량·금액)를 받는다 | `SNumberInput` | §3-7-13 |
452
539
  | 바코드를 스캔해 받는다 | `SBarcodeInput` | |
453
540
  | 목록에서 하나 고르게 한다 | `SSelect` | §3-7-2 |
454
541
  | 선택지를 항상 펼쳐 두고 하나 고르게 한다 | `SRadioGroup` | §3-7-2 |
455
542
  | 버튼 모양으로 모드를 하나 고르게 한다 | `SRadioButton` | §3-7-2 |
543
+ | 선택지마다 설명이 붙어 한 줄로는 안 된다 | `SRadioCardGroup` | §3-7-2 |
544
+ | 설명이 붙은 선택지 카드 하나를 직접 배치한다 | `SRadioCard` | §3-7-2 |
456
545
  | 라디오 하나를 표 셀 등에 직접 배치한다 | `SRadio` | §3-7-2 |
457
546
  | 여러 개를 고르게 한다 / 동의를 받는다 | `SCheckbox` | §3-7-3 |
458
547
  | 켜는 즉시 반영되는 설정을 준다 | `SSwitch` | §3-7-3 |
459
548
  | 목록을 좁히는 필터를 켜고 끄게 한다 | `SToggle` | §3-7-3 |
460
549
  | 자유 입력값을 여러 개 쌓게 한다 | `SChipInput` | §3-1 |
461
550
  | 입력된 값 하나를 지우거나 고치게 한다 | `SChip` | §3-1 |
462
- | 파일을 받는다 | `SFilePicker` | |
551
+ | 파일을 받는다 (고르게 한다) | `SFilePicker` | 고른·보낸 파일을 **보여주는** 것은 `SChatAttachedFile`·`SChatFile` |
552
+ | 보내기 전 첨부를 입력창 위에 보여준다 (눌러 뺀다) | `SChatAttachedFile` | 보낸 뒤는 `SChatFile` · 고르게 하는 것은 `SFilePicker` |
553
+ | 보낸 파일을 대화 흐름에 보여준다 (눌러 연다) | `SChatFile` | `SChatMessage` 의 `attachments` 로 넘긴다 — 직접 나열하지 않는다 |
463
554
  | 날짜 하나를 받는다 | `SDatePicker` | §3-7-4 |
464
555
  | 연도 선택 리스트만 커스텀 조합에 넣는다 | `SDatePickerYearListbox` | §3-7-4 |
465
556
  | 연도+월 선택 리스트만 커스텀 조합에 넣는다 | `SDatePickerMonthListbox` | §3-7-4 |
@@ -479,14 +570,25 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
479
570
  | 여러 건을 여러 열로 보여주고 열끼리 비교하게 한다 | `STable` | §3-7-6 |
480
571
  | 항목 하나의 속성들을 `라벨: 값` 으로 보여준다 | `SKeyValueTable` | §4-4 |
481
572
  | 한 줄로 읽히는 항목을 세로로 나열한다 | `SList` + `SListItem` | §3-7-6 |
573
+ | 대화의 메시지 한 건을 보여준다 | `SChatMessage` | |
574
+ | 대화 중간에 시스템이 알리는 사실을 끼운다 (상담사 배정·대화 종료 등) | `SChatSystemMessage` | 사람이 보낸 말이면 `SChatMessage` |
482
575
  | 나열한 항목을 펼쳐 하위 내용을 보여준다 | `SExpansionList` + `SExpansionItem` | §3-7-7 |
483
576
  | 부모-자식 계층을 들여쓰기로 보여준다 | `STree` | §3-7-7 |
484
577
  | 사용자가 순서를 드래그로 바꾸게 한다 | `SDraggableList` + `SDraggableItem` | §3-7-6 |
485
- | 표 위에 건수 요약과 액션을 얹는다 | `STableBar` | §4-2 |
578
+ | 날짜별 일정을 한 달치 격자로 펼쳐 보여준다 | `SCalendarBoard` | §3-7-4 |
579
+ | 항목끼리 크기를 눈으로 견주게 한다 (채널별 매출·상품별 판매량) | `SBarChart` | 정확한 값을 읽어야 하면 `STable` — 그래프는 "어느 쪽이 큰가" 를 답하지 "얼마인가" 를 답하지 않는다 |
580
+ | 순서가 있는 항목의 **추이**를 보여준다 (일·월별 매출 흐름, 기간별 방문 수) | `SLineChart` | 항목 순서에 뜻이 없으면(채널별·상품별) `SBarChart` — 선은 "이어진다" 는 뜻을 덤으로 얹어, 순서 없는 항목을 이으면 없는 흐름을 만든다. 항목이 대여섯 개 안쪽이고 크기를 견주는 것이 목적이면 막대가 낫다 |
581
+ | 여러 항목의 추이를 **합계와 함께** 보여준다 (채널별 매출이 쌓여 전체가 되는 흐름) | `SLineChart stacked area` | 합계가 뜻을 갖는 값일 때만 쌓는다. 음수가 섞이거나 더해서 뜻이 없는 값(비율·평균)은 쌓지 말고 겹쳐 그린다 |
582
+ | 항목의 크기와 **그 안의 구성**을 함께 보여준다 (주문 상태별 내역, 유입 경로별 몫) | `SBarChart stacked` | 계열끼리 견주는 것이 목적이면 `stacked` 없이 나란히 세운다. 더해서 뜻이 없는 값(비율·평균)은 쌓지 않는다 — 합계 라벨이 거짓말이 된다 |
583
+ | 한 시점의 **전체를 몇 개의 몫으로** 나눠 보여준다 (채널별 매출 비중, 회원 등급 구성) | `SPieChart` | 조각이 대여섯 개를 넘거나 몫끼리 크기를 견주는 것이 목적이면 `SBarChart` — 사람은 각도보다 길이를 정확히 견준다. 순서가 있는 추이(월별)는 몫이 아니라 흐름이라 `SLineChart`. 0·음수는 그려지지 않으므로 증감은 원으로 나타내지 않는다. 조각은 큰 순으로 넘긴다. 폭은 부모를 따르고 그림은 그 폭의 정사각형이다 — 폭이 필요하면 `className` 으로 폭만 주고 높이는 주지 않는다 (`SDonutChart` 도 같다) |
584
+ | 한 시점의 전체를 몫으로 나누되 **가운데가 빈 고리 모양**으로 보여준다 (시안이 도넛일 때) | `SDonutChart` | 쓰임·데이터 규칙은 `SPieChart` 와 같고 모양만 다르다 — 시안이 정한 쪽을 쓴다. **구멍에 합계·라벨을 겹쳐 올리지 않는다.** 합계 같은 값이 필요하면 차트 바깥(카드 제목·요약 값)에 적는다. 여러 기간에 걸친 구성 변화는 원을 여러 개 늘어놓지 말고 `SBarChart stacked` |
585
+ | 표 위에 건수 요약과 액션을 얹는다 | `STableBar` | 표의 이름도 여기 `title` 슬롯에 넣는다 — 제목만으로 블록을 따로 세우지 않는다. §4-2 |
486
586
  | 상태·분류를 라벨로 찍는다 | `STag` | §3-1 |
487
587
  | 색 점만으로 상태를 찍는다 | `SBadge` | §3-1 |
488
588
  | 아이콘을 넣는다 | `SIcon` | |
589
+ | 셀메이트 로고를 넣는다 | `SLogo` (`SIcon` 과 달리 `size` 가 **높이**이고, 색을 주지 않으면 브랜드색이다) | §3-7-14 |
489
590
  | 사진·썸네일을 보여준다 (로딩·실패 상태 포함) | `SImage` | §3-7-12 |
591
+ | 작은 썸네일을 호버해서 크게 확인하게 한다 | `SImage previewOnHover` | 툴팁·팝오버로 직접 만들지 않는다 — 미리보기는 마우스를 통과시키고 원본의 로딩·실패도 스스로 표현한다. §3-7-12 |
490
592
  | 문장 안에서 다른 화면으로 보낸다 | `STextLink` | §3-5-6 |
491
593
 
492
594
  #### C. 동작을 실행시킨다
@@ -495,7 +597,9 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
495
597
  | --- | --- | --- |
496
598
  | 라벨이 있는 일반 액션을 준다 | `SButton` | §3-5 |
497
599
  | 아이콘 하나로 뜻이 통하는 부가 조작을 준다 | `SGhostButton` | §3-5-5 |
600
+ | 자리가 좁아 부가 조작 여러 개를 ⋯ 뒤 메뉴로 접어 둔다 | `SMeatballButton` | §3-5-5 |
498
601
  | 한 버튼에 여러 선택지를 매단다 | `SDropdownButton` | §3-5-4 |
602
+ | GNB system 패널에서 한 줄짜리 행 액션을 준다 | `SSystemActionButton` | §3-5-7 |
499
603
 
500
604
  #### D. 화면을 담고 나눈다
501
605
 
@@ -503,14 +607,23 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
503
607
  | --- | --- | --- |
504
608
  | 앱 셸(상단바 + 내비 + 본문)을 세운다 | `SLayout` | §4-1 |
505
609
  | 좌측 내비게이션을 만든다 | `SGnb` | §4-1 |
610
+ | 좌측 내비게이션 맨 아래(또는 전폭 상단바 오른쪽 끝)에 서비스·도메인·알림·설정·계정 묶음을 붙인다 | `SGnbSystem` | §4-1 |
611
+ | 앱 상단바 오른쪽 끝에 알림 벨·계정 이름을 둔다 | `SGnbSystem` (`SGnb` 의 `system` 슬롯 — 직접 만들지 않는다) | §4-1 |
612
+ | 계정 이름을 눌러 이메일·권한·계정 설정·언어 변경·로그아웃을 띄운다 | `SAccountListBox` (`SGnbSystem` 의 `account.listBox` 로 넘긴다 — 직접 띄우지 않는다) | §4-1 |
613
+ | 앱런처 버튼을 눌러 옮겨 갈 수 있는 서비스 목록을 띄운다 | `SLauncherListBox` (`SGnb` 의 `launcher.items` 로 넘긴다 — 직접 띄우지 않는다) | §4-1 |
506
614
  | 페이지 본문을 담는다 (패딩·스크롤) | `SPage` | §4-1 |
507
- | 페이지 제목(+ 서브 텍스트·뒤로가기·우측 슬롯)을 만든다 | `SPage` 의 `header` prop | §4-1 |
615
+ | 페이지 제목(+ 서브 텍스트·뒤로가기·우측 슬롯)을 만든다 | `SPageHeader` (`SLayout` 안에서 `SPage` 앞에 둔다) | §4-1 |
508
616
  | 제목 있는 섹션으로 묶는다 | `SSectionHeaderCard` | §3-7-8 |
509
617
  | 제목 없이 흰 면으로만 묶는다 | `SCard` | §3-7-8 |
618
+ | 통합 계정 로그인 화면을 만든다 | `SLoginCard` | §4-6 |
510
619
  | 가로선으로 끊는다 | `SDivider` | §3-6 |
511
620
  | 사용자가 영역 크기를 조절하게 한다 | `SSplitter` | §3-6 |
512
621
  | 특정 영역 안에서만 스크롤시킨다 | `SScrollArea` | |
513
622
 
623
+ **`SScrollArea` 로 페이지 본문 전체를 감싸지 않는다.** 페이지 스크롤은 `SPage` 의 `<main>` 몫이다 — 감싸면 스크롤바가 본문 패딩 안쪽으로 들어와 페이지 가장자리에서 떨어져 그려진다 (§2-2).
624
+
625
+ **`SScrollArea` 에는 상한을 정해 준다 — `maxHeight` 를 주거나, 부모가 높이를 정한 자리에 놓는다.** 둘 다 없으면 영역이 내용만큼 자라 스크롤이 생기지 않는다. 남은 높이를 쓰는 자리라면 `maxHeight` 가 아니라 `className="min-h-0 flex-1"` 로 사슬을 잇는다 — 픽셀로 박으면 창 높이가 바뀔 때 따라가지 못한다.
626
+
514
627
  #### E. 다른 곳으로 이동시킨다
515
628
 
516
629
  | 하려는 일 | 컴포넌트 | 갈림 |
@@ -527,7 +640,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
527
640
  | --- | --- | --- |
528
641
  | 실행 여부만 확정받는다 | `SModal.confirm()` | §3-3-1 |
529
642
  | 모달 안에서 작성·선택하게 한다 | `SActionModal` + `SModal.create()` | §3-3-1 |
530
- | 띄우는 것 자체가 하나의 화면이다 | `SPopup` | §3-3-1 |
643
+ | 띄우는 것 자체가 하나의 화면이다 | `SPopup` | §3-3-1 · 골격은 §4-7 |
531
644
  | 화면 옆에서 밀려 나오는 작업 패널을 연다 | `SDrawer` | §3-3-5 |
532
645
  | 확인 다이얼로그를 화면에 직접 배치한다 | `SConfirmModal` | §3-3-4 |
533
646
  | 클릭하면 상호작용 가능한 작은 콘텐츠를 띄운다 | `SPopover` | §3-3 |
@@ -539,6 +652,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
539
652
  | 하려는 일 | 컴포넌트 | 갈림 |
540
653
  | --- | --- | --- |
541
654
  | 화면에 상시 노출되는 안내·경고를 둔다 | `SCallout` | §3-2 |
655
+ | 한 자리에 붙는 **한 줄** 알림 + 후속 동작 버튼을 둔다 | `SContextAlert` | §3-2 |
542
656
  | 작업 결과를 일시적으로 알린다 | `SToast` | §3-2 |
543
657
  | 기능 온보딩·도움말을 붙인다 | `SGuide` | §3-2 |
544
658
 
@@ -546,8 +660,8 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
546
660
 
547
661
  | 하려는 일 | 컴포넌트 | 갈림 |
548
662
  | --- | --- | --- |
549
- | 화면 전체를 잠그고 기다리게 한다 | `SLoadingModal` (또는 `SModal.loading()`) | §3-2 |
550
- | 특정 영역만 덮고 기다리게 한다 | `SLoadingContainer` | §3-2 |
663
+ | 화면 전체를 잠그고 기다리게 한다 | `loading.show()` — 진행률·실패 후 재시도 버튼이 필요하면 `SLoadingModal` (`SModal.loading()`) | §3-3-6 |
664
+ | 특정 영역만 덮고 기다리게 한다 | `SLoadingContainer` | §3-3-6 |
551
665
  | 진행률을 가로 막대로 보여준다 | `SLinearProgress` | §3-7-9 |
552
666
  | 진행률·대기를 원형으로 보여준다 | `SCircleProgress` | §3-7-9 |
553
667
 
@@ -572,11 +686,36 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
572
686
 
573
687
  | 상황 | 사용 |
574
688
  | --- | --- |
575
- | 화면에 **상시 노출**되는 안내·경고 문구 | `SCallout` — `type` + `message` 배열(중첩 = 들여쓰기) |
689
+ | 화면에 **상시 노출**되는 안내·경고 문구 (여러 줄 목록) | `SCallout` — `type` + `message` 배열(중첩 = 들여쓰기) |
690
+ | 한 자리의 상태를 **한 줄**로 알리고, 필요하면 후속 동작 버튼을 붙임 | `SContextAlert` — `intent` + `messages` 배열(한 줄씩) + `action` 슬롯 |
576
691
  | 작업 결과를 **일시적으로** 알림 | `SToast` (+ 루트에 `SToastContainer`) |
577
692
  | 진행 전 **확인/취소**를 받아야 할 때 | `SModal.confirm({...}).onOk(...)` — 무엇을 할지가 모달 밖에서 이미 정해진 경우다 (§3-3-2) |
578
693
  | 특정 UI 요소에 대한 **온보딩·기능 안내** | `SGuide` |
579
- | 로딩 중 화면 잠금 | `SLoadingModal` / 영역 로딩은 `SLoadingContainer` |
694
+ | 로딩 중 화면 잠금 | `loading.show()` (§3-3-6) / 영역 로딩은 `SLoadingContainer` |
695
+
696
+ **`SCallout` 의 `type` 은 놓이는 면이 정한다.** 안내의 강도가 아니라 배경 대비의 문제다.
697
+
698
+ | `type` | 언제 |
699
+ | --- | --- |
700
+ | `default` | 흰 면 위 (`SPage background="frame"`) — 옅은 회색 면이라 페이지와 구분된다 |
701
+ | `white` | 회색 면 위 (`background="neutral"`·`"screen"`) — 그 위에서는 `default` 가 배경에 묻힌다 |
702
+ | `danger` | 경고. 좌측에 "주의사항" 타이틀이 붙으므로 단순 안내에는 쓰지 않는다. 주변에 이미 경고 맥락(제목 등)이 있어 타이틀이 중복되면 `showTitle={false}` 로 타이틀만 뺀다 — 경고 면은 유지된다 |
703
+
704
+ #### 3-2-1. SCallout vs SContextAlert
705
+
706
+ **둘은 면·테두리·패딩이 같은 상자다.** 색만 보고 고르면 같은 화면에서 같은 모양의 상자가 서로 다른 규칙으로 늘어난다. **무엇을 담는지**로 고른다.
707
+
708
+ | | `SCallout` | `SContextAlert` |
709
+ | --- | --- | --- |
710
+ | 담는 것 | 읽을 **문장들** — 여러 줄, 중첩 들여쓰기 | 지금 상태 **한 줄** |
711
+ | 후속 동작 | 없다 | `action` 슬롯에 버튼 하나 |
712
+ | 고르는 축 | `type` — **놓이는 면**이 정한다(위 표) | `intent` — **알림의 성격**이 정한다 |
713
+ | 자리 | 페이지·섹션 머리의 상시 안내 | 그 알림이 가리키는 **대상 바로 옆** |
714
+
715
+ - **`SContextAlert` 는 대상 옆에 둔다.** 페이지 맨 위에 놓고 "아래 어딘가의 이야기"를 하면 그건 `SCallout` 이 할 일이다.
716
+ - **`action` 에는 `SButton size="xs"` 를 넣는다.** 그 높이가 곧 상자의 콘텐츠 높이라, 더 큰 버튼을 넣으면 상자가 세로로 늘어나 한 줄 알림의 리듬이 깨진다. 버튼이 둘 이상 필요하면 한 줄로 끝날 이야기가 아니다 — 모달(§3-3)을 본다.
717
+ - **`messages` 는 줄을 나누는 용도지 문장을 자르는 용도가 아니다.** 한 문장이 길어 넘치는 것은 그냥 두면 줄바꿈된다. 서로 다른 사실일 때만 배열로 나눈다.
718
+ - **아이콘은 기본적으로 없다.** `icon` 을 주지 않으면 아이콘 칸 자체가 없고, 알림의 성격은 면 색이 말한다. 붙이기로 했다면 인텐트 6종 모두 같은 글리프(`notificationOutline`) 하나를 쓴다 — 인텐트마다 아이콘을 바꾸면 면의 색과 아이콘의 뜻이 따로 논다. 색도 같다: `iconColor` 는 아이콘이 성격까지 말해야 하는 자리에서만 쓰고, 면 색에 맞춰 인텐트마다 바꾸지 않는다.
580
719
 
581
720
  ### 3-3. 플로팅/오버레이류
582
721
 
@@ -584,7 +723,8 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
584
723
  | --- | --- |
585
724
  | hover 시 **짧은 보조 설명** (상호작용 없음) | `STooltip` |
586
725
  | 클릭 시 **상호작용 가능한** 작은 콘텐츠 (메뉴·미니 폼) | `SPopover` |
587
- | 로딩 중 화면 잠금 | `SModal.loading(...)` / `SLoadingModal` |
726
+ | 로딩 중 화면 잠금 — 스피너와 메시지만 (기본) | `loading.show(...)` — 3-3-6 |
727
+ | 로딩 중 화면 잠금 — **진행률·실패 후 재시도 버튼**이 필요하다 | `SModal.loading(...)` / `SLoadingModal` |
588
728
  | 그 밖에 **흐름을 끊고 띄우는 창** | `SPopup` · `SActionModal` · `SModal.confirm` — 아래 3-3-1 판별 순서로 고른다 |
589
729
 
590
730
  #### 3-3-1. SPopup vs SActionModal vs SModal.confirm — 판별 순서
@@ -667,34 +807,11 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
667
807
 
668
808
  - 작은 창이 아니라 **화면 하나가 통째로** 들어간다 — 검색 필터·테이블·페이지네이션이 그대로 있는 목록(`엑셀 파일 관리`), 헤더 카드·탭이 있는 상세(`이동 오더 상세`).
669
809
  - 상세를 팝업으로 여는 이유는 **목록을 떠나지 않고 여러 건을 번갈아 보기** 위해서다. 단 상세가 **항상** 팝업인 것은 아니고, 데이터 양이 많을 때 팝업을 쓴다.
670
- - 구조는 헤더(제목 중앙) + 본문이고 **푸터는 기본으로 없다**. 조회만 하는 팝업은 그대로 두고, **확정할 작업이 있을 때만 `useFooter` 로 푸터를 켜서 `submitButton` 에 `저장`** 을 둔다.
671
- - **본문 패딩은 `SPopup` 이 토큰으로 넣는다. 직접 주지 않는다** (`p-sd-*` 로 덮어쓰면 토큰이 바뀌어도 안 따라간다). 표를 가장자리까지 채우는 등 콘텐츠가 여백을 직접 다뤄야 할 때만 `noPadding` 으로 끈다.
810
+ - 구조는 헤더(제목 중앙) + 본문이고 **푸터는 기본으로 없다**. 조회만 하는 팝업은 그대로 두고, **확정할 작업이 있을 때만 `showFooter` 로 푸터를 켜서 `submitButton` 에 `저장`** 을 둔다.
811
+ - **본문 패딩은 `SPopup` 이 토큰으로 넣는다. 직접 주지 않는다** (`p-sd-*` 로 덮어쓰면 토큰이 바뀌어도 안 따라간다). 표를 가장자리까지 채우는 등 콘텐츠가 여백을 직접 다뤄야 할 때만 `padding="none"` 으로 끈다.
672
812
  - 그 밖에는 팝업 안도 일반 페이지와 같은 규칙(§4)을 따른다: 블록 간격 `gap-sd-12`, 표는 `SKeyValueTable` / `STable`.
673
813
 
674
- ```tsx
675
- // 1) 목록에서 별도 창을 연다 — 창 크기 = 콘텐츠 크기
676
- function openDetailPopup(orderId: string) {
677
- window.open(
678
- `${window.location.origin}/popup/transfer-orders/${orderId}`,
679
- `transfer-order-${orderId}`,
680
- 'width=1200, height=800, toolbar=no, menubar=no, location=no, resizable=no',
681
- );
682
- }
683
-
684
- // 2) 그 라우트의 루트에 SPopup 을 둔다 (조회만 → 푸터 없음)
685
- export default function TransferOrderPopupPage() {
686
- return (
687
- <SPopup popupTitle="이동 오더 상세">
688
- {/* 본문 패딩은 SPopup 이 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다 */}
689
- <div className="flex flex-col gap-sd-12">
690
- <SSectionHeaderCard>…</SSectionHeaderCard>
691
- <STabs value={tab} tabs={TABS} onValueChange={setTab} />
692
- <STable columns={columns} rows={rows} rowKey="id" />
693
- </div>
694
- </SPopup>
695
- );
696
- }
697
- ```
814
+ **창을 여는 코드와 그 라우트의 골격은 §4-7 에 있다.** 창 높이를 `SPopup` 까지 잇는 한 줄이 빠지면 헤더·푸터가 창 밖으로 밀려나므로, 팝업 라우트는 반드시 §4-7 골격에서 시작한다.
698
815
 
699
816
  #### 3-3-4. 모달 만드는 법
700
817
 
@@ -729,6 +846,34 @@ SModal.create({ component: OrderModal, componentProps: { orderId } })
729
846
 
730
847
  **하단 버튼을 본문(children)에 직접 두지 않는다.** 주 액션은 `button`, 보조 버튼은 `footerLeft` 로 넘긴다 — 푸터 배경·여백·양끝 분리가 컴포넌트 규칙대로 잡히는 자리다. `button` 은 클릭해도 **모달을 닫지 않으므로**(`onClick` 만 발화) 저장 API 응답을 보고 `modalRef.ok()` 로 닫으면 되고, 그 때문에 본문에 버튼을 따로 둘 이유가 없다. `footerLeft` 는 슬롯이라 `SButton` 을 직접 배치하며 `size="md"` 를 명시한다(§3-5-2).
731
848
 
849
+ **`button` 은 `SButton` 의 prop 을 그대로 받는다** — `icon`·`rightIcon`·`outline`·`disabled` 등을 함께 넘길 수 있다. 단 **`size` 는 타입에 없다**: 버튼 크기는 놓이는 자리가 정하므로(§3-5-2) 푸터가 준다. 모달·드로어·팝업의 푸터는 **언제나 `md`** 라 받을 것이 없다 — 크기를 고를 수 있는 것은 카드 둘뿐이다(§3-7-8 `footerSize`). **`color` 는 주지 않는 것이 기본**이다 — 주지 않으면 주 액션 규칙대로 `primary` 가 되고, 그 버튼이 파괴적 액션일 때만 §3-5-3 에 따라 `danger` 를 준다. 카드(§3-7-8)의 `button` 도 같다.
850
+
851
+ **확인 모달은 확인 버튼 하나로 세우는 것이 기본이다 — `subButtonLabel` 을 주지 않는다.** 비워 두면 서브 버튼이 아예 서지 않는다. 답하지 않고 나가는 길은 **X 버튼이 이미 맡고**(늘 서 있다), `persistent` 를 켜지 않았으면 백드롭·ESC 로도 닫힌다(§3-3-5).
852
+
853
+ **서브 버튼은 `onCancel` 이 닫는 것 말고 따로 할 일이 있을 때만 둔다.** 눌러도 창만 닫힌다면 X 와 똑같은 일을 하는 버튼이 하나 더 서는 것이라, 어느 것을 눌러야 하는지가 오히려 흐려진다. **되묻는 모달이라고 예외가 아니다** — "삭제할까요?" 에 답하지 않고 나가는 것은 X 로 충분하다.
854
+
855
+ 그래서 서브 버튼이 설 자리는 **확인과 대등한 두 번째 선택지**뿐이고, 그때 글자는 `취소`·`닫기` 가 아니라 그 버튼이 하는 일의 이름이 된다.
856
+
857
+ ```tsx
858
+ ✅ SModal.confirm({ type: 'negative', modalTitle: '삭제할까요?', mainButtonLabel: '삭제' })
859
+ .onOk(() => remove())
860
+ ✅ SModal.confirm({ type: 'negative', modalTitle: '재고가 모자란 주문이 있습니다',
861
+ mainButtonLabel: '자동 취소', subButtonLabel: '수동 처리' })
862
+ .onOk(() => cancelShortOrders()).onCancel(() => openManualFlow())
863
+ // 서브가 창을 닫는 것이 아니라 다른 흐름을 시작한다
864
+
865
+ ❌ SModal.confirm({ …, mainButtonLabel: '삭제', subButtonLabel: '취소' }).onCancel(() => {})
866
+ // 눌러도 닫히기만 한다 — X 가 이미 하는 일
867
+ ❌ SModal.confirm({ …, mainButtonLabel: '확인', subButtonLabel: '닫기' })
868
+ // 닫으려고 붙인 버튼 — X 와 겹친다
869
+ ❌ SModal.confirm({ type: 'positive', modalTitle: '저장되었습니다', mainButtonLabel: '확인', subButtonLabel: '취소' })
870
+ // 이미 끝난 일 옆의 취소 — 무엇이 취소되는지 알 수 없다
871
+ ```
872
+
873
+ 습관적으로 `subButtonLabel: '취소'` 를 옵션에 넣지 않는다. **`onCancel` 에 적을 것이 없으면 서브 버튼도 없다.**
874
+
875
+ **확인 모달의 확인 버튼은 `mainButtonDisabled` 로 잠근다.** 기본값이 `false` 이므로 **아직 확정할 수 없을 때만** 준다 — 동의를 안 받았거나, `contentSlot` 의 입력이 덜 찼거나, 확인 뒤에 나갈 요청이 이미 나가는 중일 때. **왜 못 누르는지를 화면에 함께 둔다**(`bottomMessage` 나 `contentSlot`): 흐려진 버튼만 남으면 사용자가 고장으로 읽는다. **취소는 따라 잠그지 않는다** — 확정하지 못하는 상태일수록 나가는 길은 열려 있어야 해서 서브 버튼을 막는 prop 자체가 없다. 조건이 채워지면 `SModal.confirm` 이 돌려준 핸들로 `update({ mainButtonDisabled: false })` 해서 푼다.
876
+
732
877
  **모달 안에서도 앱의 훅을 그냥 쓴다 — 단, 앱 루트에 `SModalOutlet` 이 있어야 한다 (§4-1).** outlet 이 있으면 명령형 모달이 앱 렌더 트리의 자식으로 그려지므로 `useQuery`·`useNavigate`·`useTheme` 같은 Context 기반 훅이 페이지에서와 똑같이 동작한다. **모달 컴포넌트를 Provider 로 다시 감싸지 않는다.** outlet 없이 띄우면 모달이 별도 React 루트로 떠서 Provider 가 하나도 닿지 않고, `No QueryClient set` 처럼 모달을 여는 순간에만 터진다.
733
878
 
734
879
  #### 3-3-5. 닫기 경로 — `persistent` 기본값은 컴포넌트마다 다르다
@@ -738,49 +883,139 @@ SModal.create({ component: OrderModal, componentProps: { orderId } })
738
883
  | | `persistent` 기본값 | 기본 닫기 경로 | 백드롭·ESC |
739
884
  | --- | --- | --- | --- |
740
885
  | `SActionModal` · `SLoadingModal` | `true` | X 버튼, 모달 안의 버튼 | 막힘 (흔들림) |
886
+ | ↳ `SActionModal` 에 `showClose={false}` | `true` | **모달 안의 버튼만** | 막힘 (흔들림) |
741
887
  | `SDrawer` | `true` | X 버튼, footer 버튼 | 막힘 (흔들림) |
742
- | `SConfirmModal` | `false` | X 버튼, 확인/취소 버튼 | **닫힌다** |
888
+ | `SConfirmModal` | `false` | X 버튼, 확인 버튼 | **닫힌다** |
743
889
  | `SPopover` · `STooltip` · `SSelect` 등 floating | — | 바깥 클릭·ESC | **막지 않는다** — 이 규칙의 대상이 아니다 |
744
890
 
745
891
  따라서 다음을 지킨다.
746
892
 
747
- - **닫을 수단을 반드시 하나는 둔다.** `SActionModal` 에 `showClose` 도 `button`/`footerLeft` 도 없으면 사용자가 모달을 닫을 방법이 없다. 백드롭이 더 이상 탈출구가 아니다.
893
+ - **닫을 수단을 반드시 하나는 둔다.** X 버튼은 기본으로 서 있으므로, 이 규칙이 걸리는 것은 `SActionModal` 에 `showClose={false}` 를 준 경우다. 그때 `button` 도 `footerLeft` 도 없으면 사용자가 모달을 닫을 방법이 없다 — 백드롭이 더 이상 탈출구가 아니기 때문이다. 개발 중 콘솔 경고가 뜬다.
894
+ - **`SActionModal` 의 `showClose={false}` 는 액션을 끝내야만 나갈 수 있는 모달에만.** 필수 약관 동의, 필수 정보 입력처럼 **중간에 빠져나가면 앱이 정의되지 않은 상태로 남는 경우**로 한정한다. 그 외에는 기본값(X 가 있는 상태)을 그대로 둔다 — "집중시키고 싶다"는 이유로 X 를 없애지 않는다. `persistent` 기본값(true)과 합쳐져 나가는 길이 버튼 하나뿐이 되므로, 그 버튼은 **비활성 상태로 시작하더라도 사용자가 스스로 풀 수 있는 조건**이어야 한다(동의 체크 등). 서버 응답을 기다려야 푸는 조건이면 실패 시 사용자가 갇힌다.
748
895
  - **기본값과 같은 `persistent` 를 직접 주지 않는다.** 중복이다.
749
896
  - **`SActionModal`·`SDrawer` 에 `persistent={false}` 는 잃을 입력이 없을 때만.** 단순 알림처럼 임의로 닫혀도 아무것도 사라지지 않는 경우로 한정한다.
750
897
  - **`SConfirmModal` 에 `persistent` 를 켜는 것은 반드시 답을 받아야 할 때만.** 되돌릴 수 없는 파괴적 작업의 확인처럼, 임의로 닫히면 안 되는 경우로 한정한다.
898
+ - **`SConfirmModal` 에 `persistent` 와 `mainButtonDisabled` 를 함께 켜면 나가는 길이 X 하나만 남는다.** 갇히지는 않지만(X 는 늘 서 있다) 확인이 왜 잠겼는지가 화면에 없으면 막다른 길로 읽힌다 — 잠근 이유를 `bottomMessage` 나 `contentSlot` 에 함께 둔다(§3-3-4).
751
899
 
752
900
  작성 중인 내용이 있을 때 닫기를 시도하면 이탈 안내를 띄우는 것은 **소비 앱 몫**이다. 디자인 시스템은 dirty 상태를 알지 못하므로 백드롭·ESC 를 일괄 차단할 뿐이다. 안내가 필요하면 앱이 자체 dirty 판정 후 `SModal.confirm` 으로 띄운다.
753
901
 
754
- ### 3-4. 테이블 컬럼 — 정렬과 너비
902
+ #### 3-3-6. 전역 로딩 오버레이 — `loading`
903
+
904
+ 화면 전체를 잠그고 기다리게 할 때 쓰는 명령형 싱글톤이다. 마운트할 것이 없다 — 첫 `show` 에 오버레이 루트가 `body` 에 자동으로 붙는다.
905
+
906
+ **표면은 둘뿐이다.**
907
+
908
+ | 하려는 일 | 사용 |
909
+ | --- | --- |
910
+ | 기다리게 한다 (거의 항상 이것) | `loading.show({ message })` → **핸들**. 부르면 걷고, `.update({ message })` 로 이 오버레이의 메시지만 바꾼다 |
911
+ | 화면 전환·에러 복구로 짝을 더는 맞출 수 없다 | `loading.hideAll()` — 비상구다. 평소에는 쓰지 않는다 |
912
+
913
+ **걷는 것은 `finally` 에 둔다.** 빠뜨리면 그 즉시 화면이 갇히고, 정상 경로에서는 아무 표시가 없다.
914
+
915
+ ```tsx
916
+ const done = loading.show({ message: '저장 중입니다...' });
917
+ try {
918
+ const payload = buildPayload(form);
919
+ done.update({ message: '전송 중입니다...' }); // 이 오버레이의 메시지만 바뀐다
920
+ await api.submit(payload);
921
+ } finally {
922
+ done(); // 이 오버레이만 걷는다
923
+ }
924
+ ```
925
+
926
+ 한 화면에서 같은 모양이 반복되면 **앱이 자기 헬퍼를 둔다.** 디자인 시스템은 원시 도구만 준다.
755
927
 
756
- #### 정렬
928
+ ```tsx
929
+ // 소비 앱 쪽 유틸 — DS 가 가지지 않는다
930
+ export const withLoading = async <T,>(task: Promise<T>, options?: { message?: string }) => {
931
+ const done = loading.show(options);
932
+ try {
933
+ return await task;
934
+ } finally {
935
+ done();
936
+ }
937
+ };
938
+ ```
757
939
 
758
- **값의 크기를 비교하는 숫자 컬럼은 예외 없이 오른쪽 정렬한다** (`align: 'right'`).
759
- 자릿수가 세로로 맞아야 값의 크기를 눈으로 비교할 수 있기 때문이다.
940
+ - **반환 핸들을 반드시 잡는다.** 잡지 않으면 그 오버레이를 걷을 방법이 `hideAll()`(= 전부 걷기) 밖에 남지 않는다.
941
+ - **메시지를 바꾸려고 `show()` 를 다시 부르지 않는다.** 하나 더 쌓여 한 번 걷는 것으로는 닫히지 않는다. 핸들의 `update()` 를 쓴다.
942
+ - 겹쳐 부르면 그만큼 쌓이고 **마지막 하나가 걷힐 때** 닫힌다. 목록 재조회와 상세 저장이 겹쳐도 먼저 끝난 쪽이 아직 진행 중인 작업의 화면 잠금을 풀지 않는다. 화면에 그려지는 오버레이는 몇 개를 띄우든 **하나**이고, 표시되는 메시지는 맨 위 것이다.
760
943
 
761
- **판별 기준은 "숫자인가"가 아니라 "크기를 비교하는가"다.** 자릿수 차이가 거의 없고 값끼리 대소를 견줄 일이 없으면 숫자로만 이루어져 있어도 우측 정렬하지 않는다.
944
+ ##### 제거된 API — 올라오면서 바꿀 것
762
945
 
763
- | 값 성격 | 정렬 | 예 |
946
+ 「스택 맨 위를 집는」 **위치 기준 API 를 전부 없앴다.** 호출자가 누구인지 볼 수 없어 겹친 작업끼리 남의 것을 걷거나 덮었고, 화면이 영영 닫히지 않는 사고의 원인이었다.
947
+
948
+ | 없어진 것 | 무엇이었나 | 무엇으로 바꾸나 |
764
949
  | --- | --- | --- |
765
- | **금액·수량·개수·비율 등 양을 나타내는 값** | **`'right'`** | `39,000원` · `12개` · `3건` · `15%` |
766
- | 코드·식별자 (주문번호, 상품코드, 순번) | **`'center'`** | `RV20250728-000010` · `1024` |
767
- | 전화번호·사업자번호 | **`'center'`** | `010-1234-5678` |
768
- | 일자·일시 | **`'center'`** | `2024-10-23` |
769
- | 텍스트 | 생략(기본 `left`) | 상품명, 카테고리 |
770
- | 상태 태그·아이콘·체크박스 등 고정폭 요소 | `'center'` | `STag`, `SIcon` |
950
+ | ❌ `loading.hide()` | 맨 위 **하나**를 걷었다 | **거의 전부 `show()` 핸들**을 `finally` 에서 부르는 것으로 바뀐다. `hideAll()` 은 라우트 이탈·에러 복구처럼 **진행 중이던 남의 작업까지 정리하는 것이 의도였던** 소수의 자리에만 |
951
+ | ❌ `loading.update()` | 맨 위 항목의 메시지를 바꿨다 | `show()` 핸들의 `update()` |
952
+ | ❌ `SLoadingViewport` | 뷰포트를 직접 배치하게 했다 | 트리에서 뺀다. 루트는 첫 `show` 에 자동으로 붙으므로 직접 두면 오버레이가 **두 겹**이 되어 딤만 진해졌다 |
953
+
954
+ ⚠️ **`hideAll()` 로 일괄 치환하지 않는다.** `hide()` 가 참조 카운트 이전에는 「전부 걷기」였으므로 그쪽이 맞아 보이지만, 그 시절 호출자의 의도는 **「내가 띄운 걸 닫는다」**였다 — 큐가 있다는 것을 몰랐을 뿐이다. `hideAll()` 로 옮기면 겹친 화면에서 **아직 진행 중인 작업의 잠금까지 풀어** 재클릭·중복 요청이 난다. 기본은 핸들이다.
955
+
956
+ > **왜 이 실수가 쉬운가** — `show()` 를 몇 번 부르든 **화면에 그려지는 오버레이는 하나**다. 내부 카운트는 화면에 드러나지 않으므로 `hide()` 뒤에 오버레이가 남아도 「안 닫혔다」로만 보이고 「하나 남았다」로는 보이지 않는다. 그래서 위치로 짝을 맞추는 방식은 눈으로 검증할 수 없고, 핸들로 소유권을 명시하는 것이 유일한 방법이다.
957
+
958
+ ⚠️ **`show()` 가 두 번 불리는 경로도 함께 본다.** 같은 변경에서 `show()` 의 의미도 바뀌었다 — 그전에는 다시 부르는 것이 **갱신**이었고 지금은 하나 더 쌓인다. 그래서 아래는 `hide()` 를 안 썼어도 오버레이를 남긴다.
959
+
960
+ ```tsx
961
+ ❌ loading.show({ message: '1/2 ...' });
962
+ loading.show({ message: '2/2 ...' }); // 그때는 갱신, 지금은 하나 더 쌓인다
963
+ done(); // 하나만 걷힌다 → 남는다
964
+
965
+ ✅ const done = loading.show({ message: '1/2 ...' });
966
+ done.update({ message: '2/2 ...' });
967
+ done();
968
+ ```
969
+
970
+ > **원본(Quasar `Loading`)과의 차이는 둘뿐이고 둘 다 안전 때문이다.** 원본의 인자 없는 `hide()` 는 「전부 걷기」이므로 이름을 되쓰면 기존 호출의 의미가 컴파일 에러 없이 바뀐다 — 그래서 `hideAll()` 이다. 그리고 원본은 겹친 작업을 앱이 준 `group` 이름으로 가르지만(안 주면 먼저 끝난 쪽이 남의 잠금을 푼다), 여기서는 `show()` 마다 항목이 따로 생겨 앱이 무엇을 빠뜨려도 그 일이 없다.
971
+
972
+ > **영역만 덮을 때는 `SLoadingContainer` 를 직접 배치한다** (`open` prop 제어). `loading` 은 화면 전체를 잠그는 전역 오버레이 전용이다.
973
+
974
+ ### 3-4. 테이블 컬럼 — 정렬·너비·헤더
975
+
976
+ #### 정렬과 너비는 같은 표에서 정한다
977
+
978
+ 컬럼을 정의할 때 정렬과 너비는 따로 판단하는 것이 아니다. 둘 다 **값의 성격**에서 나온다.
979
+
980
+ 원칙 한 줄: **길이를 형식이 정하면 고정, 사용자가 정하면 가변.**
981
+
982
+ | 값 성격 | 정렬 | 너비 | 예 |
983
+ | --- | --- | --- | --- |
984
+ | **금액·수량·개수·비율** (양을 나타내는 값) | **`'right'`** | 고정 | `39,000원` · `12개` · `3건` · `15%` |
985
+ | 코드·식별자, 전화번호, 일자·일시 | **`'center'`** | 고정 | `RV20250728-000010` · `010-1234-5678` · `2026-08-13` |
986
+ | **닫힌 값 집합** (enum · 마스터 목록에서 고르는 값) | **`'center'`** | 고정 | 상태 · 직급 · 공개 범위 · 고용 형태 · 요일 |
987
+ | 상태 태그·아이콘·버튼·체크박스 | `'center'` | 고정 (`contentType: 'control'`) | `STag` · `SIcon` · `SGhostButton` |
988
+ | **텍스트** (사용자가 자유 입력) | 생략(기본 `left`) | 기준 폭 + `resizable` | 이름 · 목표명 · 이메일 · 메모 |
771
989
 
772
990
  **중앙 정렬은 `align: 'center'` 를 명시한다.** 기본값이 좌측이라 생략하면 중앙이 되지 않는다.
773
991
 
992
+ ##### 판별 — 값의 크기를 비교하는가
993
+
994
+ 우측 정렬의 근거는 "자릿수를 세로로 맞춰 크기를 읽는다"다. 자릿수 차이가 거의 없고 값끼리 대소를 견줄 일이 없으면 숫자로만 이루어져 있어도 우측 정렬하지 않는다 — 송장번호 `123456789` 는 크기를 비교하는 값이 아니다.
995
+
996
+ ##### 판별 — 값 집합이 닫혀 있는가
997
+
998
+ **닫힌 값 집합이면 태그로 그리든 맨 텍스트로 그리든 `center` 다.** 판별 질문 하나 — *사용자가 그 칸을 직접 치는 값인가?* 아니면 닫힌 집합이다.
999
+
1000
+ | | 값의 출처 | 정렬 | 예 |
1001
+ | --- | --- | --- | --- |
1002
+ | **닫힘** | enum · 마스터 목록에서 선택 (`SSelect` 의 `options` 에서 오는 값) | `center` | 직급 · 상태 · 공개 범위 · 최종 등급 · 고용 형태 · 요일 |
1003
+ | **열림** | 사용자가 자유 입력 (자유 입력 필드에서 오는 값) | `left` | 이름 · 목표명 · 이메일 · 문항 그룹명 |
1004
+
1005
+ - **무엇으로 그렸는지로 가르지 않는다.** 같은 성격의 값이 `STag` 면 `center`, 맨 텍스트면 `left` 가 되면 한 테이블 안에서 기준이 어긋난다.
1006
+ - **길이로도 가르지 않는다.** "짧은 라벨이면 center" 같은 단서를 붙이면 `프로덕트디자인팀`(8자)처럼 경계에 걸리는 값에서 매번 판단이 갈린다.
1007
+ - 한 열에 텍스트와 태그가 함께 오면 태그 기준(`center`)에 맞춘다.
1008
+
774
1009
  ```tsx
775
1010
  const columns: STableColumn[] = [
776
- { name: 'orderNo', label: '주문번호', field: 'orderNo', width: '140px', align: 'center' },
777
- { name: 'orderedAt', label: '주문일자', field: 'orderedAt', width: '100px', align: 'center' },
778
- { name: 'name', label: '상품명', field: 'name' }, // 텍스트 → 생략
779
- { name: 'qty', label: '수량', field: 'qty', width: '80px', align: 'right',
1011
+ { name: 'orderNo', label: '주문번호', field: 'orderNo', width: 140, align: 'center' },
1012
+ { name: 'orderedAt', label: '주문일자', field: 'orderedAt', width: 100, align: 'center' },
1013
+ { name: 'name', label: '상품명', field: 'name', width: 240 }, // 자유 입력 → 생략
1014
+ { name: 'qty', label: '수량', field: 'qty', width: 80, align: 'right',
780
1015
  format: (v: number) => `${Number(v).toLocaleString()}개` },
781
- { name: 'price', label: '판매가', field: 'price', width: '120px', align: 'right',
1016
+ { name: 'price', label: '판매가', field: 'price', width: 120, align: 'right',
782
1017
  format: (v: number) => `${Number(v).toLocaleString()}원` },
783
- { name: 'status', label: '상태', field: 'status', width: '100px', align: 'center',
1018
+ { name: 'status', label: '상태', field: 'status', width: 100, align: 'center',
784
1019
  render: () => <STag size="sm" color="green" label="판매중" /> },
785
1020
  ];
786
1021
  ```
@@ -788,29 +1023,56 @@ const columns: STableColumn[] = [
788
1023
  - `format` 으로 단위를 붙이더라도 **양을 나타내면 오른쪽 정렬**이다. 단위 때문에 문자열이 되는 것은 정렬 판단과 무관하다.
789
1024
  - 양을 나타내는 숫자는 §1-4 대로 **`toLocaleString()` 이 필수**다. 세 자리 콤마 없이 출력하지 않는다.
790
1025
  - **번호·코드에는 세 자리 콤마를 넣지 않는다.** 송장번호 `123456789` 를 `123,456,789` 로 표시하면 값 자체가 달라 보인다.
1026
+ - 날짜는 §1-4 대로 `YYYY-MM-DD` 로 적는다. 자릿수가 고정이라 폭을 형식으로 계산할 수 있다.
791
1027
  - **헤더는 가운데, 셀만 우측**으로 두려면 `align` 이 아니라 `tdClass` 를 쓴다. `align` 은 `<th>` 와 `<td>` 에 함께 적용된다.
792
1028
 
793
1029
  ```tsx
794
- { name: 'views', label: '조회수', field: 'views', align: 'center', tdClass: 'text-right!',
1030
+ { name: 'views', label: '조회수', field: 'views', align: 'center', width: 100, tdClass: 'text-right!',
795
1031
  format: (v: number) => Number(v).toLocaleString() },
796
1032
  ```
797
1033
 
798
1034
  - `SKeyValueTable` 의 값 셀도 같은 기준을 따른다.
799
1035
 
800
- #### 컨트롤이 들어가는 컬럼은 너비를 명시한다
1036
+ #### 너비는 px 로만 준다
1037
+
1038
+ **컬럼 폭은 px 이다.** 숫자를 주면 px 로 읽고, 문자열은 `'120px'` 형태만 받는다. `%` · `clamp()` · `min()` 은 쓰지 않는다.
1039
+
1040
+ 컬럼 폭은 `<colgroup>` 의 `<col width>` 로 들어가고 테이블이 `table-fixed` 라, 함수형 값은 계산되지 않고 통째로 무시된 뒤 auto 폭으로 떨어진다. `'30%'` 는 더 나쁘게 `30`(px)으로 읽힌다. **둘 다 에러 없이 화면만 틀어진다.**
1041
+
1042
+ - **내용이 들어가는 열은 전부 폭을 명시한다.** 생략하면 기본 120px 이 조용히 들어가고, "짧은 열이라 그대로 둔 것"과 "판단을 빠뜨린 것"이 구분되지 않는다. 120px 이 맞더라도 `width: 120` 을 적는다.
1043
+ - **`autoWidth` 는 남은 폭을 흡수하는 스페이서 열 하나에만 쓴다.** 내용이 들어가는 열에는 쓰지 않는다 — 폭이 다른 열에 좌우돼 화면마다 달라진다. 스페이서 열은 값을 그리지 않으므로 `field` 도 생략한다.
1044
+
1045
+ ```tsx
1046
+ { name: 'spacer', label: '', autoWidth: true },
1047
+ ```
1048
+
1049
+ - **`minWidth` · `maxWidth` 는 `resizable` 손잡이의 이동 범위일 뿐, 레이아웃에는 관여하지 않는다.** 폭을 주지 않은 열이 이 값 안에서 잡히는 것이 아니다.
1050
+ - **고정폭 합이 최소 창 폭을 넘으면 가로 스크롤이 된다.** `STable` 이 자기 안에서 가로로 스크롤하고 헤더·바디가 함께 움직이므로 별도 조치는 필요 없다 — 폭을 줄여 맞추지 말고, 열이 정말 그만큼 필요한지를 본다.
1051
+
1052
+ ##### 고정폭을 어떻게 정하는가
1053
+
1054
+ ```text
1055
+ 폭 = ceil( ( max(값 폭 + 값 기준 패딩, 헤더 폭 + 32) + 여유 ) / 8 ) × 8
1056
+ ```
1057
+
1058
+ - **값과 헤더를 따로 계산해 큰 쪽을 쓴다.** `contentType: 'control'` 은 `<td>` 에만 적용되고 `<th>` 는 항상 텍스트 패딩이라, 짧은 컨트롤 + 긴 헤더 조합에서 헤더가 잘린다.
1059
+ - 값에 따라 폭이 달라지는 요소(`STag` 라벨, 라벨 있는 버튼)는 **가장 긴 값** 기준으로 잡는다. `판매중` 에 맞춰두면 `판매중지 요청` 에서 잘린다.
1060
+ - **정렬 가능한 헤더(`sortable`)는 아이콘 버튼 + 간격만큼 `+20px` 더 든다.** `helpText` 를 함께 달면 그만큼 또 더한다.
1061
+ - **`editable` · `navigable` 표식과 `required` 표시(`*`) 도 각각 폭을 먹는다.** 헤더에 붙는 것이 늘수록 **라벨이 먼저 잘리므로**, 붙인 열은 폭을 함께 넓힌다.
1062
+ - **계산값은 픽셀 단위까지 맞추면 어긋난다** — 서브픽셀 반올림 때문이다. 여유 8px 을 얹고 8 단위로 올림한다.
1063
+ - 좌우 패딩은 `STable` 이 토큰으로 넣으므로 직접 주지 않는다. 그만큼을 뺀 나머지가 요소 몫이라는 점만 계산에 넣는다.
801
1064
 
802
- 컬럼 폭은 `width` 로 **고정**되고, `<td>` 는 그 폭을 넘는 내용을 잘라낸다(`overflow: hidden`). 텍스트라면 말줄임으로 끝나지만, 셀에 `STag` · `SButton` · `SGhostButton` · `SSelect` · `SInput` · `SNumberInput` 처럼 **자기 폭을 가진 요소**를 넣으면 요소 자체가 잘려 **누르거나 읽거나 입력할 수 없게 된다.** `width` 를 생략해도 내용에 맞춰 늘어나지 않고 `STable` 의 기본 폭이 될 뿐이므로, 컨트롤이 들어가는 컬럼은 폭을 직접 판단해서 준다.
1065
+ #### 컨트롤이 들어가는 컬럼
1066
+
1067
+ `<td>` 는 폭을 넘는 내용을 잘라낸다(`overflow: hidden`). 텍스트라면 말줄임으로 끝나지만, 셀에 `STag` · `SButton` · `SGhostButton` · `SSelect` · `SInput` · `SNumberInput` 처럼 **자기 폭을 가진 요소**를 넣으면 요소 자체가 잘려 **누르거나 읽거나 입력할 수 없게 된다.**
803
1068
 
804
1069
  - **컨트롤이 들어가는 컬럼에는 `contentType: 'control'` 을 함께 준다.** 좌우 패딩이 텍스트용(넓게)에서 컨트롤용(좁게)으로 바뀌어, 같은 컬럼 폭에서도 요소가 쓸 폭이 넓어진다. 기본값은 `text` 다.
805
- - 기준은 **요소가 온전히 보이는 폭 + 셀 좌우 패딩**이다. 좌우 패딩은 `STable` 이 토큰으로 넣으므로(직접 주지 않는다) 그만큼을 뺀 나머지가 요소 몫이라는 점을 계산에 넣는다.
806
1070
  - 요소가 둘 이상이면 요소 폭의 합에 **`gap` 까지** 더한다 (행 내부 인라인 액션 간격은 `gap-sd-4` 고정, §3-5-5).
807
- - 값에 따라 폭이 달라지는 요소(`STag` 라벨, 라벨 있는 버튼)는 **가장 긴 값** 기준으로 잡는다. `판매중` 에 맞춰두면 `판매중지 요청` 에서 잘린다.
808
1071
  - `SSelect` · `SInput` 처럼 셀 폭을 채우는 컨트롤은 **컬럼 폭이 곧 컨트롤 폭**이다. 실제 선택값·입력값이 말줄임 없이 읽히는 폭인지 확인한다.
809
1072
  - 폭을 넉넉히 줄 수 없는 자리는 폭을 줄이는 게 아니라 **요소를 바꾼다** — 라벨 버튼 대신 아이콘만 있는 `SGhostButton`, `size="xs"` (§3-5-2, §3-5-5).
810
- - **`autoWidth` 는 해법이 아니다.** 내용에 맞춰 늘어나는 게 아니라 고정폭 컬럼들이 가져가고 **남은 폭을 나눠 갖는 것**이라, 테이블이 좁으면 역시 잘린다. 컨트롤 컬럼은 `width` 로 직접 확보한다.
811
1073
 
812
1074
  ```tsx
813
- { name: 'normal', label: '정상', field: 'normal', width: '96px',
1075
+ { name: 'normal', label: '정상', field: 'normal', width: 96,
814
1076
  align: 'center', contentType: 'control', render: row => <SNumberInput … /> },
815
1077
  ```
816
1078
 
@@ -821,25 +1083,48 @@ const columns: STableColumn[] = [
821
1083
  ```tsx
822
1084
  const columns: STableColumn[] = [
823
1085
  // 태그 — 가장 긴 라벨 기준
824
- { name: 'status', label: '상태', field: 'status', width: '120px', minWidth: 120, align: 'center',
1086
+ { name: 'status', label: '상태', field: 'status', width: 120, minWidth: 120, align: 'center',
825
1087
  render: (row: SRow) => <STag size="sm" color="green" label={row.statusLabel} /> },
826
1088
  // 셀 안 입력 — 컬럼 폭이 곧 입력 폭
827
- { name: 'qty', label: '수량', field: 'qty', width: '100px', minWidth: 100, align: 'right',
1089
+ { name: 'qty', label: '수량', field: 'qty', width: 100, minWidth: 100, align: 'right',
828
1090
  render: (row: SRow) => <SNumberInput value={row.qty} onValueChange={v => setQty(row, v)} /> },
829
1091
  // 인라인 액션 둘 — 폭 = xs 버튼 2개 + gap-sd-4 + 셀 좌우 패딩
830
- { name: 'actions', label: '', field: 'id', width: '84px', minWidth: 84, align: 'center',
1092
+ { name: 'actions', label: '', field: 'id', width: 84, minWidth: 84, align: 'center',
831
1093
  render: (row: SRow) => (
832
1094
  <div className="flex items-center justify-center gap-sd-4">
833
1095
  <SGhostButton size="xs" intent="action" icon="edit" ariaLabel="수정" onClick={() => editRow(row)} />
834
- <SGhostButton size="xs" icon="delete" ariaLabel="삭제" onClick={() => confirmRemove(row)} />
1096
+ <SGhostButton size="xs" icon="remove" ariaLabel="삭제" onClick={() => confirmRemove(row)} />
835
1097
  </div>
836
1098
  ) },
837
1099
 
838
- // ❌ 컨트롤 컬럼에 width 생략 — 기본 폭에 맡기면 버튼이 잘린다
1100
+ // ❌ 컨트롤 컬럼에 width 생략 — 기본 폭(120px)에 맡기면 버튼이 잘린다
839
1101
  { name: 'move', label: '', field: 'id', render: () => <SButton label="재고 이동" size="xs" /> },
840
1102
  ];
841
1103
  ```
842
1104
 
1105
+ #### 정렬 가능한 컬럼
1106
+
1107
+ **정렬 상태는 `STable` 이 갖지 않는다.** 컬럼에 `sortable: true` 를 주고, 페이지가 `sort` · `onSortChange` 로 상태를 들고 있는다.
1108
+
1109
+ ```tsx
1110
+ const [sort, setSort] = useState<STableSort | null>({ name: 'orderedAt', dir: 'desc' });
1111
+
1112
+ <STable
1113
+ columns={columns}
1114
+ rows={rows}
1115
+ sort={sort}
1116
+ onSortChange={setSort}
1117
+ />
1118
+ ```
1119
+
1120
+ - **정렬은 조회 조건이다.** 서버 정렬이면 `?sort=createdAt&dir=desc` 가 곧 요청이고, 뒤로가기·새로고침·링크 공유로 복원돼야 한다. 컴포넌트가 사본을 들면 URL 과 화면이 어긋난다 — `SExpansionList` 의 선택을 앱이 드는 것과 같은 이유다 (§3-7-7).
1121
+ - **행을 실제로 정렬하는 것도 페이지 몫이다.** `STable` 은 받은 순서대로 그린다.
1122
+ - **동작** — 헤더 클릭 시 `asc → desc → 해제` 3단. 다른 열을 누르면 그 열의 `asc` 로 시작한다. 해제되면 `onSortChange(null)`.
1123
+ - **아이콘** — 미정렬 `updown`, 오름 `arrowUp`, 내림 `arrowDown`. 정렬 중인 열만 `action` 색으로 올라온다. `SGhostButton size="xxs"` 로 그려지므로 직접 만들지 않는다.
1124
+ - **클릭 영역은 정렬 버튼뿐이다.** 헤더 셀 전체를 누르게 하지 않는다 — 라벨을 드래그해 고르거나 `helpText` 아이콘에 hover 하는 것과 뒤섞인다.
1125
+ - **다중 정렬은 지원하지 않는다.** 한 번에 한 열이다.
1126
+ - `renderHeader` 로 헤더를 통째로 교체하면 정렬 아이콘도 클릭도 그리지 않는다 — 헤더 전체가 소비 앱 책임이 된다.
1127
+
843
1128
  #### 값이 없는 셀은 회색 하이픈
844
1129
 
845
1130
  셀을 **빈칸으로 두지 않는다.** 값이 `null` · `undefined` · 빈 문자열이면 `-` 를 `text-fg-tertiary`(`grey_65`)로 표시한다.
@@ -851,9 +1136,9 @@ const emptyCell = <span className="text-fg-tertiary">-</span>;
851
1136
  const hasValue = (v: unknown) => v !== null && v !== undefined && v !== '';
852
1137
 
853
1138
  const columns: STableColumn[] = [
854
- { name: 'memo', label: '메모', field: 'memo',
1139
+ { name: 'memo', label: '메모', field: 'memo', width: 240,
855
1140
  render: (row: SRow) => (hasValue(row.memo) ? row.memo : emptyCell) },
856
- { name: 'price', label: '판매가', field: 'price', width: '120px', align: 'right',
1141
+ { name: 'price', label: '판매가', field: 'price', width: 120, align: 'right',
857
1142
  render: (row: SRow) =>
858
1143
  hasValue(row.price) ? `${Number(row.price).toLocaleString()}원` : emptyCell },
859
1144
  ];
@@ -861,6 +1146,166 @@ const columns: STableColumn[] = [
861
1146
 
862
1147
  `0` 은 값이 있는 것이므로 하이픈으로 바꾸지 않는다 — `0원` 그대로 표시한다.
863
1148
 
1149
+ #### 라벨만으로 뜻이 안 통하는 컬럼은 `helpText`
1150
+
1151
+ 헤더 라벨은 컬럼 폭 안에 들어가야 해서 짧아진다. **산출 기준·단위·상태 값의 뜻처럼 라벨에 담기지 않는 설명은 `column.helpText` 로 준다** — 라벨 뒤에 도움말 아이콘이 붙고 hover 하면 툴팁이 뜬다. 배열의 각 항목이 한 줄이다. `SKeyValueTable` 의 `field.helpText`, `SSectionHeaderCard` 의 `helpText` 와 같은 것이다.
1152
+
1153
+ 판단 기준 한 줄: *컬럼 제목이 줄임말·사내 용어·계산식이거나, 값이 아니라 열 자체의 설명이 필요할 때 헤더에 단다.*
1154
+
1155
+ ```tsx
1156
+ const columns: STableColumn[] = [
1157
+ { name: 'orderCount', label: '주문 수', field: 'orderCount', width: 120, align: 'right',
1158
+ helpText: ['취소·반품을 제외한 확정 주문 수입니다.'],
1159
+ format: (v: number) => `${Number(v).toLocaleString()}건` },
1160
+ { name: 'status', label: '상태', field: 'status', width: 100, align: 'center',
1161
+ helpText: ['활성: 최근 30일 내 주문 있음', '보관됨: 거래 종료'] },
1162
+ ];
1163
+ ```
1164
+
1165
+ - **`renderHeader` 로 헤더를 직접 만들어 `STooltip` 을 붙이지 않는다.** `renderHeader` 는 헤더 전체를 교체하므로 `helpText` 가 무시되고, 아이콘·크기·색·간격을 손으로 맞추게 된다.
1166
+ - **모든 컬럼에 달지 않는다.** 라벨로 뜻이 통하는 컬럼(`주문번호`·`상품명`)까지 붙이면 헤더가 아이콘으로 뒤덮여 정작 설명이 필요한 컬럼이 묻힌다.
1167
+ - 긴 문장을 넣는 자리가 아니다. 한 줄에 한 가지 사실만 담고, 그 이상은 페이지 상단 안내(`SCallout`)로 뺀다.
1168
+ - 아이콘도 폭을 먹는다 — 헤더 폭 계산에 넣는다.
1169
+
1170
+ #### 헤더에 붙는 것들의 순서 · 열을 어떻게 다루는지 알리는 표식
1171
+
1172
+ 헤더 라벨 뒤에 붙는 것은 네 가지고, **순서는 `STable` 이 고정한다.** 소비 앱이 바꾸는 것이 아니다.
1173
+
1174
+ ```text
1175
+ 라벨 [helpText ?] [editable] [navigable] [required *] [sortable 정렬버튼]
1176
+ ```
1177
+
1178
+ **둘을 가르는 기준은 "무엇을 하느냐"가 아니라 "현재 화면을 떠나느냐"다.**
1179
+
1180
+ | 셀을 눌렀을 때 | 표식 |
1181
+ | --- | --- |
1182
+ | 셀에서 바로 고친다 | `editable` |
1183
+ | 모달·바텀시트를 띄워 고친다 | `editable` |
1184
+ | 새 창·새 팝업을 띄운다, 페이지를 이동한다 | `navigable` |
1185
+ | 모달로 보여주기만 한다 (고치지 않는다) | 둘 다 없음 |
1186
+
1187
+ **모달은 `navigable` 이 아니다.** 현재 화면 위에 겹쳐 뜰 뿐 화면을 떠나는 것이 아니다 — 거기서 값을 고치면 고치는 자리가 셀이든 모달이든 `editable` 이다. 반대로 새 창·새 팝업은 거기서 값을 고치더라도 `navigable` 이다. 화면을 떠나기 때문이다.
1188
+
1189
+ 둘 다 **표식일 뿐 버튼이 아니다** — 아이콘·크기·색은 컴포넌트가 고정하고 클릭은 받지 않는다.
1190
+
1191
+ ```tsx
1192
+ const columns: STableColumn[] = [
1193
+ // 새 창으로 상세를 연다 → navigable
1194
+ { name: 'name', label: '상품명', field: 'name', width: 200,
1195
+ navigable: true,
1196
+ render: (row: SRow) => <STextLink label={row.name} onClick={() => openDetailWindow(row.id)} /> },
1197
+ // 셀에서 바로 고친다 → editable
1198
+ { name: 'stock', label: '재고', field: 'stock', width: 160, contentType: 'control',
1199
+ editable: true, required: true,
1200
+ render: (row: SRow) => <SNumberInput value={row.stock} width="100%" /> },
1201
+ // 수정 모달을 띄워 고친다 → 화면을 떠나지 않으므로 navigable 이 아니라 editable
1202
+ { name: 'memo', label: '메모', field: 'memo', width: 200,
1203
+ editable: true,
1204
+ render: (row: SRow) => <STextLink label={row.memo} onClick={() => openMemoModal(row.id)} /> },
1205
+ ];
1206
+ ```
1207
+
1208
+ - **실제로 그렇게 동작하는 열에만 켠다.** 표식만 켜고 셀은 텍스트 그대로 두면, 고칠 수 있다고 해 놓고 고칠 방법이 없고 넘어갈 수 있다고 해 놓고 누를 것이 없다. `editable` 이면 셀에 고치는 수단(입력 컨트롤이나 수정 모달을 여는 링크·클릭)이, `navigable` 이면 셀에 새 화면을 여는 링크·클릭이 있어야 한다.
1209
+ - **한 열에 둘 다 켜는 것은 두 동작이 모두 있을 때만이다** — 예를 들어 셀에서 값을 고치면서 라벨을 누르면 새 창으로 상세가 열리는 열. 판단이 서지 않으면 하나만 켠다.
1210
+ - **표식으로 동작을 대신하지 않는다.** 고치는 UI 도 넘어가는 동작도 셀(`column.render`) 몫이다.
1211
+ - **`renderHeader` 로 헤더를 통째로 교체하면 표식도 `required` 도 그려지지 않는다** — 헤더 전체가 소비 앱 책임이 되므로 순서·크기·색을 손으로 맞추게 된다.
1212
+
1213
+ **값을 반드시 채워야 하는 열에는 `column.required`** 를 준다 — 라벨 뒤에 `*` 가 붙는다. `SKeyValueTable` 의 `field.required` 와 같은 것이다. **읽기 전용 열에 붙이지 않는다** — 표시만 있고 채울 방법이 없어 사용자가 막힌다.
1214
+
1215
+ #### 고를 수 없는 행은 `isRowSelectable` 로 잠근다
1216
+
1217
+ `selectable` 을 켠 표에서 **일부 행만 대상이 될 수 있을 때** 쓴다 — 실패 0건인 차수처럼 자리는 보여야 하지만 고를 수는 없는 행이다.
1218
+
1219
+ ```tsx
1220
+ <STable selectable isRowSelectable={row => row.failedCount > 0} … />
1221
+ ```
1222
+
1223
+ - **잠긴 행은 회색으로 가라앉는다.** 배경과 글자색이 `--cmp-table-body-disabled-*` 로 바뀌고 hover 도 꺼진다. `selectable` · `dragSelectable` 어느 쪽이든 같다. **그 회색을 직접 그리지 않는다** — `tdClass` 로 따로 칠하면 토큰이 바뀔 때 그 열만 어긋난 채 남는다.
1224
+ - **셀이 자기 색을 정한 내용까지는 닿지 않는다.** `column.render` 안의 `STag` · `SIcon` 처럼 색을 직접 받는 것은 그대로 선명하게 남는다. 잠긴 행에서 그것도 가라앉혀야 하면 `render` 에서 행 상태를 보고 정한다.
1225
+ - **체크박스를 직접 잠그려 하지 않는다.** 색·커서·hover 가 한 벌로 움직이므로 밖에서 속성만 바꾸면 "잠기지 않았는데 멀쩡해 보이는" 상태가 된다. 이 prop 하나로 셋이 함께 잡힌다.
1226
+ - **전체 선택과 Shift 구간 선택의 셈에서도 빠진다.** 잠긴 행이 셈에 남으면 "전부 선택됨"에 닿지 못해 헤더 체크박스가 해제 방향으로 못 가고 한 방향이 된다. DS 가 이걸 처리하므로 직접 보정하지 않는다.
1227
+ - **아예 대상이 아닌 행이라면 `rows` 에서 거르는 편이 낫다.** 잠긴 행이 잔뜩 섞이면 무엇을 고를 수 있는지가 오히려 안 읽힌다. 고를 수 있는 행이 한 줄도 없으면 헤더까지 잠기는데, 그 상태라면 `selectable` 을 켤 자리가 아니다.
1228
+ - 잠금은 그리는 시점의 판정이라, **이미 `selected` 에 든 행이 나중에 잠겨도 DS 가 빼지 않는다** — 제어 상태를 말없이 바꾸지 않기 때문이다. 헤더의 전체 해제로는 걷힌다.
1229
+
1230
+ #### 체크박스 대신 드래그로 고르게 하려면 `dragSelectable`
1231
+
1232
+ 고르는 **수단만** 다른 같은 선택이다. `selected` · `onSelectedChange` · `isRowSelectable` 을 그대로 쓰고, 체크박스 열 대신 고른 구간이 배경색과 바깥 테두리로 표시된다.
1233
+
1234
+ ```tsx
1235
+ <STable dragSelectable selected={selected} onSelectedChange={setSelected} … />
1236
+ ```
1237
+
1238
+ | | `selectable` | `dragSelectable` |
1239
+ | --- | --- | --- |
1240
+ | 고르는 법 | 체크박스 클릭 · Shift 구간 | 행을 눌러 끌기 |
1241
+ | 흩어진 행 모으기 | 하나씩 체크 | `Ctrl`(macOS `Cmd`)을 짚고 끌기 |
1242
+ | 선택 열 | 생긴다 (48px, 왼쪽 고정) | 없다 |
1243
+ | 헤더 전체 선택 | 있다 | 없다 |
1244
+ | 셀 글자 복사 | 된다 | **안 된다** |
1245
+
1246
+ - **둘 다 켜면 `selectable` 이 이긴다.** 체크박스가 있는 표에서 드래그까지 걸리면 글자를 긁으려던 손이 선택을 통째로 갈아치운다. 어느 쪽이 맞는지 정해서 하나만 켠다.
1247
+ - **값을 복사해 가는 표에는 켜지 않는다.** 끌기가 곧 선택이라 글자 선택과 같은 손짓을 두고 다투고, 그래서 셀 안의 텍스트를 긁을 수 없다. 주문번호·송장번호처럼 복사해 쓰는 열이 있으면 `selectable` 이다.
1248
+ - **새로 끌면 이전 선택은 풀린다.** 여러 구간을 모으려면 `Ctrl`/`Cmd` 를 짚고 끈다. 이 규칙을 화면에 안내할 자리가 없으면, 흩어진 행을 자주 고르는 표에는 맞지 않는다.
1249
+ - **한 번에 이어진 구간을 고르는 표에 쓴다.** 목록에서 연속한 기간·회차를 통째로 집어 처리하는 화면이 제자리다.
1250
+ - 셀 안의 버튼·입력·링크는 그대로 눌린다 — 거기서 시작한 손짓은 드래그로 세지 않는다. 그래도 컨트롤이 빽빽한 표라면 끌 여백이 없어 잘 맞지 않는다.
1251
+ - 잠긴 행(`isRowSelectable`)은 구간 안에 있어도 그냥 지나간다 — 체크박스 쪽과 같은 규칙이다.
1252
+
1253
+ ##### 고른 행에 바로 할 일을 붙이려면 `contextMenuItems`
1254
+
1255
+ 행을 **오른쪽 클릭**하면 커서 자리에 메뉴가 뜬다. 고른 항목의 `value` 와 **그 메뉴가 다루는 행들**이 함께 온다.
1256
+
1257
+ ```tsx
1258
+ <STable
1259
+ dragSelectable
1260
+ contextMenuItems={[
1261
+ { value: 'export', label: '내보내기', icon: 'download' },
1262
+ { value: 'delete', label: '삭제', icon: 'remove' },
1263
+ ]}
1264
+ onContextMenuItemClick={(value, rows) => run(value, rows)}
1265
+ … />
1266
+ ```
1267
+
1268
+ - **`dragSelectable` 전용이다.** 체크박스 모드에 주면 무시된다. 체크박스 표에서 일괄 작업을 붙이는 자리는 표 위의 `STableBar` 다 (§4 페이지 레시피).
1269
+ - **행 목록을 인자로 받는다. `selected` 를 따로 읽지 않는다.** 오른쪽 클릭이 선택을 바꾸는 경우가 있어서, 그때 앱이 든 `selected` 는 아직 이전 값일 수 있다.
1270
+ - **고르지 않은 행에서 누르면 그 행만 골라진 뒤 열린다.** 메뉴가 다룰 대상과 화면에 칠해진 것이 어긋나지 않게 하기 위함이다. 잠긴 행 위에서는 열리지 않고 브라우저 기본 메뉴가 그대로 나온다.
1271
+ - **오른쪽 클릭에만 있는 기능을 두지 않는다.** 뜨는 것을 모르면 닿을 수 없고, 키보드로도 열 수 없다. 여기 넣는 것은 표 위 버튼이나 행 안 메뉴에도 있는 **지름길**이어야 한다.
1272
+ - 항목을 주지 않으면 오른쪽 클릭을 가로채지 않는다 — 브라우저 기본 메뉴가 그대로 뜬다.
1273
+
1274
+ #### 헤더 전체 선택이 집는 범위
1275
+
1276
+ **모드가 정한다. 앱이 보정하지 않는다.**
1277
+
1278
+ | 표 | 전체 선택이 집는 것 |
1279
+ | --- | --- |
1280
+ | 기본 | `rows` 전부 |
1281
+ | `pagination` · `internalPagination` | **현재 페이지** — 사용자가 고른 범위다 |
1282
+ | `virtualScroll` | `rows` **전부** — 그려진 창이 아니다 |
1283
+
1284
+ 가상 스크롤의 창은 사용자가 고른 범위가 아니라 렌더러의 사정이라, 창을 집으면 같은 버튼이 스크롤 위치에 따라 다른 결과를 낸다. 헤더의 체크 표시도 같은 집합을 본다 — 그래서 전체 선택 뒤 스크롤해도 "전부"가 "일부"로 흔들리지 않는다.
1285
+
1286
+ - **선택 열을 직접 그리지 않는다.** 전체 선택 범위 때문에 `selectable` 을 끄고 48px 열을 손으로 만들면 Shift 구간 선택 · `isRowSelectable` 연동 · sticky 선택 열 · 열 폭/정렬 기본값을 통째로 잃는다.
1287
+ - **`toggleSelectAll(checked, rows)`**(ref 명령형)은 대상 행을 인자로 받으므로 모드와 무관하다 — 앱이 원하는 집합을 그대로 넘긴다.
1288
+
1289
+ #### 표 본문의 스크롤에 닿아야 할 때
1290
+
1291
+ 표는 자기 안에서 스크롤한다(§2-2). 그 스크롤을 **읽거나 되돌려야** 하면 두 가지가 있다.
1292
+
1293
+ - **`onScroll`** — 본문이 스크롤될 때 온다. 실제 스크롤에만 울리므로 "사용자가 스크롤했다"는 신호로 쓸 수 있다. `onVirtualUpdate` 는 그리는 창이 바뀔 때만 오므로 그 사이 스크롤은 알 수 없다.
1294
+ - **`ref.current.getScrollElement()`** — 본문의 스크롤 컨테이너. 위치를 되돌리거나 스크롤 여지를 잴 때 쓴다.
1295
+
1296
+ **내부 마크업을 겨냥한 선택자로 스크롤 컨테이너를 찾지 않는다.** `querySelector` 로 `overflow` 가 걸린 조상을 거슬러 올라가는 코드는 DS 내부 구조에 기대는 것이라, 표 안쪽이 바뀌는 순간 조용히 어긋난다.
1297
+
1298
+ ##### 위치를 저장했다 되돌릴 때는 픽셀이 아니라 행 번호로 한다
1299
+
1300
+ ```tsx
1301
+ const row = tableRef.current.getFirstVisibleRow(); // 저장
1302
+ tableRef.current.scrollToRow(row); // 복원
1303
+ ```
1304
+
1305
+ `virtualScroll` 에서 `scrollTop` 은 **실측이 쌓이며 좌표계째 움직이는 값**이다. 저장할 때의 픽셀은 실측된 좌표계의 값인데 되돌릴 때는 아직 아무것도 안 잰 추정 좌표계로 읽히므로, 같은 픽셀이 다른 행을 가리킨다. `rowHeight` 가 실제와 멀수록 크게 벌어진다.
1306
+
1307
+ `scrollToRow` 는 옮긴 뒤에도 실측이 정착할 때까지 그 행을 다시 겨냥하므로 `rowHeight` 가 빗나가도 제자리에 선다. 가상 스크롤을 쓰지 않는 표는 좌표계가 하나뿐이라 픽셀로 해도 되지만, **두 경우를 갈라 쓰기보다 행 번호로 통일하는 편이 낫다.**
1308
+
864
1309
  ### 3-5. 버튼류
865
1310
 
866
1311
  | 상황 | 사용 |
@@ -868,8 +1313,10 @@ const columns: STableColumn[] = [
868
1313
  | 일반 액션 | `SButton` (`color`: `primary` / `secondary` / `neutral` / `danger`, `outline`, `size`: xs~md) |
869
1314
  | **삭제 등 파괴적 액션** | **`SButton color="danger"`** 또는 **`color="danger" outline`** (아래 3-5-3) |
870
1315
  | **아이콘 하나로 뜻이 통하는 부가 조작** | **`SGhostButton`** — 라벨이 없는 아이콘 전용 버튼 (아래 3-5-5) |
1316
+ | **부가 조작 여러 개를 ⋯ 메뉴로 접기** | **`SMeatballButton`** — ⋯ 아이콘 버튼 + `SDropdownButton` 과 같은 메뉴 (아래 3-5-5) |
871
1317
  | 본문 속 이동 링크 | `STextLink` |
872
1318
  | 메인 액션 + 부가 메뉴 | `SDropdownButton` |
1319
+ | **GNB system 패널의 한 줄 액션** | **`SSystemActionButton`** — 행 전체가 버튼인 표면형 (아래 3-5-7) |
873
1320
 
874
1321
  #### 3-5-1. 채움 버튼은 페이지당 개수 제한이 있다
875
1322
 
@@ -884,18 +1331,35 @@ const columns: STableColumn[] = [
884
1331
 
885
1332
  `SDropdownButton` 도 같은 규칙을 따르며, **페이지당 `primary` 채움 1개 계산에 포함**된다.
886
1333
 
1334
+ ##### 무엇이 어느 위계인가
1335
+
1336
+ 개수만으로는 후보가 여럿일 때 어느 것을 올릴지 갈리지 않는다. 기준은 **그 조작이 무엇에 미치는가**다.
1337
+
1338
+ | 위계 | 무엇에 쓰나 |
1339
+ | --- | --- |
1340
+ | `primary` 채움 | **페이지 전체에 해당하는 데이터를 확정**하는 실행 (폼 저장, 상세 수정 확정, 일괄 반영) |
1341
+ | `secondary` 채움 | **페이지 안 중심 데이터에 대한 처리** (선택 항목 상태 변경, 발송, 승인) |
1342
+ | `outline` (`neutral` · `primary`) | 단순 등록, 설정 변경, 이동·취소 |
1343
+
1344
+ - **`primary` 채움은 페이지 전체를 대표하는 실행 하나에만 쓴다. 그런 조작이 없으면 페이지에 `primary` 가 없어도 된다.** 개수 제한이 "반드시 하나 있어야 한다"는 뜻은 아니다.
1345
+ - **`secondary` 연속 배치 금지는 섹션이 다르면 적용되지 않는다.** 섹션마다 독립 인라인 폼이 있는 상세 페이지(§4-4)가 그렇다 — 나란히 놓인 두 버튼이 같은 판단 단위 안에 있을 때의 규칙이다.
1346
+
887
1347
  #### 3-5-2. `size` 는 놓이는 위치가 정한다
888
1348
 
889
1349
  | 위치 | size |
890
1350
  | --- | --- |
891
1351
  | 테이블 **행 내부** 인라인 액션 | `xs` |
892
1352
  | 화면·목록 액션 (툴바, `STableBar`) | `sm` |
893
- | **모달·위저드 푸터** | `md` |
1353
+ | **모달·드로어·팝업 푸터** | `md` (고정) |
894
1354
  | **페이지 하단 실행 버튼** (폼 저장·상세 수정 등) | `md` |
1355
+ | **카드 푸터** — 카드가 화면의 주 액션을 쥔 경우 | `md` (`footerSize` 기본값) |
1356
+ | **카드 푸터** — 한 화면에서 부분만 확정하는 카드 | `sm` (`footerSize="sm"`) |
895
1357
  | 스크롤 시 헤더로 승격된 버튼 | `sm` (최대) |
896
1358
 
897
1359
  `lg` 는 현재 사용처가 없다. 페이지 골격에서 쓰지 않는다.
898
1360
 
1361
+ **푸터 버튼의 크기는 버튼이 아니라 푸터가 정한다.** 모달·드로어·팝업은 예외가 없어 타입에 `size` 가 없고, 카드 둘(`SCard`·`SSectionHeaderCard`)만 `footerSize` 로 고른다 — 자세한 기준은 §3-7-8. **`footerLeft` 슬롯에 직접 배치하는 `SButton` 은 그 푸터와 같은 크기를 명시한다**(모달이면 `size="md"`, `footerSize="sm"` 인 카드면 `size="sm"`). 슬롯까지는 타입이 못 막으므로 어긋나면 좌우 버튼 높이가 갈린다.
1362
+
899
1363
  #### 3-5-3. 파괴적 액션의 채움 / outline
900
1364
 
901
1365
  물리적 위치가 아니라 **적용 범위 + 위험도**로 가른다.
@@ -957,7 +1421,7 @@ const columns: STableColumn[] = [
957
1421
  <SGhostButton size="xs" intent="action" icon="edit"
958
1422
  ariaLabel="수정" onClick={() => editRow(row)} />
959
1423
  {/* 되돌릴 수 없는 삭제일 때만 intent="danger" */}
960
- <SGhostButton size="xs" icon="delete"
1424
+ <SGhostButton size="xs" icon="remove"
961
1425
  ariaLabel="삭제" onClick={() => confirmRemove(row)} />
962
1426
  </div>
963
1427
  ) },
@@ -967,6 +1431,24 @@ const columns: STableColumn[] = [
967
1431
  ❌ <SGhostButton icon="download" ariaLabel="엑셀 다운로드" /> // 목록의 주 액션 → SButton
968
1432
  ```
969
1433
 
1434
+ ##### `SMeatballButton` — ⋯ 뒤에 메뉴를 접어 둘 때
1435
+
1436
+ 자리가 좁아 부가 조작을 하나하나 아이콘으로 늘어놓을 수 없을 때, **⋯ 하나로 접고 누르면 메뉴를 띄운다.** 버튼의 크기·색·hover·툴팁은 `SGhostButton` 과, 메뉴 모양은 `SDropdownButton` 과 같다.
1437
+
1438
+ - **메뉴는 컴포넌트가 띄운다.** `items` 를 넘기고 선택은 `onItemClick(value)` 로 받는다. 메뉴를 따로 만들어 붙이지 않고, 열림 상태(`aria-expanded`)도 직접 넣지 않는다.
1439
+ - **`SGhostButton icon="etc"` 로 직접 만들지 않는다.** '더 보기' 메뉴는 항상 `SMeatballButton` 이다.
1440
+ - **`intent` 는 `default` / `inverse` 뿐이다.** ⋯ 는 조작이 아니라 메뉴를 여는 것이므로 `action`·`danger` 색을 받지 않는다. 어두운 배경 위에서만 `inverse`.
1441
+ - 툴팁·`ariaLabel` 은 기본 문구가 들어 있어 **주지 않아도 된다.** 한 화면에 ⋯ 가 여러 개라 무엇의 메뉴인지 구분해야 할 때만 `tooltipText` 를 바꾼다(`ariaLabel` 이 따라간다).
1442
+ - 메인 액션에 변형 동작을 곁들이는 것이면 `SMeatballButton` 이 아니라 `SDropdownButton split`(§3-5-4)이다.
1443
+
1444
+ ```tsx
1445
+ ✅ <SMeatballButton size="xs" // 행 끝 부가 조작 묶음
1446
+ items={[{ value: 'copy', label: '복제', icon: 'copy' }, { value: 'remove', label: '삭제', icon: 'remove' }]}
1447
+ onItemClick={v => handleRowAction(row, v)} />
1448
+ ✅ <SMeatballButton intent="inverse" items={items} onItemClick={onAction} /> // 어두운 배경 위
1449
+ ❌ <SGhostButton icon="etc" ariaLabel="더 보기" onClick={openMyMenu} /> // 직접 조립 → SMeatballButton
1450
+ ```
1451
+
970
1452
  #### 3-5-6. 그 밖
971
1453
 
972
1454
  - 라벨은 **동사(+목적어)** — `저장` · `적치 지시` · `송장 재출력`
@@ -978,6 +1460,7 @@ const columns: STableColumn[] = [
978
1460
  - **왼쪽 끝**: 취소·닫기·목록 등 되돌리는 액션
979
1461
  - **오른쪽 끝**: 저장·등록·수정·삭제 등 실행 액션
980
1462
  - 체크박스·안내 문구 등 **부가 요소는 오른쪽 그룹 안, 실행 버튼 바로 왼쪽**에 둔다
1463
+ - **되돌리는 액션이 없어도 양끝 분리는 그대로다** — 오른쪽에 모으지 않는다. 그때는 **위계가 낮은 실행 액션이 왼쪽 끝, 가장 강한 실행 액션이 오른쪽 끝**이다. 뒤로가기를 헤더의 `onBack` 이 맡은 상세 페이지(§4-4)의 `삭제`(`danger outline`) · `수정`(`primary` 채움)이 이 경우다.
981
1464
 
982
1465
  ```tsx
983
1466
  <div className="flex items-center justify-between">
@@ -989,6 +1472,147 @@ const columns: STableColumn[] = [
989
1472
  </div>
990
1473
  ```
991
1474
 
1475
+ #### 3-5-7. `SSystemActionButton` — GNB system 패널의 한 줄 액션
1476
+
1477
+ **행 전체가 하나의 버튼**인 표면형 컨트롤이다. 아이콘 프레임 + 라벨 + 우측 액션이 한 줄에 붙고, hover 하면 표면색이 한 단계 바뀐다.
1478
+
1479
+ | 상황 | 사용 |
1480
+ | --- | --- |
1481
+ | 페이지·모달의 일반 액션 | `SButton` — 라벨 폭만큼만 차지한다 |
1482
+ | 목록에서 항목 하나를 고르게 한다 | `SListItem` · `STree` (§3-7-6·§3-7-7) |
1483
+ | 좌측 내비게이션의 메뉴 | `SGnb` 의 `menus` (§4-1) |
1484
+ | **GNB system 패널에서 설정·연결 상태를 한 줄씩 쌓고, 그 줄을 눌러 들어가게 한다** | **`SSystemActionButton`** |
1485
+
1486
+ **무엇이 그려지는지는 `type` 이 정한다.** 타입마다 다른 값만 `option` 으로 받으므로, 타입에 맞지 않는 `option` 은 타입 에러가 된다.
1487
+
1488
+ | `type` | 선행 아이콘 | 라벨 | 우측 | 언제 |
1489
+ | --- | --- | --- | --- | --- |
1490
+ | `account` | `user` 고정 | `label` | 없음 | 로그인한 계정을 보여줄 때. 누르면 **언제나** 계정 리스트박스가 뜬다 — `option` 필수 (§3-5-8) |
1491
+ | `select` | `option.icon` (선택) | `label` | 화살표 | 눌러서 하위 화면으로 들어갈 때 |
1492
+ | `switch` | `option.icon` (선택) | `label` | `option.text` + 새로고침 | 지금 붙어 있는 대상을 갈아 끼울 때 |
1493
+ | `subSelect` | `option.icon` (필수) | `label` | 지금 값 + 화살표 | 값을 보여주고 **눌러서 바꿀** 때. 누르면 언제나 메뉴가 뜬다 — `option.options` 필수 |
1494
+ | `status` | 없음 | 상태 태그(고른 칸의 `color`·`label`) | 화살표 | 상태를 보여주고 **눌러서 바꿀** 때. 누르면 언제나 메뉴가 뜬다 — `option.options` 필수 |
1495
+
1496
+ - **`SPage` 본문에 직접 놓지 않는다.** 폭을 가득 채우는 행이라 페이지에 바로 놓으면 표처럼 보인다. GNB system 패널·`SDrawer`·`SPopover` 처럼 **좁은 폭이 정해진 자리**에 쌓는다. 행 사이 간격은 `--cmp-gnb-system-panel-gap` 이다.
1497
+ - **GNB 의 system 자리(맨 아래 판 · 전폭 상단바)에 놓는 것이라면 이 버튼을 직접 배치하지 말고 `SGnbSystem` 의 `actions` 에 넘긴다** (§4-1). 패널의 배경·구분선·도메인 태그·알림·계정까지 한 벌로 그려주므로, 조각을 손으로 조립할 이유가 없다. **단 `type="account"` 는 `actions` 가 받지 않는다** — 계정 행은 패널에 하나뿐이고 자리도 맨 아래로 정해져 있어 `account` prop 이 따로 있다 (§3-5-8).
1498
+ - **`color` 는 놓이는 표면을 따라간다** — 흰 면이면 `light`, 오션블루 면이면 `dark`, 네이비 면이면 `darker`. 배경과 다른 색을 고르면 버튼만 떠 보인다.
1499
+ - **상태는 default·hover 둘뿐이다.** 토큰에 selected 가 없으므로 "지금 열려 있는 항목"을 이 버튼으로 표시하지 않는다. 선택 상태가 필요한 자리면 `SGnb` 메뉴나 `SListItem`(§3-7-6) 이다.
1500
+ - **선행 아이콘은 타입이 정한다.** `account` 는 `user` 고정, `status` 는 태그가 자리를 차지해 없고, `subSelect` 는 `option.icon` 이 필수다. **`select` · `switch` 만 선택이다** — 주지 않으면(`option` 을 통째로 생략해도 된다) 아이콘 프레임 없이 라벨부터 시작한다. **한 판 안에서는 아이콘 유무를 통일한다** — 섞으면 라벨 시작점이 행마다 어긋나 세로 정렬이 깨진다.
1501
+ - **`account` 의 `option` 만 성격이 다르다** — 아이콘이 아니라 **누르면 뜨는 계정 리스트박스의 내용**이고, **필수다** (§3-5-8). 눌러도 아무것도 뜨지 않는 계정 행은 없다 — 계정 화면으로 넘기는 것은 이 행이 아니라 패널의 `accountSetting` 이 맡는다.
1502
+ - **우측 아이콘은 `type` 이 정하므로 밖에서 바꾸지 않는다.** 화살표가 필요한데 문구가 없으면 `select`, 문구가 함께 필요하면 `subSelect` 다.
1503
+ - **`subSelect` · `status` 는 언제나 값 고르기 메뉴의 트리거다** — 지금 값이 적혀 있는 행이라 누르면 그 값을 바꾸는 메뉴가 뜬다. 그래서 `option.options` 가 **필수**이고, 메뉴는 **행 오른쪽으로**, 행 위쪽 끝에 맞춰 펼쳐진다 — 이 행은 판 안에 세로로 쌓이는 자리라 아래로 펴면 바로 다음 행을 덮는다. 고르면 `onChange(value)` 가 돌며 닫힌다. **메뉴를 손으로 만들지 않는다** — 뜨는 자리·닫히는 시점이 어긋난다. 계정 패널의 언어 변경 행(§3-5-8)도 같은 메뉴이지만, 그쪽은 판 안에 갇혀 있어 우측 값 아래로 편다.
1504
+
1505
+ ```tsx
1506
+ <SSystemActionButton
1507
+ type="subSelect" label="작업 공간"
1508
+ option={{ icon: 'board', value: workspaceId, options: WORKSPACES, onChange: setWorkspace }}
1509
+ />
1510
+ <SSystemActionButton
1511
+ type="status"
1512
+ option={{ value: status, options: STATUSES, onChange: setStatus }} // STATUSES: { value, label, color? }[]
1513
+ />
1514
+ ```
1515
+
1516
+ - **`value` 를 함께 준다.** 우측 값(`subSelect`)·상태 태그(`status`)는 `options` 에서 같은 `value` 를 찾아 그 `label`(과 `status` 는 `color`)로 그린다. 못 찾으면 `value` 문자열이 그대로 노출되므로 반드시 맞춰 준다.
1517
+ - **눌러서 값 고르는 화면으로 보낼 것이면 `select` 다.** 그 타입은 행을 누르는 것 자체가 액션이라 `onClick` 만 돈다. 상태를 **보여주기만** 할 것이면 이 버튼이 아니라 `STag`(§3-7-2) 다 — 화살표가 붙는 행은 "눌러서 바꾼다"는 약속이다.
1518
+ - **태그의 색·이름을 밖에서 직접 주지 않는다** (`status`). `option.color`·`option.label` 은 없다 — 지금 상태가 가리키는 `options` 칸이 곧 태그다. 색이 상태의 뜻이라 둘이 어긋나면 안 되기 때문이다.
1519
+ - **상태 메뉴의 색은 칸마다 준다** — 점과 글자가 그 상태의 색으로 함께 물든다. 상태는 색 자체가 뜻이라 고르는 자리에서도 색을 보고 고른다.
1520
+ - `switch` 는 메뉴를 갖지 않는다 — 우측 문구가 "지금 값"이 아니라 보조 조작 문구라, 고를 목록이 있으면 그것은 `subSelect` 다.
1521
+ - 높이·라운드·아이콘 크기·좌우 여백은 전부 토큰이 넣는다. 직접 주지 않는다.
1522
+
1523
+ #### 3-5-8. `SAccountListBox` — 계정 행을 눌러 뜨는 계정 패널
1524
+
1525
+ **"지금 누구로 로그인해 있는가"와 "여기서 나간다"를 한 자리에 모은 패널**이다. 위에서부터 [사용자정보(이메일 · 이름 + 권한)] · [계정 설정] · [언어 변경] · [계정 로그아웃] 이다.
1526
+
1527
+ **직접 띄우지 않는다.** 계정 행에 붙여 여는 것은 두 곳이 맡는다 — 어느 쪽이든 뜨는 방향·닫히는 시점을 컴포넌트가 정하므로 앱이 팝오버를 조립하지 않는다. **두 곳 모두 이 내용이 필수다** — 계정 행은 예외 없이 이 패널의 트리거라, 눌러도 아무것도 뜨지 않는 계정 행은 만들 수 없다.
1528
+
1529
+ | 어디에 | 어떻게 |
1530
+ | --- | --- |
1531
+ | GNB 의 system 자리 (기본) | `SGnbSystem` 의 `account.listBox` — 필수 (§4-1) |
1532
+ | system 밖의 **좁은 세로 판 아래쪽** (SDrawer·SPopover 하단 등) | `SSystemActionButton type="account"` 의 `option` — 필수 |
1533
+
1534
+ ```tsx
1535
+ <SGnbSystem
1536
+ account={{
1537
+ label: userName,
1538
+ listBox: {
1539
+ email: user.email,
1540
+ name: user.name,
1541
+ authority: user.authority, // 옵션
1542
+ accountSetting: { onClick: openAccountSetting }, // 옵션
1543
+ language: { // 옵션
1544
+ value: languageCode,
1545
+ options: LANGUAGES, // 주면 하위 메뉴가 펼쳐진다
1546
+ onChange: setLanguage,
1547
+ },
1548
+ logout: { onClick: signOut },
1549
+ },
1550
+ }}
1551
+ />
1552
+ ```
1553
+
1554
+ - **`authority` · `accountSetting` · `language` 는 옵션이고, 사용자정보와 로그아웃 행은 늘 있다.** 이 패널이 존재하는 이유가 그 둘이다. 옵션을 빼면 그 줄이 통째로 사라지고 나머지가 위로 붙으므로, 줄을 지우려고 빈 문자열이나 빈 객체를 넣지 않는다 (`accountSetting={{}}` 은 "이 행을 쓴다" 는 뜻이다).
1555
+ - **권한 prop 은 `authority` 다 (`role` 아님).** `role` 은 DOM 의 ARIA 속성이라, 그 이름을 쓰면 소비 앱의 a11y lint 가 "최고 관리자" 를 ARIA 롤로 읽고 실패한다.
1556
+ - **`language` 를 쓰면 `value` 를 함께 준다.** 우측에 붙는 그 값이 "지금 무슨 언어인가" 다 — 없으면 우측이 비어 눌러야 알 수 있는 행이 된다.
1557
+ - **언어를 앱 안에서 바꾸면 `language.options` 를 준다.** 그 행이 우측 값 아래로 하위 메뉴를 펴고, 고르면 `onChange(value)` 가 돌며 하위 메뉴와 계정 패널이 함께 닫힌다. `value` 는 `options` 의 `value` 와 맞추면 되고, 우측에는 그 항목의 `label` 이 적힌다. **하위 메뉴를 손으로 만들지 않는다** — 뜨는 자리·닫히는 시점이 어긋난다.
1558
+ - **언어를 고르는 화면이 따로 있으면 `options` 를 주지 않는다.** 그러면 행을 누르는 것 자체가 액션이라 `onClick` 이 돌고 계정 패널이 닫힌다. 둘을 같이 주면 화면으로 가지 않고 메뉴만 펴진다.
1559
+ - 하위 메뉴가 열려 있는 동안 계정 패널은 닫히지 않는다 — 언어를 고르는 일이 아직 끝나지 않았기 때문이다.
1560
+ - **행 아이콘은 밖에서 바꾸지 않는다.** 무엇을 하는 행인지가 곧 그 행의 정체다. 서비스 문구가 다르면 `label` 만 바꾼다.
1561
+ - **폭을 늘리지 않는다.** `--cmp-gnb-system-accountListBox-width` 가 정하는 고정 폭이다 — GNB 폭에 맞춰 뜨는 패널이라 늘리면 행 여백이 어긋난다.
1562
+ - **화면 위쪽에 계정 행을 직접 세우지 않는다.** system 밖에 세운 계정 행의 패널은 **늘 행 위로** 펼쳐진다 — 방향을 바꾸는 prop 이 없으므로 위가 화면 끝인 자리(앱 상단바·헤더)에 두면 잘린다. 앱 상단바의 계정은 손으로 만들지 말고 `SGnb` 의 `system` 슬롯에 맡긴다(`header="full"` 이면 조각들이 상단바 오른쪽 끝으로 올라가고, 그 자리에서는 패널이 아래로 펼쳐진다 — §4-1).
1563
+ - **뜨는 방향은 prop 이 아니다 — 놓인 자리가 정한다.** 판이면 계정 행이 맨 아래에 있으므로 **위로 · 행 왼쪽 끝에 맞춰**, 전폭 상단바면 **아래로 · 오른쪽 끝에 맞춰** 펼친다(접힌 레일은 판과 같다). 방향을 바꾸는 prop 은 없으니 찾지 않는다 — 같은 계정 행이 앱마다 다른 방향으로 뜨면 안 되기 때문이다.
1564
+ - **`header="fix"` 에서 GNB 를 접거나 펴면 열려 있던 패널은 닫힌다.** 계정 행이 메뉴 컬럼 바닥에서 접힘 레일 바닥으로(또는 반대로) 옮겨 서기 때문이다 — 닫히는 것을 앱이 막을 수 없고, 막을 이유도 없다(누른 그 행이 화면에서 사라진다). 전폭 상단바(`header="full"`)는 접어도 계정 행이 제자리라 열린 채 남는다. **그 닫힘을 알아야 하면 `account.onOpenChange` 를 준다** — 열림을 따라 그리는 화면이 앱에 있을 때만 필요하다.
1565
+ - **로그아웃을 여기 말고 다른 곳에 또 두지 않는다.** 계정에서 나가는 길이 화면마다 다르면 사용자가 매번 찾는다.
1566
+
1567
+ #### 3-5-9. `SLauncherListBox` — 런처 버튼을 눌러 뜨는 서비스 목록
1568
+
1569
+ **"이 계정으로 갈 수 있는 다른 서비스가 무엇인가"를 한 자리에 모은 목록**이다. 한 줄이 서비스 하나이고, 위에 브랜드 로고(+ 상태 태그) 아래에 서비스 이름이 선다.
1570
+
1571
+ **직접 띄우지 않는다.** 런처 버튼에 붙여 여는 것은 `SGnb` 의 `launcher` 가 맡는다 — 목록만 넘기면 버튼 아래로 · 버튼 왼쪽 끝에 맞춰 뜨고, 서비스를 고르거나 바깥을 누르면 닫힌다. 고른 서비스는 **새 탭에서 열린다** — 이동도 앱이 만들지 않는다.
1572
+
1573
+ **앱이 넘기는 것은 서비스 키와 갈 주소 둘뿐이다.** 로고·서비스 표기·이름·상태 태그는 디자인 시스템의 카탈로그가 서비스마다 정해 두므로 앱이 만들지 않는다.
1574
+
1575
+ ```tsx
1576
+ <SGnb
1577
+ items={MENU} value={current} onValueChange={navigate}
1578
+ logo={<Logo />}
1579
+ launcher={{
1580
+ items: [
1581
+ { service: 'sellmate' }, // 갈 수 없는 줄 — 주소를 주지 않는다
1582
+ { service: 'wms', href: env.WMS_URL }, // 주소는 앱의 환경(QA·실서버)에서 온다
1583
+ { service: 'account', href: env.ACCOUNT_URL },
1584
+ { service: 'crm', href: env.CRM_URL },
1585
+ ],
1586
+ }}
1587
+ />
1588
+ ```
1589
+
1590
+ - **`launcher` 를 주지 않으면 런처 버튼 자체가 렌더되지 않는다.** 옮겨 갈 서비스가 없는 앱은 이 슬롯을 비워 둔다 — 눌러도 아무것도 없는 버튼을 상단바에 남기지 않는다.
1591
+ - **서비스는 키(`service`)로 고른다 — 보이는 것은 아무것도 넘기지 않는다.** 쓸 수 있는 키는 `'sellmate' | 'wms' | 'account' | 'crm'`(`SLauncherService`)이고, 로고 오른쪽 표기(`WMS`·`Account`·`CRM`)·아래 줄 한글 이름·상태 태그(`NEW`·`출시예정`)·출시 여부를 카탈로그(`LAUNCHER_SERVICE_CATALOG`)가 그 키로 정한다. 앱이 넘기게 하면 같은 서비스가 앱마다 다른 이름·색·문구로 선다. **서비스가 늘거나 이름·상태가 바뀌면 디자인 시스템 릴리스로 따라온다** — 앱은 고칠 것이 없다.
1592
+
1593
+ ```tsx
1594
+ ✅ { service: 'wms', href: env.WMS_URL }
1595
+ ❌ { service: 'WMS', name: '셀메이트 WMS', href: … } // 표기·이름을 앱이 정하지 않는다
1596
+ ❌ { service: 'wms', tag: 'NEW', href: … } // 태그도 앱이 정하지 않는다
1597
+ ❌ { service: <><SLogo size={12} />WMS</>, … } // 로고를 다시 넘기지 않는다
1598
+ ```
1599
+ - **주소는 앱이 준다 — 디자인 시스템은 주소를 들지 않는다.** 앱마다 QA·실서버 주소가 달라 한곳에서 관리할 수 없기 때문이다. `href` 를 주면 그 줄은 링크가 되어 **새 탭에서 열린다** — 가운데 클릭·⌘클릭·주소 복사가 그대로 듣는다.
1600
+ - **이동을 앱이 다시 하지 않는다.** `launcher.onSelect` 는 **고른 것을 알려 줄 뿐 화면을 옮기지 않는다** — 여기서 `window.open` 이나 라우터를 부르면 탭이 두 번 열린다. 기록을 남기는 것처럼 이동 말고 할 일이 있을 때만 준다. 고르면 목록은 스스로 닫히고, 갈 수 없는 줄은 눌리지 않아 오지 않는다. (런처 **버튼**을 누른 것은 `launcher.onClick` 으로 따로 온다.)
1601
+ - **`href` 를 주는 것이 곧 "갈 수 있다"는 뜻이다 — 줄을 막는 prop 은 없다.** 주소를 주지 않으면 그 줄은 목록에 남되 눌리지 않고 물러난다. 지금 보고 있는 서비스든 이 앱이 계약하지 않은 서비스든 갈 곳이 없기는 같아서 한 가지로 다룬다.
1602
+
1603
+ ```tsx
1604
+ ✅ { service: 'sellmate' } // 갈 수 없다 — 주소를 주지 않는다
1605
+ ❌ { service: 'sellmate', disabled: true } // 줄을 막는 prop 은 없다
1606
+ ```
1607
+ - **갈 수 없는 서비스도 목록에서 빼지 않는다.** 그 서비스가 있다는 사실과 지금은 못 간다는 사실을 함께 알리는 것이 런처의 일이다. 지금 보고 있는 서비스를 빼면 사용자가 지금 어디에 있는지가 런처에서 사라진다.
1608
+ - **출시 전인 서비스는 앱이 할 일이 없다.** 카탈로그가 `출시예정` 태그와 함께 막아 두므로 주소를 줘도 열리지 않고, 열리는 날 디자인 시스템 릴리스 하나로 모든 앱에서 함께 열린다. **앱이 앞질러 열 수단은 없다.**
1609
+ - **목록 순서를 앱이 정하지 않는다.** 넘긴 순서가 아니라 카탈로그 순서(`LAUNCHER_SERVICES`)로 선다 — 같은 런처가 앱마다 다른 순서로 뜨면 사용자가 매번 다시 찾는다. 정렬해서 넘기려 애쓰지 않는다.
1610
+ - **줄에 얹으면 "여기서 나간다"는 것이 드러난다** — 배경이 옅은 파랑으로 물들고 이름이 한 단계 진해지며, 오른쪽 끝에 페이지 이동 아이콘이 떠오른다. **앱이 켜고 끄는 prop 이 없다** — 다른 서비스로 넘어가는 자리라는 신호는 화면마다 달라지면 안 된다. 갈 수 없는 줄은 얹어도 반응하지 않는다.
1611
+ - **폭을 늘리지 않는다.** 로고 길이에 따라 패널이 출렁이지 않도록 고정 폭이다.
1612
+ - **뜨는 방향은 prop 이 아니다.** 런처 버튼이 상단바 왼쪽 끝에 있으므로 **아래로 · 버튼 왼쪽 끝에 맞춰** 펼친다. 방향을 바꾸는 prop 은 없으니 찾지 않는다.
1613
+ - **`header="fix"` 에서 GNB 를 접으면 열려 있던 목록은 닫힌다.** 접힘 레일에는 폴드 버튼만 남아 런처 버튼이 화면에서 사라지기 때문이다. **그 닫힘을 알아야 하면 `launcher.onOpenChange` 를 준다** — 열림을 따라 그리는 화면이 앱에 있을 때만 필요하다.
1614
+ - **서비스 전환을 `topContent` 나 GNB 메뉴에 또 만들지 않는다.** 다른 서비스로 가는 길은 런처 하나다.
1615
+
992
1616
  ### 3-6. 영역 나누기 — SDivider vs SSplitter
993
1617
 
994
1618
  | 상황 | 사용 |
@@ -998,6 +1622,25 @@ const columns: STableColumn[] = [
998
1622
 
999
1623
  `SSplitter` 의 구분선은 평소 자리만 잡고 칠해지지 않다가, 경계에 커서를 올리거나 포커스를 주면 그때 드러난다 — 조절 가능한 자리라는 신호다. **항상 보이는 선이 필요하면 `SDivider` 를 쓴다.** 선 색·두께·주변 여백은 토큰이 정하므로 직접 주지 않는다.
1000
1624
 
1625
+ **세로 구분선의 길이는 `height` prop 으로 준다 — `className` 의 `h-*` 로 주지 않는다.**
1626
+
1627
+ ```tsx
1628
+ // 줄 높이를 그대로 채운다 (기본)
1629
+ <div className="flex items-center gap-sd-8">
1630
+ <span>총 주문 3건</span>
1631
+ <SDivider vertical />
1632
+ <span>총 품목 12건</span>
1633
+ </div>
1634
+
1635
+ // 양옆 글자보다 짧은 선이 필요할 때만 길이를 정한다
1636
+ <SDivider vertical height={20} />
1637
+
1638
+ // 하지 말 것 — 선이 줄 맨 위에 붙는다
1639
+ <SDivider vertical className="h-sd-20" />
1640
+ ```
1641
+
1642
+ 길이를 주지 않으면 `align-self: stretch` 로 부모 줄 높이를 채우는데, `stretch` 는 높이가 `auto` 일 때만 늘린다. `className` 으로 높이를 정하면 `stretch` 가 조용히 무효가 되어 선이 위로 솟는다. `height` 는 길이와 교차축 가운데 정렬을 함께 적용하므로 이 함정이 없다 — 위·아래 정렬이 필요하면 `className="self-start"` 처럼 명시한다. (`sellmate/divider-vertical-height` 규칙이 잡는다.)
1643
+
1001
1644
  ```tsx
1002
1645
  <SSplitter defaultValue={30} limits={[20, 60]}>
1003
1646
  <SSplitter.Before>내비게이션</SSplitter.Before>
@@ -1009,10 +1652,11 @@ const columns: STableColumn[] = [
1009
1652
  - 크기 단위는 `unit` 이 정한다. 기본 `'%'` 는 창이 바뀌어도 비율을 유지하고, `'px'` 는 폭을 유지한다. **사이드바처럼 폭이 고정돼야 하는 자리는 `'px'`**, 화면을 비율로 나누는 자리는 기본값 그대로 둔다.
1010
1653
  - 본문이 읽을 수 없을 만큼 좁아지지 않도록 `limits={[최소, 최대]}` 를 준다. 생략하면 `'%'` 는 `[10, 90]`, `'px'` 는 `[50, Infinity]`.
1011
1654
  - 각 패널은 넘치는 만큼 **스스로 스크롤한다.** 패널 안에 `SScrollArea` 를 겹쳐 넣지 않는다.
1655
+ - **높이는 놓는 자리가 준다.** `SSplitter` 는 부모를 채우기만 하므로, 부모 높이가 `auto` 면 패널이 내용 높이로 자라 스크롤이 생기지 않는다 (`vertical` 은 위아래 비율 자체가 무의미해진다). 페이지 본문의 남은 높이를 쓰려면 `contentHeight="fill"` 에 스택을 `min-h-0 flex-1` 로 이어 준다 — `SCalendarBoard`·`STable` 과 같은 사슬이다 (§2-2).
1012
1656
  - 모델은 항상 **첫 패널**(`SSplitter.Before`) 크기다. 사이드가 기준인 화면이면 사이드를 `Before` 에 둔다.
1013
1657
  - 앱 셸의 GNB 폭은 `SGnb` 가 소유한다. `SLayout`/`SGnb` 를 `SSplitter` 로 감싸지 않는다 — **GNB 폭을 끌 수 있게 하려면 `SGnb` 에 `resizable` 을 준다**(§4-1).
1014
1658
 
1015
- **Quasar `q-splitter` 에서 옮겨올 때** — `unit` · `limits` · `emitImmediately` 는 이름과 뜻이 같고, 나머지는 아래처럼 바뀐다. `emitImmediately` 를 주지 않으면 **드래그를 놓는 순간 한 번만** `onValueChange` 가 온다.
1659
+ **Quasar `q-splitter` 에서 옮겨올 때** — `unit` · `limits` · `updateWhileDragging` 는 이름과 뜻이 같고, 나머지는 아래처럼 바뀐다. `updateWhileDragging` 를 주지 않으면 **드래그를 놓는 순간 한 번만** `onValueChange` 가 온다.
1016
1660
 
1017
1661
  | q-splitter | SSplitter |
1018
1662
  | --- | --- |
@@ -1028,18 +1672,46 @@ const columns: STableColumn[] = [
1028
1672
 
1029
1673
  > §3-0 라우팅에서 이 절을 가리키는 자리들이다. <!-- TODO(디자인): 전체 검수·확정 -->
1030
1674
 
1031
- #### 3-7-1. SInput vs STextarea
1675
+ #### 3-7-1. SInput vs STextarea vs SEditor vs SSearchInput
1032
1676
 
1033
- **줄 수가 아니라 값의 성격으로 고른다.** 값의 길이를 미리 알 수 있으면 `SInput`, 없으면 `STextarea` 다.
1677
+ **먼저 "그 값이 저장되는가"를 본다.** 저장되면 폼 필드(`SInput`·`STextarea`), 화면을 좁히기만 하고 사라지면 `SSearchInput` 이다.
1034
1678
 
1035
1679
  | 값 | 사용 |
1036
1680
  | --- | --- |
1037
1681
  | 이름·코드·전화번호·URL 처럼 형식이 정해진 값 | `SInput` |
1038
1682
  | 메모·사유·설명처럼 길이가 예측되지 않는 문장 | `STextarea` |
1683
+ | 서식(제목·굵게·목록·정렬·색·링크·이미지)이 값의 일부로 저장되어야 하는 글 | `SEditor` |
1684
+ | 지금 보이는 목록·결과를 좁히는 검색어 | `SSearchInput` |
1685
+
1686
+ 폼 필드 둘은 **줄 수가 아니라 값의 성격으로** 갈린다. 값이 길어질 수 있는데 `SInput` 을 쓰면 사용자가 자기가 쓴 것을 다시 읽지 못한다 — 한 줄 안에서 좌우로 스크롤해야 하기 때문이다. 반대로 짧은 값에 `STextarea` 를 쓰면 빈 공간이 남아 입력량을 잘못 기대하게 한다.
1687
+
1688
+ **`STextarea` 의 기본 높이는 두 줄이다.** 크기는 `size`(`'sm'` 기본 · `'md'`)로 정하고, 한 줄 필드와 나란히 놓이는 자리라면 `SInput` 과 같은 등급으로 맞춘다 — 등급이 글자·행간·안쪽 여백·모서리를 함께 정하므로 섞으면 같은 줄에서 어긋나 보인다. **더 높게 쓰려면 `rows` 를 준다**(줄 수). 높이를 `style` 이나 `textareaStyle` 로 직접 주지 않는다 — 등급이 정하는 값이고, 사용자가 모서리를 끌어 늘릴 수 있다. `rows` 를 두 줄 아래로 줄여도 등급이 정한 높이 밑으로는 내려가지 않는다. 한 줄만 받을 자리면 `SInput` 이다.
1689
+
1690
+ **입력한 만큼 늘어나게 하려면 `autogrow` 다.** 스크롤 대신 필드가 자라므로 쓴 글을 한눈에 다시 읽을 수 있다. 켜면 모서리를 끌어 크기를 바꾸는 손잡이는 사라진다 — 끌어 둔 높이를 다음 타이핑이 도로 계산하기 때문이다. **모달·드로어·카드처럼 아래에 버튼이 있는 자리에서는 `maxRows` 를 반드시 함께 준다** — 상한이 없으면 긴 글에서 필드가 계속 자라 그 버튼을 화면 밖으로 밀어낸다. 상한에 닿으면 그 안에서 스크롤한다. 페이지 본문처럼 아래로 밀려도 괜찮은 자리라면 상한 없이 써도 된다.
1691
+
1692
+ `SEditor` 는 **서식이 값의 일부일 때만** 쓴다. 값을 HTML 문자열로 주고받으므로 저장·검색·비교가 평문보다 비싸고, 화면에 다시 보여줄 때도 HTML 로 렌더해야 한다. 서식이 필요 없는 메모·사유는 `STextarea` 다 — "입력창이 커 보여서" 고르는 컴포넌트가 아니다. 반대로 공지·안내문·상품 상세처럼 **작성자가 정한 강조와 목록이 그대로 보여야 하는 글**이면 `STextarea` 로는 표현할 수 없다.
1039
1693
 
1040
- 값이 길어질 수 있는데 `SInput` 을 쓰면 사용자가 자기가 쓴 것을 다시 읽지 못한다 — 한 줄 안에서 좌우로 스크롤해야 하기 때문이다. 반대로 짧은 값에 `STextarea` 를 쓰면 빈 공간이 남아 입력량을 잘못 기대하게 한다.
1694
+ `SEditor` 도 `SInput`·`STextarea` 와 같은 폼 필드다 — `label`·`hint`·`rules`·`errorMessage` 를 자기 prop 으로 받고 `SForm` 제출 검증에도 들어간다. 빈 문서는 빈 문자열로 나오므로 `required` 규칙이 그대로 걸린다. **툴바를 끄는 길은 없다** — 서식 입력이 필요 없는 자리라면 서식 없는 `SEditor` 가 아니라 `STextarea` 를 고른다.
1041
1695
 
1042
- #### 3-7-2. 하나를 고르게 하는 다섯 — SSelect vs SRadioGroup vs SRadioButton vs STabs vs SRadio
1696
+ 툴바는 **프리셋 둘 중 하나뿐이다.** 기본은 쓸 수 있는 것을 모두 보이고, **`simple` 을 켜면 글자에 거는 서식만 남는다** — 목록·정렬·인용·코드·링크·이미지·구분선이 빠지고 선택했을 때 뜨는 판도 같은 범위로 줄어든다. 받은 글의 **문단 구조까지 작성자를 따라가면 곤란한 자리**(좁은 칸의 메모·사유·짧은 안내문)가 `simple` 이다. 항목을 직접 조합하는 prop 은 없다 — 화면마다 다른 툴바가 서면 그 자체가 학습 비용이 된다.
1697
+
1698
+ 본문에서 따옴표·하이픈·화살표는 **자동으로 치환된다**(`"` → `“”`, `--` → `—`, `->` → `→`). 끌 수 없으므로, 상품 코드·규격 문자열처럼 **입력한 그대로 남아야 하는 값**은 `SEditor` 본문이 아니라 `SInput` 으로 따로 받는다.
1699
+
1700
+ 툴바는 **자리를 지킨다**(고정). 긴 글을 쓰는 동안 막대가 화면 밖으로 나가지 않으므로, 툴바를 따로 감싸거나 위치를 주지 않는다.
1701
+
1702
+ 툴바에는 **글자 크기 드롭다운**과 **글자색 팔레트**가 들어 있다 — 작성자가 문단마다 크기·색을 직접 지정할 수 있고, 크기 목록 맨 위 `기본` 은 지정을 떼는 자리다. 크기 눈금은 화면 타이포(§2-1)가 아니라 워드프로세서의 눈금이라 본문보다 훨씬 큰 단계까지 있다. **눈금도 팔레트도 좁히는 prop 이 없다** — 화면마다 고를 수 있는 것이 다르면 같은 글이 어디에 붙느냐에 따라 다르게 보이기 때문이다. 그래서 **작성자가 화면 리듬을 벗어나면 곤란한 자리(상품 상세 설명·반복 노출되는 안내문 등)라면 `SEditor` 가 맞는 자리인지 먼저 본다** — 서식이 값의 일부가 아니라면 `STextarea` 다. 고른 크기·색은 저장되는 HTML 에 그대로 남아 나중에 되돌릴 수 없다.
1703
+
1704
+ 본문에는 **표**도 들어간다 — 툴바의 `표` 드롭다운에서 격자를 끌어 크기를 고르고(최대 8행 × 10열), 행·열을 늘리고, 칸을 병합한다. **열 너비는 균등 고정이고 바꿀 수 없다**: 너비를 저장하면 그 값이 px 로 박혀 작성한 화면보다 좁은 곳에서 표가 넘친다. 대신 어떤 폭에서도 표가 상자 안에 들어오도록 열을 고르게 나눈다 — 열이 많은 표는 좁은 칸에서 글자가 잘게 접히므로, **열이 넷을 넘어가는 표라면 `SEditor` 본문이 아니라 `STable` 이 맞는 자리인지 본다.** 표는 작성자가 쓰는 글의 일부일 때만 여기에 있고, 데이터를 줄 세워 보여 주는 것은 `STable` 이다.
1705
+
1706
+ 글을 선택하면 **그 위에 서식 판이 뜬다**(버블 메뉴). 툴바까지 커서를 옮기지 않고 바로 굵게·색·링크를 거는 길이다. **끄고 켜는 prop 은 없다** — 편집할 수 있으면 언제나 뜨고, 읽기 전용·비활성일 때는 뜨지 않는다. 화면마다 있고 없고가 달라지면 그 자체가 학습 비용이 되기 때문이다. 판의 구성은 `simple` 이 막대와 함께 정한다. 뜨는 자리는 DS 가 잡는다, 직접 감싸거나 위치를 주지 않는다.
1707
+
1708
+ `SEditor` 는 화면에 처음 놓일 때 **에디터 엔진을 따로 불러온다** — 앱 초기 번들에는 들어가지 않는다. 그동안은 같은 크기의 빈 편집 영역이 자리를 지키므로 레이아웃은 흔들리지 않지만, **마운트하자마자 `ref.current.getHTML()` 로 값을 읽거나 툴바를 누를 수는 없다.** 열자마자 커서를 놓고 싶으면 `ref.current.focus()` 를 그냥 부르면 된다 — 준비되는 순간 대신 실행된다.
1709
+
1710
+ **이미지를 넣으려면 `onImageUpload` 를 준다** — 고른 파일을 저장하고 표시할 URL 을 돌려주는 훅이다. 저장 위치는 앱마다 다르므로 DS 가 정하지 않고, 훅이 없으면 툴바에서 이미지 항목이 빠진다. 본문에 base64 를 박는 길은 막아 두었다 — 저장 HTML 이 수 MB 로 부풀어 그대로 DB·API 에 실리기 때문이다.
1711
+
1712
+ `SSearchInput` 은 폼 필드가 아니다 — 라벨·힌트·유효성 규칙·에러 메시지를 받지 않고, `SForm` 의 제출 검증 대상에도 들어가지 않는다. 돋보기 아이콘이 항상 앞에 붙어 "여기는 검색"임을 스스로 밝히므로 라벨을 따로 붙이지 않는다. 검색 실행은 `onSearch`(Enter) 로 받고, 값이 바뀔 때마다 좁히는 실시간 필터라면 `onValueChange` 만 쓴다. 반대로 검색어를 **저장하거나 검증해야 한다면** 그것은 폼 값이므로 `SInput` 이다.
1713
+
1714
+ #### 3-7-2. 하나를 고르게 하는 여섯 — SSelect vs SRadioGroup vs SRadioCardGroup vs SRadioButton vs STabs vs SRadio
1043
1715
 
1044
1716
  **먼저 "고르면 무엇이 바뀌는가"를 본다.**
1045
1717
 
@@ -1050,10 +1722,68 @@ const columns: STableColumn[] = [
1050
1722
  | --- | --- |
1051
1723
  | 6개 이상, 또는 서버에서 오는 동적 목록 | `SSelect` |
1052
1724
  | 2~5개 고정 + 선택지를 항상 보여야 함 | `SRadioGroup` |
1725
+ | 2~5개 고정 + **선택지마다 설명 한 줄이 필요함** | `SRadioCardGroup` |
1053
1726
  | 2~4개 + 짧은 라벨의 배타적 모드 전환 (세그먼트) | `SRadioButton` |
1054
1727
 
1055
1728
  - **`SRadio` 를 직접 나열하지 않는다.** 그룹 간격은 `SRadioGroup` 이 맞춘다 (§2-2). `SRadio` 단독은 `SRadioGroup` 이 만들 수 없는 배치 — 표 셀 안에 행마다 하나씩 놓는 경우 — 에만 쓴다.
1056
1729
  - `SRadioButton` 은 `options` 를 통째로 받는 세그먼트 컨트롤이라 `SRadio` 를 여러 개 넣는 게 아니다.
1730
+ - **`SRadioGroup` 과 `SRadioCardGroup` 의 갈림길은 "설명이 필요한가" 하나다.** 라벨만으로 뜻이 통하면 `SRadioGroup`, 고르기 전에 한 줄 설명을 읽어야 하면 `SRadioCardGroup` 이다. 라벨 옆에 설명을 괄호로 덧붙여 늘이지 않는다.
1731
+ - **`SRadioCardGroup` 은 `SRadioGroup` 의 대체가 아니라 격상판이다.** 라벨이 한 단계 크고 그 아래 설명 줄까지 들어가 카드 한 장이 자리를 넉넉히 쓴다 — 선택지가 조밀하게 반복되는 자리에는 과하다.
1732
+ - **쓰는 자리는 설정 화면·마법사(스텝)의 소수 결정이다.** 고르고 나면 뒤 화면이 통째로 바뀌거나 되돌리기 어려운 선택 — 평가 대상자 범위(전체/특정 구성원), 상담 자동 배정 방식 같은 것. 목록 필터·표 안 조건처럼 같은 선택지가 화면마다 반복 노출되는 자리에는 `SRadioGroup` 을 쓴다.
1733
+ - `SRadioCard` 단독은 `SRadioCardGroup` 이 만들 수 없는 배치에만 쓴다 — 나열은 그룹이 맡는다 (§2-2).
1734
+ - 카드 안에 상태 배지를 넣으려면 `tag` 슬롯에 `STag` 를 준다. 라벨 문자열에 "(추천)" 처럼 섞어 쓰지 않는다.
1735
+ - **카드처럼 생겼다고 `SCard`/`SSectionHeaderCard` 로 감싸지 않는다.** `SRadioCard` 자체가 완결된 요소이고, 나열 간격은 `SRadioCardGroup` 의 `direction` 이 맞춘다 (§2-2).
1736
+
1737
+ ##### 옵션이 수백~수천 개면 — `onReachEnd`
1738
+
1739
+ **렌더는 걱정하지 않아도 된다.** `SSelect` 는 언제나 보이는 범위의 행만 그린다 — 켜고 끄는 prop 이 없고, 옵션이 5개든 5,000개든 여는 비용이 같다. 행 높이가 균일하다고 가정하지도 않으므로 계층 목록이나 큰 글씨가 섞인 라벨도 그대로 넘기면 된다.
1740
+
1741
+ **남는 부담은 전달이다.** 수천 건을 한 번에 받아 오는 것 자체가 무거우면 페이지 단위로 받는다.
1742
+
1743
+ - **`onReachEnd`** — 목록 끝이 가까워지면 불린다. 다음 페이지를 받아 `options` **뒤에 이어붙인다**(갈아끼우지 않는다). `hasMore` · `loading` 을 함께 준다. 없으면 마지막 페이지 뒤로도 계속 청하거나, 받는 중에 같은 페이지를 두 번 청한다.
1744
+ - **`onReachEnd` 를 쓰면 검색도 서버로 넘긴다** — `serverSearch` 를 켜고 `onSearchChange` 로 온 검색어에 맞는 목록을 내려준다. 켜지 않으면 검색이 "지금까지 받은 페이지" 안에서만 걸러져, 아직 받지 않은 항목은 검색해도 나오지 않는다. 검색어가 바뀌면 첫 페이지부터 다시 받는다.
1745
+ - **`reachEndThreshold` 는 한 페이지 크기보다 충분히 작게 잡는다.** 한 페이지가 드롭다운을 채우고도 이 문턱만큼 남기지 못하면 페이지가 도착하는 족족 다음 페이지를 다시 청해, 사용자가 스크롤하지 않아도 목록 전체를 받아 온다 — 페이징을 한 의미가 사라진다. 기본값이면 대개 그대로 두면 된다.
1746
+ - **늦게 온 응답이 최신 목록을 덮지 않게 한다.** 검색어나 페이지가 바뀌면 앞선 요청은 버려야 한다 — `useEffect` 의 cleanup 에서 취소 플래그를 세우는 것이 정석이다. 빠뜨리면 빠르게 지나간 검색어의 결과가 화면에 남는다. **무한 로딩에서 가장 흔히 새는 곳이다.**
1747
+ - **`showSelectAll` 은 함께 쓰지 않는다.** 아직 받지 않은 옵션은 고를 수 없어 "전체"가 거짓이 된다. 함께 주면 무시하고 개발 모드에서 경고한다.
1748
+ - **`valueAsPrimitive` 를 켜지 않는다.** 기본값(옵션 객체)이면 고른 값이 라벨을 함께 들고 다녀, 그 옵션이 지금 페이지나 검색 결과에서 빠져도 트리거에 이름이 그대로 남는다. 원시값만 들고 있으면 그 자리에 코드가 뜬다.
1749
+ - **계층 목록도 페이지로 받을 수 있다.** 단 이어붙일 때 **이미 있는 그룹의 `children` 에 이어야** 한다 — 같은 그룹을 새 항목으로 또 밀어 넣으면 목록에 같은 헤더가 두 번 뜬다.
1750
+
1751
+ ```tsx
1752
+ const [query, setQuery] = useState('');
1753
+ const [page, setPage] = useState(0);
1754
+ const [options, setOptions] = useState<SSelectOption[]>([]);
1755
+
1756
+ useEffect(() => {
1757
+ let cancelled = false; // 늦게 온 이전 요청이 최신 목록을 덮지 않게
1758
+ setLoading(true);
1759
+ fetchClients(query, page).then(res => {
1760
+ if (cancelled) return;
1761
+ setOptions(prev => (page === 0 ? res.items : [...prev, ...res.items])); // 갈아끼우지 않고 이어붙인다
1762
+ setHasMore(res.hasMore);
1763
+ setLoading(false);
1764
+ });
1765
+ return () => {
1766
+ cancelled = true;
1767
+ };
1768
+ }, [query, page]);
1769
+
1770
+ <SSelect
1771
+ label="거래처"
1772
+ width="lg"
1773
+ options={options}
1774
+ hasMore={hasMore}
1775
+ loading={loading}
1776
+ onReachEnd={() => setPage(p => p + 1)}
1777
+ showSearch
1778
+ serverSearch
1779
+ onSearchChange={q => {
1780
+ setPage(0); // 새 검색은 첫 페이지부터
1781
+ setQuery(q);
1782
+ }}
1783
+ value={value}
1784
+ onValueChange={setValue}
1785
+ />;
1786
+ ```
1057
1787
 
1058
1788
  #### 3-7-3. 켜고 끄는 셋 — SCheckbox vs SSwitch vs SToggle
1059
1789
 
@@ -1069,7 +1799,7 @@ const columns: STableColumn[] = [
1069
1799
  - 약관 동의처럼 **제출 시점에 값이 필요한 것은 항상 `SCheckbox`** 다 — 모양이 스위치에 가까워 보여도 그렇다.
1070
1800
  - `SToggle` 은 알약형 버튼이라 여러 개를 가로로 늘어놓는 필터 자리에 맞는다. 설정 화면의 on/off 한 줄에는 쓰지 않는다.
1071
1801
 
1072
- #### 3-7-4. 날짜 셋 — SDatePicker vs SDateRangePicker vs SCalendar
1802
+ #### 3-7-4. 날짜 셋 — SDatePicker vs SDateRangePicker vs SCalendar vs SCalendarBoard
1073
1803
 
1074
1804
  | 판별 | 사용 |
1075
1805
  | --- | --- |
@@ -1077,10 +1807,21 @@ const columns: STableColumn[] = [
1077
1807
  | 연도 선택 리스트만 필요하다 (트리거·팝오버는 직접 조합) | `SDatePickerYearListbox` |
1078
1808
  | 연도+월 선택 리스트만 필요하다 (트리거·팝오버는 직접 조합) | `SDatePickerMonthListbox` |
1079
1809
  | **시작~종료** 를 값으로 받는다 | `SDateRangePicker` |
1080
- | 달력 격자 **자체가 화면 콘텐츠** 다 (일정·이벤트 보기) | `SCalendar` |
1810
+ | 달력 격자 **자체가 화면 콘텐츠** 다 (날짜를 고르게 한다) | `SCalendar` |
1811
+ | 한 달치 **일정을 읽게** 펼친다 (칸마다 여러 건, 여러 날에 걸친 일정 포함) | `SCalendarBoard` |
1081
1812
 
1082
1813
  - **기간을 `SDatePicker` 두 개로 만들지 않는다.** 시작이 종료보다 뒤인 입력을 막는 검증과 한쪽만 고른 중간 상태 처리가 `SDateRangePicker` 안에 이미 있다. 두 개로 쪼개면 그게 전부 앱 몫이 된다.
1083
1814
  - `SDatePicker`·`SDateRangePicker` 는 내부적으로 `SCalendar` 를 팝오버로 띄운다. 값을 받는 자리에 `SCalendar` 를 직접 쓰지 않는다.
1815
+ - **`SCalendar` 와 `SCalendarBoard` 는 방향이 반대다.** `SCalendar` 는 날짜를 **고르는** 달력(칸에는 점만 찍힌다)이고, `SCalendarBoard` 는 일정을 **읽는** 달력이다 — 칸마다 일정이 줄로 쌓이고, 칸 높이에 담기는 줄 수를 넘으면 "N개 더보기" 로 접히고, **"더보기" 또는 일정이 있는 칸 아무 데나** 누르면 **그 칸을 덮으며 뜨는 판**에 그 날짜의 일정이 전부 펼쳐진다. 폭도 다르다: `SCalendar` 는 제 폭을 갖는 요소, `SCalendarBoard` 는 페이지 폭을 채우는 블록이다.
1816
+ - **`SCalendarBoard` 는 기본이 읽기 전용 판이다.** 날짜 칸을 눌러 **고르게** 하려면 `selectable` 을 켠다 — 켜야 `onValueChange(date, events)` 로 고른 날짜와 **그 날짜의 일정**이 함께 온다. 다만 **일정이 있는 칸은 `selectable` 과 무관하게 눌린다** — 그때는 목록 판이 열린다. 둘 다 켜져 있으면 고르기와 열기가 함께 일어난다. 일정 버튼과 "N개 더보기" 도 항상 눌린다.
1817
+ - **여러 날에 걸친 일정은 `events[].endDate` 로 준다.** 그날까지(그날 포함) **칸 경계를 넘어 하나의 막대로** 이어져 그려진다. 같은 일정을 날짜 수만큼 쪼개 넣지 않는다 — 쪼개면 칸마다 막대가 끊겨 보이고, 목록 판과 `onValueChange` 에도 건수가 부풀려 나간다. 줄은 **주 단위**로 정해져서, 막대가 지나가는 칸은 그 줄을 비워 두므로 다른 일정과 겹치지 않는다. 주 경계에서는 줄이 바뀌므로 조각이 나뉘고 **잘린 쪽 모서리가 평평해진다** — 앞/뒤로 이어진다는 표시다. **막대가 걸친 날은 모두 그 일정을 가진 날이다** — 어느 칸을 눌러도 목록 판에 함께 나오고 `onValueChange` 에도 실려 온다. `endDate` 가 `date` 보다 앞서면 하루짜리로 본다.
1818
+ - **칸 배경과 일정 색은 다른 층이다.** 그날의 성격(마감·완료·휴무 등)은 `dayColors` 로 **칸 전체**에 칠하고(`default` `danger` `accent` `complete` `dim`), 일정 하나의 색은 `events[].color` 로 준다. 둘을 섞어 쓰면 서로를 가리지 않고 함께 읽힌다.
1819
+ - 일정 왼쪽의 색 점은 `SBadge` 다 — `events[].color` 프리셋이 점 색까지 정하고, 따로 주려면 `dotColor` 에 `SBadge` 색(`SBadgeColor`) 중 하나를 넣는다 (§3-1).
1820
+ - **칸 본문을 통째로 바꾸려면 `dayContent` 다.** 일정으로 표현되지 않는 그날치 요약(재고·매출 같은)을 칸에 얹을 때 쓴다. **슬롯을 준 날짜는 그 날짜의 `events` 를 그리지 않고 목록 판도 열지 않는다** — 한 칸에 둘을 겹쳐 담지 않는다. 일정과 요약을 함께 보여야 하면 요약을 일정 한 건(`events[]`)으로 넣는다.
1821
+ - **`SCalendarBoard` 는 높이를 부모에게서 받는다.** 행이 그 높이를 균등하게 나눠 갖고, 칸 높이가 다시 "한 칸에 몇 줄이 보일지" 를 정한다. 그래서 **놓는 자리에 높이를 준다** — 페이지 본문의 남은 높이를 채우려면 `SPage` 안에서 스택을 `flex-1 min-h-0` 으로 두고 판에 `h-full` 을 준다. 높이를 주지 않으면 칸이 최소 높이로 주저앉아 한두 줄만 보인다.
1822
+ - **한 칸에 몇 줄을 펼칠지는 컴포넌트가 잰다.** 조절하는 prop 이 없다 — 칸 높이에서 나오는 값이라 따로 정할 것이 아니다. 줄이 넘치면 마지막 한 줄을 "N개 더보기" 에 내주므로 **접히든 아니든 칸 높이가 같다.** 기간 막대가 접히면 **걸친 날마다 한 건으로** 세므로 날짜별 "N개" 가 서로 다를 수 있다. **소비 앱이 `events` 를 미리 잘라 넘기지 않는다.** 잘라 넘기면 "N개 더보기" 의 수가 실제 건수와 어긋나고, 눌러도 나머지가 나오지 않는다.
1823
+ - **판 밖의 것에 손이 닿았을 때 그에 맞는 일정을 짚어 주려면 `focusedEventId` 다.** 지목한 일정만 또렷하게 남고 나머지 일정과 "N개 더보기" 는 물러난다. **지목한 일정이 "N개 더보기" 에 접혀 있으면 그 더보기 자리에 대신 그려져** 지목하는 동안만 드러나고, 보이던 일정들의 줄 배정은 어느 쪽이든 달라지지 않는다 — 지목이 오가도 판이 출렁이지 않는다. 자리를 내준 칸은 그동안 "더보기" 를 그리지 않으므로, 거기 남은 일정은 칸을 눌러 목록 판으로 본다. **물러나게 하는 것을 앱에서 직접 하지 않는다** — 접혀 있다 드러난 막대는 앱의 CSS 가 닿지 않아 그 하나만 어긋난다. `events[].id` 가 없는 일정은 지목할 수 없다. 호버·포커스를 **감지**하는 것은 앱 몫이고, 판은 "지금 어느 일정인가" 만 받는다.
1824
+ - **`events[].label` 은 필수다** — 일정이 무엇인지 알리는 부분이라 비워 두지 않는다. 인라인 HTML 을 받으므로 부분 강조가 필요할 때만 태그를 쓰고, 나머지는 `description`·`value` 로 나눠 담는다. 칸이 좁아지면 `label`+`description` 이 한 덩어리로 말줄임되고 `value` 만 끝에 온전히 남는다 — **금액·수량은 반드시 `value` 에 담는다.** `description` 에 넣으면 잘린다.
1084
1825
  - `SDatePickerYearListbox`·`SDatePickerMonthListbox` 는 `SDatePicker` 의 mode listbox 조각만 떼어낸 컴포넌트다. 일반 폼 입력에는 `SDatePicker mode="year" | "month"` 를 우선 쓰고, 다른 트리거·팝오버 안에 리스트만 끼워 넣을 때만 직접 쓴다.
1085
1826
 
1086
1827
  **날짜·시간 피커는 폭 상한을 스스로 갖는다 — `width` 를 주지 않는다.** 값 길이가 `YYYY-MM-DD` 처럼 정해져 있어 컴포넌트가 사이즈별 상한을 안다. `SKeyValueTable` 이 모든 컨트롤에 `width="100%"` 를 넘기지만, 이 상한 덕분에 행 전체로 늘어나지 않고 제 폭에서 멈춘다.
@@ -1094,9 +1835,27 @@ const columns: STableColumn[] = [
1094
1835
 
1095
1836
  `SDateRangePicker` 가 한 등급씩 위인 것은 값이 `YYYY-MM-DD ~ YYYY-MM-DD` 로 두 배가 넘기 때문이다. 같은 이유로 `STimeRangePicker` 의 오전/오후 모드도 sm 에서 한 등급 위를 쓴다 — 그 모드의 최소 폭이 md 등급을 이미 넘어, 그대로 두면 하한이 상한을 넘어 상한이 무력해진다.
1096
1837
 
1838
+ 네 컴포넌트 모두 이 상한을 `maxWidth` 로 덮을 수 있다 — 등급을 주면 그 등급이 상한이 되고, `width="100%" maxWidth="100%"` 면 부모 폭을 그대로 채운다. **폭이 이미 좁게 정해진 자리(팝오버·좁은 카드)에서 쓴다.**
1839
+
1840
+ **`SKeyValueTable` 안에서는 필드마다 `maxWidth` 를 주지 않는다.** 표 안에서 이 상한이 거슬리는 이유는 대개 "같은 행의 다른 필드와 오른쪽 끝이 어긋난다" 이고, 그건 표 전체의 문제다. 표의 `fieldWidth="fill"` 로 한 번에 정한다 (§4-3). 그 표에서 한 필드만 예외로 둘 때만 `options.maxWidth` 를 쓴다.
1841
+
1842
+ ##### 값을 지울 수 있게 하려면 `clearable`
1843
+
1844
+ `SSelect` · `SDatePicker` · `SDateRangePicker` · `STimePicker` · `STimeRangePicker` 가 같은 규칙으로 갖는다. 값이 있을 때만 지우기 버튼이 나타나고, 누르면 **`onValueChange` 로 `null` 이 온다** (빈 문자열이 아니다). 받는 쪽 상태도 `null` 을 담을 수 있어야 한다.
1845
+
1846
+ ```tsx
1847
+ const [from, setFrom] = useState<string | null>(null);
1848
+
1849
+ <SDatePicker label="시작일" clearable value={from} onValueChange={setFrom} />;
1850
+ ```
1851
+
1852
+ - **조회 조건(필터)에는 켠다.** 한 번 고른 날짜를 되돌릴 방법이 없으면 전체 조회로 돌아가려고 새로고침하게 된다.
1853
+ - **필수 입력 필드에는 켜지 않는다.** 지우면 다시 고르기 전까지 폼이 통과하지 못한다 — 지울 수 있어야 하는 값이면 애초에 필수가 아니다.
1854
+ - `disabled` 이면 지우기 버튼도 함께 사라진다. 끈 필드를 지울 수 있으면 안 되기 때문이다.
1855
+
1097
1856
  #### 3-7-5. SField 를 직접 쓰는 경우
1098
1857
 
1099
- **거의 없다.** `SInput`·`SNumberInput`·`STextarea`·`SSelect`·날짜/시간 피커는 이미 내부에서 `SField` 를 쓰고 있어서 `label`·`required`·에러 표시를 자기 prop 으로 받는다. 그 위에 `SField` 를 한 겹 더 감싸면 라벨이 두 번 나온다.
1858
+ **거의 없다.** `SInput`·`SNumberInput`·`STextarea`·`SEditor`·`SSelect`·날짜/시간 피커는 이미 내부에서 `SField` 를 쓰고 있어서 `label`·`required`·에러 표시를 자기 prop 으로 받는다. 그 위에 `SField` 를 한 겹 더 감싸면 라벨이 두 번 나온다.
1100
1859
 
1101
1860
  직접 쓰는 경우는 하나뿐이다 — **디자인 시스템에 없는 컨트롤**에 다른 필드와 똑같은 라벨·필수·에러 모양을 붙일 때.
1102
1861
 
@@ -1109,18 +1868,37 @@ const columns: STableColumn[] = [
1109
1868
  | **순서 자체가 데이터**라 사용자가 끌어서 바꾼다 | `SDraggableList` + `SDraggableItem` |
1110
1869
 
1111
1870
  - **항목 하나의 속성을 나열하는 것은 목록이 아니다.** `라벨: 값` 이 세로로 쌓이는 것은 `SKeyValueTable` 이다 (§4-4).
1112
- - `SList` 는 레이아웃만 담당한다. 펼침·단일 선택 동작이 필요하면 `SExpansionList` 다 (§3-7-7).
1113
- - **항목 사이 구분선은 리스트가 그린다.** `SListItem`·`SExpansionItem`·`SDraggableItem` 셋 다 스스로 구분선을 그리지 않으므로, 목록을 감싸는 `SList`·`SExpansionList`·`SDraggableList` 에 `separator` 를 준다 — 아이템에 `border-b` 를 직접 붙이지 않는다. 테두리형(`bordered`)은 테두리가 구분 역할을 하므로 `separator` 대신 `useGap` 으로 띄운다.
1114
- - **`SListItem` 은 기본이 표시 전용이다.** 눌러서 이동·선택하게 하려면 `clickable` 을 준다 — hover·`selected`·`interaction="chevron"` 표현이 전부 여기에 딸려 있어서, `clickable` 없이 `selected` 만 주면 선택 표시가 나오지 않는다. `SExpansionList` 는 선택을 자기가 관리하므로 자식 아이템을 알아서 클릭 가능하게 만든다.
1871
+ - `SList` 는 레이아웃만 담당한다. depth 별 단일 펼침이 필요하면 `SExpansionList` 다 (§3-7-7).
1872
+ - **`SList` 의 자식은 `SListItem` 을 권장한다.** 다른 자식도 그대로 렌더되지만, 펼치는 항목은 `SExpansionList` + `SExpansionItem` 이, 끌어서 순서를 바꾸는 항목은 `SDraggableList` + `SDraggableItem` 이 여닫힘·정렬 동작까지 함께 관리하므로 그쪽을 쓴다 (§3-7-7).
1873
+ - **항목 사이 구분선은 리스트가 알아서 그린다.** `SListItem`·`SExpansionItem`·`SDraggableItem` 셋 다 스스로 구분선을 그리지 않는다. `SList`·`SExpansionList`·`SDraggableList` 가 자식 **사이에** 구분선을 넣으므로 아이템에 `border-b` 를 직접 붙이지 않고, 켜는 prop 도 따로 없다. 마지막 항목 아래에는 선이 남지 않는다. 테두리형(`bordered`)은 테두리가 구분 역할을 하므로 리스트가 구분선을 빼고, `gap` 으로 띄운다 — `gap` 을 준 목록에도 구분선은 들어가지 않는다.
1874
+ - **`SListItem` 은 기본이 표시 전용이다.** 눌러서 이동·선택하게 하려면 `clickable` 을 준다 — hover·`selected`·`interaction="chevron"` 표현이 전부 여기에 딸려 있어서, `clickable` 없이 `selected` 만 주면 선택 표시가 나오지 않는다. `SExpansionList` 는 자식 아이템을 알아서 클릭 가능하게 만들어 이 함정을 막아 준다 — 선택 상태 자체는 앱이 든다 (§3-7-7).
1115
1875
 
1116
1876
  ```tsx
1117
- ✅ <SList separator><SListItem title="일반 문의" />…</SList> {/* 줄로 구분되는 목록 */}
1118
- ✅ <SList useGap><SListItem title="일반 문의" bordered />…</SList> {/* 카드처럼 떨어진 목록 */}
1877
+ ✅ <SList><SListItem title="일반 문의" />…</SList> {/* 줄로 구분되는 목록 — 구분선은 자동 */}
1878
+ ✅ <SList gap><SListItem title="일반 문의" bordered />…</SList> {/* 카드처럼 떨어진 목록 */}
1119
1879
  ✅ <SListItem title="일반 문의" clickable selected onClick={…} /> {/* 눌러서 고르는 목록 */}
1120
- ❌ <SList><SListItem title="일반 문의" className="border-b" />…</SList>
1880
+ ❌ <SList><SListItem title="일반 문의" className="border-b" />…</SList> {/* 구분선을 직접 붙이지 않는다 */}
1881
+ ❌ <SList><><SListItem title="일반 문의" /><SListItem title="결제 문의" /></></SList> {/* Fragment 로 묶으면 그 안쪽은 구분되지 않는다 */}
1121
1882
  ❌ <SListItem title="일반 문의" selected /> {/* clickable 없으면 선택 표시가 안 나온다 */}
1122
1883
  ```
1123
1884
 
1885
+ ##### 행 안에 컨트롤을 둘 때
1886
+
1887
+ `renderItem` 이 앱 몫이라 행 안에 입력·버튼이 들어오는 것은 예외가 아니다 — 순서를 정하는 목록은 대개 「몇 번째로 보낼지」를 숫자로도 받는다. 그냥 두면 된다.
1888
+
1889
+ - **행 선택은 옵트인이다.** `selectedKey`·`defaultSelectedKey`·`onSelectedKeyChange` 중 하나라도 주면 행이 `role="button"` 이 되어 클릭·Enter·Space 로 선택된다. 하나도 주지 않으면 행은 아무 상호작용도 갖지 않는다. **순서만 바꾸는 목록에 선택 prop 을 습관적으로 붙이지 않는다** — 붙이지 않아야 행 안의 컨트롤이 중첩 인터랙티브가 되지 않고 스크린리더에 그대로 노출된다.
1890
+ - **선택을 함께 쓰더라도 자손 컨트롤의 이벤트를 끊지 않는다.** 입력·버튼·링크에서 난 클릭과 Enter·Space 는 선택으로 새지 않는다 — 행의 제목 텍스트를 눌러 고르는 것만 선택으로 간다. `stopPropagation` 을 직접 넣을 자리가 아니다.
1891
+
1892
+ ```tsx
1893
+ ✅ <SDraggableList items={rows} getKey={r => r.id} {/* 선택을 안 쓰면 선택 prop 도 없다 */}
1894
+ renderItem={(row, _i, { onDragHandleMouseDown }) => (
1895
+ <SDraggableItem title={row.name} onDragHandleMouseDown={onDragHandleMouseDown}
1896
+ trailing={<SNumberInput width={72} value={row.order} onValueChange={…} />} />
1897
+ )} />
1898
+
1899
+ ❌ <div onKeyDown={e => { e.stopPropagation(); … }}> {/* 리스트가 이미 걸러 준다 */}
1900
+ ```
1901
+
1124
1902
  #### 3-7-7. 펼치는 셋 — SExpansionItem vs SExpansionList vs STree
1125
1903
 
1126
1904
  | 판별 | 사용 |
@@ -1129,7 +1907,32 @@ const columns: STableColumn[] = [
1129
1907
  | **한 번에 하나만** 열려야 한다 (아코디언) | `SExpansionList` + `SExpansionItem` |
1130
1908
  | **부모-자식 계층 자체**를 보여줘야 한다 (2단 이상, 연결선) | `STree` |
1131
1909
 
1132
- `SExpansionList` 는 depth 별 단일 확장·선택을 관리하는 wrapper 다. 직접 `useState` 로 "열린 항목 하나"를 들고 있지 않는다. 항목 사이 구분선은 여기서도 리스트가 그린다 — `separator` 를 준다 (§3-7-6).
1910
+ `SExpansionList` 는 depth 별 **단일 확장**을 관리하는 wrapper 다. 직접 `useState` 로 "열린 항목 하나"를 들고 있지 않는다. 항목 사이 구분선은 여기서도 리스트가 알아서 그린다 — 따로 줄 prop 이 없다 (§3-7-6).
1911
+
1912
+ ##### 펼침 ≠ 선택
1913
+
1914
+ **펼침은 리스트가 관리하고, 선택은 앱이 관리한다.**
1915
+
1916
+ 펼침은 화면 밖에 진실이 없는 순간 UI 상태다. 각 항목이 자기 `expanded` 를 들고 있으면 "하나만 열림"을 만들 수 없어 누군가 나머지를 닫아야 하고, 그것이 이 wrapper 다.
1917
+
1918
+ 선택은 다르다. 앱이 `selectedId` 스칼라 하나를 들면 상호배제가 구조적으로 보장되고, 그 값은 URL·store 로 복원돼야 한다. 리스트가 사본을 들면 그 순간 진실이 둘이 되어 어긋난다.
1919
+
1920
+ 같은 이유로 `STree` 노드의 `disabled` 는 **선택만** 막는다. 체크박스는 잠기고 전체 선택·부모 선택에서 빠지지만, 캐럿·행 클릭·키보드로 접고 펴는 것은 그대로 된다 — 부모가 disabled 라고 자식에 닿는 길까지 막지 않는다.
1921
+
1922
+ ```tsx
1923
+ const [selectedId, setSelectedId] = useState<string>();
1924
+
1925
+ <SExpansionList>
1926
+ {/* 하위가 없는 항목(전체·미분류)은 SExpansionItem 이 아니라 SListItem 이다 */}
1927
+ <SListItem title="전체" selected={selectedId === 'all'} onClick={() => setSelectedId('all')} />
1928
+ <SExpansionItem title="조직">
1929
+ <SListItem title="영업팀" selected={selectedId === 'sales'} onClick={() => setSelectedId('sales')} />
1930
+ </SExpansionItem>
1931
+ </SExpansionList>
1932
+ ```
1933
+
1934
+ - `clickable` 은 리스트가 자식 `SListItem` 에 기본으로 켜 준다 — `clickable` 없이 `selected` 만 주면 표시가 안 나오는 함정(§3-7-6)을 막는 값이다.
1935
+ - **하위를 가지지 않는 항목은 `SExpansionItem` 이 아니라 `SListItem`** 으로 둔다. 펼칠 것이 없는데 펼침 항목으로 만들면 화살표만 남는다.
1133
1936
 
1134
1937
  #### 3-7-8. SCard vs SSectionHeaderCard
1135
1938
 
@@ -1142,6 +1945,46 @@ const columns: STableColumn[] = [
1142
1945
  - **카드 안에 카드를 겹치지 않는다.** 섹션 안을 더 나눠야 하면 `SDivider` 로 끊거나(§3-6) 섹션을 둘로 분리한다.
1143
1946
  - 안쪽 여백은 `SSectionHeaderCard` 의 `padding` prop 으로 준다 — `p-sd-*` 를 직접 주지 않는다 (§2-2).
1144
1947
 
1948
+ **카드가 자기 안에서 확정하는 액션을 가지면 하단 버튼을 `children` 에 직접 두지 않는다.** 두 카드 모두 모달·드로어와 같은 하단 액션 영역을 갖는다 — 주 액션은 `button`, 보조 버튼은 `footerLeft` 로 넘긴다(§3-3-4 와 같은 규칙). 배경·상단 구분선·좌우 여백·양끝 분리가 컴포넌트 규칙대로 잡히고, 좌우 끝이 헤더·본문과 맞는다. `footerLeft` 는 슬롯이라 `SButton` 을 직접 배치하며 푸터와 같은 크기를 명시한다(§3-5-2).
1949
+
1950
+ ```tsx
1951
+ {/* 섹션 안에서 바로 수정·저장하는 인라인 폼 섹션 (§4-4) */}
1952
+ <SSectionHeaderCard
1953
+ title="배송지"
1954
+ marker
1955
+ footerLeft={<SButton color="neutral" outline size="md" label="취소" onClick={reset} />}
1956
+ button={{ label: '저장', onClick: save }}
1957
+ >
1958
+ <SKeyValueTable … />
1959
+ </SSectionHeaderCard>
1960
+ ```
1961
+
1962
+ **카드 푸터만 크기를 고른다 — `footerSize`.** 모달·드로어·팝업의 푸터는 `md` 고정이지만(§3-5-2), 카드는 화면에서 차지하는 자리가 둘로 갈린다.
1963
+
1964
+ | 카드가 놓인 자리 | `footerSize` |
1965
+ | --- | --- |
1966
+ | 카드가 **화면의 주 액션**을 쥔다 (한 폭을 차지하는 상세·폼 섹션) | `md` — 기본값이라 주지 않는다 |
1967
+ | 한 화면에서 **부분만 확정한다** (옆 패널·분할 영역처럼 화면의 일부를 맡는 카드) | `sm` |
1968
+
1969
+ - **`sm` 은 카드에만 있다.** 그 카드가 모달·팝업 안에 들어 있어도 같다 — 모달 자신의 푸터는 여전히 `md` 다.
1970
+ - **`footerSize="sm"` 이면 `footerLeft` 슬롯 버튼도 `size="sm"` 으로 맞춘다.** 슬롯은 타입이 못 막아서, 한쪽만 `md` 로 남으면 좌우 버튼 높이가 갈린 푸터가 된다.
1971
+ - 여백은 `md`·`sm` 이 같다 — 푸터 패딩 토큰이 한 벌이라 **바뀌는 것은 버튼 크기뿐**이다. 여백을 직접 주지 않는다.
1972
+
1973
+ ```tsx
1974
+ {/* 화면 오른쪽 패널만 확정하는 카드 — 좌우 버튼 크기를 footerSize 에 맞춘다 */}
1975
+ <SSectionHeaderCard
1976
+ title="옵션 템플릿"
1977
+ marker
1978
+ footerSize="sm"
1979
+ footerLeft={<SButton color="danger" outline size="sm" icon="remove" label="템플릿 삭제" onClick={remove} />}
1980
+ button={{ label: '저장', onClick: save }}
1981
+ >
1982
+ …
1983
+ </SSectionHeaderCard>
1984
+ ```
1985
+
1986
+ **페이지 전체를 확정하는 액션은 카드 푸터가 아니라 페이지 하단에 둔다.** 카드 푸터는 **그 카드 안에서 닫히는 액션**의 자리다 — 여러 섹션을 한 번에 저장하는 버튼을 마지막 카드의 푸터에 넣으면 그 카드에만 걸리는 액션으로 읽힌다. 이때는 §4-4 처럼 카드 밖 하단 줄에 둔다.
1987
+
1145
1988
  #### 3-7-9. SLinearProgress vs SCircleProgress
1146
1989
 
1147
1990
  | 판별 | 사용 |
@@ -1166,8 +2009,12 @@ const columns: STableColumn[] = [
1166
2009
 
1167
2010
  - **기본은 `SKeyValueTable` 이다** (§4-2). 조건이 대여섯 개 이하로 고정이면 표로 펼쳐 두는 편이 한눈에 읽힌다.
1168
2011
  - `SChipFilter` 는 조건을 **칩 한 줄**로 접고, "필터 추가" 로 필요한 것만 꺼내 쓰게 한다. 칩을 누르면 편집 팝오버가 열리고, 날짜는 프리셋(오늘·지난 7일·사용자 지정)으로 고른다. 조건 후보가 많은 목록 화면에서 필터가 화면을 세로로 잡아먹는 것을 막는 용도다.
1169
- - 검색 실행 시점이 다르다 — `SKeyValueTable` 필터는 앱이 검색 버튼을 직접 놓지만, `SChipFilter` 는 편집 팝오버가 닫히거나 "검색" 을 누를 때 `onSearch` 가 값 맵과 함께 호출된다. 값이 바뀌지 않았으면 호출되지 않는다.
1170
- - 함께 걸어야 하는 조건(예: 기간 중 하나는 필수)이 있으면 `fields` 를 그룹으로 넘긴다. 규칙을 못 채운 동안 경고 툴팁이 떠 있고 `onSearch` 가 막힌다.
2012
+ - 검색 실행 시점이 다르다 — `SKeyValueTable` 필터는 앱이 검색 버튼을 직접 놓지만, `SChipFilter` 는 편집 팝오버가 닫히거나 "검색" 을 누를 때 `onSearch` 가 값 맵과 함께 호출된다. 값이 바뀌지 않았으면 호출되지 않는다. **단 마운트 후 첫 호출은 값이 처음 그대로여도 나간다** — 마운트 시 자동 조회하지 않는 화면(다이얼로그 등)에서 조건을 하나도 넣지 않고 누른 첫 "검색" 이 막히면 안 되기 때문이다. 그래서 **마운트 시 조회할지 말지는 앱이 정한다** — `SChipFilter` 는 마운트만으로 `onSearch` 를 부르지 않는다.
2013
+ - **keyword 칩은 입력창에 남은 텍스트까지 조회에 넣는다.** Enter 로 담지 않고 팝오버를 닫아도(="검색" 클릭·바깥 클릭·Esc·다른 칩으로 전환) 그 텍스트를 키워드로 확정한 뒤 조회한다. 앱이 따로 확정시킬 일은 없다.
2014
+ - **노출할 칩을 앱이 계산하지 않는다.** 바에 놓이는 것은 `fixed`·`required` 필드와 **`value` 에 값이 들어 있는 필드**다. 노출 목록을 밖에서 넘기는 prop 은 없다. 쿼리스트링·서버 상태에서 조건을 복원하는 목록 화면도 **`value` 만 넘기면 칩이 함께 살아나고**, 그 조건을 빼면 칩도 함께 빠진다 — 칩과 조회 조건이 어긋날 자리가 없다. "필터 추가" 로 꺼낸 필터는 값을 넣기 전에도 자리를 지키지만, "검색 초기화" 를 누르거나 화면을 다시 그리면 사라진다. 걸린 조건이 없으니 문제되지 않는다.
2015
+ - **후보에 상하 관계가 있으면 `select-depth`·`select-multi-depth` 다.** 분류>세부분류처럼 묶어서 보여야 할 후보를 평평한 `select`·`select-multi` 에 늘어놓지 않는다. 계층은 `options` 의 `children` 으로 만들고, `children` 을 가진 항목은 **그 자체로 고를 수 없는 그룹 헤더**라 **값에는 언제나 리프만 담긴다** — 그룹의 `value` 가 값 맵에 들어오는 경로는 없으니 조회 파라미터를 짤 때 리프만 온다고 보면 된다. `select-depth` 의 헤더는 라벨 전용이고, `select-multi-depth` 의 헤더는 체크박스로 그 아래 리프를 한 번에 토글한다(일부만 골랐으면 부분 선택으로 뜨고 `(고른 수/전체)` 가 붙는다). 그룹 아래가 전부 비활성이면 어미 헤더도 함께 잠기므로, 앱이 `disabled` 를 어미에 다시 달아 줄 필요가 없다.
2016
+ - **`fixed`·`required` 이면서 후보가 하나뿐인 `select` 계열은 자동으로 선택된다.** 고를 여지가 없는 목록이라 컴포넌트가 그 값을 채우고, 사용자가 고르지 않아도 `onValueChange` 가 그 값과 함께 호출된다 — 마운트 직후부터 그 조건이 값 맵에 들어 있다고 보고 조회를 짠다. 옵션이 API 응답이라 늦게 도착해도 도착한 시점에 채워지므로, 앱이 따로 채워 넣을 필요가 없다. 사용자가 그 값을 지우면 다시 채우지 않는다. **뺄 수 있는 필터는 후보가 하나여도 채우지 않는다** — 처음에 바에 없는 필터라, 채우면 보이지도 지울 수도 없는 조건이 검색에 걸린다(`defaultValue` 를 `fixed`·`required` 로 제한하는 것과 같은 이유). 후보가 하나뿐인 조건을 반드시 걸어야 하면 그 필드를 `fixed` 나 `required` 로 준다.
2017
+ - **`fields` 는 항상 그룹 배열이다.** 묶을 것이 없어도 `[{ fields: [...] }]` 로 한 겹 감싼다. 함께 걸어야 하는 조건(예: 기간 중 하나는 필수)이 있으면 그 필드들만 별도 그룹으로 떼어 `rule` 을 준다 — 규칙을 못 채운 동안 경고 툴팁이 떠 있고 `onSearch` 가 막힌다. 그룹 앞 구분선은 `divider` 로 켠다. 검증 단위와 구분선은 별개라, 묶어서 검증만 하고 싶으면 `divider` 를 주지 않는다.
1171
2018
 
1172
2019
  #### 3-7-12. 이미지 — SImage
1173
2020
 
@@ -1177,13 +2024,69 @@ const columns: STableColumn[] = [
1177
2024
  - **크기는 `ratio` 와 너비로 준다.** `ratio` 기본값이 `1`(정사각형)이라 너비만 주면 정사각형이 된다. `className`/`style` 로 높이를 직접 주면 그쪽이 이기고 `ratio` 는 무시된다.
1178
2025
  - 상자를 채우는 방식은 `fit`(기본 `cover`), 붙는 위치는 `position` 이다. 안쪽 `<img>` 에 네이티브 속성이 필요하면 `imgProps` 로 넘긴다 — 목록에서 고스트 드래그를 막는 `imgProps={{ draggable: false }}` 가 대표적이다.
1179
2026
  - 썸네일이 많은 목록에서는 `loadingShowDelay` 를 준다. 캐시된 이미지가 즉시 로드될 때 스피너가 한 프레임 번쩍이는 것을 막는다.
2027
+ - **썸네일이 작아 내용을 알아볼 수 없으면 `previewOnHover` 를 켠다.** 호버하면 큰 이미지가 포털로 뜬다 — 상품 목록에서 사진을 확인하려고 상세 화면까지 들어가는 일을 없앤다. **툴팁이나 팝오버로 직접 만들지 않는다**: 미리보기는 마우스를 통과시켜 아래 행의 클릭을 가로채지 않고, 원본의 로딩·실패도 스스로 표현한다.
2028
+ - 목록 썸네일과 원본 URL 이 다르면 원본을 `previewSrc` 로 준다. 주지 않으면 썸네일을 그대로 확대해서 흐릿하다.
2029
+ - **뜨는 자리를 앱이 정하지 않는다.** 미리보기는 썸네일 한가운데에서 오른쪽 아래로 펼쳐지고, 창 밖으로 넘치면 넘친 만큼만 왼쪽·위로 밀린다(반대편으로 뒤집히지 않는다). 방향을 고르는 prop 은 없다.
2030
+ - 크기는 `previewSize` 로 줄일 수 있고 **상한은 200px** 이다. 그보다 큰 값을 줘도 200 으로 잘린다 — 미리보기가 화면을 덮지 않게 하는 규격이다.
2031
+ - 미리보기 상자는 정사각형이고 이미지는 `contain` 으로 들어간다 — 세로로 긴 이미지도 잘리지 않는다. `ratio`·`fit` 은 썸네일 쪽에만 적용된다.
1180
2032
 
1181
2033
  ```tsx
1182
2034
  ✅ <SImage src={item.thumbnailUrl} alt={item.name} style={{ width: 64 }} /> {/* 정사각 썸네일 */}
1183
2035
  ✅ <SImage src={banner} ratio={16 / 9} className="w-full" /> {/* 가로형 배너 */}
2036
+ ✅ <SImage src={item.thumbnailUrl} previewOnHover previewSrc={item.imageUrl} {/* 호버로 원본 확인 */}
2037
+ alt={item.name} style={{ width: 64 }} />
1184
2038
 
1185
2039
  ❌ <img src={item.thumbnailUrl} /> {/* 로딩·실패 상태가 없다 */}
1186
2040
  ❌ {loading ? <SCircleProgress indeterminate /> : <SImage src={url} />} {/* SImage 가 이미 한다 */}
2041
+ ❌ <STooltip content={<img src={item.imageUrl} />}> {/* previewOnHover 를 쓴다 */}
2042
+ <SImage src={item.thumbnailUrl} />
2043
+ </STooltip>
2044
+ ```
2045
+
2046
+ #### 3-7-14. 로고 — SLogo
2047
+
2048
+ 셀메이트 워드마크는 `<svg>` 를 직접 붙이지 않고 `SLogo` 를 쓴다. **`SIcon` 과 같은 손맛(`name`·`size`·`color`)이지만 두 가지가 다르다.**
2049
+
2050
+ - **`size` 는 높이다.** 로고는 가로로 긴 워드마크라 정사각으로 그리면 찌그러진다 — 폭은 비율에서 저절로 나오므로 주지 않는다.
2051
+ - **색을 주지 않으면 브랜드색이다.** `SIcon` 과 달리 currentColor 를 상속하지 않는다 — 로고가 주변 글자색을 따라 물들면 브랜드 색이 화면마다 달라진다. **비활성처럼 물러나야 하는 자리에서만** `color` 를 준다. 주면 워드마크 전체가 그 한 색이 된다.
2052
+ - **담고 있는 로고는 셀메이트 워드마크 하나다.** 서비스별 표기(WMS · Account · CRM …)는 그 오른쪽에 **앱이 자기 텍스트나 자기 로고로 붙인다** — 서비스가 늘어날 때마다 디자인 시스템을 다시 배포하지 않으려는 것이다.
2053
+ - **`label` 은 로고가 그 자리의 유일한 이름일 때만 준다**(로그인 화면·상단바). 옆이나 아래에 서비스 이름이 이미 적혀 있으면(런처 목록의 한 줄) 주지 않는다 — 같은 이름이 두 번 읽힌다.
2054
+
2055
+ ```tsx
2056
+ ✅ <SLogo size={20} /> {/* 상단바 로고 — 브랜드색 */}
2057
+ ✅ <SLogo size={32} label="Sellmate" /> {/* 로고가 그 자리의 유일한 이름일 때 */}
2058
+ ✅ <span className="inline-flex items-center gap-sd-2"> {/* 서비스별 표기는 앱이 잇는다 */}
2059
+ <SLogo size={12} />
2060
+ <span className="typo-body-xs-default text-(--sys-color-fg-success)">WMS</span>
2061
+ </span>
2062
+ ✅ <SLogo size={12} color="grey_45" /> {/* 아직 못 쓰는 서비스 */}
2063
+
2064
+ ❌ <svg viewBox="0 0 276 40">…</svg> {/* 워드마크를 손으로 붙이지 않는다 */}
2065
+ ❌ <SLogo size={24} style={{ width: 120 }} /> {/* 폭은 비율이 정한다 — 찌그러진다 */}
2066
+ ❌ <SLogo color="grey_45" /> {/* 물러날 이유가 없으면 브랜드색 그대로 둔다 */}
2067
+ ```
2068
+
2069
+ #### 3-7-13. 숫자 — SNumberInput 의 min·max 는 검증 경계다
2070
+
2071
+ `min`·`max` 는 `<input type="number">` 의 그것과 같다 — **값을 고쳐 쓰지 않는 검증 경계**다. 범위를 벗어나면 blur·제출 시 에러 상태가 서고, 사용자가 친 값은 그대로 남아 앱의 범위 가드에 도달한다.
2072
+
2073
+ - **문구를 앱이 대지 않아도 된다.** `rules` 도 `SForm` 도 없는 화면에서 `min`·`max` 만 주면 DS 가 「1~99 사이로 입력해 주세요.」 같은 기본 안내를 자동으로 붙인다. 범위 이탈이 아무 표시 없이 지나가는 경로가 없다.
2074
+ - **화면의 말로 바꾸려면 `rules` 를 준다.** 「우선순위는 1부터」처럼 그 화면에서만 통하는 문구가 있으면 `rules` 가 DS 기본 문구를 이긴다. 앱이 직접 준 `errorMessage` 도 마찬가지다.
2075
+ - **`SForm` 안에서는 범위 이탈이 제출을 막는다.** `rules` 를 따로 걸지 않아도 그렇다 — 네이티브 폼과 같다.
2076
+ - **빈 칸(`null`)은 범위 판정 대상이 아니다.** 필수 입력은 `min` 이 아니라 `rules` 로 막는다.
2077
+ - **경계로 붙여도 사용자가 놀라지 않는 값에서만 `clampOnBlur` 를 켠다.** 켜면 blur 시 값이 경계로 **말없이 바뀌고** `onValueChange` 로 되쏘아진다 — 그래서 앱의 범위 가드에는 범위 밖 값이 도달하지 않는다. 재고 조정 수량처럼 보정이 자연스러운 자리에만 쓰고, 업무 규칙(최소 주문 수량·우선순위 시작값)에는 쓰지 않는다.
2078
+ - 스테퍼(`showButton`)와 위/아래 화살표는 `clampOnBlur` 와 무관하게 언제나 `min`·`max` 를 한계로 삼는다.
2079
+
2080
+ ```tsx
2081
+ ✅ <SNumberInput label="우선순위" min={1} max={99} value={v} onValueChange={setV} />
2082
+ {/* 0 을 넣고 blur 하면 0 이 남고 에러가 선다 — 저장 가드의 v < 1 이 그대로 걸린다 */}
2083
+
2084
+ ✅ <SNumberInput label="우선순위" min={1} max={99} value={v} onValueChange={setV}
2085
+ rules={[v => (typeof v === 'number' && (v < 1 || v > 99) ? '우선순위는 1부터 99까지입니다.' : true)]} />
2086
+ {/* 문구만 화면의 말로 바꾼다 */}
2087
+
2088
+ ❌ <SNumberInput label="우선순위" min={1} clampOnBlur value={v} onValueChange={setV} />
2089
+ {/* 0 이 1 로 바뀐 뒤 도착하므로 저장 직전의 v < 1 가드는 영원히 걸리지 않는다 */}
1187
2090
  ```
1188
2091
 
1189
2092
  ---
@@ -1194,6 +2097,8 @@ const columns: STableColumn[] = [
1194
2097
  >
1195
2098
  > **아래 레시피는 §2-0 조합 문법으로 유도된 결과다.** 여기 없는 화면(대시보드·설정·마법사 등)을 만들 때는 임의로 짜지 말고 §2-0 의 층 구조·포함 규칙·블록 순서로 직접 유도한다.
1196
2099
  >
2100
+ > **레시피 코드의 `./AppShell` 은 §4-1 의 셸, `./api`·`./router` 는 앱의 데이터 계층·라우터 자리다.** 각 레시피는 Storybook `Recipes/…` 에서 실제로 띄워 볼 수 있고, 아래에 적힌 분기(칩 필터·섹션 나눔 등)의 변형 코드도 거기 있다.
2101
+ >
1197
2102
  > **핵심 원칙 — 표 형태의 정보는 `SKeyValueTable` 로 만든다.** 필터·등록/수정 폼·상세 정보가 모두 여기 해당한다.
1198
2103
  > `SField` 컨트롤을 `div` 로 직접 나열해 폼을 만들지 않는다.
1199
2104
  > (필터만 예외가 하나 있다 — 조건 후보가 많아 골라 붙이게 해야 하면 `SChipFilter` 다. §3-7-11)
@@ -1201,7 +2106,11 @@ const columns: STableColumn[] = [
1201
2106
  ### 4-1. 앱 셸 (모든 페이지 공통)
1202
2107
 
1203
2108
  ```tsx
1204
- import { SLayout, SGnb, SPage, type SGnbMenuItem, type SPageHeaderProps } from 'sellmate-design-system-react';
2109
+ import type { ReactNode } from 'react';
2110
+ import {
2111
+ SLayout, SGnb, SPage, SPageHeader,
2112
+ type SGnbMenuItem, type SPageHeaderProps, type SPageContentHeight, type SPageContentWidth, type SPagePadding,
2113
+ } from 'sellmate-design-system-react';
1205
2114
 
1206
2115
  const MENU: SGnbMenuItem[] = [
1207
2116
  { label: '주문', value: 'orders', icon: 'bill' },
@@ -1213,25 +2122,34 @@ export default function AppShell({
1213
2122
  header,
1214
2123
  scrollEndSpacing,
1215
2124
  contentHeight,
2125
+ contentWidth,
2126
+ padding,
1216
2127
  }: {
1217
- children: React.ReactNode;
2128
+ children: ReactNode;
1218
2129
  header?: SPageHeaderProps;
1219
2130
  scrollEndSpacing?: boolean;
1220
2131
  contentHeight?: SPageContentHeight;
2132
+ contentWidth?: SPageContentWidth;
2133
+ padding?: SPagePadding;
1221
2134
  }) {
1222
2135
  return (
1223
2136
  <SLayout type="box" header="fix">
1224
2137
  {/* type/header/folded 는 SLayout 에만 준다 — SGnb 는 context 에서 읽는다 */}
1225
2138
  <SGnb items={MENU} value={current} onValueChange={navigate} logo={<Logo />} />
1226
- {/* 콘텐츠 패딩은 SPage 가 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다 */}
1227
- {/* 스크롤 끝 여백도 SPage 가 넣는다. 끄는 건 페이지네이션 있는 목록뿐이라 페이지가 정한다 */}
1228
- {/* 본문이 남은 높이를 채우는 목록 페이지는 contentHeight="fill" 을 넘긴다 (§4-2) */}
1229
- {/* header 는 페이지마다 달라 AppShell 이 그대로 받아 넘긴다 — 페이지 제목은 여기서 만들지 않는다 */}
2139
+ {/* 페이지 제목은 SPage 앞에 형제로 둔다 — SLayout 이 헤더부터 뒤를 한 열로 묶는다.
2140
+ header 는 페이지마다 달라 AppShell 이 그대로 받아 넘긴다 — 여기서 만들지 않는다 */}
2141
+ {header && <SPageHeader {...header} />}
2142
+ {/* 콘텐츠 패딩은 SPage 가 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다.
2143
+ 가장자리까지 차는 블록이 맨 위에 서는 페이지만 padding="none" 으로 끈다 (§4-2 칩 필터) */}
2144
+ {/* 높이 모드는 페이지가 정한다 — 대부분 contentHeight="fill" 이다 (§2-2) */}
2145
+ {/* 스크롤 끝 여백도 SPage 가 넣는다. 페이지가 실제로 스크롤되는 화면에서만 켠다 */}
2146
+ {/* 가로 폭 모드도 페이지가 정한다 — 넓은 창에서 본문을 늘릴지 최소 폭으로 세울지 */}
1230
2147
  <SPage
1231
2148
  background="frame"
1232
2149
  scrollEndSpacing={scrollEndSpacing}
1233
2150
  contentHeight={contentHeight}
1234
- header={header}
2151
+ contentWidth={contentWidth}
2152
+ padding={padding}
1235
2153
  >
1236
2154
  {children}
1237
2155
  </SPage>
@@ -1240,20 +2158,55 @@ export default function AppShell({
1240
2158
  }
1241
2159
  ```
1242
2160
 
1243
- **페이지는 `AppShell` 을 직접 호출하며 자기 `header` 를 넘긴다** — SPage 는 셸 안에 하나뿐이므로, 페이지 제목이 페이지마다 다르다는 사실은 이렇게 프레임 컴포넌트를 통해 흘려보낸다(§4-2·§4-3·§4-4 참고).
2161
+ **셸을 마운트하는 자리가 뷰포트 높이를 준다 — 앱 루트에 `h-screen` 을 둔다.** `SLayout` 은 부모 높이를 채우도록만 되어 있어서, 마운트 지점의 높이가 `auto` 면 100% 가 풀려 셸이 내용 높이로 줄어든다. 그러면 GNB 가 화면 바닥까지 내려오지 않고, `SPage` 안에서 일어나야 할 스크롤이 문서(브라우저 창) 스크롤이 되어 `contentHeight="fill"` 도 최소 너비의 가로 스크롤 규칙도 함께 무너진다. `html`·`body` 에 전역 CSS 를 걸 필요는 없다 — 루트 한 겹이면 된다.
2162
+
2163
+ ```tsx
2164
+ // 앱 진입점 (main.tsx) — 셸이 창 높이를 받는 자리는 여기 하나다
2165
+ createRoot(document.getElementById('root')!).render(
2166
+ <div className="h-screen">
2167
+ <AppShell header={{ variant: 'bar', title: '주문 목록' }}>…</AppShell>
2168
+ </div>,
2169
+ );
2170
+ ```
2171
+
2172
+ **페이지는 `AppShell` 을 직접 호출하며 자기 `header` 를 넘긴다** — 셸은 앱에 하나뿐이므로, 페이지 제목이 페이지마다 다르다는 사실은 이렇게 프레임 컴포넌트를 통해 흘려보낸다(§4-2·§4-3·§4-4 참고).
2173
+
2174
+ **자식 순서는 `SGnb` → `SPageHeader` → `SPage` 다.** `SLayout` 은 `SPageHeader` 자식을 보면 **그 자식부터 뒤를** 하나의 페이지 열로 묶어 헤더를 페이지 위에 고정한다 — 스크롤도 페이지 패딩도 그 아래 `SPage` 안에서만 일어난다. 그래서 순서가 규칙이다: 헤더를 `SGnb` 앞에 두면 GNB 까지 페이지 열로 딸려 들어가고, `SPage` 의 `children` 안에 넣으면 본문 패딩 안으로 들어가 스크롤과 함께 밀려 올라간다.
1244
2175
 
1245
- **페이지 헤더(`header`) 사용 규칙 — 이 앱에서는 값이 아니라 값의 일관성이 규칙이다.**
2176
+ **`scrolled` 를 직접 주지 않는다.** plain 헤더의 그림자는 같은 열의 `SPage` 가 올린 스크롤 상태로 켜진다 — 손으로 준 값은 그 자동 동작을 덮어써 스크롤과 어긋난 그림자를 남긴다.
1246
2177
 
1247
- - **`fix` 는 앱 전체에서 하나로 고정한다.** 어떤 페이지는 `fix: true`(바), 다른 페이지는 `fix: false`(투명) 로 섞어 쓰지 않는다. 프로젝트에서 하나를 고르면(예: 전부 `fix: true`) 모든 `header` 가 그 값을 쓴다.
2178
+ **페이지 헤더 사용 규칙 — 이 앱에서는 값이 아니라 값의 일관성이 규칙이다.**
2179
+
2180
+ - **`variant` 는 필수이고, 앱 전체에서 하나로 고정한다.** 어떤 페이지는 `"bar"`(높이 고정 + 하단 구분선), 다른 페이지는 `"plain"`(최소 높이) 로 섞어 쓰지 않는다. 프로젝트에서 하나를 고르면(예: 전부 `"bar"`) 모든 페이지 헤더가 그 값을 쓴다. **기본값이 없는 것도 이 규칙 때문이다** — 기본값이 있으면 안 준 페이지만 조용히 다른 모양이 되어 메뉴를 옮길 때마다 제목 줄이 튄다. 셸을 한 번 감싸 쓰면 지정하는 자리는 `AppShell` 한 곳이다. `SLayout` 의 `header`(`"fix" | "full"`)와는 다른 축이다 — 그쪽은 상단바가 GNB 컬럼 안에 있는지 화면 전폭인지를 정한다.
1248
2181
  - **`slot`·`onBack` 도 페이지 성격이 실제로 다른 경우가 아니면 있는 대로 통일한다.** "목록 페이지엔 없고 상세·등록 페이지엔 있다"처럼 화면 종류에 따라 갈리는 것은 허용되지만, 같은 종류의 화면끼리는 임의로 넣었다 뺐다 하지 않는다.
1249
2182
  - **`slot` 은 `ReactNode` 를 그대로 받지만, 원칙은 `size="sm"` 버튼 위주로만 채운다.** `STextLink`·복잡한 커스텀 마크업을 슬롯에 넣지 않는다 — 그 이상이 필요하면 페이지 헤더가 아니라 §4-2 의 `STableBar` 처럼 본문 쪽 액션 자리를 쓴다.
1250
2183
 
2184
+ **셸 밖의 단독 화면(로그인·온보딩 등)에서도 같은 제목 영역을 쓸 수 있다.** 이때는 페이지 열이 없으므로 `scrolled` 가 앱 몫이 된다.
2185
+
2186
+ ```tsx
2187
+ import { SPageHeader } from 'sellmate-design-system-react';
2188
+
2189
+ const [scrolled, setScrolled] = useState(false);
2190
+
2191
+ {/* 헤더는 자기 흰 면을 스스로 칠한다 — 감싸는 쪽은 본문이 놓일 면만 정하면 된다 */}
2192
+ <div className="flex h-full min-h-0 flex-col bg-[var(--sys-color-bg-frame)]">
2193
+ <SPageHeader variant="plain" title="상품 목록" subText="판매 중인 상품을 관리합니다." scrolled={scrolled} />
2194
+ {/* 스크롤을 앱이 쥐므로 scrolled 도 앱이 넣는다 — 패딩은 SPage 가 없으니 여기서 준다 */}
2195
+ <div
2196
+ className="min-h-0 flex-1 overflow-auto p-sd-16"
2197
+ onScroll={e => setScrolled(e.currentTarget.scrollTop > 0)}
2198
+ >
2199
+ …본문…
2200
+ </div>
2201
+ </div>;
2202
+ ```
2203
+
1251
2204
  **GNB 폭을 사용자가 조절하게 하려면 `SGnb` 에 `resizable` 을 준다.** 메뉴 오른쪽 경계가 조절선이 되고, 레일 폭은 고정된 채 메뉴 컬럼만 늘고 준다. 범위는 컴포넌트가 정하므로 숫자를 직접 주지 않는다.
1252
2205
 
1253
2206
  ```tsx
1254
2207
  {/* 폭을 기억해야 하면 menuWidth 를 앱이 쥐고 onMenuWidthChange 로 되받아 저장한다.
1255
2208
  초기값만 정하면 되면 defaultMenuWidth 하나로 끝난다. */}
1256
- <SGnb items={MENU} value={current} onValueChange={navigate} resizable defaultMenuWidth={240} />
2209
+ <SGnb items={MENU} value={current} onValueChange={navigate} resizable defaultMenuWidth={220} />
1257
2210
  ```
1258
2211
 
1259
2212
  `onMenuWidthChange` 는 **드래그를 놓는 순간**(또는 방향키 조작) 한 번만 온다 — 저장 로직을 그대로 붙여도 프레임마다 쓰이지 않는다. 접혀 있거나 레일 리프가 활성이라 깔 메뉴가 없으면 조절선은 나오지 않는다.
@@ -1274,19 +2227,36 @@ import { SModalOutlet } from 'sellmate-design-system-react';
1274
2227
 
1275
2228
  **최소 너비는 `SLayout` 이 보장한다 — 앱이 `min-w-*` 를 직접 주지 않는다.** 창이 최소 너비(`SLAYOUT_MIN_WIDTH`, GNB 포함한 전체 기준)보다 좁아지면 GNB·상단바는 제자리에 남고 **`SPage` 안에만 가로 스크롤이 생긴다.** 문서(브라우저 창)에는 가로 스크롤이 생기지 않는다. 그래서 셸을 감싸는 요소에 `min-width` 나 `overflow-x` 를 걸지 않는다 — 걸면 창 전체가 스크롤되어 GNB 가 화면 밖으로 밀린다.
1276
2229
 
1277
- **셸의 `SPage` 는 모든 페이지가 공유하므로, 페이지마다 달라지는 것은 페이지가 셸에 알려야 한다.** 위처럼 프레임 컴포넌트가 `scrollEndSpacing` · `contentHeight` 를 받아 그대로 넘기고, 페이지네이션이 있는 목록 페이지만 `contentHeight="fill"` 을 준다 (§4-2). 나머지 페이지는 넘기지 않으면 기본값(`auto`, 스크롤 끝 여백 켬)이 적용된다.
2230
+ **창이 최소 너비보다 넓을 때 본문을 어떻게 세울지는 `SPage` 의 `contentWidth` 가 정한다 — 폭을 직접 계산하지 않는다.** 넓은 모니터에서 본문이 끝까지 늘어나 성기어지는 화면이 있으므로 세 가지 중 하나를 고른다.
2231
+
2232
+ | `contentWidth` | 넓은 창에서 | 쓰는 곳 |
2233
+ | --- | --- | --- |
2234
+ | `"full"` (기본) | 남는 폭을 본문이 다 쓴다 | 표·목록처럼 가로를 넓게 쓸수록 정보가 더 들어가는 화면 |
2235
+ | `"fixed"` | 최소 너비일 때의 본문 폭으로 고정, 왼쪽 정렬 (남는 폭은 오른쪽) | 시안 폭 그대로 세우되 왼쪽 기준을 유지할 화면 |
2236
+ | `"fixedCenter"` | 같은 폭으로 고정, 가운데 정렬 (남는 폭은 좌우) | 폼·상세처럼 가로를 넓게 써도 얻을 것이 없는 화면 |
2237
+
2238
+ - **고정 폭에 숫자를 쓰지 않는다.** `max-w-*` 나 `mx-auto` 로 흉내 내면 GNB 폭·접힘이 반영되지 않아 시안과 어긋난다. 고정 폭은 "창이 최소 너비일 때의 본문 폭"이고 `SPage` 가 GNB 상태까지 반영해 계산한다.
2239
+ - **창이 최소 너비보다 좁아지면 세 모드가 모두 같다** — 남는 폭이 없으므로 위 문단대로 `SPage` 안에만 가로 스크롤이 생긴다.
2240
+ - 같은 열의 `SPageHeader` 는 자동으로 같은 폭·정렬을 따른다 — 제목 줄에 별도로 주지 않는다. 헤더의 흰 면·구분선은 늘 전폭이다.
2241
+ - **같은 종류의 화면끼리는 값을 통일한다** (헤더 `fix` 와 같은 이유). 목록 페이지마다 `full`·`fixedCenter` 가 섞이면 메뉴를 옮길 때마다 본문 폭이 튄다.
2242
+
2243
+ **셸의 `SPage` 는 모든 페이지가 공유하므로, 페이지마다 달라지는 것은 페이지가 셸에 알려야 한다.** 위처럼 프레임 컴포넌트가 `contentHeight` · `scrollEndSpacing` · `contentWidth` 를 받아 그대로 넘긴다.
2244
+
2245
+ **대부분의 페이지는 `contentHeight="fill"` 이다** — 본문이 창을 꽉 채우고 스크롤은 각 영역 안에서 일어나는 것이 표준이다(§2-2). 블록의 높이가 정해져 있고 그 높이가 창보다 커서 페이지 자체가 스크롤돼야 하는 화면에서만 `contentHeight="auto"`(기본값) + `scrollEndSpacing` 을 켠다.
1278
2246
 
1279
2247
  **상단바 배치는 `header` 가 정한다.** 요소 순서가 달라지므로 슬롯을 채우기 전에 어느 쪽인지부터 정한다.
1280
2248
 
1281
2249
  | `header` | 상단바 배치 | 로고 폭 | `topContent` |
1282
2250
  | --- | --- | --- | --- |
1283
2251
  | `"fix"` (기본) | `[런처 · 로고 … 폴드]` — 상단바가 GNB 컬럼 안에 있고 폴드가 컬럼 오른쪽 끝 | 내용 폭 | **렌더되지 않는다** (놓을 자리가 없다) |
1284
- | `"full"` | `[런처 · 폴드 · 로고 · topContent]` — 상단바가 화면 전폭 | **140px 고정** (런처 없으면 172px) | 로고 오른쪽 남는 폭 전체 |
2252
+ | `"full"` | `[런처 · 폴드 · 로고 · topContent … system]` — 상단바가 화면 전폭이고 `system` 은 오른쪽 끝 | **140px 고정** (런처 없으면 172px) | 로고 오른쪽 남는 폭 전체 |
1285
2253
 
1286
- - `topContent` 는 상단바 로고 오른쪽 슬롯이다. 전역 검색·계정 메뉴·알림처럼 **모든 페이지에 공통인 것만** 넣는다. 페이지별 액션은 여기가 아니라 §4-2 의 `STableBar` 로 간다.
2254
+ - `topContent` 는 상단바 로고 오른쪽 슬롯이다. 전역 검색처럼 **모든 페이지에 공통인 것만** 넣는다. 페이지별 액션은 여기가 아니라 §4-2 의 `STableBar` 로 간다.
2255
+ - **계정·알림·설정·도메인·서비스 전환은 `topContent` 에 손으로 만들지 않는다.** `header="full"` 이면 `system` 슬롯의 조각들이 상단바 오른쪽 끝으로 올라오므로, 그것들은 전부 `SGnbSystem` 이 그린다 (바로 아래 절).
1287
2256
  - 슬롯이 남는 폭을 통째로 받으므로 **정렬은 안에서 직접 잡는다** (좌측 정렬 + 우측은 `ml-auto`).
1288
2257
  - `header="full"` 에서 로고 자리는 140px 로 고정된다 — 로고 내용이 바뀌어도 `topContent` 시작점이 흔들리지 않게 하기 위함이다. 로고가 그보다 넓으면 잘리므로 이 폭에 맞춰 준비한다.
1289
- - `onLauncherClick` 을 주지 않으면 런처가 렌더되지 않고, 그 자리(버튼 + 간격)를 로고 슬롯이 이어받아 172px 가 된다. `topContent` 시작점은 런처 유무와 관계없이 같은 자리다.
2258
+ - `launcher` 를 주지 않으면 런처가 렌더되지 않고, 그 자리(버튼 + 간격)를 로고 슬롯이 이어받아 172px 가 된다. `topContent` 시작점은 런처 유무와 관계없이 같은 자리다.
2259
+ - **런처 버튼은 서비스 목록의 트리거다.** `launcher.items` 에 옮겨 갈 서비스를 **키와 주소로** 넘기면 버튼 아래로 목록이 뜨고, 고른 서비스가 새 탭에서 열린다 — 팝오버도 이동도 손으로 만들지 않는다 (§3-5-9).
1290
2260
 
1291
2261
  ```tsx
1292
2262
  <SLayout type="box" header="full">
@@ -1294,26 +2264,28 @@ import { SModalOutlet } from 'sellmate-design-system-react';
1294
2264
  items={MENU} value={current} onValueChange={navigate}
1295
2265
  logo={<Logo />}
1296
2266
  topContent={
1297
- /* 남는 폭 전체를 받는다 — 왼쪽은 그대로, 오른쪽 끝은 ml-auto */
2267
+ /* 남는 폭 전체를 받는다 — 왼쪽은 그대로, 오른쪽 끝은 ml-auto.
2268
+ 계정·알림은 여기 만들지 않는다 — system 슬롯이 상단바 오른쪽 끝에 그린다 */
1298
2269
  <div className="flex w-full items-center gap-sd-8">
1299
- <SInput value={keyword} onValueChange={setKeyword} placeholder="통합 검색" />
1300
- <SButton size="sm" color="neutral" outline label="내 계정" className="ml-auto" onClick={openAccount} />
2270
+ <SSearchInput value={keyword} onValueChange={setKeyword} onSearch={runSearch} placeholder="통합 검색" />
2271
+ <SButton size="sm" color="neutral" outline label="도움말" className="ml-auto" onClick={openHelp} />
1301
2272
  </div>
1302
2273
  }
2274
+ system={<SGnbSystem alert={{ count: unreadCount, onClick: openAlerts }} account={{ label: userName, listBox: accountPanel }} />}
1303
2275
  />
1304
2276
  <SPage background="frame">{children}</SPage>
1305
2277
  </SLayout>
1306
2278
  ```
1307
2279
 
1308
- **메뉴 목록과 함께 스크롤되면 안 되는 것은 `SGnb` 의 위아래 고정 슬롯에 둔다.** 레일과 메뉴에 각각 위(`railTop`·`menuTop`)와 아래(`railFooter`·`menuFooter`) 슬롯이 있다. 아이템이 많아 넘치면 **목록만 스크롤되고 이 슬롯들은 제자리에 남는다** — 메뉴 검색, 워크스페이스 전환, 계정 행처럼 항상 보여야 하는 것이 여기 온다. 레일 슬롯은 `useRail` 일 때만, 메뉴 슬롯은 깔 메뉴가 있을 때만 렌더된다.
2280
+ **메뉴 목록과 함께 스크롤되면 안 되는 것은 `SGnb` 의 위아래 고정 슬롯에 둔다.** 레일과 메뉴에 각각 위(`railTop`·`menuTop`)와 아래(`railFooter`·`menuFooter`) 슬롯이 있다. 아이템이 많아 넘치면 **목록만 스크롤되고 이 슬롯들은 제자리에 남는다** — 메뉴 검색, 워크스페이스 전환, 계정 행처럼 항상 보여야 하는 것이 여기 온다. 레일 슬롯은 `showRail` 일 때만, 메뉴 슬롯은 깔 메뉴가 있을 때만 렌더된다.
1309
2281
 
1310
2282
  **접으면 레일·메뉴가 통째로 빠져나가면서 그 슬롯들도 함께 사라진다.** 접힌 상태에서도 남겨야 할 것은 `foldedTop`·`foldedFooter` 로 따로 준다 — `header="fix"` 로 접혔을 때만 나타나며, 폭이 좁은 폴드 레일이므로 아이콘 버튼 하나 정도로 줄인다.
1311
2283
 
1312
2284
  ```tsx
1313
2285
  {/* 접히면 menuTop·menuFooter 가 함께 빠지므로, 폴드 레일에 남길 것만 foldedTop 으로 따로 준다 */}
1314
2286
  <SGnb
1315
- items={MENU} value={current} onValueChange={navigate} useRail
1316
- menuTop={<SInput value={keyword} onValueChange={setKeyword} placeholder="메뉴 검색" />}
2287
+ items={MENU} value={current} onValueChange={navigate} showRail
2288
+ menuTop={<SSearchInput value={keyword} onValueChange={setKeyword} placeholder="메뉴 검색" />}
1317
2289
  menuFooter={<AccountRow />}
1318
2290
  foldedTop={<SGhostButton icon="search" size="sm" ariaLabel="메뉴 검색" onClick={openSearch} />}
1319
2291
  />
@@ -1321,112 +2293,304 @@ import { SModalOutlet } from 'sellmate-design-system-react';
1321
2293
 
1322
2294
  슬롯 안쪽 여백은 **슬롯 내용이 직접 갖는다** — 컴포넌트는 자리만 잡는다(폴드 슬롯만 좁은 폭에 맞춰 가운데 정렬한다). 메뉴 폭은 `resizable` 로 바뀔 수 있으므로 슬롯 내용은 고정 폭 대신 `w-full` 로 따라가게 둔다.
1323
2295
 
2296
+ #### 서비스·도메인·알림·설정·계정 묶음 (system 슬롯)
2297
+
2298
+ **서비스 전환·도메인·알림·설정·계정처럼 "지금 어떤 자격으로 보고 있는가" 를 다루는 묶음은 `SGnbSystem` 을 `SGnb` 의 `system` 슬롯에 넣는다.** 이 조각들을 `menuFooter` 나 `topContent` 에 손으로 조립하지 않는다 — 배경·경계·구분선·행 간격이 전부 토큰으로 정해져 있어 직접 만들면 어긋난다.
2299
+
2300
+ **놓이는 자리는 `header` 가 정한다. 앱이 넘기는 것은 양쪽 모두 똑같다.**
2301
+
2302
+ | `header` | system 이 서는 자리 | 형태 |
2303
+ | --- | --- | --- |
2304
+ | `"fix"` (기본) | GNB 맨 아래 (접히면 `foldedFooter` 아래 레일 바닥) | 세로 판 — 위에서부터 `actions` · 도메인 · `[알림 \| 설정]` · 계정 |
2305
+ | `"full"` | 전폭 상단바 **오른쪽 끝** | 가로 한 줄 — `actions` \| 알림 \| 설정 \| 도메인·계정 |
2306
+
2307
+ `system` 은 다른 슬롯과 달리 **펼침·접힘 양쪽을 혼자 맡는다** — `header="fix"` 면 펼쳤을 때 메뉴 컬럼 바닥에, 접혔을 때 레일 바닥에 여백 없이 붙는다. 그래서 `menuFooter`/`foldedFooter` 로 나눠 줄 필요가 없다.
2308
+
2309
+ ```tsx
2310
+ <SGnb
2311
+ items={MENU} value={current} onValueChange={navigate} logo={<Logo />}
2312
+ system={
2313
+ <SGnbSystem
2314
+ /* 상단에 쌓는 행 — SSystemActionButton 의 props 배열이다 (§3-5).
2315
+ color 는 넘기지 않고, 계정 행은 여기가 아니라 아래 account 로 준다 */
2316
+ actions={[{ type: 'select', label: '서비스 전환', option: { icon: 'robot' }, onClick: openServices }]}
2317
+ domain={domainName}
2318
+ alert={{ count: unreadCount, onClick: openAlerts }}
2319
+ setting={{ onClick: openSettings }}
2320
+ /* 계정 행. listBox 를 주면 눌렀을 때 계정 패널이 뜬다 (§3-5-8) */
2321
+ account={{
2322
+ label: userName,
2323
+ listBox: {
2324
+ email: user.email, name: user.name, authority: user.authority,
2325
+ accountSetting: { onClick: openAccountSetting },
2326
+ language: { value: currentLanguage, onClick: openLanguage },
2327
+ logout: { onClick: signOut },
2328
+ },
2329
+ }}
2330
+ />
2331
+ }
2332
+ />
2333
+ ```
2334
+
2335
+ - **`color`·`folded` 를 주지 않는다.** `system` 슬롯에 있으면 GNB 가 자기 색과 형태를 내려준다. 이 둘은 GNB 밖에서 단독으로 쓸 때만 준다.
2336
+ - **놓이는 자리는 prop 이 아니다.** 세로 판이냐 상단바 한 줄이냐는 GNB 의 `header` 가 정한다(fix → 판, full → 상단바). 고르는 prop 은 없으니 찾지 않는다 — 자리와 어긋난 형태를 세울 이유가 있는 화면이 없다.
2337
+ - **각 조각은 해당 prop 을 줄 때만 나타난다 — 어떤 조합이어도 그것만으로 판이 성립한다.** 계정만, 알림만, 설정만, 알림+계정 … 서비스가 쓰는 것만 넘긴다. 조각을 빼려고 빈 문자열이나 빈 객체를 넣지 않는다(`setting={{}}` 은 "설정 버튼을 쓴다" 는 뜻이다).
2338
+ - 구분선은 **위아래 양쪽에 내용이 있을 때만** 그어진다 — `actions` 만 넘겨도 바닥에 뜻 없는 선이 남지 않는다.
2339
+ - 알림·설정은 **혼자 서면 폭을 다 먹고**, 둘이 나란히 서면 알림이 남는 폭을·설정이 제 폭을 갖는다. 어느 쪽이 빠져도 왼쪽 끝은 위아래 행과 맞으므로 앱이 정렬을 맞출 일이 없다.
2340
+ - **켜진 조각이 하나도 없으면 아무것도 렌더되지 않는다.** 조건부로 조각이 다 빠지는 화면에서도 GNB 바닥에 빈 판이 남지 않는다.
2341
+ - **`header="fix"` 를 접으면 알림·계정 아이콘만 남는다** — `actions`·`domain`·`setting` 은 48px 폭에 놓을 자리가 없어 렌더되지 않고, 알림 개수는 점 배지가 된다. **알림도 계정도 안 쓰는 조합이면 접힌 패널은 아예 렌더되지 않는다** — 접힌 상태에서도 반드시 눌러야 하는 것이 그 둘 밖에 있으면 `foldedTop`/`foldedFooter` 로 따로 준다.
2342
+ - **`header="full"` 은 접어도 상단바가 남으므로 조각들도 그대로 남는다.** 대신 가로로 자리가 넉넉하지 않아 알림·설정이 라벨을 벗고 아이콘(알림은 개수까지)만 남는다 — 이건 컴포넌트가 알아서 하므로 앱이 라벨을 지우지 않는다.
2343
+ - **`account` 를 쓰면 `account.listBox` 가 필수다** (§3-5-8) — 계정 행을 누르면 이메일·이름·권한과 계정 설정·언어 변경·로그아웃이 예외 없이 뜬다. 뜨는 방향은 판이 놓인 자리가 정하므로 앱이 넘기지 않는다 — 판이면 계정 행 위로, 상단바면 아래로 펼치고, 접힌 레일도 판과 같다. `account.onClick` 은 패널을 여는 것 말고 따로 할 일(로깅 등)이 있을 때만 준다.
2344
+ - **알림 개수는 `alert.count` 하나로 표현한다.** 1 이상이면 벨이 울리는 아이콘과 강조색으로 바뀐다 — 색은 GNB 색이 정하므로 앱이 직접 칠하지 않는다. **`resizable` 로 메뉴를 기본 폭보다 좁게 줄이면 개수를 적을 가로가 모자라 접힘과 같은 점 배지로 바뀐다** — 컴포넌트가 알아서 하므로 앱이 폭을 보고 `count` 를 지우지 않는다.
2345
+ - **알림을 눌러 뜨는 목록은 `alert.listBox` 에 내용만 넘긴다** — `SPortal` 을 따로 띄워 알림 버튼에 붙이지 않는다. 목록에 무엇을 쌓을지는 서비스마다 다르지만 **뜨는 자리는 늘 같아야 하므로**, 방향(판·접힌 레일은 버튼 오른쪽 · 상단바는 아래로 오른쪽 끝)과 바깥 클릭·ESC 닫힘, 배경·라운드·그림자는 컴포넌트가 쥔다. 여닫는 일도 알림 버튼이 하므로 **`onOpenChange` 는 그 사실을 알아야 할 때만** 받는다(연 김에 읽음 처리하는 화면 등) — 이 값으로 목록을 대신 열거나 막지 않는다. 계정 행(`account.listBox`)과 같은 규칙이다.
2346
+ - **알림에 안내 툴팁을 띄우려면 `alert.tooltip` 에 문구만 넘긴다** — `STooltip` 을 직접 세워 알림 버튼에 붙이지 않는다. 색(판이 light 면 기본 · 그 밖은 강조)과 방향(판·접힌 레일은 오른쪽 · 상단바는 아래)은 자리가 정하고, 스스로 사라지지 않다가 **알림을 누르거나 버튼에서 커서를 뺄 때** 닫힌다.
2347
+ - **닫힘은 `onTooltipDismiss` 로 받아 `tooltip` 을 비워 둔다.** 같은 값을 계속 넘기는 동안에는 다시 뜨지 않고, **비웠다 다시 넘기면 문구가 같아도 새 안내로 뜬다** — 새 알림마다 같은 안내를 다시 띄우는 화면이 막히지 않는다.
2348
+ - 재노출 신호는 문구가 **없음 → 있음** 으로 바뀌는 것이다. 한 이벤트 핸들러 안에서 비웠다 곧바로 넣으면 렌더가 한 번으로 묶여 신호가 되지 않는다 — 닫을 때 비우고, 다시 필요할 때 넘긴다.
2349
+ - 컴포넌트가 아는 범위는 그 자리가 서 있는 동안뿐이다(새로고침하거나 GNB 를 접어 알림이 다른 자리로 옮겨 서면 새로 마운트된다). **그 사용자에게 두 번 다시 보이지 않아야 하면 앱이 닫힘을 저장해 두고 `tooltip` 을 넘기지 않는다.**
2350
+ - **`header="fix"` + `showRail` 에서 하위 메뉴가 없는 레일 아이템이 활성이면 메뉴 컬럼째 사라지므로 이 패널도 함께 빠진다.** 항상 보여야 하는 계정·알림이라면 그런 레일 리프를 두지 않는다 — 또는 `header="full"` 로 두면 상단바에 남는다(그런 레일 리프가 있으면 GNB 가 `header` 를 `full` 로 강제한다).
2351
+
1324
2352
  ### 4-2. 목록 페이지 (필터 + 테이블)
1325
2353
 
1326
- 구조: **페이지 헤더(`AppShell` 의 `header` prop, 가이드 등 부가 액션은 slot) → 필터(`SKeyValueTable`) → `STableBar` → `STable`**
2354
+ 구조: **페이지 헤더(`AppShell` 의 `header` prop, 가이드 등 부가 액션은 slot) → 필터(`SKeyValueTable`) → (`STableBar` + `STable`)**
1327
2355
 
1328
- 필터 자리는 `SChipFilter` 로 바꿔 놓을 수 있다 — 조건 후보가 많아 쓸 것만 골라 붙이게 하는 화면이면 그쪽이다 (§3-7-11). 나머지 골격은 같다.
2356
+ **뒤의 둘은 한 블록이라 붙여 쓴다** — 툴바는 자기 아래 표의 건수와 액션을 말하므로 사이를 벌리지 않는다. 묶는 방법과 맞닿는 변 처리는 §2-0 에 있다.
2357
+
2358
+ 필터 자리는 `SChipFilter` 로 바꿔 놓을 수 있다 — 조건 후보가 많아 쓸 것만 골라 붙이게 하는 화면이면 그쪽이다 (§3-7-11). 나머지 골격은 같지만 여백이 다르다 — **칩 필터 바는 페이지 가장자리까지 찬다.** `SPage` 에 `padding="none"` 을 주고(§4-1 의 셸이 넘긴다), 바에 `className="border-b"` 로 아래를 긋고(흰 바탕이라 흰 페이지 위에서 경계가 없다 — 선 색은 바가 토큰으로 들고 있어 두께만 준다), 그 아래 툴바+표 블록이 `p-[var(--cmp-pageBody-padding-default)]` 로 같은 토큰의 여백을 잡는다. 코드는 Storybook `Recipes/목록 페이지` 의 "칩 필터" 에 있다.
1329
2359
 
1330
2360
  액션 버튼의 위치가 핵심이다:
1331
2361
 
1332
2362
  - **페이지 제목 줄에는 이 페이지의 주요 액션을 두지 않는다.** 부가적인 것만 `header.slot` 에 `SButton size="sm"` 으로 온다 (§4-1 "페이지 헤더 사용 규칙").
1333
2363
  - **주요 액션(등록 등)은 `STableBar` 의 `rightActions`** 에 둔다.
1334
2364
  - **선택 상태 액션(선택 삭제 등)은 `STableBar` 의 `actions`** 에 둔다. `actions` 슬롯은 건수 요약이 있으면 앞에 구분선(`SDivider`)을 **자동으로** 넣으므로 직접 구분선을 만들지 않는다.
1335
- - **페이지네이션이 있으면 본문이 남은 높이를 채우게 한다** — `AppShell` 에 `contentHeight="fill"` 을 넘긴다. 페이지가 통째로 스크롤되면 페이지네이션이 화면 밖으로 밀려 "여기서 끝"이 읽히지 않는다. `fill` 이면 **표만 자기 안에서 스크롤하고 페이지네이션은 하단에 고정**된다.
1336
- - 본문 루트에 `h-full min-h-0` 으로 세로 축을 잇고, 남은 높이를 먹을 `STable` 에 `min-h-0 flex-1` 을 준다. 이 사슬이 하나라도 끊기면 표가 높이를 못 잡는다.
2365
+ - **본문이 남은 높이를 채우게 한다** — `AppShell` 에 `contentHeight="fill"` 을 넘긴다(§2-2 표준). 페이지가 통째로 스크롤되면 페이지네이션이 화면 밖으로 밀려 "여기서 끝"이 읽히지 않는다. `fill` 이면 **표만 자기 안에서 스크롤하고 페이지네이션은 하단에 고정**된다.
2366
+ - 본문 루트에 `h-full min-h-0` 으로 세로 축을 잇고, 남은 높이를 먹을 `STable` 에 `min-h-0 flex-1` 을 준다. 이 사슬이 하나라도 끊기면 표가 높이를 못 잡는다. **툴바+표를 묶은 `div` 도 사슬의 한 칸이라 거기에도 `min-h-0 flex-1` 이 필요하다** — 빠뜨리기 가장 쉬운 자리다.
1337
2367
  - `fill` 에서는 페이지가 스크롤하지 않으므로 **`scrollEndSpacing` 은 무시된다** — 따로 끄지 않는다 (§2-2).
2368
+ - **정렬 가능한 컬럼은 `sortable` 로 준다.** 정렬 상태(`sort`)는 이 페이지가 들고 `onSortChange` 로 받는다 — 조회 조건이라 URL 에 실려야 한다 (§3-4).
1338
2369
 
2370
+ <!-- recipe:src/recipes/ProductListPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
1339
2371
  ```tsx
2372
+ import { useState } from 'react';
1340
2373
  import {
1341
- SButton, SKeyValueTable, STableBar, STable, STag,
1342
- type STableColumn, type SRow, type SKeyValueField,
2374
+ SButton,
2375
+ SKeyValueTable,
2376
+ STable,
2377
+ STableBar,
2378
+ STag,
2379
+ type SDateRangeValue,
2380
+ type SKeyValueField,
2381
+ type SRow,
2382
+ type STableColumn,
2383
+ type STableSort,
1343
2384
  } from 'sellmate-design-system-react';
2385
+ import AppShell from './AppShell';
2386
+ import { STATUS_OPTIONS, removeProducts, useProductList, type ProductQuery } from './api';
2387
+ import { useNavigate } from './router';
1344
2388
 
1345
2389
  /** 필터도 표다 — SKeyValueTable 로 만든다 (서비스 전용 전역 필터가 따로 있는 경우 제외) */
1346
2390
  const filterFields: SKeyValueField[][] = [
1347
2391
  [
1348
- { name: 'status', label: '상태', type: 'select',
1349
- options: { options: STATUS_OPTIONS, emitValue: true } },
1350
- { name: 'keyword', label: '검색어', type: 'input',
1351
- options: { placeholder: '상품명 / 상품코드' } },
2392
+ // 조회 조건으로 쓰는 셀렉트·날짜 피커는 clearable — 걸어 둔 조건을 풀 수 있어야 한다 (§3-7-4)
2393
+ {
2394
+ name: 'status',
2395
+ label: '상태',
2396
+ type: 'select',
2397
+ options: { options: STATUS_OPTIONS, valueAsPrimitive: true, clearable: true },
2398
+ },
2399
+ {
2400
+ name: 'keyword',
2401
+ label: '검색어',
2402
+ type: 'input',
2403
+ options: { placeholder: '상품명 / 상품코드' },
2404
+ },
1352
2405
  ],
1353
2406
  [
1354
- { name: 'period', label: '등록일', type: 'date-range-picker', tdColSpan: 3 },
2407
+ {
2408
+ name: 'period',
2409
+ label: '등록일',
2410
+ type: 'date-range-picker',
2411
+ tdColSpan: 3,
2412
+ options: { clearable: true },
2413
+ },
1355
2414
  ],
1356
2415
  ];
1357
2416
 
2417
+ // 정렬·폭은 값의 성격에서 나온다 (§3-4). 모든 컬럼에 폭을 px 로 명시한다
1358
2418
  const columns: STableColumn[] = [
1359
- // 숫자 컬럼은 전부 align: 'right' — §3-4
1360
- { name: 'id', label: 'ID', field: 'id', width: '80px', align: 'right' },
1361
- { name: 'name', label: '상품명', field: 'name' },
1362
- { name: 'stock', label: '재고', field: 'stock', width: '90px', align: 'right',
1363
- format: (v: number) => `${Number(v).toLocaleString()}개` },
1364
- { name: 'price', label: '판매가', field: 'price', width: '120px', align: 'right',
1365
- format: (v: number) => `${Number(v).toLocaleString()}원` },
1366
- // 상태 컬럼은 STag size="sm" — SBadge 색 점을 기본으로 쓰지 않는다
1367
- { name: 'status', label: '상태', field: 'status', width: '100px', align: 'center',
2419
+ // ID·코드는 크기를 비교하는 숫자가 아니라 식별자다 → center. right 는 양을 나타내는 값에만
2420
+ { name: 'id', label: 'ID', field: 'id', width: 80, align: 'center' },
2421
+ // 자유 입력 텍스트 → 기준 폭을 주고, 표에 resizable 을 켜서 사용자가 늘릴 수 있게 한다
2422
+ { name: 'name', label: '상품명', field: 'name', width: 240 },
2423
+ // 값의 크기를 비교하는 컬럼만 sortable — 정렬 상태는 페이지가 들고 있다
2424
+ {
2425
+ name: 'stock',
2426
+ label: '재고',
2427
+ field: 'stock',
2428
+ width: 100,
2429
+ align: 'right',
2430
+ sortable: true,
2431
+ format: (v: unknown) => `${Number(v).toLocaleString()}개`,
2432
+ },
2433
+ {
2434
+ name: 'price',
2435
+ label: '판매가',
2436
+ field: 'price',
2437
+ width: 120,
2438
+ align: 'right',
2439
+ sortable: true,
2440
+ format: (v: unknown) => `${Number(v).toLocaleString()}원`,
2441
+ },
2442
+ // 상태 컬럼은 STag size="sm" — SBadge 색 점을 기본으로 쓰지 않는다.
2443
+ // 컨트롤이 들어가는 컬럼은 contentType: 'control', resizable 표에서는 minWidth 도 함께 (§3-4)
2444
+ {
2445
+ name: 'status',
2446
+ label: '상태',
2447
+ field: 'status',
2448
+ width: 100,
2449
+ minWidth: 100,
2450
+ align: 'center',
2451
+ contentType: 'control',
1368
2452
  render: (row: SRow) =>
1369
- row.status === 'selling'
1370
- ? <STag size="sm" color="green" label="판매중" />
1371
- : <STag size="sm" color="grey" label="판매중지" /> },
2453
+ row.status === 'selling' ? (
2454
+ <STag size="sm" color="green" label="판매중" />
2455
+ ) : (
2456
+ <STag size="sm" color="grey" label="판매중지" />
2457
+ ),
2458
+ },
1372
2459
  ];
1373
2460
 
1374
2461
  export default function ProductListPage() {
2462
+ const navigate = useNavigate();
2463
+ // 편집 중인 필터 값과, 검색을 눌러 실제로 걸린 조회 조건은 따로 든다.
2464
+ // 조회 조건(필터·정렬·페이지)은 실제 앱에서는 URL 쿼리에 싣는다 — 새로고침·공유에 살아남아야 한다
1375
2465
  const [filters, setFilters] = useState<Record<string, unknown>>({});
2466
+ const [query, setQuery] = useState<ProductQuery>({ page: 1, rowsPerPage: 20 });
1376
2467
  const [selected, setSelected] = useState<SRow[]>([]);
2468
+ const { rows, total, lastPage, loading } = useProductList(query);
2469
+
2470
+ const search = () => {
2471
+ setQuery(q => ({
2472
+ ...q,
2473
+ status: (filters.status as string | null) ?? undefined,
2474
+ keywords: filters.keyword ? [String(filters.keyword)] : undefined,
2475
+ period: (filters.period as SDateRangeValue) ?? undefined,
2476
+ page: 1,
2477
+ }));
2478
+ };
2479
+
2480
+ const removeSelected = async () => {
2481
+ await removeProducts(selected.map(row => Number(row.id)));
2482
+ setSelected([]);
2483
+ };
1377
2484
 
1378
2485
  return (
1379
2486
  <AppShell
1380
2487
  header={{
2488
+ variant: 'bar',
1381
2489
  title: '상품 목록',
1382
2490
  // 이 페이지의 주요 액션이 아니라 부가 액션 — slot 은 sm 버튼으로만 채운다
1383
- slot: <SButton size="sm" color="neutral" outline label="이용 가이드" onClick={openGuide} />,
2491
+ slot: (
2492
+ <SButton
2493
+ size="sm"
2494
+ color="neutral"
2495
+ outline
2496
+ label="이용 가이드"
2497
+ onClick={() => navigate('/guide/products')}
2498
+ />
2499
+ ),
1384
2500
  }}
1385
2501
  contentHeight="fill" // 표가 남은 높이를 채우고 페이지네이션이 하단에 고정된다
1386
2502
  >
1387
2503
  {/* h-full min-h-0 → STable 의 min-h-0 flex-1 로 세로 축이 이어진다 */}
1388
2504
  <div className="flex h-full min-h-0 flex-col gap-sd-12">
1389
- {/* 필터 — search 를 켜면 우측에 검색 패널이 붙는다 */}
2505
+ {/* 필터 — search 를 켜면 우측에 검색 패널이 붙는다.
2506
+ 조건 칸이 줄지어 서는 자리라 fieldWidth="fill" 로 오른쪽 끝을 맞춘다 (§4-3) */}
1390
2507
  <SKeyValueTable
1391
2508
  fields={filterFields}
1392
2509
  values={filters}
2510
+ fieldWidth="fill"
1393
2511
  search
1394
2512
  onChange={({ values }) => setFilters(values)}
1395
- onSearch={fetchList}
2513
+ onSearch={search}
1396
2514
  />
1397
2515
 
1398
- {/* 툴바 — 좌: 건수 + (구분선 자동) + 선택 액션 / 우: 주요 액션 */}
1399
- <STableBar
1400
- total={total}
1401
- selected={selected.length}
1402
- actions={
1403
- /* 선택 항목 단위 파괴 액션 → danger outline (§3-5-3) */
1404
- <SButton size="sm" color="danger" outline label="선택 삭제"
1405
- disabled={!selected.length} onClick={removeSelected} />
1406
- }
1407
- rightActions={
1408
- /* 이 페이지의 유일한 primary 채움 (§3-5-1) */
1409
- <SButton size="sm" label="상품 등록" onClick={goCreate} />
1410
- }
1411
- />
2516
+ {/* 툴바+표 = 한 블록. 사이를 벌리지 않는다 (§2-0).
2517
+ 이 div 도 높이 사슬의 한 칸이라 min-h-0 flex-1 이 필요하다 */}
2518
+ <div className="flex min-h-0 flex-1 flex-col">
2519
+ {/* 툴바 — 좌: 건수 + (구분선 자동) + 선택 액션 / 우: 주요 액션
2520
+ 맞닿는 가로선은 표가 그리므로 아래 테두리는 지운다 */}
2521
+ <STableBar
2522
+ className="border-b-0"
2523
+ total={total}
2524
+ selected={selected.length}
2525
+ actions={
2526
+ /* 선택 항목 단위 파괴 액션 → danger outline (§3-5-3) */
2527
+ <SButton
2528
+ size="sm"
2529
+ color="danger"
2530
+ outline
2531
+ label="선택 삭제"
2532
+ disabled={!selected.length}
2533
+ onClick={removeSelected}
2534
+ />
2535
+ }
2536
+ rightActions={
2537
+ /* 이 페이지의 유일한 primary 채움 (§3-5-1) */
2538
+ <SButton size="sm" label="상품 등록" onClick={() => navigate('/products/new')} />
2539
+ }
2540
+ />
1412
2541
 
1413
- {/* 남은 높이를 채우고 본문만 스크롤한다 — 페이지네이션 바는 표 안에서 하단 고정 */}
1414
- <STable
1415
- className="min-h-0 flex-1"
1416
- columns={columns}
1417
- rows={rows}
1418
- rowKey="id"
1419
- selectable
1420
- selected={selected}
1421
- onSelectedChange={setSelected}
1422
- pagination={{ currentPage, lastPage }}
1423
- isLoading={isLoading}
1424
- />
2542
+ {/* 남은 높이를 채우고 본문만 스크롤한다 — 페이지네이션 바는 표 안에서 하단 고정.
2543
+ dense 는 시작 밀도이자 밀도 토글의 스위치 — onDenseChange 값을 되돌려 넣지 않는다 (§4-2) */}
2544
+ <STable
2545
+ className="min-h-0 flex-1"
2546
+ columns={columns}
2547
+ rows={rows}
2548
+ rowKey="id"
2549
+ dense
2550
+ resizable
2551
+ selectable
2552
+ selected={selected}
2553
+ onSelectedChange={setSelected}
2554
+ sort={query.sort}
2555
+ onSortChange={(sort: STableSort | null) => setQuery(q => ({ ...q, sort, page: 1 }))}
2556
+ pagination={{ page: query.page, rowsPerPage: query.rowsPerPage, lastPage }}
2557
+ onPageChange={page => setQuery(q => ({ ...q, page }))}
2558
+ loading={loading}
2559
+ />
2560
+ </div>
1425
2561
  </div>
1426
2562
  </AppShell>
1427
2563
  );
1428
2564
  }
1429
2565
  ```
2566
+ <!-- /recipe -->
2567
+
2568
+ #### 한 화면에 더 많은 행을 — `dense` 와 밀도 토글
2569
+
2570
+ 행 높이를 줄이는 것은 `dense` 다. 세로 여백만 줄고 좌우 패딩은 그대로라, 값이 잘리지 않으면서 한 화면에 들어가는 행 수가 늘어난다.
2571
+
2572
+ **어느 쪽이 편한지는 화면이 아니라 사용자가 안다.** 그래서 `dense` 는 밀도를 고정하는 스위치가 아니라 **시작 밀도이자 밀도 토글의 스위치**다 — 켜면 하단 바 우측(rows per page 셀렉트 왼쪽)에 `좁게 보기` · `넓게 보기` 링크가 붙는다. 켜고 끄는 별도 prop 은 없다.
2573
+
2574
+ ```tsx
2575
+ // 좁게 시작하고, 사용자가 바꾸는 밀도는 표가 알아서 들고 간다
2576
+ <STable dense showRowsPerPageSelect pagination={{ page, rowsPerPage, lastPage }} />;
2577
+
2578
+ // 사용자가 고른 밀도를 다음 방문에도 남기려는 화면만 받아서 저장한다.
2579
+ // 저장한 값은 시작 밀도로만 돌려준다 — 되돌려 넣지 않는다
2580
+ const [initialDense] = useState(() => loadPref('list.dense', true));
2581
+
2582
+ <STable
2583
+ dense={initialDense}
2584
+ onDenseChange={next => savePref('list.dense', next)}
2585
+ showRowsPerPageSelect
2586
+ pagination={{ page, rowsPerPage, lastPage }}
2587
+ />;
2588
+ ```
2589
+
2590
+ - **누른 뒤의 밀도는 `STable` 이 내부 상태로 들고 간다.** `onDenseChange` 없이도 토글은 동작한다. 저장이 필요한 화면만 받아서 저장하면 된다.
2591
+ - **`dense` 에 `onDenseChange` 값을 되돌려 넣지 않는다.** 토글을 붙일지는 이 prop 이 정하므로, 넓게 본 순간 `dense` 가 `false` 가 되면 토글이 사라져 다시 좁힐 길이 없다. prop 값을 바꿔 넘기는 것은 외부 버튼 등으로 밀도를 **되돌릴 때**만 쓴다 — 내부 밀도가 그 값으로 맞춰진다.
2592
+ - **`dense` 면 페이지네이션이 없어도 토글이 나온다** — 하단 바를 토글만 담아 그린다. `dense` 가 아니면 토글도 없다.
2593
+ - 라벨과 아이콘은 현재 상태가 아니라 **누르면 되는 상태**를 가리킨다. 좁게 보고 있으면 `넓게 보기` 다.
1430
2594
 
1431
2595
  ### 4-3. 폼 페이지 (등록/수정)
1432
2596
 
@@ -1435,62 +2599,169 @@ export default function ProductListPage() {
1435
2599
  - 필드를 `div` 로 나열하지 않고 **`SKeyValueTable` 의 행으로 구성**한다.
1436
2600
  - 검증 규칙은 각 field 의 `options.rules` 로 넘긴다. `SForm` 이 하위 컨트롤을 자동 수집해 submit 시 일괄 검증하고, 실패 시 첫 실패 필드로 포커스를 옮긴다.
1437
2601
  - **버튼 순서: 취소·닫기가 왼쪽, 저장·등록·수정·삭제가 오른쪽.** 이 순서는 모든 화면에서 동일하다.
2602
+ - **폼 페이지도 `contentHeight="fill"` 이 기본이다**(§2-2). 폼이 길어 페이지가 실제로 스크롤되는 화면에서만 `auto` + `scrollEndSpacing` 을 켠다.
2603
+ - **필드 폭은 등급으로 준다** — `width="md"` 처럼 `'xs' | 'sm' | 'md' | 'lg' | 'xl'` 중 하나다. px 를 직접 적지 않는다. 등급은 `maxLength`(= 스키마 상한)로 고르고, 상한이 `xl` 을 넘거나 상한이 없으면 `width="100%"` 로 행 전체를 쓴다 (§6 `field-width-grade`).
2604
+ - **이 골격에서는 `labelPosition` 을 쓰지 않는다.** 라벨은 컨트롤이 아니라 `SKeyValueTable` 의 왼쪽 칸이 그리므로, 컨트롤에는 `label` 자체가 가지 않아 아무 일도 일어나지 않는다.
2605
+
2606
+ **라벨을 컨트롤 위에 두는 자리 — `labelPosition="top"`.** 표 밖에서 컨트롤이 **제 `label` 을 직접 들고 설 때만** 쓴다. 모달 안의 짧은 폼, 좁은 단처럼 라벨과 컨트롤을 한 줄에 같이 세우면 컨트롤이 너무 좁아지는 자리다. `SField` 를 쓰는 입력 컨트롤 전부가 같은 이름으로 받는다.
2607
+
2608
+ - **한 폼 안에서는 섞지 않는다.** 라벨 시작점과 컨트롤 윗변이 행마다 어긋나 세로 정렬이 깨진다. 한 폼은 통째로 `left`(기본) 이거나 통째로 `top` 이다.
2609
+ - **간격을 직접 주지 않는다** — 라벨과 컨트롤 사이는 `SField` 가 `--sys-space-field-vertical-gap` 으로 넣는다. `size` 에 따라 달라지지 않는다.
2610
+ - **`labelWidth`·`labelHeight` 는 함께 쓰지 않는다.** 둘 다 라벨을 열로 줄 세우는 장치라 위에 선 라벨에는 뜻이 없고, 주더라도 무시된다. (`addonLabel` 의 폭에는 그대로 적용된다 — addon 은 테두리 박스 안이라 배치와 무관하다.)
2611
+
2612
+ **날짜·시간 피커는 값 길이에 맞춘 폭 상한을 스스로 갖는다.** 표가 모든 컨트롤에 `width="100%"` 를 넘기지만 이 넷만 자기 상한에서 멈춘다 — `SDatePicker` · `SDateRangePicker` · `STimePicker` · `STimeRangePicker`. 그래서 한 행에 이런 필드가 섞이면 **왼쪽 끝만 맞고 오른쪽 끝이 어긋나** 보인다. 이것이 폼이 들쭉날쭉해 보이는 가장 흔한 원인이다.
2613
+
2614
+ **어느 쪽으로 갈지는 표 하나가 한 번에 정한다 — `SKeyValueTable` 의 `fieldWidth` 다.** 필드마다 폭을 손보지 않는다.
2615
+
2616
+ | 값 | 무엇이 달라지나 | 쓰는 자리 |
2617
+ | --- | --- | --- |
2618
+ | `"auto"`(기본) | 컨트롤이 자기 폭을 정한다. 폭이 값 길이를 알려준다 | 등록·수정 폼 — 입력 길이를 짐작하게 하는 편이 낫다 |
2619
+ | `"fill"` | 모든 컨트롤이 셀 끝까지 찬다. 오른쪽 끝이 맞는다 | 조회 필터 — 가지런함이 우선이다 (§4-2) |
2620
+
2621
+ ```tsx
2622
+ {/* 필터는 조건 칸이 줄지어 서는 자리다 — 오른쪽 끝을 맞춘다 */}
2623
+ <SKeyValueTable fields={filterFields} fieldWidth="fill" onSearch={search} search />
2624
+ ```
2625
+
2626
+ 한 필드만 예외로 두려면 그 필드의 `options.maxWidth` 를 준다 — 표의 정책보다 우선한다. 표 밖에 홀로 선 피커도 같은 prop 으로 상한을 푼다. 상한 값 자체는 컴포넌트가 폭 등급으로 갖고 있으므로 px 를 직접 적지 않는다.
2627
+
2628
+ **`SKeyValueTable` 의 전체 열 수는 가장 긴 행이 정한다.** 어떤 행이 그보다 짧으면 남는 자리에 셀이 없어 그 구간의 행 구분선이 끊긴다. 마지막 필드에 `tdColSpan` 을 주어 채운다.
2629
+
2630
+ ```tsx
2631
+ [
2632
+ [{ name: 'category', … }, { name: 'price', … }], // 필드 2개 → 4칸
2633
+ [{ name: 'memo', …, tdColSpan: 3 }], // th(1) + td(3) = 4칸
2634
+ ]
2635
+ ```
2636
+
2637
+ **한 행에 필드를 추가하면 다른 행들의 `tdColSpan` 도 함께 봐야 한다.** 전체 열 수가 늘면 나머지 행들이 조용히 짧아진다 — 화면에서만 드러나는 컴포넌트 고유 동작이라 자동으로 채워 주지 않는다.
2638
+
2639
+ **세로 병합(`thRowSpan` · `tdRowSpan`)은 아래 행에 쓰는 법이 둘로 갈린다.** 무엇을 병합했는지에 따라 아래 행에 적는 내용이 달라진다 — 섞으면 셀이 겹치거나 값 칸이 빈다.
1438
2640
 
1439
2641
  ```tsx
2642
+ // 레이블만 병합 — 아래 행에는 label 없는 "값만 있는 필드" 를 둔다
2643
+ [
2644
+ [{ name: 'roadAddress', label: '주소', thRowSpan: 3, … }],
2645
+ [{ name: 'detailAddress', … }], // 레이블 칸은 위 행 th 가 덮는다
2646
+ [{ name: 'zipcode', … }],
2647
+ ]
2648
+
2649
+ // 레이블 + 값을 한 덩어리로 병합 — 아래 행에서는 그 필드를 아예 뺀다
2650
+ [
2651
+ [{ name: 'name', label: '이름', … }, { name: 'role', label: '직급', thRowSpan: 2, tdRowSpan: 2, … }],
2652
+ [{ name: 'email', label: '이메일', … }], // 오른쪽 [th|td] 는 위 행 직급이 채운다
2653
+ ]
2654
+ ```
2655
+
2656
+ **병합으로 찬 자리는 배열 순서가 아니라 실제 열 위치로 판정한다.** 그러니 아래 행의 필드를 병합된 열에 맞추려고 자리 채우기용 빈 필드를 끼우지 않는다 — 앞 필드에 `tdColSpan` 이나 `hideTh` 가 있어도 남은 필드가 알아서 빈 열부터 놓인다.
2657
+
2658
+ <!-- recipe:src/recipes/ProductCreatePage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
2659
+ ```tsx
2660
+ import { useState } from 'react';
1440
2661
  import {
1441
- SForm, SKeyValueTable, SButton, SCheckbox,
1442
- type SFormHandle, type SKeyValueField, type Rule,
2662
+ SButton,
2663
+ SCheckbox,
2664
+ SForm,
2665
+ SKeyValueTable,
2666
+ type Rule,
2667
+ type SKeyValueField,
1443
2668
  } from 'sellmate-design-system-react';
2669
+ import AppShell from './AppShell';
2670
+ import { CATEGORY_OPTIONS, saveProduct } from './api';
2671
+ import { useNavigate } from './router';
1444
2672
 
1445
- const required = (msg: string): Rule => v =>
1446
- v != null && String(v).trim() !== '' ? true : msg;
2673
+ const required =
2674
+ (msg: string): Rule =>
2675
+ v =>
2676
+ v != null && String(v).trim() !== '' ? true : msg;
1447
2677
 
2678
+ /** 필드를 div 로 나열하지 않는다 — SKeyValueTable 의 행으로 구성한다 */
1448
2679
  const fields: SKeyValueField[][] = [
1449
2680
  [
1450
- { name: 'name', label: '상품명', required: true, type: 'input',
1451
- options: { placeholder: '상품명 입력', rules: [required('상품명을 입력해 주세요.')] } },
2681
+ {
2682
+ name: 'name',
2683
+ label: '상품명',
2684
+ required: true,
2685
+ type: 'input',
2686
+ options: { placeholder: '상품명 입력', rules: [required('상품명을 입력해 주세요.')] },
2687
+ },
1452
2688
  { name: 'code', label: '상품코드', type: 'input', options: { placeholder: '자동 생성' } },
1453
2689
  ],
1454
2690
  [
1455
- { name: 'category', label: '카테고리', required: true, type: 'select',
1456
- options: { options: CATEGORY_OPTIONS, emitValue: true,
1457
- rules: [required('카테고리를 선택해 주세요.')] } },
2691
+ {
2692
+ name: 'category',
2693
+ label: '카테고리',
2694
+ required: true,
2695
+ type: 'select',
2696
+ options: {
2697
+ options: CATEGORY_OPTIONS,
2698
+ valueAsPrimitive: true,
2699
+ rules: [required('카테고리를 선택해 주세요.')],
2700
+ },
2701
+ },
1458
2702
  { name: 'price', label: '판매가', type: 'number-input' },
1459
2703
  ],
1460
2704
  [
1461
- { name: 'memo', label: '메모', type: 'textarea', tdColSpan: 3,
1462
- helpText: ['내부 관리용 메모입니다.'] },
2705
+ // 가장 긴 행(필드 2개 = 4칸)에 맞춰 마지막 필드가 남는 칸을 채운다
2706
+ {
2707
+ name: 'memo',
2708
+ label: '메모',
2709
+ type: 'textarea',
2710
+ tdColSpan: 3,
2711
+ helpText: ['내부 관리용 메모입니다.'],
2712
+ },
1463
2713
  ],
1464
2714
  ];
1465
2715
 
1466
2716
  export default function ProductCreatePage() {
1467
- const formRef = useRef<SFormHandle>(null);
2717
+ const navigate = useNavigate();
1468
2718
  const [values, setValues] = useState<Record<string, unknown>>({});
2719
+ const [keepOpen, setKeepOpen] = useState(false);
2720
+
2721
+ // SForm 이 하위 컨트롤의 rules 를 모아 submit 때 일괄 검증한다 — 통과했을 때만 불린다
2722
+ const save = async () => {
2723
+ await saveProduct(values);
2724
+ if (keepOpen) setValues({});
2725
+ else navigate('/products');
2726
+ };
1469
2727
 
1470
2728
  return (
1471
- <AppShell header={{ title: '상품 등록', onBack: goBack }}>
1472
- <div className="flex flex-col gap-sd-12">
1473
- <SForm ref={formRef} formClass="flex flex-col gap-sd-12" onSubmit={save}>
1474
- <SKeyValueTable
1475
- fields={fields}
1476
- values={values}
1477
- onChange={({ values }) => setValues(values)}
1478
- />
2729
+ <AppShell
2730
+ header={{ variant: 'bar', title: '상품 등록', onBack: () => navigate(-1) }}
2731
+ contentHeight="fill" // 폼 페이지도 fill 이 기본 — 폼이 길어 실제로 스크롤될 때만 auto + scrollEndSpacing
2732
+ >
2733
+ <SForm formClassName="flex flex-col gap-sd-12" onSubmit={save}>
2734
+ <SKeyValueTable
2735
+ fields={fields}
2736
+ values={values}
2737
+ onChange={({ values }) => setValues(values)}
2738
+ />
1479
2739
 
1480
- {/* 하단 버튼은 양끝으로 벌린다. 부가 요소(체크박스 등)는 저장 바로 왼쪽 */}
1481
- <div className="flex items-center justify-between">
1482
- <SButton type="button" color="neutral" outline label="취소" onClick={goBack} />
1483
- <div className="flex items-center gap-sd-8">
1484
- <SCheckbox label="계속 등록하기" value={keepOpen} onValueChange={v => setKeepOpen(v as boolean)} />
1485
- <SButton type="submit" label="저장" />
1486
- </div>
2740
+ {/* 버튼 순서는 모든 화면에서 같다 — 취소는 왼쪽 끝, 저장은 오른쪽 끝.
2741
+ 부가 요소(체크박스 등)는 저장 바로 왼쪽 */}
2742
+ <div className="flex items-center justify-between">
2743
+ <SButton
2744
+ type="button"
2745
+ color="neutral"
2746
+ outline
2747
+ label="취소"
2748
+ onClick={() => navigate(-1)}
2749
+ />
2750
+ <div className="flex items-center gap-sd-8">
2751
+ <SCheckbox
2752
+ label="계속 등록하기"
2753
+ value={keepOpen}
2754
+ onValueChange={v => setKeepOpen(v as boolean)}
2755
+ />
2756
+ <SButton type="submit" label="저장" />
1487
2757
  </div>
1488
- </SForm>
1489
- </div>
2758
+ </div>
2759
+ </SForm>
1490
2760
  </AppShell>
1491
2761
  );
1492
2762
  }
1493
2763
  ```
2764
+ <!-- /recipe -->
1494
2765
 
1495
2766
  ### 4-4. 상세(조회) 페이지
1496
2767
 
@@ -1498,26 +2769,28 @@ export default function ProductCreatePage() {
1498
2769
 
1499
2770
  - 조회 값은 `type: 'text'` 행으로 표시한다. **상태·분류 태그도 별도 영역이 아니라 표의 한 행**으로 넣는다 (`render` 에 `STag`).
1500
2771
  - 행이 많아지면 **유형별로 섹션을 나누고, 각 섹션을 `SSectionHeaderCard` 로 감싼다.**
1501
- 섹션 제목은 `title` prop 으로, 바디 여백은 `padding` prop 으로 준다.
2772
+ 섹션 제목은 `title` prop 으로, 바디 여백은 `padding` prop 으로 준다. **카드 안이 `SKeyValueTable` 하나뿐이면 `padding="none"` 으로 가장자리까지 채우고 표는 `bordered={false} radius="useTop"`** 으로 자기 테두리를 끈다 (§2-2 "카드 가장자리까지 채우는 표").
2773
+ - **한 화면에 행이 많아 훑어보기가 목적이면 `SKeyValueTable` 에 `dense` 를 켠다** — 셀 상하 여백과
2774
+ 최소 높이가 함께 내려가 행이 좁아진다. 다만 **읽기 전용(`text`) 행에서만 실제로 좁아진다**:
2775
+ 폼 필드가 든 행은 필드 자체의 높이가 행 높이를 정하므로 거의 변하지 않는다. 그래서 필터(§4-2)나
2776
+ 폼(§4-3) 표에 켜 봐야 눈에 띄는 변화가 없다.
1502
2777
  - **수정·삭제 버튼은 하단에 둔다.** 내용이 짧아 우측 상단에 두는 변형도 있으나 기본은 하단이다.
2778
+ - **상세 페이지도 `contentHeight="fill"` 이 기본이다**(§2-2). 섹션이 많아 페이지가 실제로 스크롤되는 화면에서만 `auto` + `scrollEndSpacing` 을 켠다.
2779
+ - **섹션마다 독립 인라인 폼이 있는 형태**도 상세 페이지의 변형이다. 섹션 안에서 바로 수정·저장하게 하는 화면인데, 이때 버튼 강조는 **섹션 단위가 아니라 페이지 단위로 판단한다** — §3-5-1 의 "`secondary` 연속 배치 금지"는 섹션이 다르면 적용되지 않는다. 그 섹션 안에서 닫히는 저장·취소는 `SSectionHeaderCard` 의 `button`·`footerLeft` 로 넘긴다 (§3-7-8). 아래 예처럼 **페이지 전체를 확정하는 버튼은 카드 밖 하단 줄**에 둔다 — 둘을 섞지 않는다.
1503
2780
 
2781
+ <!-- recipe:src/recipes/ProductDetailPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
1504
2782
  ```tsx
1505
2783
  import {
1506
- SSectionHeaderCard, SKeyValueTable, SButton, STag, type SKeyValueField,
2784
+ SButton,
2785
+ SKeyValueTable,
2786
+ SModal,
2787
+ SSectionHeaderCard,
2788
+ STag,
2789
+ type SKeyValueField,
1507
2790
  } from 'sellmate-design-system-react';
1508
-
1509
- const basicFields: SKeyValueField[][] = [
1510
- [
1511
- { name: 'code', label: '상품코드', type: 'text' },
1512
- { name: 'createdAt', label: '등록일', type: 'text' },
1513
- ],
1514
- [
1515
- { name: 'category', label: '카테고리', type: 'text' },
1516
- // 상태 태그도 표의 한 행으로 표현한다
1517
- { name: 'status', label: '상태',
1518
- render: <STag size="sm" color="green" label="판매중" /> },
1519
- ],
1520
- ];
2791
+ import AppShell from './AppShell';
2792
+ import { removeProducts, useProduct } from './api';
2793
+ import { useNavigate, useParams } from './router';
1521
2794
 
1522
2795
  const priceFields: SKeyValueField[][] = [
1523
2796
  [
@@ -1527,41 +2800,96 @@ const priceFields: SKeyValueField[][] = [
1527
2800
  ];
1528
2801
 
1529
2802
  export default function ProductDetailPage() {
2803
+ const navigate = useNavigate();
2804
+ const { id } = useParams();
2805
+ const product = useProduct(Number(id));
2806
+
2807
+ // 되돌릴 수 없는 액션은 실행 여부만 확정받는다 — 직접 만든 confirm 대신 SModal.confirm (§3-3-1)
2808
+ const confirmDelete = () =>
2809
+ SModal.confirm({
2810
+ type: 'negative',
2811
+ modalTitle: '상품을 삭제할까요?',
2812
+ topMessage: ['삭제된 상품은 복구할 수 없습니다.'],
2813
+ mainButtonLabel: '삭제',
2814
+ }).onOk(async () => {
2815
+ await removeProducts([product.id]);
2816
+ navigate('/products');
2817
+ });
2818
+
2819
+ // 조회 값은 type: 'text' 행으로 보인다.
2820
+ // 상태 태그도 별도 영역이 아니라 표의 한 행이다 — 값에 따라 달라지므로 컴포넌트 안에서 만든다
2821
+ const basicFields: SKeyValueField[][] = [
2822
+ [
2823
+ { name: 'code', label: '상품코드', type: 'text' },
2824
+ { name: 'createdAt', label: '등록일', type: 'text' },
2825
+ ],
2826
+ [
2827
+ { name: 'category', label: '카테고리', type: 'text' },
2828
+ {
2829
+ name: 'status',
2830
+ label: '상태',
2831
+ render:
2832
+ product.status === 'selling' ? (
2833
+ <STag size="sm" color="green" label="판매중" />
2834
+ ) : (
2835
+ <STag size="sm" color="grey" label="판매중지" />
2836
+ ),
2837
+ },
2838
+ ],
2839
+ ];
2840
+
1530
2841
  return (
1531
2842
  // 목록에서 들어온 상세 페이지 — onBack 으로 뒤로가기를 준다
1532
- <AppShell header={{ fix: true, title: '클래식 셔츠', onBack: goList }}>
2843
+ <AppShell
2844
+ header={{ variant: 'bar', title: product.name, onBack: () => navigate('/products') }}
2845
+ contentHeight="fill" // 섹션이 많아 실제로 스크롤될 때만 auto + scrollEndSpacing
2846
+ >
1533
2847
  <div className="flex flex-col gap-sd-12">
1534
- <SSectionHeaderCard title="기본 정보" marker thickness="accent">
1535
- <SKeyValueTable fields={basicFields} values={product} />
2848
+ {/* 행이 많아지면 유형별로 섹션을 나눈다. 카드가 둘 이상이라 marker 를 켠다 —
2849
+ 한 페이지 안의 카드 강조(marker·thickness)는 전부 같게 간다 (§4-5).
2850
+ 카드 안이 표 하나뿐이면 padding="none" 으로 가장자리까지 채우고, 표는 bordered={false} 로
2851
+ 자기 테두리를 끄고 radius="useTop" 으로 위 모서리를 죽인다 — 카드가 테두리·아래 라운드를 그린다 (§2-2) */}
2852
+ <SSectionHeaderCard title="기본 정보" marker padding="none">
2853
+ <SKeyValueTable fields={basicFields} values={product} bordered={false} radius="useTop" />
1536
2854
  </SSectionHeaderCard>
1537
2855
 
1538
- {/* 헤더 우측에 액션이 필요하면 slot 을 쓴다 */}
2856
+ {/* 헤더 우측에 액션이 필요하면 slot 을 쓴다 — 이동 액션이라 outline (§3-5-1) */}
1539
2857
  <SSectionHeaderCard
1540
2858
  title="가격 정보"
1541
2859
  marker
1542
2860
  helpText={['부가세 포함 금액입니다.']}
1543
- slot={<SButton size="sm" color="secondary" label="이력" onClick={openHistory} />}
2861
+ padding="none"
2862
+ slot={
2863
+ <SButton
2864
+ size="sm"
2865
+ color="neutral"
2866
+ outline
2867
+ label="이력"
2868
+ onClick={() => navigate(`/products/${id}/price-history`)}
2869
+ />
2870
+ }
1544
2871
  >
1545
- <SKeyValueTable fields={priceFields} values={product} />
2872
+ <SKeyValueTable fields={priceFields} values={product} bordered={false} radius="useTop" />
1546
2873
  </SSectionHeaderCard>
1547
2874
 
1548
- {/* 액션은 하단 — 목록(되돌리기)은 왼쪽 끝, 실행 액션은 오른쪽 끝 */}
2875
+ {/* 액션은 하단 — 그룹을 모으지 않고 양끝으로 벌린다 (§3-5).
2876
+ 목록으로 돌아가기는 헤더의 onBack 이 이미 하므로 되돌리는 버튼을 따로 두지 않는다.
2877
+ 되돌리는 액션이 없어도 양끝 분리는 그대로다 — 위계가 낮은 삭제(danger outline)가 왼쪽 끝,
2878
+ 가장 강한 실행 액션인 수정이 오른쪽 끝 */}
1549
2879
  <div className="flex items-center justify-between">
1550
- <SButton color="neutral" outline label="목록" onClick={goList} />
1551
- <div className="flex items-center gap-sd-8">
1552
- <SButton color="danger" outline label="삭제" onClick={confirmDelete} />
1553
- <SButton label="수정" onClick={goEdit} />
1554
- </div>
2880
+ <SButton color="danger" outline label="삭제" onClick={confirmDelete} />
2881
+ <SButton label="수정" onClick={() => navigate(`/products/${id}/edit`)} />
1555
2882
  </div>
1556
2883
  </div>
1557
2884
  </AppShell>
1558
2885
  );
1559
2886
  }
1560
2887
  ```
2888
+ <!-- /recipe -->
1561
2889
 
1562
2890
  ### 4-5. 섹션 카드 — SSectionHeaderCard
1563
2891
 
1564
- 폼 페이지에서도 입력 항목이 많으면 유형별로 `SSectionHeaderCard` 로 나눈다. 주요 옵션:
2892
+ 폼 페이지에서도 입력 항목이 많으면 유형별로 `SSectionHeaderCard` 로 나눈다. **카드 안이 `SKeyValueTable` 하나뿐이면 `padding="none"` 으로 가장자리까지 채우고, 표는 `bordered={false} radius="useTop"` 으로 자기 테두리를 끈다** (§2-2 "카드 가장자리까지 채우는 표"). 코드는 Storybook `Recipes/폼 페이지` 의 "섹션 나눔" 에 있다. 주요 옵션:
1565
2893
 
1566
2894
  | Prop (Header) | 용도 |
1567
2895
  | --- | --- |
@@ -1576,6 +2904,275 @@ export default function ProductDetailPage() {
1576
2904
  | Prop (Body) | 용도 |
1577
2905
  | --- | --- |
1578
2906
  | `padding` | 안쪽 여백 — `'default'`(기본) / `'wide'` / `'none'`. 판정은 §2-2 "섹션·패널 안쪽 여백". `p-sd-*` 를 직접 주지 않는다 |
2907
+ | `background` | 본문 바탕 — `'frame'`(기본) / `'neutral'`. 판정은 §2-2 "본문 바탕 눌러앉히기" |
2908
+
2909
+ | Prop (Footer) | 용도 |
2910
+ | --- | --- |
2911
+ | `button` | 하단 액션 영역 우측 주 액션 (§3-7-8) |
2912
+ | `footerLeft` | 하단 액션 영역 좌측 슬롯 — 보조 버튼. `SButton` 에 `footerSize` 와 같은 크기를 명시한다 |
2913
+ | `footerSize` | 하단 액션 영역 크기 — `'md'`(기본) / `'sm'`. 판정은 §3-7-8 |
2914
+
2915
+ `button`·`footerLeft` 중 하나라도 주면 하단 액션 영역이 렌더된다. 회색 바탕 + 상단 구분선이며 좌우 끝은 헤더에 맞는다 — 배경·여백을 직접 주지 않는다.
2916
+
2917
+ **한 페이지에 섹션 카드가 둘 이상이면 `marker` 를 켠다.** 점은 섹션을 서로 구분할 대상이 여럿일 때만 의미가 있어, 카드가 하나뿐인 페이지에서는 켜지 않는다. 한 페이지 안에서는 켜거나 끄거나 전부 같게 간다.
2918
+
2919
+ **섹션 본문이 자기 안에서 스크롤해야 하면 루트 `className` 으로 마지막 자식에 세로 축을 잇는다.**
2920
+
2921
+ ```tsx
2922
+ <SSectionHeaderCard
2923
+ title="…"
2924
+ className="[&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
2925
+ >
2926
+ <STable className="min-h-0 flex-1" … />
2927
+ </SSectionHeaderCard>
2928
+ ```
2929
+
2930
+ 본문 래퍼는 `className` 을 받지 않으므로(여백은 `padding` prop 으로만 받는다) 루트에서 내려 준다. **하단 액션 영역이 있으면 본문이 더 이상 마지막 자식이 아니다** — 그때는 `[&>div:nth-last-child(2)]` 로 겨눈다. 흔한 구성은 아니다 — 대부분은 `STable` 이 자기 안에서 스크롤하므로 여기까지 갈 일이 없다.
2931
+
2932
+ ### 4-6. 로그인 화면 — SLoginCard
2933
+
2934
+ 로그인 전이라 앱 셸이 아직 없다. `SLayout`·`SGnb`·`SPage` 가 없고, 회색 바탕 위에 카드 하나만 선다. (앱 셸 없이 서는 화면은 이것과 팝업 라우트(§4-7) 둘뿐이다.)
2935
+
2936
+ <!-- recipe:src/recipes/LoginPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
2937
+ ```tsx
2938
+ import { SLoginCard, STextLink } from 'sellmate-design-system-react';
2939
+ import { useNavigate } from './router';
2940
+
2941
+ /**
2942
+ * 로그인 전이라 앱 셸이 없다 — AppShell 을 쓰지 않고 회색 바탕 위에 카드 하나만 선다.
2943
+ * 카드는 화면을 잡지 않으므로 바깥 한 겹은 앱이 만든다. 그 한 겹이 하는 일은 셋뿐이다:
2944
+ * 바탕·여백 / items-stretch 로 카드가 높이를 채우게 / justify-center 로 가로 가운데
2945
+ */
2946
+ export default function LoginPage() {
2947
+ const navigate = useNavigate();
2948
+
2949
+ return (
2950
+ // 여백은 카드가 최소 폭을 지킬 수 있는 값으로 둔다 — 카드 폭은 className 으로 박지 않는다
2951
+ <div className="flex h-screen items-stretch justify-center bg-bg-neutralLight p-sd-24">
2952
+ <SLoginCard
2953
+ product="AI assistant"
2954
+ title="셀메이트 AI 어시스턴트와 함께 자료 찾는 시간은 줄이고, 업무는 빠르게!"
2955
+ description="(사내 답변은 사내 문서를 기반으로 답변합니다.)"
2956
+ // 로그인 버튼은 카드가 그린다 — 동작만 넘긴다
2957
+ onLogin={() => navigate('/auth/sso')}
2958
+ footerNote="계정이 없다면 담당자에게 문의를 요청해 주세요."
2959
+ // 로그인과 성격이 다른 보조 진입점만 — STextLink 하나. 버튼을 세우지 않는다
2960
+ bottomAction={
2961
+ <STextLink
2962
+ label="관리자 로그인"
2963
+ icon="settingOutline"
2964
+ rightArrow="chevron"
2965
+ onClick={() => navigate('/admin/login')}
2966
+ />
2967
+ }
2968
+ />
2969
+ </div>
2970
+ );
2971
+ }
2972
+ ```
2973
+ <!-- /recipe -->
2974
+
2975
+ **바깥 한 겹은 앱이 만든다.** 카드는 화면을 잡지 않는다 — 덕분에 로그인 화면 밖(세션 만료 재인증 등)에서도 같은 카드를 그대로 쓴다. 그 한 겹은 위 세 가지만 하면 되고, 그 이상은 하지 않는다.
2976
+
2977
+ - 바탕은 `bg-bg-neutralLight`, 여백은 `p-sd-24`. **여백은 카드가 최소 폭(368px)을 지킬 수 있는 값으로 둔다** — 좌우 합이 창 폭에서 368px 을 남기지 못하면 카드가 넘친다
2978
+ - `items-stretch` — 카드가 남은 높이를 채운다. `items-center` 로 두면 카드가 내용 높이로 줄어든다
2979
+ - `justify-center` — 카드를 가로 가운데에
2980
+
2981
+ | Prop | 용도 |
2982
+ | --- | --- |
2983
+ | `product` | 로고 아래 제품명 (`AI assistant` · `account` · `chat`) |
2984
+ | `title` | 제품을 한 줄로 소개하는 문장 |
2985
+ | `description` | 소개 바로 아래 붙는 보조 설명. 소개와 한 문단으로 읽힌다 — 없으면 생략한다 |
2986
+ | `onLogin` | 로그인 버튼을 눌렀을 때. **버튼은 카드가 그리므로 동작만 넘긴다** — 문구·색·크기는 세 제품에서 같아야 한다 |
2987
+ | `footerNote` | 버튼 아래 안내 문구 |
2988
+ | `bottomAction` | 카드 **맨 아래**에 서는 보조 진입점 (예: 관리자 로그인). `STextLink` 하나만 넣는다 — 없으면 생략한다 |
2989
+
2990
+ **`bottomAction` 은 로그인과 성격이 다른 진입점만 받는다.** 관리자 로그인처럼 "이 화면의 주인공은 아니지만 여기서만 갈 수 있는 곳"이 그 자리다. 카드가 바닥에 붙여 세우고, 가운데 덩어리는 그만큼 밀리지 않고 한가운데에 그대로 선다. **버튼을 세우지 않는다** — 로그인 버튼과 무게가 같아지는 순간 「이 화면에서 눌러야 하는 것」이 흐려진다. 읽을 문장은 여기가 아니라 `description`·`footerNote` 로 간다.
2991
+
2992
+ **문구와 `bottomAction` 말고는 주지 않는다.** 라운드·간격·타이포·버튼 색은 카드가 쥐고 있어 제품이 달라도 같은 화면이 선다.
2993
+
2994
+ **크기는 부모가 정한다 — `className` 으로 폭을 박지 않는다.** 카드는 폭을 368~640px 사이에서 부모에 맞추고 높이도 부모가 준 만큼 채운다. 밖에서 고정하면 상하한이 무의미해져, 좁은 화면에서 함께 좁아져야 할 카드가 그대로 남아 넘친다. 좁게 세워야 하면 카드가 아니라 바깥 한 겹을 좁힌다.
2995
+
2996
+ **Sellmate 워드마크와 로그인 버튼은 카드가 직접 그린다 — prop 이 없다.** 세 제품이 같은 로고·같은 버튼 문구(`통합 계정으로 로그인`)로 서므로 넘길 것도, 갈아 끼울 것도 없다. 앱이 주는 것은 문구와 `onLogin` 동작뿐이다.
2997
+
2998
+ **로그인 카드 옆에 다른 블록을 두지 않는다.** 이 화면에서 할 일은 로그인 하나이고, 옆에 공지·배너가 붙는 순간 그 뜻이 깨진다. 안내가 필요하면 `description` 이나 `footerNote` 로 넣는다.
2999
+
3000
+ ### 4-7. 팝업 창 라우트 — SPopup
3001
+
3002
+ 앱 셸 없이 서는 또 하나의 화면이다. 별도 브라우저 창으로 열리는 전용 라우트이므로 `SLayout`·`SGnb`·`SPage` 가 없고, 창을 통째로 `SPopup` 하나가 채운다. 무엇을 팝업으로 열지는 §3-3-1·§3-3-3 에서 고르고, 여기서는 그 라우트의 골격만 다룬다.
3003
+
3004
+ <!-- recipe:src/recipes/TransferOrderPopupPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
3005
+ ```tsx
3006
+ import { useState } from 'react';
3007
+ import {
3008
+ SKeyValueTable,
3009
+ SPopup,
3010
+ SSectionHeaderCard,
3011
+ STable,
3012
+ STabs,
3013
+ type SKeyValueField,
3014
+ type STableColumn,
3015
+ } from 'sellmate-design-system-react';
3016
+ import { ORDER_TABS, useTransferOrder } from './api';
3017
+ import { useParams } from './router';
3018
+
3019
+ // 1) 목록에서 별도 창을 연다 — 창 크기 = 콘텐츠 크기
3020
+ export function openTransferOrderPopup(orderId: string) {
3021
+ window.open(
3022
+ `${window.location.origin}/popup/transfer-orders/${orderId}`,
3023
+ `transfer-order-${orderId}`,
3024
+ 'width=1200, height=800, toolbar=no, menubar=no, location=no, resizable=no',
3025
+ );
3026
+ }
3027
+
3028
+ const summaryFields: SKeyValueField[][] = [
3029
+ [
3030
+ { name: 'from', label: '출발 창고', type: 'text' },
3031
+ { name: 'to', label: '도착 창고', type: 'text' },
3032
+ { name: 'requestedAt', label: '요청일', type: 'text' },
3033
+ ],
3034
+ ];
3035
+
3036
+ // 정렬·폭은 값의 성격에서 나온다 (§3-4) — 코드·일시·닫힌 집합은 center, 양은 right, 자유 텍스트는 기준 폭
3037
+ const itemColumns: STableColumn[] = [
3038
+ { name: 'sku', label: '품목 코드', field: 'sku', width: 140, align: 'center' },
3039
+ { name: 'name', label: '품목명', field: 'name', width: 240 },
3040
+ { name: 'qty', label: '수량', field: 'qty', width: 100, align: 'right' },
3041
+ ];
3042
+
3043
+ const historyColumns: STableColumn[] = [
3044
+ { name: 'at', label: '일시', field: 'at', width: 160, align: 'center' },
3045
+ // 처리 종류는 닫힌 값 집합(요청·승인 …) → center
3046
+ { name: 'action', label: '처리', field: 'action', width: 100, align: 'center' },
3047
+ { name: 'by', label: '처리자', field: 'by', width: 120 },
3048
+ ];
3049
+
3050
+ // 2) 그 라우트의 루트는 SPopup 하나다 — 앱 셸(SLayout·SGnb·SPage)이 없다. 조회만 하므로 푸터도 없다
3051
+ export default function TransferOrderPopupPage() {
3052
+ const { orderId } = useParams();
3053
+ const order = useTransferOrder(orderId);
3054
+ const [tab, setTab] = useState('items');
3055
+
3056
+ return (
3057
+ // h-screen 으로 창 높이를 잡는다 — 이게 없으면 본문이 창 밖으로 자란다
3058
+ <SPopup className="h-screen" popupTitle="이동 오더 상세">
3059
+ {/* 본문 패딩은 SPopup 이 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다 */}
3060
+ <div className="flex flex-col gap-sd-12">
3061
+ {/* 카드 안이 표 하나뿐이라 padding="none" 으로 가장자리까지 채운다 — 표는 자기 테두리를 끄고 위 모서리를 죽인다 (§2-2) */}
3062
+ <SSectionHeaderCard title={order.orderId} padding="none">
3063
+ <SKeyValueTable fields={summaryFields} values={order} bordered={false} radius="useTop" />
3064
+ </SSectionHeaderCard>
3065
+ <STabs value={tab} tabs={ORDER_TABS} onValueChange={setTab} />
3066
+ {tab === 'items' ? (
3067
+ <STable columns={itemColumns} rows={order.items} rowKey="id" resizable />
3068
+ ) : (
3069
+ <STable columns={historyColumns} rows={order.history} rowKey="id" resizable />
3070
+ )}
3071
+ </div>
3072
+ </SPopup>
3073
+ );
3074
+ }
3075
+ ```
3076
+ <!-- /recipe -->
3077
+
3078
+ **높이는 라우트가 준다 — `SPopup` 에 `h-screen` 을 준다.** `SPopup` 자체는 부모 높이를 채우도록만 되어 있어서, 부모(라우트 루트·`body`)가 높이를 정해 주지 않으면 100% 가 풀려 본문이 창 밖으로 자란다. 그러면 헤더·푸터가 위아래로 밀려 창 안에 보이지 않는다. `h-screen` 이면 `html`·`body`·마운트 루트에 전역 CSS 를 걸지 않고도 창 높이가 바로 들어온다. (`body` 여백은 0 이어야 한다 — Tailwind preflight 가 이미 0 으로 만든다.)
3079
+
3080
+ **창 높이를 고정으로 가정하지 않는다.** `window.open` 의 `resizable=no` 는 브라우저가 무시하는 경우가 많아 사용자가 창을 늘리고 줄일 수 있고, 화면 해상도에 따라 처음 열리는 높이도 요청값과 달라진다. `h-screen` 으로 이어 두면 본문의 가용 높이가 창과 함께 변하고, 넘칠 때만 **본문만** 스크롤한다 — 헤더와 푸터는 자리에 남는다. 높이를 `px` 로 박거나 리사이즈를 JS 로 따라가지 않는다.
3081
+
3082
+ **표를 담으면 본문이 아니라 표가 스크롤한다.** 본문이 통째로 스크롤되면 표 헤더와 페이지네이션 바가 위아래로 밀려 사라진다. `SPopup` 의 본문은 이미 남은 높이를 잡고 있으므로, 본문 루트에 `h-full min-h-0` 으로 세로 축을 잇고 표에 `min-h-0 flex-1` 을 준다. **툴바+표를 묶은 `div` 도 사슬의 한 칸이라 거기에도 `min-h-0 flex-1` 이 필요하다** (§4-2 목록 페이지와 같은 사슬이다 — 한 칸만 끊겨도 표가 높이를 못 잡는데 실패가 조용하다).
3083
+
3084
+ <!-- recipe:src/recipes/ExcelFilePopupPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
3085
+ ```tsx
3086
+ import { useState } from 'react';
3087
+ import {
3088
+ SPopup,
3089
+ SSearchInput,
3090
+ STable,
3091
+ STableBar,
3092
+ type SRow,
3093
+ type STableColumn,
3094
+ } from 'sellmate-design-system-react';
3095
+ import { applyExcelFiles, useExcelFiles } from './api';
3096
+
3097
+ // 정렬·폭은 값의 성격에서 나온다 (§3-4) — 자유 텍스트는 기준 폭 + resizable, 일자는 center, 양은 right
3098
+ const columns: STableColumn[] = [
3099
+ { name: 'fileName', label: '파일명', field: 'fileName', width: 320 },
3100
+ { name: 'uploadedAt', label: '업로드일', field: 'uploadedAt', width: 120, align: 'center' },
3101
+ {
3102
+ name: 'rows',
3103
+ label: '행 수',
3104
+ field: 'rows',
3105
+ width: 100,
3106
+ align: 'right',
3107
+ format: (v: unknown) => Number(v).toLocaleString(),
3108
+ },
3109
+ ];
3110
+
3111
+ /**
3112
+ * 표를 담은 팝업 — 본문이 아니라 표가 스크롤한다. 툴바·푸터는 늘 보인다.
3113
+ * SPopup 의 본문은 이미 남은 높이를 잡고 있으므로, 본문 루트부터 표까지 세로 축을 잇는다 (§4-2 와 같은 사슬).
3114
+ */
3115
+ export default function ExcelFilePopupPage() {
3116
+ // 입력 중인 검색어와 Enter 로 확정된 조회 조건은 따로 든다
3117
+ const [keyword, setKeyword] = useState('');
3118
+ const [query, setQuery] = useState({ keyword: '', page: 1, rowsPerPage: 20 });
3119
+ const [selected, setSelected] = useState<SRow[]>([]);
3120
+ const { rows, total, lastPage } = useExcelFiles(query);
3121
+
3122
+ const save = () => applyExcelFiles(selected.map(row => Number(row.id)));
3123
+
3124
+ return (
3125
+ <SPopup
3126
+ className="h-screen"
3127
+ popupTitle="엑셀 파일 관리"
3128
+ showFooter // 확정할 작업이 있을 때만 (§3-3-3)
3129
+ submitButton={{ label: '저장', disabled: !selected.length }}
3130
+ onSubmit={save}
3131
+ >
3132
+ {/* 툴바+표 = 한 블록이자 본문 루트. h-full min-h-0 → 표의 min-h-0 flex-1 로 세로 축이 이어진다.
3133
+ 필터 표가 위에 더 서면 이 블록을 한 겹 감싸고 여기에 min-h-0 flex-1 을 준다 (§4-2 와 같은 사슬) */}
3134
+ <div className="flex h-full min-h-0 flex-col">
3135
+ {/* 조건이 검색어 하나뿐이면 필터 표를 세우지 않는다 — 목록을 좁히기만 하는 검색어는
3136
+ 폼 필드가 아니라 SSearchInput 이다 (§3-7-1). 툴바 우측에 둔다 */}
3137
+ <STableBar
3138
+ className="border-b-0"
3139
+ total={total}
3140
+ selected={selected.length}
3141
+ rightActions={
3142
+ <SSearchInput
3143
+ value={keyword}
3144
+ onValueChange={value => {
3145
+ setKeyword(value);
3146
+ // 지우기(×)는 값만 비우고 onSearch 를 부르지 않는다 — 걸려 있던 조건도 여기서 함께 푼다
3147
+ if (!value) setQuery(q => ({ ...q, keyword: '', page: 1 }));
3148
+ }}
3149
+ onSearch={value => setQuery(q => ({ ...q, keyword: value, page: 1 }))}
3150
+ placeholder="파일명 검색"
3151
+ width="md"
3152
+ clearable
3153
+ />
3154
+ }
3155
+ />
3156
+ <STable
3157
+ className="min-h-0 flex-1"
3158
+ columns={columns}
3159
+ rows={rows}
3160
+ rowKey="id"
3161
+ resizable
3162
+ selectable
3163
+ selected={selected}
3164
+ onSelectedChange={setSelected}
3165
+ pagination={{ page: query.page, rowsPerPage: query.rowsPerPage, lastPage }}
3166
+ onPageChange={page => setQuery(q => ({ ...q, page }))}
3167
+ />
3168
+ </div>
3169
+ </SPopup>
3170
+ );
3171
+ }
3172
+ ```
3173
+ <!-- /recipe -->
3174
+
3175
+ 조회만 하고 표가 없는 팝업은 이 사슬이 필요 없다 — 본문이 통째로 스크롤되어도 헤더는 고정이고 밀려날 푸터가 없다.
1579
3176
 
1580
3177
  ---
1581
3178
 
@@ -1586,13 +3183,19 @@ export default function ProductDetailPage() {
1586
3183
  - [ ] 생 HTML 컨트롤(`<button>` `<input>` `<select>` `<table>` …)이 없는가
1587
3184
  - [ ] `text-[14px]`, `bg-[#...]` 같은 리터럴 임의 값이 없는가 (`var(--sys-*)` 참조는 허용)
1588
3185
  - [ ] 텍스트에 `typo-*` 프리셋을 썼는가
3186
+ - [ ] 글꼴을 직접 지정하지 않았는가 (글꼴 유틸리티·`fontFamily` 인라인 ❌ — 전역 기본이 이미 적용된다, §2-1)
1589
3187
  - [ ] 간격이 전부 `sd-` 접두 스케일 값인가 (`gap-13`·`gap-sd-13` ❌ → `gap-sd-12` ✅)
1590
3188
  - [ ] 본문이 12px(`typo-body-sm-default`)인가 (14px 본문 ❌)
1591
3189
  - [ ] 텍스트 회색 위계를 순차 적용했는가 (기본 → `text-fg-secondary` → `text-fg-tertiary`, 단계 건너뛰기 ❌)
1592
3190
  - [ ] `SPage`·`SPopup` 의 기본 패딩을 `p-sd-*` 로 덮어쓰지 않았는가, 블록·섹션 **간격**이 `gap-sd-12` 인가 (`gap-sd-16`/`gap-sd-24` ❌ — 24 는 안쪽 여백에만 열린다)
1593
3191
  - [ ] 섹션·패널의 안쪽 **여백**이 §2-2 판정과 맞는가 (덩어리 두 종류 → 16 / 세 종류 이상 → 24, 서면 16)
1594
3192
  - [ ] `SSectionHeaderCard` 의 여백을 `p-sd-*` 가 아니라 `padding` prop 으로 줬는가
1595
- - [ ] 자체 스크롤하는 패널의 하단에 `pb-[var(--cmp-pageBody-padding-scrollEnd)]` 이 있는가, 페이지네이션 있는 목록에서 `contentHeight="fill"` 을 넘기고 `h-full min-h-0` → `STable` 의 `min-h-0 flex-1` 사슬을 이었는가
3193
+ - [ ] 페이지에 `contentHeight="fill"` 을 넘겼는가 (§2-2 표준 — 페이지 스크롤을 쓰는 화면에서만 `auto` + `scrollEndSpacing`)
3194
+ - [ ] `fill` 을 쓴 블록에서 **자식까지 `min-h-0 flex-1` 이 끊기지 않았는가** (한 군데만 끊겨도 자식이 높이를 못 잡는데 실패가 조용하다)
3195
+ - [ ] 셸을 마운트하는 앱 루트가 뷰포트 높이인가 (§4-1 — `h-screen` 이 없으면 GNB 가 바닥까지 안 오고 페이지 스크롤이 문서 스크롤이 된다)
3196
+ - [ ] 팝업 라우트라면 `SPopup` 에 `h-screen` 을 줬는가 (§4-7 — 없으면 본문이 창 밖으로 자라 헤더·푸터가 밀려난다), 표를 담았다면 표만 스크롤하는가
3197
+ - [ ] 자체 스크롤하는 패널의 하단에 `pb-[var(--cmp-pageBody-padding-scrollEnd)]` 이 있는가
3198
+ - [ ] 한 페이지에 섹션 카드가 둘 이상이면 `marker` 를 켰는가, 하나뿐이면 껐는가 (§4-5)
1596
3199
  - [ ] 같은 컴포넌트를 나열할 때 §2-2 그룹 간격을 썼는가 (체크박스 가로 `gap-sd-24` 등)
1597
3200
  - [ ] 페이지가 §4의 표준 골격에서 시작했는가
1598
3201
  - [ ] `header.fix` 가 프로젝트 전체와 같은 값인가 (다른 페이지와 다르게 섞어 쓰지 않았는가, §4-1)
@@ -1601,21 +3204,39 @@ export default function ProductDetailPage() {
1601
3204
  - [ ] 필터·폼·상세 정보를 `SKeyValueTable` 로 만들었는가 (컨트롤을 `div` 로 나열하지 않았는가). 필터를 `SChipFilter` 로 했다면 §3-7-11 의 판정을 거쳤는가
1602
3205
  - [ ] 섹션 구분에 `SSectionHeaderCard` 를 썼는가 (직접 만든 카드가 아니라)
1603
3206
  - [ ] 목록의 주요 액션이 `STableBar` 의 `rightActions` 에 있는가 (페이지 제목 줄이 아니라)
3207
+ - [ ] `STableBar` 와 그 표를 **붙였는가** — 사이에 `gap-sd-12` 를 넣지 않았고, 툴바에 `border-b-0` 을 줬는가 (§2-0 — 안 주면 맞닿는 변만 2px 로 보인다)
3208
+ - [ ] 목록 페이지 표에 `dense` 로 밀도 토글을 띄웠는가, 그 값을 `onDenseChange` 결과로 되돌려 넣지는 않았는가 (§4-2 — 되돌려 넣으면 넓게 본 순간 토글이 사라진다)
1604
3209
  - [ ] 상태 표시에 `STag size="sm"` 을 썼는가
1605
3210
  - [ ] 테이블에서 양을 나타내는 컬럼(금액·수량·개수 등)이 전부 `align: 'right'` 인가
1606
3211
  - [ ] 번호·코드·전화번호·일자 컬럼에 `align: 'center'` 를 **명시**했는가 (생략하면 좌측이 된다)
1607
- - [ ] 컨트롤(`STag`·`SButton`·`SSelect`·`SInput` …)이 들어가는 컬럼에 `width` 를 명시했는가, `resizable` 이면 `minWidth` 도 줬는가 (§3-4 — 폭이 모자라면 요소가 잘려 못 쓴다)
3212
+ - [ ] 닫힌 값 집합(enum·마스터 목록에서 고르는 값) 컬럼에 `align: 'center'` 를 줬는가 — 태그로 그렸든 맨 텍스트로 그렸든 같다 (§3-4)
3213
+ - [ ] **모든 컬럼에 폭을 명시**했는가, px 로만 줬는가 (`%`·`clamp()` ❌), `autoWidth` 는 스페이서 열 하나뿐인가 (§3-4)
3214
+ - [ ] 컨트롤(`STag`·`SButton`·`SSelect`·`SInput` …)이 들어가는 컬럼이 `resizable` 이면 `minWidth` 도 줬는가 (§3-4 — 폭이 모자라면 요소가 잘려 못 쓴다)
3215
+ - [ ] 정렬 가능한 열에 `sortable` 을 줬는가 (`renderHeader` 로 직접 만들지 않았는가), 정렬 상태를 페이지가 들고 있는가 (§3-4)
3216
+ - [ ] 일부 행만 고를 수 있는 표라면 `isRowSelectable` 로 잠갔는가 — 체크박스를 밖에서 직접 잠그거나 전체 선택을 손으로 보정하지 않았는가 (§3-4)
3217
+ - [ ] 표 본문의 스크롤이 필요하면 `onScroll` · `ref.getScrollElement()` 를 썼는가 — 내부 마크업을 겨냥한 선택자로 스크롤 컨테이너를 찾지 않았는가 (§3-4)
3218
+ - [ ] 스크롤 위치를 저장했다 되돌린다면 **행 번호**(`getFirstVisibleRow` → `scrollToRow`)로 했는가, `scrollTop` 픽셀을 저장하지는 않았는가 (§3-4 — 가상 스크롤에서는 좌표계가 실측으로 움직여 다른 행이 나온다)
3219
+ - [ ] `editable` · `navigable` 표식을 켠 열이 **셀에서도 실제로 그렇게 동작하는가** (입력 컨트롤 · 링크가 있는가), **모달로 처리하는 열에 `navigable` 을 켜지는 않았는가** (화면을 떠나지 않으므로 `editable` 이다), 표식을 붙인 열의 폭을 함께 넓혔는가 (§3-4)
1608
3220
  - [ ] 금액·수량 등 양을 나타내는 숫자에 빠짐없이 `toLocaleString()` 을 썼는가 (번호·코드는 제외)
1609
- - [ ] 하단 버튼이 양끝 분리(`justify-between`)이고, 되돌리기가 왼쪽 끝 · 실행이 오른쪽 끝인가
3221
+ - [ ] 날짜를 `YYYY-MM-DD` 로 표기했는가 (`toLocaleDateString()` ❌, §1-4)
3222
+ - [ ] 필드 폭을 등급(`'xs' | 'sm' | 'md' | 'lg' | 'xl'`)으로 줬는가 — px 직접 지정 ❌ (§4-3)
3223
+ - [ ] 조회 조건으로 쓰는 셀렉트·날짜·시간 피커에 `clearable` 을 줬는가, 그 상태가 `null` 을 담을 수 있는가 (§3-7-4 — 필수 입력 필드에는 켜지 않는다)
3224
+ - [ ] 서버에서 페이지 단위로 받는 `SSelect` 에 `onReachEnd` 와 `hasMore`·`loading`·`serverSearch` 를 함께 줬는가, 늦게 온 응답을 버리는 cleanup 이 있는가 (§3-7-2 — 렌더 최적화는 DS 가 알아서 한다)
3225
+ - [ ] `SKeyValueTable` 의 짧은 행에 `tdColSpan` 을 주어 전체 열 수를 채웠는가 (§4-3 — 안 채우면 그 구간의 행 구분선이 끊긴다)
3226
+ - [ ] 날짜·시간 필드가 섞인 `SKeyValueTable` 에서 오른쪽 끝을 맞출지 정했는가 (§4-3 `fieldWidth` — 필터는 `"fill"`, 등록·수정 폼은 기본값. 필드마다 `maxWidth` 를 주지 않는다)
3227
+ - [ ] 하단 버튼이 양끝 분리(`justify-between`)이고, 되돌리기가 왼쪽 끝 · 실행이 오른쪽 끝인가 (되돌리는 액션이 없어도 양끝 분리는 그대로 — 오른쪽에 모으지 않고 위계가 낮은 쪽을 왼쪽 끝에 둔다)
1610
3228
  - [ ] 페이지에 `color="primary"` 채움 버튼이 **1개뿐**인가 (`danger` 채움도 1개, `SDropdownButton` 포함)
1611
3229
  - [ ] 버튼 `size` 가 위치 규칙과 맞는가 (행 내부 `xs` / 화면 액션 `sm` / 모달 푸터 `md`)
3230
+ - [ ] 카드 `footerSize` 가 카드가 놓인 자리와 맞고(화면의 주 액션 `md` / 부분만 확정 `sm`, §3-7-8), `footerLeft` 슬롯 버튼의 `size` 를 그 값에 맞췄는가
1612
3231
  - [ ] 파괴 액션의 채움/outline 이 **적용 범위 + 위험도** 기준과 맞는가
1613
3232
  - [ ] `SGhostButton` 이 아이콘만으로 뜻이 통하는 부가 위치(표·모달 헤더·카드)에만 쓰였고, `ariaLabel` 이 전부 있는가 (라벨이 필요하면 `SButton`)
1614
3233
  - [ ] `SGhostButton` 의 `intent` 가 조작 성격과 맞는가 (되돌릴 수 없는 삭제만 `danger`, 진입·추가는 `action`, 나머지는 `default`)
1615
3234
  - [ ] 창을 띄울 때 §3-3-1 판별 순서를 따랐는가 (그 자체가 화면 → `SPopup` / 실행 여부만 확정 → `SModal.confirm` / 모달 안에서 작성 → `SActionModal`)
1616
3235
  - [ ] 작업용 모달을 `SActionModal` + `SModal.create` 로 만들었는가 (직접 오버레이 ❌)
1617
- - [ ] 모달·드로어의 하단 버튼을 본문이 아니라 `button` · `footerLeft` prop 으로 넘겼는가 (§3-3-4)
3236
+ - [ ] 모달·드로어의 하단 버튼을 본문이 아니라 `button` · `footerLeft` prop 으로 넘겼는가 (§3-3-4), 카드 안에서 닫히는 액션도 같은 prop 으로 넘겼는가 (§3-7-8)
3237
+ - [ ] 아래에 버튼이 있는 자리(모달·드로어·카드)의 `STextarea autogrow` 에 `maxRows` 를 함께 줬는가 (§3-7-1 — 없으면 긴 글이 버튼을 화면 밖으로 밀어낸다)
1618
3238
  - [ ] 앱 부트스트랩의 Provider 안쪽에 `<SModalOutlet />` 이 한 번 렌더되어 있는가 (§4-1 — 없으면 모달 안에서 앱 훅이 죽는다), 그 대신으로 모달 컴포넌트를 Provider 로 다시 감싸지 않았는가
3239
+ - [ ] 전역 로딩의 `loading.show()` 반환 핸들을 잡아 `finally` 에서 그것만 불렀는가, 메시지를 바꾸려고 `show()` 를 두 번 부르지는 않았는가 (§3-3-6 — 걷기를 빠뜨리면 화면이 갇히고, 두 번 부르면 하나 더 쌓여 오버레이가 남는다)
1619
3240
  - [ ] 고른 컴포넌트를 §2-0 의 제 층에 놓았는가 (요소를 `SPage` 에 직접 놓지 않았는가, 블록을 `div` 로 감싸지 않았는가)
1620
3241
  - [ ] §2-0 포함 규칙을 지켰는가 (카드 안 카드 ❌, 표 셀 안 블록 ❌)
1621
3242
  - [ ] 블록 순서가 §2-0 순서와 맞는가 (제목 → 안내 → 필터 → 툴바 → 본문 → 페이지네이션 → 하단 액션)
@@ -1642,6 +3263,8 @@ export default function ProductDetailPage() {
1642
3263
  | `sellmate/component-group-gap` | warn | §2-2 컴포넌트 그룹 간격 (체크박스 가로 24 / 세로 8 등) |
1643
3264
  | `sellmate/table-numeric-align` | warn | §3-4 숫자 컬럼의 `align: 'right'` 누락 (`--fix` 지원) |
1644
3265
  | `sellmate/require-locale-number` | warn | §1-4 금액·수량 등 수량 컬럼의 `toLocaleString()` 누락 |
3266
+ | `sellmate/field-width-grade` | warn | §4-3 필드 폭이 `maxLength` 상한과 맞는 등급인가, px 를 직접 적지 않았는가 (px → 등급 `--fix` 지원) |
3267
+ | `sellmate/table-column-width` | warn | §3-4 컬럼 폭 미지정(기본 120px)·px 아닌 값(`%`·`clamp()`)·`autoWidth` 오용 |
1645
3268
  | `sellmate/no-arbitrary-class` | off | §1-2 토큰 있는 속성의 임의 값 (`text-[14px]`, `bg-[#eee]`) — 팀이 켤 때만 |
1646
3269
 
1647
3270
  `configs.strict` 를 쓰는 프로젝트는 전부 error 이고 간격 `sd-` 접두까지 강제된다.
@@ -1664,34 +3287,35 @@ Tailwind 유틸리티는 아래 스케일에 있는 값만 사용한다. 리터
1664
3287
  10 11 12 13 14 16 18 20 24 28 32
1665
3288
 
1666
3289
  ### radius (rounded-*)
1667
- 2 4 6 8 full control-sm control-md feedback-xs-square feedback-sm-square feedback-pill field-sm field-md navigation item-bordered
3290
+ 2 4 6 8 12 full control-sm control-md feedback-xs-square feedback-sm-square feedback-pill field-sm field-md navigation item-bordered
1668
3291
 
1669
3292
  ### 색상 팔레트 (text-{family}-{step}, bg-…, border-…)
1670
3293
  - white
1671
3294
  - black
1672
- - grey: 10 20 25 30 35 45 50 55 60 65 70 80 85 90 95 05 strong subtle
1673
- - red: 15 20 30 45 60 70 75 80 85 90 95 99 strong subtle
1674
- - orange: 10 20 35 45 55 60 65 75 85 90 95 99 strong subtle
3295
+ - grey: 10 20 25 30 35 45 50 55 60 65 70 80 85 90 95 05 strong moderate subtle
3296
+ - red: 15 20 30 45 60 70 75 80 85 90 95 99 strong moderate subtle
3297
+ - orange: 10 20 35 45 55 60 65 75 85 90 95 99 strong moderate subtle
1675
3298
  - yellow: 10 20 25 30 40 45 50 60 70 80 90 95 strong moderate subtle
1676
3299
  - olive: 10 15 20 30 45 55 65 70 80 90 95 05
1677
- - green: 15 25 45 55 65 70 75 80 85 90 95 99 strong subtle
3300
+ - green: 15 25 45 55 65 70 75 80 85 90 95 99 strong moderate subtle
1678
3301
  - steelblue: 10 25 45 60 65 70 75 80 85 90 95 99
1679
3302
  - oceanblue: 15 25 50 60 65 70 75 80 85 90 95 99
1680
3303
  - brilliantblue: 10 20 25 40 50 60 70 75 80 85 90 95 99 05
1681
- - bg: screen frame brand subtle accent accentLight accentBright accentFaint deep danger dangerLight warning caution progress success successLight neutralLight neutralBright tableHeader
3304
+ - purple: 15 25 45 55 65 70 75 80 85 90 95 99 strong moderate subtle
3305
+ - bg: screen frame brand subtle accent accentLight accentBright accentFaint deep danger dangerLight warning caution progress success neutralLight neutralBright tableHeader
1682
3306
  - fg: primary secondary tertiary quaternary brand subtleWarm subtleCool deep success successLight dangerLight accent accentLight danger warning inverse
1683
3307
  - border: default strong bold accent accentLight danger warning success
1684
3308
  - disabled: bg text icon border
1685
3309
  - link: accent
1686
3310
  - divider: default
1687
3311
  - lightblue: strong subtle
1688
- - blue: strong subtle
1689
- - darkblue: strong subtle
1690
- - indigo: strong subtle
3312
+ - blue: strong moderate subtle
3313
+ - darkblue: strong moderate subtle
3314
+ - indigo: strong moderate subtle
1691
3315
  - primary: 10 20 25 40 50 60 70 75 80 85 90 95 99 05
1692
- - danger: 15 20 30 45 60 70 75 80 85 90 95 99 strong subtle
1693
- - success: 15 25 45 55 65 70 75 80 85 90 95 99 strong subtle
1694
- - warning: 10 20 35 45 55 60 65 75 85 90 95 99 strong subtle
3316
+ - danger: 15 20 30 45 60 70 75 80 85 90 95 99 strong moderate subtle
3317
+ - success: 15 25 45 55 65 70 75 80 85 90 95 99 strong moderate subtle
3318
+ - warning: 10 20 35 45 55 60 65 75 85 90 95 99 strong moderate subtle
1695
3319
 
1696
3320
  ### 타이포그래피 프리셋 (클래스 하나로 size/weight/line-height 적용)
1697
3321
  | 클래스 | size | weight | line-height |
@@ -1701,15 +3325,22 @@ Tailwind 유틸리티는 아래 스케일에 있는 값만 사용한다. 리터
1701
3325
  | typo-heading-sm | 14px | 700 | 24px |
1702
3326
  | typo-heading-md | 16px | 700 | 26px |
1703
3327
  | typo-heading-lg | 18px | 700 | 30px |
3328
+ | typo-heading-xl | 24px | 700 | 38px |
1704
3329
  | typo-body-xs-default | 11px | 400 | 18px |
3330
+ | typo-body-xs-medium | 11px | 500 | 18px |
3331
+ | typo-body-xs-semibold | 11px | 600 | 18px |
3332
+ | typo-body-xs-bold | 11px | 700 | 18px |
1705
3333
  | typo-body-sm-default | 12px | 400 | 20px |
1706
3334
  | typo-body-sm-medium | 12px | 500 | 20px |
3335
+ | typo-body-sm-semibold | 12px | 600 | 20px |
1707
3336
  | typo-body-sm-bold | 12px | 700 | 20px |
1708
3337
  | typo-body-md-default | 14px | 400 | 24px |
1709
3338
  | typo-body-md-medium | 14px | 500 | 24px |
3339
+ | typo-body-md-semibold | 14px | 600 | 24px |
1710
3340
  | typo-body-md-bold | 14px | 700 | 24px |
1711
3341
  | typo-body-lg-default | 16px | 400 | 26px |
1712
3342
  | typo-body-lg-medium | 16px | 500 | 26px |
3343
+ | typo-body-lg-semibold | 16px | 600 | 26px |
1713
3344
  | typo-body-lg-bold | 16px | 700 | 26px |
1714
3345
  | typo-control-xs-default | 12px | 400 | 20px |
1715
3346
  | typo-control-xs-medium | 12px | 500 | 20px |
@@ -1723,10 +3354,12 @@ Tailwind 유틸리티는 아래 스케일에 있는 값만 사용한다. 리터
1723
3354
  | typo-feedback-xs | 11px | 500 | 18px |
1724
3355
  | typo-feedback-sm-regular | 12px | 400 | 20px |
1725
3356
  | typo-feedback-sm-medium | 12px | 500 | 20px |
3357
+ | typo-feedback-sm-semibold | 12px | 600 | 20px |
1726
3358
  | typo-feedback-sm-bold | 12px | 700 | 20px |
1727
3359
  | typo-feedback-sm-underline | 12px | 500 | 20px |
1728
- | typo-feedback-md-bold | 14px | 700 | 24px |
3360
+ | typo-feedback-md-regular | 14px | 400 | 24px |
1729
3361
  | typo-feedback-md-medium | 14px | 500 | 24px |
3362
+ | typo-feedback-md-bold | 14px | 700 | 24px |
1730
3363
  | typo-feedback-md-underline | 14px | 700 | 24px |
1731
3364
  | typo-field-xs-default | 11px | 400 | 18px |
1732
3365
  | typo-field-sm-default | 12px | 400 | 20px |
@@ -1771,9 +3404,9 @@ Tailwind 유틸리티는 아래 스케일에 있는 값만 사용한다. 리터
1771
3404
  --sys-color-bg-screen
1772
3405
  --sys-color-bg-subtle
1773
3406
  --sys-color-bg-success
1774
- --sys-color-bg-successLight
1775
3407
  --sys-color-bg-tableHeader
1776
3408
  --sys-color-bg-warning
3409
+ --sys-color-blue-moderate
1777
3410
  --sys-color-blue-strong
1778
3411
  --sys-color-blue-subtle
1779
3412
  --sys-color-border-accent
@@ -1785,6 +3418,7 @@ Tailwind 유틸리티는 아래 스케일에 있는 값만 사용한다. 리터
1785
3418
  --sys-color-border-success
1786
3419
  --sys-color-border-warning
1787
3420
  --sys-color-control-bg-inverseHover
3421
+ --sys-color-darkblue-moderate
1788
3422
  --sys-color-darkblue-strong
1789
3423
  --sys-color-darkblue-subtle
1790
3424
  --sys-color-disabled-bg
@@ -1820,14 +3454,31 @@ Tailwind 유틸리티는 아래 스케일에 있는 값만 사용한다. 리터
1820
3454
  --sys-color-field-text-default
1821
3455
  --sys-color-field-text-placeholder
1822
3456
  --sys-color-field-text-readOnly
3457
+ --sys-color-indigo-moderate
1823
3458
  --sys-color-indigo-strong
1824
3459
  --sys-color-indigo-subtle
1825
3460
  --sys-color-lightblue-strong
1826
3461
  --sys-color-lightblue-subtle
1827
3462
  --sys-color-link-accent
1828
3463
  --sys-color-navigation-gnb-bg-dark
1829
-
1830
- ## 3. 컴포넌트 인덱스 (65종)
3464
+ --sys-color-navigation-gnb-bg-system
3465
+ --sys-color-purple-15
3466
+ --sys-color-purple-25
3467
+ --sys-color-purple-45
3468
+ --sys-color-purple-55
3469
+ --sys-color-purple-65
3470
+ --sys-color-purple-70
3471
+ --sys-color-purple-75
3472
+ --sys-color-purple-80
3473
+ --sys-color-purple-85
3474
+ --sys-color-purple-90
3475
+ --sys-color-purple-95
3476
+ --sys-color-purple-99
3477
+ --sys-color-purple-moderate
3478
+ --sys-color-purple-strong
3479
+ --sys-color-purple-subtle
3480
+
3481
+ ## 3. 컴포넌트 인덱스 (86종)
1831
3482
 
1832
3483
  **Props 는 이 문서에 없다.** 쓸 컴포넌트를 정했으면 그 컴포넌트의 README 를 읽는다.
1833
3484
 
@@ -1836,4 +3487,4 @@ Tailwind 유틸리티는 아래 스케일에 있는 값만 사용한다. 리터
1836
3487
  예) SButton 을 쓴다면 `dist/components/SButton/README.md`.
1837
3488
  prop 을 추측해서 쓰지 말고 반드시 README 로 확인한다 (분류별 용도는 §1 의 "전체 컴포넌트 인덱스" 참조).
1838
3489
 
1839
- 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
3490
+ SAccountListBox · SActionModal · SBadge · SBarChart · SBarcodeInput · SButton · SCalendar · SCalendarBoard · SCallout · SCard · SChatAttachedFile · SChatFile · SChatInput · SChatMessage · SChatSystemMessage · SCheckbox · SChip · SChipFilter · SChipInput · SCircleProgress · SConfirmModal · SContextAlert · SDatePicker · SDateRangePicker · SDivider · SDonutChart · SDraggableItem · SDraggableList · SDrawer · SDropdownButton · SEditor · SExpansionItem · SExpansionList · SField · SFilePicker · SForm · SGhostButton · SGnb · SGnbSystem · SGuide · SIcon · SImage · SInput · SKeyValueTable · SLauncherListBox · SLayout · SLineChart · SLinearProgress · SList · SListItem · SLoadingContainer · SLoadingModal · SLoginCard · SLogo · SMeatballButton · SModal · SNumberInput · SPage · SPagination · SPieChart · 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