@hjmds/design-contracts 1.12.1 → 1.13.1

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 (288) hide show
  1. package/dist/avatar-fallback.d.ts +11 -0
  2. package/dist/avatar-fallback.d.ts.map +1 -1
  3. package/dist/avatar-fallback.js +21 -0
  4. package/dist/avatar-fallback.js.map +1 -1
  5. package/dist/base-recipes.d.ts +17 -0
  6. package/dist/base-recipes.d.ts.map +1 -1
  7. package/dist/base-recipes.js +17 -0
  8. package/dist/base-recipes.js.map +1 -1
  9. package/dist/catalog.d.ts +26 -0
  10. package/dist/catalog.d.ts.map +1 -1
  11. package/dist/command-palette.d.ts +14 -9
  12. package/dist/command-palette.d.ts.map +1 -1
  13. package/dist/command-palette.js +8 -9
  14. package/dist/command-palette.js.map +1 -1
  15. package/dist/component-recipes.d.ts +31 -0
  16. package/dist/component-recipes.d.ts.map +1 -1
  17. package/dist/component-recipes.js +20 -0
  18. package/dist/component-recipes.js.map +1 -1
  19. package/dist/provider-button.d.ts.map +1 -1
  20. package/dist/provider-button.js +3 -0
  21. package/dist/provider-button.js.map +1 -1
  22. package/dist/reactions.d.ts +10 -0
  23. package/dist/reactions.d.ts.map +1 -1
  24. package/dist/reactions.js +7 -0
  25. package/dist/reactions.js.map +1 -1
  26. package/dist/screen-patterns.d.ts +147 -0
  27. package/dist/screen-patterns.d.ts.map +1 -0
  28. package/dist/screen-patterns.js +149 -0
  29. package/dist/screen-patterns.js.map +1 -0
  30. package/dist/slider.d.ts +8 -0
  31. package/dist/slider.d.ts.map +1 -1
  32. package/dist/slider.js +6 -1
  33. package/dist/slider.js.map +1 -1
  34. package/dist/upload-item.d.ts +5 -0
  35. package/dist/upload-item.d.ts.map +1 -1
  36. package/dist/upload-item.js +5 -0
  37. package/dist/upload-item.js.map +1 -1
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/dist/version.js.map +1 -1
  41. package/docs/action-session.md +3 -3
  42. package/docs/agreement.md +5 -0
  43. package/docs/avatar-fallback.md +7 -0
  44. package/docs/bottom-navigation.md +6 -0
  45. package/docs/brand-boundary.md +1 -1
  46. package/docs/button-label.md +6 -0
  47. package/docs/clipboard.md +3 -0
  48. package/docs/command-palette.md +45 -2
  49. package/docs/consumer-policy.md +5 -1
  50. package/docs/data-table.md +6 -4
  51. package/docs/dialog.md +8 -2
  52. package/docs/form.md +51 -0
  53. package/docs/generated/component-maturity.md +1 -1
  54. package/docs/generated/renderer-evidence.json +3 -3
  55. package/docs/generated/renderer-evidence.md +1 -1
  56. package/docs/generated/showcase-manifest.json +1 -1
  57. package/docs/link.md +8 -0
  58. package/docs/migration-native-legacy-removal.md +45 -1
  59. package/docs/optional-adapters.md +1 -1
  60. package/docs/password-field.md +5 -0
  61. package/docs/product-composition-adoption.md +40 -0
  62. package/docs/progress.md +19 -1
  63. package/docs/provider-button.md +13 -0
  64. package/docs/result.md +3 -0
  65. package/docs/screen-chrome.md +10 -0
  66. package/docs/screen-patterns.md +378 -0
  67. package/docs/sheet.md +21 -0
  68. package/docs/splitter.md +8 -2
  69. package/docs/theming.md +36 -29
  70. package/docs/toggle-group.md +13 -0
  71. package/docs/tour.md +7 -1
  72. package/docs/tree.md +5 -2
  73. package/docs/upload-item.md +7 -0
  74. package/docs/usage/README.md +236 -0
  75. package/docs/usage/STANDARD.md +108 -0
  76. package/docs/usage/components/accordion.md +107 -0
  77. package/docs/usage/components/activity-heatmap.md +104 -0
  78. package/docs/usage/components/affix.md +86 -0
  79. package/docs/usage/components/agreement.md +129 -0
  80. package/docs/usage/components/alert-dialog.md +130 -0
  81. package/docs/usage/components/anchor.md +96 -0
  82. package/docs/usage/components/aspect-ratio.md +89 -0
  83. package/docs/usage/components/asset.md +126 -0
  84. package/docs/usage/components/auth-provider-button.md +116 -0
  85. package/docs/usage/components/auth-screen-layout.md +129 -0
  86. package/docs/usage/components/avatar.md +114 -0
  87. package/docs/usage/components/badge.md +84 -0
  88. package/docs/usage/components/bottom-cta.md +125 -0
  89. package/docs/usage/components/bottom-info.md +99 -0
  90. package/docs/usage/components/bottom-navigation.md +136 -0
  91. package/docs/usage/components/breadcrumb.md +81 -0
  92. package/docs/usage/components/button.md +118 -0
  93. package/docs/usage/components/calendar.md +122 -0
  94. package/docs/usage/components/card.md +110 -0
  95. package/docs/usage/components/carousel.md +113 -0
  96. package/docs/usage/components/celebration.md +96 -0
  97. package/docs/usage/components/chat-message.md +122 -0
  98. package/docs/usage/components/chat-screen.md +112 -0
  99. package/docs/usage/components/checkbox-group.md +104 -0
  100. package/docs/usage/components/checkbox.md +103 -0
  101. package/docs/usage/components/chip.md +107 -0
  102. package/docs/usage/components/code-block.md +111 -0
  103. package/docs/usage/components/collapsible.md +112 -0
  104. package/docs/usage/components/color-picker.md +86 -0
  105. package/docs/usage/components/combobox.md +137 -0
  106. package/docs/usage/components/command-palette.md +125 -0
  107. package/docs/usage/components/comment-thread-screen.md +125 -0
  108. package/docs/usage/components/container.md +98 -0
  109. package/docs/usage/components/content-transition.md +101 -0
  110. package/docs/usage/components/context-menu.md +136 -0
  111. package/docs/usage/components/counter-badge.md +107 -0
  112. package/docs/usage/components/data-table.md +122 -0
  113. package/docs/usage/components/date-picker.md +142 -0
  114. package/docs/usage/components/date-range-picker.md +111 -0
  115. package/docs/usage/components/description-list.md +103 -0
  116. package/docs/usage/components/design-system-provider.md +124 -0
  117. package/docs/usage/components/dialog.md +176 -0
  118. package/docs/usage/components/divider.md +89 -0
  119. package/docs/usage/components/editor-screen.md +126 -0
  120. package/docs/usage/components/effect-surface.md +120 -0
  121. package/docs/usage/components/empty-state.md +114 -0
  122. package/docs/usage/components/field.md +129 -0
  123. package/docs/usage/components/file-picker.md +114 -0
  124. package/docs/usage/components/floating-action-button.md +138 -0
  125. package/docs/usage/components/form.md +162 -0
  126. package/docs/usage/components/grid.md +99 -0
  127. package/docs/usage/components/heading.md +87 -0
  128. package/docs/usage/components/icon-button.md +126 -0
  129. package/docs/usage/components/icon.md +105 -0
  130. package/docs/usage/components/image.md +122 -0
  131. package/docs/usage/components/keyboard-avoiding.md +93 -0
  132. package/docs/usage/components/keyboard-dock.md +110 -0
  133. package/docs/usage/components/keyboard-form-scroll-view.md +95 -0
  134. package/docs/usage/components/keyboard-motion-provider.md +86 -0
  135. package/docs/usage/components/layout.md +117 -0
  136. package/docs/usage/components/link.md +121 -0
  137. package/docs/usage/components/list-detail-screen.md +103 -0
  138. package/docs/usage/components/list-row.md +124 -0
  139. package/docs/usage/components/list.md +119 -0
  140. package/docs/usage/components/load-more.md +115 -0
  141. package/docs/usage/components/masonry.md +109 -0
  142. package/docs/usage/components/media-selection-screen.md +119 -0
  143. package/docs/usage/components/mentions.md +119 -0
  144. package/docs/usage/components/menu.md +129 -0
  145. package/docs/usage/components/menubar.md +93 -0
  146. package/docs/usage/components/message-composer.md +124 -0
  147. package/docs/usage/components/moderation-screen.md +113 -0
  148. package/docs/usage/components/notice.md +106 -0
  149. package/docs/usage/components/notification-inbox-screen.md +97 -0
  150. package/docs/usage/components/notification-item.md +98 -0
  151. package/docs/usage/components/number-field.md +131 -0
  152. package/docs/usage/components/onboarding-screen.md +106 -0
  153. package/docs/usage/components/otp-field.md +101 -0
  154. package/docs/usage/components/pagination.md +82 -0
  155. package/docs/usage/components/password-field.md +137 -0
  156. package/docs/usage/components/permission-screen.md +107 -0
  157. package/docs/usage/components/photo-source-sheet.md +119 -0
  158. package/docs/usage/components/popover.md +108 -0
  159. package/docs/usage/components/profile-screen.md +89 -0
  160. package/docs/usage/components/progress.md +122 -0
  161. package/docs/usage/components/qr-code.md +122 -0
  162. package/docs/usage/components/radio-group.md +124 -0
  163. package/docs/usage/components/radio.md +104 -0
  164. package/docs/usage/components/result.md +116 -0
  165. package/docs/usage/components/saved-items-screen.md +126 -0
  166. package/docs/usage/components/screen-layout.md +119 -0
  167. package/docs/usage/components/search-field.md +120 -0
  168. package/docs/usage/components/search-screen.md +221 -0
  169. package/docs/usage/components/section.md +111 -0
  170. package/docs/usage/components/segmented-control.md +146 -0
  171. package/docs/usage/components/select.md +142 -0
  172. package/docs/usage/components/settings-screen.md +126 -0
  173. package/docs/usage/components/shared-transition-element.md +111 -0
  174. package/docs/usage/components/shared-transition-screen.md +86 -0
  175. package/docs/usage/components/sheet.md +157 -0
  176. package/docs/usage/components/side-panel.md +104 -0
  177. package/docs/usage/components/sidebar.md +107 -0
  178. package/docs/usage/components/skeleton.md +105 -0
  179. package/docs/usage/components/skip-nav.md +76 -0
  180. package/docs/usage/components/slider.md +121 -0
  181. package/docs/usage/components/sortable-collection.md +127 -0
  182. package/docs/usage/components/spinner.md +86 -0
  183. package/docs/usage/components/splitter.md +103 -0
  184. package/docs/usage/components/stack.md +93 -0
  185. package/docs/usage/components/statistic.md +123 -0
  186. package/docs/usage/components/steps.md +110 -0
  187. package/docs/usage/components/surface.md +91 -0
  188. package/docs/usage/components/swipe-actions.md +124 -0
  189. package/docs/usage/components/switch.md +120 -0
  190. package/docs/usage/components/tabs.md +134 -0
  191. package/docs/usage/components/tag.md +84 -0
  192. package/docs/usage/components/tags-input.md +111 -0
  193. package/docs/usage/components/text-area.md +112 -0
  194. package/docs/usage/components/text-format.md +75 -0
  195. package/docs/usage/components/text-transition.md +104 -0
  196. package/docs/usage/components/text.md +101 -0
  197. package/docs/usage/components/thinking-orb.md +105 -0
  198. package/docs/usage/components/timeline.md +105 -0
  199. package/docs/usage/components/toast.md +145 -0
  200. package/docs/usage/components/toggle-group.md +95 -0
  201. package/docs/usage/components/tooltip.md +103 -0
  202. package/docs/usage/components/top-bar.md +124 -0
  203. package/docs/usage/components/top.md +89 -0
  204. package/docs/usage/components/tour.md +118 -0
  205. package/docs/usage/components/transfer-list.md +115 -0
  206. package/docs/usage/components/tree.md +91 -0
  207. package/docs/usage/components/upload-item.md +99 -0
  208. package/docs/usage/components/virtual-list.md +105 -0
  209. package/docs/usage/components/visually-hidden.md +72 -0
  210. package/docs/usage/components/watermark.md +78 -0
  211. package/docs/usage/compositions/action-recovery-optimistic.md +180 -0
  212. package/docs/usage/compositions/action-recovery-save.md +235 -0
  213. package/docs/usage/compositions/action-recovery-undo.md +193 -0
  214. package/docs/usage/compositions/common-message.md +132 -0
  215. package/docs/usage/compositions/common-notification.md +101 -0
  216. package/docs/usage/compositions/compound-controls.md +186 -0
  217. package/docs/usage/compositions/data-layouts.md +157 -0
  218. package/docs/usage/compositions/disclosure.md +144 -0
  219. package/docs/usage/compositions/environment-matrix.md +139 -0
  220. package/docs/usage/compositions/expo-interactions.md +149 -0
  221. package/docs/usage/compositions/family-drawer.md +201 -0
  222. package/docs/usage/compositions/floating-action-button.md +197 -0
  223. package/docs/usage/compositions/input-sheet.md +148 -0
  224. package/docs/usage/compositions/interaction-adapters.md +190 -0
  225. package/docs/usage/compositions/interaction-flow-apply.md +205 -0
  226. package/docs/usage/compositions/interaction-flow-draft.md +188 -0
  227. package/docs/usage/compositions/interaction-flow-search.md +171 -0
  228. package/docs/usage/compositions/native-renderers.md +106 -0
  229. package/docs/usage/compositions/navigation-bar-collection.md +164 -0
  230. package/docs/usage/compositions/optional-adapters.md +169 -0
  231. package/docs/usage/compositions/optional-motion.md +109 -0
  232. package/docs/usage/compositions/photo-source.md +104 -0
  233. package/docs/usage/compositions/purpose-input-comment.md +110 -0
  234. package/docs/usage/compositions/purpose-input-message.md +119 -0
  235. package/docs/usage/compositions/reference-first.md +96 -0
  236. package/docs/usage/compositions/reference-review.md +107 -0
  237. package/docs/usage/compositions/reference-settings.md +107 -0
  238. package/docs/usage/compositions/selection-scope.md +174 -0
  239. package/docs/usage/compositions/stea-event-ticket.md +166 -0
  240. package/docs/usage/compositions/stea-flip-card.md +162 -0
  241. package/docs/usage/compositions/stea-order-progress.md +184 -0
  242. package/docs/usage/compositions/stea-otp-verify.md +215 -0
  243. package/docs/usage/compositions/stea-pixel-empty.md +140 -0
  244. package/docs/usage/compositions/stea-schedule-card.md +169 -0
  245. package/docs/usage/compositions/stea-stat-summary.md +154 -0
  246. package/docs/usage/compositions/time-selection.md +174 -0
  247. package/docs/usage/compositions/toast-layout.md +128 -0
  248. package/docs/usage/compositions/visual-foundations.md +185 -0
  249. package/docs/usage/compositions/web-additions.md +146 -0
  250. package/docs/usage/compositions/web-navigation.md +143 -0
  251. package/docs/usage/screens/common-chat.md +127 -0
  252. package/docs/usage/screens/common-comments.md +108 -0
  253. package/docs/usage/screens/common-inbox.md +110 -0
  254. package/docs/usage/screens/common-login.md +98 -0
  255. package/docs/usage/screens/common-profile.md +221 -0
  256. package/docs/usage/screens/common-saved.md +127 -0
  257. package/docs/usage/screens/common-search.md +279 -0
  258. package/docs/usage/screens/common-settings.md +126 -0
  259. package/docs/usage/screens/common-shell.md +108 -0
  260. package/docs/usage/screens/dashboard.md +245 -0
  261. package/docs/usage/screens/discovery-gallery.md +306 -0
  262. package/docs/usage/screens/flow-collection.md +96 -0
  263. package/docs/usage/screens/flow-editor.md +120 -0
  264. package/docs/usage/screens/flow-media.md +111 -0
  265. package/docs/usage/screens/flow-moderation.md +120 -0
  266. package/docs/usage/screens/flow-onboarding.md +193 -0
  267. package/docs/usage/screens/flow-permission.md +103 -0
  268. package/docs/usage/screens/landing.md +347 -0
  269. package/docs/usage/screens/mockup-studio.md +190 -0
  270. package/docs/usage/screens/notification-settings.md +206 -0
  271. package/docs/usage/screens/reference-comparison.md +159 -0
  272. package/docs/usage/templates/component.md +61 -0
  273. package/docs/usage/templates/composition.md +47 -0
  274. package/docs/usage/templates/screen.md +56 -0
  275. package/docs/usage/templates/token.md +32 -0
  276. package/docs/usage/tokens/color.md +142 -0
  277. package/docs/usage/tokens/elevation-opacity.md +86 -0
  278. package/docs/usage/tokens/layers.md +98 -0
  279. package/docs/usage/tokens/layout.md +114 -0
  280. package/docs/usage/tokens/motion.md +88 -0
  281. package/docs/usage/tokens/radius.md +53 -0
  282. package/docs/usage/tokens/size.md +74 -0
  283. package/docs/usage/tokens/spacing.md +73 -0
  284. package/docs/usage/tokens/stroke.md +50 -0
  285. package/docs/usage/tokens/theme-studio.md +70 -0
  286. package/docs/usage/tokens/typography-studio.md +70 -0
  287. package/docs/usage/tokens/typography.md +89 -0
  288. package/package.json +7 -1
@@ -0,0 +1,169 @@
1
+ # 날짜 선택과 예정 목록
2
+
3
+ - 단계: 구성
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: `showcase/web/src/patterns/stea-composition-previews.tsx`(`ScheduleDayList`), `showcase/native/src/stea-composition-previews.tsx`(`ScheduleDayList`, `Frame`), `showcase/shared/stea-compositions.ts`(`scheduleCopy`), `src/card.ts`, `src/component-recipes.ts`(`segmentedControlRecipe`, `listRowRecipe`), `src/container.ts`
9
+ - 스토리북: `배포/구성/선택과 필터/날짜 선택과 예정 목록`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 한 주처럼 짧은 날짜 범위에서 날짜 하나를 고르면 같은 카드 안의 일정 목록이 그 날짜로 바뀌는 요약 카드에 쓴다.
14
+ 날짜가 5개 안팎이라 한 줄 SegmentedControl에 다 들어갈 때 맞다. 달력에서 아무 날짜나 고르면
15
+ [Calendar](../components/calendar.md)·[DatePicker](../components/date-picker.md)를 쓴다.
16
+
17
+ ## 구성 요소
18
+
19
+ | 컴포넌트 | 역할 | 지침 |
20
+ | --- | --- | --- |
21
+ | `Card` | 제목("이번 주 일정")·설명을 가진 틀 | [Card](../components/card.md) |
22
+ | `SegmentedControl` | 날짜 선택(라벨 "월 5"처럼 짧게) | [SegmentedControl](../components/segmented-control.md) |
23
+ | `ContentTransition` | 날짜가 바뀔 때 목록 교체(기본 `fade`), `stateKey`=날짜 | [ContentTransition](../components/content-transition.md) |
24
+ | `List` + `ListRow` | 일정 행. 제목 + "시각 · 장소" 설명 | [List](../components/list.md), [ListRow](../components/list-row.md) |
25
+ | 빈 상태 `Text` 두 줄 | strong "이 날은 예정된 일정이 없어요." + muted 안내 | [Text](../components/text.md) |
26
+ | `Skeleton`·`Notice` | 서버에서 받는 일정일 때 목록 자리의 로딩·실패(스토리에 없음) | [Skeleton](../components/skeleton.md), [Notice](../components/notice.md) |
27
+ | `ScrollView` + `Container`(Native) | 바깥 틀. 세로 스크롤·위아래 여백, 좌우 gutter | [Container](../components/container.md), [화면 여백과 너비](../tokens/layout.md) |
28
+
29
+ ## 배치
30
+
31
+ ```text
32
+ Native 화면(ScrollView, 위아래 spacing.lg 20) > Container gutter 16(폭 < 600) · 20(폭 ≥ 600)
33
+ ┌ Card ──────────────────────────────────────┐ body padding spacing.md 16
34
+ │ 이번 주 일정 (title) │
35
+ │ 날짜를 고르면 같은 카드 안에서… (muted) │
36
+ │ ┌──────┬──────┬──────┬──────┬──────┐ │ ← SegmentedControl, 높이 44
37
+ │ │ 월 5 │ 화 6 │ 수 7 │ 목 8 │ 금 9 │ │
38
+ │ └──────┴──────┴──────┴──────┴──────┘ │
39
+ │ ↕ spacing.md 16 │
40
+ │ ┌ 주간 계획 맞추기 ┐ │ ← ListRow 두 줄 최소 68
41
+ │ │ 오전 10:00 · 회의실 A │ │
42
+ │ ├ 디자인 검토 ┤ │
43
+ │ └ 오후 3:30 · 화상 회의 ┘ │
44
+ │ (일정 없음: 강조 문구 / 안내 문구, xs 8) │
45
+ └────────────────────────────────────────────┘
46
+ 고정 영역 없음. 목록이 길면 화면 스크롤로 내려간다. 안전 영역은 화면 골격이 맡는다
47
+ ```
48
+
49
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
50
+ | --- | --- | --- | --- |
51
+ | 바깥 틀 | Web: 제품 화면 레이아웃(문서 스크롤). Native: `ScrollView` > `Container` | 이 구성은 바깥 폭·여백을 정하지 않는다(스토리는 Card만 그린다). Native는 Card를 `ScrollView` 안 [Container](../components/container.md)에 둔다. 입력이 없어 키보드 처리는 없다. 안전 영역은 화면 골격(내비게이션 헤더·탭 바)이 맡는다 | Native ScrollView 위아래 `spacing.lg` 20, Container gutter 폭 600 미만 `compact` 16 · 이상 `regular` 20(`layout.pagePadding`) |
52
+ | 틀 | `Card` | 바깥 틀 안, 스크롤과 함께 | body padding `spacing.md` 16, 안쪽 `Stack gap="md"` 16 |
53
+ | 날짜 | `SegmentedControl` medium | Card 머리 아래 | 최소 높이 `control.minTouchTarget` 44, 트랙 padding·항목 사이 Native `spacing.xxs` 4 / Web CSS 2px |
54
+ | 목록 | `List` + `ListRow` | 날짜 아래 | 두 줄 행 최소 `layout.rowHeight.twoLine` 68(comfortable), 날짜와 `spacing.md` 16 |
55
+ | 빈 상태 | Text strong + muted | 목록 자리 | 두 줄 사이 `spacing.xs` 8 |
56
+ | 로딩·실패 | `Skeleton` 행 / `Notice` danger + 다시 시도 | 목록 자리(날짜 선택은 그대로) | 날짜와 `spacing.md` 16 |
57
+
58
+ - 날짜 선택은 목록 **위**에 둔다. 목록 높이가 바뀌어도 날짜 위치는 움직이지 않는다.
59
+ - 큰 글자(`largeTextThreshold` 이상, Web은 `data-large-text`)에서 SegmentedControl이 세로로 쌓인다(`segmentedControlRecipe.adaptive`). 보통 폭에서는 Web 항목이 `min-inline-size: max-content`라 넘치면 가로 스크롤된다. 날짜 라벨을 짧게 유지한다.
60
+
61
+ ## 흐름과 상태
62
+
63
+ 1. 첫 날짜가 선택된 채 열린다.
64
+ 2. 다른 날짜를 누르면 `ContentTransition`이 목록을 그 날짜 일정으로 바꾼다. 서버에서 받으면 그동안 목록 자리에 Skeleton이 나온다.
65
+ 3. 일정이 없는 날짜면 목록 대신 빈 상태 두 줄이 나온다.
66
+ 4. 불러오기에 실패하면 목록 자리에 Notice와 "다시 시도"가 나온다. 다시 시도는 같은 날짜를 다시 요청한다.
67
+
68
+ - 문구 키는 상태별 상수로 둔다(`schedule.empty`·`schedule.emptyHint`·`schedule.loadFailed`·`common.retry`). 행 설명은 `t("schedule.rowDescription", { time, place })`처럼 변수로 넘기고 문자열을 이어 붙이지 않는다(언어마다 순서가 다르다).
69
+
70
+ | 상태 | 모습 | 포커스·알림 |
71
+ | --- | --- | --- |
72
+ | 기본 | 첫 날짜 선택, `List` 이름 "10월 5일 월요일 일정"(긴 날짜), 행 여러 개 | 포커스는 SegmentedControl에 남는다 |
73
+ | 진행 중 | 서버에서 받는 일정이면 목록 자리에 `Skeleton` 행(`text` 두 줄). 날짜는 계속 바꿀 수 있고, 늦게 온 이전 날짜 응답은 버린다 | 포커스는 SegmentedControl에 남는다 |
74
+ | 실패 | 목록 자리에 `Notice` danger + `action` "다시 시도"(`secondary` `small`). 날짜 선택은 유지 | Web `danger`는 `role="alert"`, Native는 `announcement="assertive"` |
75
+ | 일정 없음 | strong + muted 문구 두 줄 | 알림 없음(스토리). 필요하면 제품이 live region을 더한다 |
76
+
77
+ ## 코드 골격
78
+
79
+ ```tsx
80
+ // Web
81
+ import { Button } from "@hjmds/react/actions";
82
+ import { ContentTransition } from "@hjmds/react/content-transition";
83
+ import { Card, List, ListRow } from "@hjmds/react/display";
84
+ import { Notice, Skeleton } from "@hjmds/react/feedback";
85
+ import { Stack, Text } from "@hjmds/react/layout";
86
+ import { SegmentedControl } from "@hjmds/react/selection";
87
+
88
+ <Card title={t("schedule.title")} description={t("schedule.description")}>
89
+ <Stack gap="md">
90
+ <SegmentedControl label={t("schedule.dayLabel")} value={day} onValueChange={setDay} items={dayItems} />
91
+ <ContentTransition stateKey={`${day}:${status}`}>
92
+ {status === "loading"
93
+ ? <Stack gap="xs"><Skeleton shape="text" /><Skeleton shape="text" width="60%" /></Stack>
94
+ : status === "failed"
95
+ ? <Notice tone="danger" title={t("schedule.loadFailed")}
96
+ action={<Button tone="secondary" size="small" onClick={retry}>{t("common.retry")}</Button>} />
97
+ : items.length
98
+ ? <List label={t("schedule.listLabel", { day: longDay })}>
99
+ {items.map((item) => <ListRow key={item.id} title={item.title}
100
+ description={t("schedule.rowDescription", { time: item.time, place: item.place })} />)}
101
+ </List>
102
+ : <Stack gap="xs">
103
+ <Text emphasis="strong">{t("schedule.empty")}</Text>
104
+ <Text tone="muted">{t("schedule.emptyHint")}</Text>
105
+ </Stack>}
106
+ </ContentTransition>
107
+ </Stack>
108
+ </Card>;
109
+ ```
110
+
111
+ ```tsx
112
+ // Native
113
+ import { ScrollView, useWindowDimensions } from "react-native";
114
+ import { spacing } from "@hjmds/design-contracts/foundations";
115
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
116
+ import { Button } from "@hjmds/react-native/actions";
117
+ import { ContentTransition } from "@hjmds/react-native/content-transition";
118
+ import { Card, List, ListRow } from "@hjmds/react-native/data-display";
119
+ import { Notice, Skeleton } from "@hjmds/react-native/feedback";
120
+ import { SegmentedControl } from "@hjmds/react-native/inputs";
121
+ import { Container, Stack, Text } from "@hjmds/react-native/primitives";
122
+
123
+ const { width } = useWindowDimensions();
124
+ const gutter = resolveWindowClass(width) === "compact" ? "compact" : "regular";
125
+
126
+ <ScrollView contentContainerStyle={{ paddingVertical: spacing.lg }}>
127
+ <Container gutter={gutter}>
128
+ <Card title={t("schedule.title")} description={t("schedule.description")}>
129
+ <Stack gap="md">
130
+ <SegmentedControl label={t("schedule.dayLabel")} value={day} onValueChange={setDay} items={dayItems} />
131
+ <ContentTransition stateKey={`${day}:${status}`}>
132
+ {status === "loading"
133
+ ? <Stack gap="xs"><Skeleton shape="text" accessibilityLabel={t("schedule.loading")} /><Skeleton shape="text" width="60%" /></Stack>
134
+ : status === "failed"
135
+ ? <Notice tone="danger" announcement="assertive" title={t("schedule.loadFailed")}
136
+ action={<Button tone="secondary" size="small" onPress={retry}>{t("common.retry")}</Button>} />
137
+ : items.length
138
+ ? <List label={t("schedule.listLabel", { day: longDay })}>
139
+ {items.map((item) => <ListRow key={item.id} title={item.title}
140
+ description={t("schedule.rowDescription", { time: item.time, place: item.place })} />)}
141
+ </List>
142
+ : <Stack gap="xs">
143
+ <Text emphasis="strong">{t("schedule.empty")}</Text>
144
+ <Text tone="muted">{t("schedule.emptyHint")}</Text>
145
+ </Stack>}
146
+ </ContentTransition>
147
+ </Stack>
148
+ </Card>
149
+ </Container>
150
+ </ScrollView>;
151
+ ```
152
+
153
+ 날짜 목록(10월 5~9일)과 일정 다섯 개는 예시 데이터다. 날짜 계산·시간 표기(로케일)·일정 데이터는 제품 소유다.
154
+
155
+ ## 플랫폼 차이
156
+
157
+ | 항목 | Web | Native |
158
+ | --- | --- | --- |
159
+ | `SegmentedControl` import | `@hjmds/react/selection` | `@hjmds/react-native/inputs` |
160
+ | `Card`·`List` import | `@hjmds/react/display` | `@hjmds/react-native/data-display` |
161
+ | 큰 글자 | `data-large-text`에서 세로로 쌓음 | 기준 글자 크기 이상에서 세로로 쌓음 |
162
+ | 트랙 padding·항목 간격 | CSS 2px | `spacing.xxs` 4 |
163
+ | 바깥 틀 | 제품 화면 레이아웃(문서 스크롤) | `ScrollView` > `Container` |
164
+ | 실패 Notice 발표 | `danger`는 늘 `role="alert"` | `announcement="assertive"`를 지정해야 발표(기본 `none`) |
165
+
166
+ ## 함정
167
+
168
+ - 날짜를 빨리 바꾸면 이전 날짜 응답이 나중에 도착할 수 있다. 응답의 날짜가 현재 선택과 같을 때만 반영한다.
169
+ - 현재 스토리는 로컬 데이터라 로딩·실패 경로가 없고, 행 설명을 `` `${time} · ${place}` ``로 이어 붙인다.
@@ -0,0 +1,154 @@
1
+ # 수치와 이전 대비 변화
2
+
3
+ - 단계: 구성
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: [Statistic](../../statistic.md), [Progress](../../progress.md), `showcase/shared/stea-compositions.ts`, `showcase/{web/src/patterns,native/src}/stea-composition-previews.tsx`(`StatChangeSummary`, Native `Frame`), `src/container.ts`
9
+ - 스토리북: `배포/구성/정보 표시/수치와 이전 대비 변화`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 매출·주문·반품처럼 몇 개의 핵심 수치를 비교 기간과 함께 보이고, 증감의 방향과 좋고 나쁨을 색 없이도 읽히게 할 때 쓴다.
14
+ 기간을 바꾸면 수치와 목표 달성률이 같은 기간으로 함께 바뀌는 요약 카드다.
15
+
16
+ ## 구성 요소
17
+
18
+ | 컴포넌트 | 역할 | 지침 |
19
+ | --- | --- | --- |
20
+ | Card `title`·`description` | 요약 묶음과 제목 | [Card](../components/card.md) |
21
+ | SegmentedControl | 비교 기간(지난주와 비교·지난달과 비교) | [SegmentedControl](../components/segmented-control.md) |
22
+ | Statistic | 수치 하나 + `trend`(방향·tone·보이는 문구) | [Statistic](../components/statistic.md) |
23
+ | Progress | 같은 기간의 목표 달성률 | [Progress](../components/progress.md) |
24
+ | Stack | 세로 간격 | [Stack](../components/stack.md) |
25
+ | Skeleton · Notice | 서버에서 받는 수치일 때 로딩·실패(스토리에 없음) | [Skeleton](../components/skeleton.md), [Notice](../components/notice.md) |
26
+ | `ScrollView` + `Container`(Native) | 바깥 틀. 세로 스크롤·위아래 여백, 좌우 gutter | [Container](../components/container.md), [화면 여백과 너비](../tokens/layout.md) |
27
+
28
+ ## 배치
29
+
30
+ ```text
31
+ Native 화면(ScrollView, 위아래 spacing.lg 20) > Container gutter 16(폭 < 600) · 20(폭 ≥ 600)
32
+ ┌──────────── Card (padding spacing.md 16, radius lg 16) ────────────┐
33
+ │ 판매 요약 (title) │
34
+ │ 설명 (description) │
35
+ │ [ 지난주와 비교 | 지난달과 비교 ] ← SegmentedControl, 전체 폭 │
36
+ │ ↕ spacing.lg 20 │
37
+ │ 매출 3,950,000원 ▲ 지난주보다 20% 늘었어요 (success) │
38
+ │ ↕ spacing.md 16 │
39
+ │ 주문 128건 ― 지난주와 같아요 (neutral) │
40
+ │ 반품 9건 ▲ 지난주보다 3건 늘었어요 (danger) │
41
+ │ ↕ spacing.lg 20 │
42
+ │ 이번 주 목표 달성률 76% │
43
+ │ ████████████████████░░░░░░ │
44
+ └────────────────────────────────────────────────────────────────────┘
45
+ 고정 영역 없음. 안전 영역은 화면 골격이 맡는다
46
+ ```
47
+
48
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
49
+ | --- | --- | --- | --- |
50
+ | 바깥 틀 | Web: 제품 화면 레이아웃(문서 스크롤). Native: `ScrollView` > `Container` | 이 구성은 바깥 폭·여백을 정하지 않는다(스토리는 Card만 그린다). Native는 Card를 `ScrollView` 안 [Container](../components/container.md)에 둔다. 입력이 없어 키보드 처리는 없다. 안전 영역은 화면 골격(내비게이션 헤더·탭 바)이 맡는다 | Native ScrollView 위아래 `spacing.lg` 20, Container gutter 폭 600 미만 `compact` 16 · 이상 `regular` 20(`layout.pagePadding`) |
51
+ | 틀 | Card | 바깥 틀 안, 함께 스크롤 | padding `md` 16, radius `lg` 16, 테두리 있음 |
52
+ | 기간 선택 | SegmentedControl | 카드 본문 맨 위 | `size` `medium`, 아래 `spacing.lg` 20 |
53
+ | 수치 목록 | Stack > Statistic ×3 | 기간 선택 아래 | 수치 사이 `spacing.md` 16, 값은 `comfortable`(`heading` 크기) |
54
+ | 목표 | Progress | 카드 맨 아래 | 위 `spacing.lg` 20, `size` `medium` |
55
+ | 로딩·실패 | Skeleton / Notice danger + 다시 시도 | 수치 목록 자리(기간 선택은 그대로) | 수치 목록과 같은 자리 |
56
+
57
+ 수치가 많아 가로로 늘어놓을 때는 [Statistic](../components/statistic.md)의 `StatisticGroup`(`columns` 기본 3, 좁은 폭·큰 글자에서 1열까지 줄어듦)을 쓴다.
58
+
59
+ ## 흐름과 상태
60
+
61
+ 1. 기간을 바꾸면 세 수치, 증감 문구, 목표 라벨("이번 주"/"이번 달")과 값이 한꺼번에 바뀐다.
62
+ 2. 증가가 나쁜 지표(반품)는 `direction: "up"`이어도 `tone: "danger"`로 둔다. 방향과 의미를 따로 준다.
63
+
64
+ | 상태 | 모습 | 포커스·알림 |
65
+ | --- | --- | --- |
66
+ | 기본 | 선택한 기간의 수치와 증감 | `trend.label`이 보이는 문구이자 읽히는 문구다 |
67
+ | 진행 중 | 서버에서 받는 수치면 수치 목록·목표 자리에 Skeleton(`text`). 기간 선택은 계속 바꿀 수 있고, 늦게 온 이전 기간 응답은 버린다 | 포커스는 SegmentedControl에 남는다 |
68
+ | 실패 | 수치 목록·목표 자리에 Notice danger + `action` "다시 시도". 이전 기간 수치를 남겨 두지 않는다(기간과 수치가 어긋난다) | Web `danger`는 `role="alert"`, Native는 `announcement="assertive"` |
69
+ | 기간 변경 | 수치·목표가 함께 바뀐다 | 포커스는 SegmentedControl에 남는다 |
70
+ | 큰 글자 | Native SegmentedControl이 글자 배율 1.6 이상에서 세로로 쌓인다 | — |
71
+ | 빈 값 | 스토리에 없다. 기간 안 데이터가 전혀 없으면 수치 목록 자리를 [EmptyState](../components/empty-state.md)로 바꾼다 | — |
72
+
73
+ - 문구 키는 상태·기간별 상수로 둔다. 기간 이름으로 키를 조립하지 않는다.
74
+
75
+ | 기간·상태 | 문구 키 |
76
+ | --- | --- |
77
+ | 주간 목표 | `sales.goal.week` |
78
+ | 월간 목표 | `sales.goal.month` |
79
+ | 실패 | `sales.loadFailed` |
80
+ | 다시 시도 | `common.retry` |
81
+
82
+ ## 코드 골격
83
+
84
+ ```tsx
85
+ // Web
86
+ import { Button } from "@hjmds/react/actions";
87
+ import { Card, Statistic } from "@hjmds/react/display";
88
+ import { Notice, Progress, Skeleton } from "@hjmds/react/feedback";
89
+ import { Stack } from "@hjmds/react/layout";
90
+ import { SegmentedControl } from "@hjmds/react/selection";
91
+
92
+ // 기간 → 목표 문구 키
93
+ const goalKey = { week: "sales.goal.week", month: "sales.goal.month" } as const;
94
+
95
+ <Card title={t("sales.title")} description={t("sales.description")}>
96
+ <Stack gap="lg">
97
+ <SegmentedControl label={t("sales.period")} value={period} onValueChange={changePeriod} items={periods} />
98
+ {status === "loading"
99
+ ? <Stack gap="md"><Skeleton shape="text" /><Skeleton shape="text" /><Skeleton shape="text" width="60%" /></Stack>
100
+ : status === "failed"
101
+ ? <Notice tone="danger" title={t("sales.loadFailed")}
102
+ action={<Button tone="secondary" size="small" onClick={retry}>{t("common.retry")}</Button>} />
103
+ : <>
104
+ <Stack gap="md">{data.items.map((item) => <Statistic key={item.id} descriptor={item} />)}</Stack>
105
+ <Progress label={t(goalKey[period])} value={data.goal} valueText={formatPercent(data.goal)} />
106
+ </>}
107
+ </Stack>
108
+ </Card>;
109
+ ```
110
+
111
+ ```tsx
112
+ // Native
113
+ import { ScrollView, useWindowDimensions } from "react-native";
114
+ import { spacing } from "@hjmds/design-contracts/foundations";
115
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
116
+ import { Button } from "@hjmds/react-native/actions";
117
+ import { Card, Statistic } from "@hjmds/react-native/data-display";
118
+ import { Notice, Progress, Skeleton } from "@hjmds/react-native/feedback";
119
+ import { SegmentedControl } from "@hjmds/react-native/inputs";
120
+ import { Container, Stack } from "@hjmds/react-native/primitives";
121
+
122
+ const goalKey = { week: "sales.goal.week", month: "sales.goal.month" } as const;
123
+ const { width } = useWindowDimensions();
124
+ const gutter = resolveWindowClass(width) === "compact" ? "compact" : "regular";
125
+
126
+ <ScrollView contentContainerStyle={{ paddingVertical: spacing.lg }}>
127
+ <Container gutter={gutter}>
128
+ <Card title={t("sales.title")} description={t("sales.description")}>
129
+ <Stack gap="lg">
130
+ <SegmentedControl label={t("sales.period")} value={period} onValueChange={changePeriod} items={periods} />
131
+ {status === "loading"
132
+ ? <Stack gap="md"><Skeleton shape="text" accessibilityLabel={t("sales.loading")} /><Skeleton shape="text" /><Skeleton shape="text" width="60%" /></Stack>
133
+ : status === "failed"
134
+ ? <Notice tone="danger" announcement="assertive" title={t("sales.loadFailed")}
135
+ action={<Button tone="secondary" size="small" onPress={retry}>{t("common.retry")}</Button>} />
136
+ : <>
137
+ <Stack gap="md">{data.items.map((item) => <Statistic key={item.id} descriptor={item} />)}</Stack>
138
+ <Progress label={t(goalKey[period])} value={data.goal} valueText={formatPercent(data.goal)} />
139
+ </>}
140
+ </Stack>
141
+ </Card>
142
+ </Container>
143
+ </ScrollView>;
144
+ ```
145
+
146
+ Statistic descriptor는 `{ id, label, value(포맷 끝난 문자열), suffix, trend: { direction, tone, label } }`이다. 수치·문구·목표는 제품 데이터다.
147
+
148
+ ## 함정
149
+
150
+ - 증감을 색만으로 전하지 않는다. `trend.label`은 필수이고 비교 기준("지난주보다")을 문구에 넣는다.
151
+ - 목표 라벨을 기간과 따로 고정하면("월 목표") 주간 비교와 섞여 읽힌다. 기간과 함께 바꾼다.
152
+ - 로딩·실패 자리를 Fragment로 바꿔 넣으면 수치 목록과 Progress가 바깥 `Stack gap="lg"`의 직계 자식으로 남는다. 둘을 한 `Stack`으로 묶으면 사이가 `spacing.lg` 20에서 바뀐다.
153
+ - `onValueChange`는 `string`을 넘긴다. 기간 타입으로 좁힐 때 허용 값인지 확인하고 바꾼다(스토리는 `as StatPeriod`로 단언한다).
154
+ - 현재 스토리는 로컬 데이터라 로딩·실패 경로가 없고, 목표 값 문구를 `` `${goal}%` ``로 이어 붙인다. 퍼센트 표기는 로케일 포맷(`formatPercent`)으로 만든다.
@@ -0,0 +1,174 @@
1
+ # 시간 선택
2
+
3
+ - 단계: 구성
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: `showcase/web/src/patterns/TimeSelection.stories.tsx`, `showcase/native/src/TimeSelection.stories.tsx`, `showcase/native/src/pattern-status.tsx`, `showcase/shared/time-example.ts`, `src/foundations.ts`(`control`, `spacing`), `src/component-recipes.ts`(`stackRecipe`, `sectionRecipe`), `src/container.ts`
9
+ - 스토리북: `배포/구성/선택과 필터/시간 선택`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 알림 시각·마감 시각처럼 하루 안의 시각 하나를 시·분 두 Select로 나눠 고르게 할 때 쓴다. 둘 다 고른 뒤에만
14
+ 확정 버튼이 열리고, 확정하면 같은 흐름 아래에 성공 Notice가 붙는다. 날짜까지 함께 고르면
15
+ [DatePicker](../components/date-picker.md)를, 길이(시·분·초)를 고르면 `DurationField`([NumberField](../components/number-field.md) 지침)를 먼저 검토한다.
16
+
17
+ ## 구성 요소
18
+
19
+ | 컴포넌트 | 역할 | 지침 |
20
+ | --- | --- | --- |
21
+ | `Section` | 제목과 한 줄 설명(양쪽 모두 Section) | [Section](../components/section.md) |
22
+ | `Stack` `gap="md"` | 세로 흐름 전체 | [Stack](../components/stack.md) |
23
+ | `Select` × 2 | 시(0~23), 분(0~59). 항목은 `{ id, label, textValue }` | [Select](../components/select.md) |
24
+ | 상태 문구 `Text` | "선택한 시각 HH:MM" 또는 "시와 분을 모두 선택해 주세요". Web `role="status"`, Native live region(iOS는 announce 보완) | [Text](../components/text.md) |
25
+ | `Button` `tone="primary"` | 선택 완료. 둘 다 고르기 전엔 `disabled`, 저장 중 `loading` | [Button](../components/button.md) |
26
+ | `Button` `tone="ghost"` | 다시 고르기(두 값과 결과를 비움) | [Button](../components/button.md) |
27
+ | `Notice` `tone="success"` / `"danger"` | 확정 결과 / 저장 실패. 결과가 나기 전엔 마운트하지 않는다 | [Notice](../components/notice.md) |
28
+ | `ScrollView` + `Container`(Native) | 바깥 틀. 세로 스크롤·위아래 여백, 좌우 gutter | [Container](../components/container.md), [화면 여백과 너비](../tokens/layout.md) |
29
+
30
+ ## 배치
31
+
32
+ ```text
33
+ Native 화면(ScrollView, 위아래 spacing.lg 20) > Container gutter 16(폭 < 600) · 20(폭 ≥ 600)
34
+ ┌ Section(스크롤과 함께 흐름) ───────────┐
35
+ │ 제목 (Section title) │
36
+ │ 설명 (muted) │ 제목–설명 spacing.xxs 4
37
+ │ ↕ spacing.xs 8 │ 머리–내용(sectionRecipe.gap)
38
+ │ 시 │
39
+ │ [ 시 선택 ▾ ] │ 필드 높이 44
40
+ │ ↕ spacing.md 16 │
41
+ │ 분 │
42
+ │ [ 분 선택 ▾ ] │
43
+ │ ↕ spacing.md 16 │
44
+ │ 선택한 시각 09:30 ← 상태 문구 │
45
+ │ ↕ spacing.md 16 │
46
+ │ [ 선택 완료 ] │ ← 주 행동(primary), 꽉 찬 폭
47
+ │ ↕ spacing.md 16 │
48
+ │ [ 다시 고르기 ] │ ← 보조 행동(ghost)
49
+ │ ↕ spacing.md 16 │
50
+ │ ┌ ✓ 시간을 정했어요 · 09:30 ────────┐ │ ← 확정 후에만
51
+ │ └────────────────────────────────────┘ │
52
+ └────────────────────────────────────────┘
53
+ 고정 영역 없음. 안전 영역은 화면 골격이 맡는다
54
+ ```
55
+
56
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
57
+ | --- | --- | --- | --- |
58
+ | 바깥 틀 | Web: 제품 화면 레이아웃(문서 스크롤). Native: `ScrollView` > `Container` | 이 구성은 바깥 폭·여백을 정하지 않는다(Web 스토리는 Section만 그린다). Native는 `ScrollView` 안 [Container](../components/container.md)에 둔다. Select는 키보드 대신 목록 표면(Web popover, Native modal sheet)을 열어 키보드 처리는 없다. 안전 영역은 화면 골격(내비게이션 헤더·탭 바)이 맡는다 | Native ScrollView 위아래 `spacing.lg` 20, Container gutter 폭 600 미만 `compact` 16 · 이상 `regular` 20(`layout.pagePadding`) |
59
+ | 머리 | Section 제목·설명 | 흐름 맨 위, 스크롤과 함께 | 제목–설명 `spacing.xxs` 4, 머리–내용 `spacing.xs` 8 |
60
+ | 입력 | Select 시 → 분 | 머리 아래, 시가 먼저 | 필드 `control.fieldHeight` 44, 사이 `spacing.md` 16 |
61
+ | 상태 | 상태 문구 | 입력 바로 아래 | 위 `spacing.md` 16 |
62
+ | 행동 | Button primary → ghost | 상태 아래, 세로로 쌓음. Stack 기본 `align="stretch"`라 꽉 찬 폭 | 높이 `control.buttonHeight.medium` 44, 사이 `spacing.md` 16 |
63
+ | 결과 | Notice success / danger | 행동 아래 | 위 `spacing.md` 16 |
64
+
65
+ - 주 행동이 위, 되돌리기(ghost)가 아래다. 가로로 놓아야 하면 [Button 배치](../components/button.md#배치)의 보조 → 주 순서를 따른다.
66
+ - 이 구성에는 고정 영역이 없다. 긴 화면 하단에 확정을 고정해야 하면 "선택 완료"를 [BottomCTA](../components/bottom-cta.md)로 옮긴다.
67
+
68
+ ## 흐름과 상태
69
+
70
+ 1. 시 Select를 연다(Web은 트리거에 붙은 listbox popover, Native는 modal sheet) → 시를 고른다.
71
+ 2. 분 Select를 같은 방식으로 고른다. 어느 값을 바꿔도 이전 확정 결과를 지운다.
72
+ 3. 두 값이 모두 있으면 상태 문구가 시각으로 바뀌고 "선택 완료"가 활성화된다.
73
+ 4. "선택 완료" → (서버에 저장하면 버튼 `loading`) → success Notice가 붙는다. "다시 고르기" → 두 Select와 Notice를 비운다.
74
+ 5. 저장이 실패하면 danger Notice가 붙고 고른 값은 그대로 남아 "선택 완료"를 다시 누를 수 있다.
75
+
76
+ | 상태 | 모습 | 포커스·알림 |
77
+ | --- | --- | --- |
78
+ | 기본 | 두 Select에 placeholder, 상태 문구 "시와 분을 모두 선택해 주세요", 선택 완료 `disabled` | 상태 문구가 live region |
79
+ | 하나만 고름 | 초기와 같은 상태 문구, 선택 완료 `disabled` | — |
80
+ | 둘 다 고름 | 상태 문구에 "선택한 시각 HH:MM", 선택 완료 활성 | 상태 문구 변경을 알린다(Native iOS는 announce 보완) |
81
+ | 진행 중 | 서버에 저장하는 제품이면 선택 완료 `loading`, 두 Select `busy`(포커스 순서 유지, 조작 막음), 다시 고르기 `disabled` | 포커스는 버튼에 남는다 |
82
+ | 실패 | 행동 아래 Notice danger(값 유지), 선택 완료 다시 활성 | Web `danger`는 `role="alert"`, Native는 `announcement="assertive"` |
83
+ | 확정 | 행동 아래 Notice success | Web Notice는 `role="status"`로 알린다. Native는 `announcement="polite"`를 줘야 알린다. 포커스는 버튼에 남는다 |
84
+
85
+ - Select는 선택 해제가 기본 허용이라 `onSelectionChange`에 `null`이 올 수 있다. 값은 `string | null`로 둔다.
86
+ - 값은 `"HH"`·`"MM"` 문자열 키다. 시간대·예약 계산은 제품 소유다.
87
+ - 문구 키는 상태별 상수로 둔다. 상태 이름으로 키를 조립하지 않는다.
88
+
89
+ | 상태 | 상태 문구 키 | Notice 키 |
90
+ | --- | --- | --- |
91
+ | 기본·하나만 고름 | `reminder.time.incomplete` | — |
92
+ | 둘 다 고름 | `reminder.time.selected`(`value`) | — |
93
+ | 실패 | 둘 다 고름과 같다 | `reminder.time.saveFailed` |
94
+ | 확정 | 둘 다 고름과 같다 | `reminder.time.saved` |
95
+
96
+ ## 코드 골격
97
+
98
+ ```tsx
99
+ // Web
100
+ import { Select } from "@hjmds/react/forms";
101
+ import { Button } from "@hjmds/react/actions";
102
+ import { Section, Stack, Text } from "@hjmds/react/layout";
103
+ import { Notice } from "@hjmds/react/feedback";
104
+
105
+ <Section title={t("reminder.time.title")} description={t("reminder.time.description")}>
106
+ <Stack gap="md">
107
+ <Select label={t("reminder.time.hour")} placeholder={t("reminder.time.hourPlaceholder")}
108
+ emptySelectionLabel={t("reminder.time.hourClear")} busy={saving}
109
+ items={hourOptions} selectedKey={hour} onSelectionChange={changeHour} />
110
+ <Select label={t("reminder.time.minute")} placeholder={t("reminder.time.minutePlaceholder")}
111
+ emptySelectionLabel={t("reminder.time.minuteClear")} busy={saving}
112
+ items={minuteOptions} selectedKey={minute} onSelectionChange={changeMinute} />
113
+ <Text role="status">{value ? t("reminder.time.selected", { value }) : t("reminder.time.incomplete")}</Text>
114
+ <Button disabled={!value} loading={saving} onClick={confirm}>{t("reminder.time.confirm")}</Button>
115
+ <Button tone="ghost" disabled={saving} onClick={reset}>{t("reminder.time.reset")}</Button>
116
+ {result === "saved" && value ? <Notice tone="success" title={t("reminder.time.saved")} description={value} /> : null}
117
+ {result === "failed" ? <Notice tone="danger" title={t("reminder.time.saveFailed")} /> : null}
118
+ </Stack>
119
+ </Section>;
120
+ ```
121
+
122
+ ```tsx
123
+ // Native
124
+ import { ScrollView, useWindowDimensions } from "react-native";
125
+ import { spacing } from "@hjmds/design-contracts/foundations";
126
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
127
+ import { Select } from "@hjmds/react-native/forms";
128
+ import { Button } from "@hjmds/react-native/actions";
129
+ import { Container, Section, Stack, Text } from "@hjmds/react-native/primitives";
130
+ import { Notice } from "@hjmds/react-native/feedback";
131
+
132
+ const { width } = useWindowDimensions();
133
+ const gutter = resolveWindowClass(width) === "compact" ? "compact" : "regular";
134
+
135
+ <ScrollView contentContainerStyle={{ paddingVertical: spacing.lg }}>
136
+ <Container gutter={gutter}>
137
+ <Section title={t("reminder.time.title")} description={t("reminder.time.description")}>
138
+ <Stack gap="md">
139
+ <Select label={t("reminder.time.hour")} placeholder={t("reminder.time.hourPlaceholder")}
140
+ dismissLabel={t("reminder.time.hourClose")} busy={saving}
141
+ items={hourOptions} selectedKey={hour} onSelectionChange={changeHour} />
142
+ <Select label={t("reminder.time.minute")} placeholder={t("reminder.time.minutePlaceholder")}
143
+ dismissLabel={t("reminder.time.minuteClose")} busy={saving}
144
+ items={minuteOptions} selectedKey={minute} onSelectionChange={changeMinute} />
145
+ {/* iOS는 live region을 무시하므로 문구가 바뀔 때 AccessibilityInfo.announceForAccessibility로 보완한다 */}
146
+ <Text accessibilityLiveRegion="polite">{value ? t("reminder.time.selected", { value }) : t("reminder.time.incomplete")}</Text>
147
+ <Button disabled={!value} loading={saving} onPress={confirm}>{t("reminder.time.confirm")}</Button>
148
+ <Button tone="ghost" disabled={saving} onPress={reset}>{t("reminder.time.reset")}</Button>
149
+ {result === "saved" && value ? <Notice tone="success" announcement="polite" title={t("reminder.time.saved")} description={value} /> : null}
150
+ {result === "failed" ? <Notice tone="danger" announcement="assertive" title={t("reminder.time.saveFailed")} /> : null}
151
+ </Stack>
152
+ </Section>
153
+ </Container>
154
+ </ScrollView>;
155
+ ```
156
+
157
+ `hourOptions`·`minuteOptions`(항목 라벨 "9시"·"30분")와 문구는 제품 소유이며 라벨도 i18n으로 만든다.
158
+
159
+ ## 플랫폼 차이
160
+
161
+ | 항목 | Web | Native |
162
+ | --- | --- | --- |
163
+ | 목록 표면 | 트리거에 붙은 listbox popover | modal sheet |
164
+ | 필수 문구 | `emptySelectionLabel`(선택 해제 항목) | `dismissLabel`(시트 닫기) |
165
+ | 상태 문구 낭독 | `role="status"` | `accessibilityLiveRegion`(Android), iOS는 `announceForAccessibility`로 보완(스토리 `PatternStatus`) |
166
+ | 테마·글자 스토리 | 기본·어두운 테마·큰 글자(textScale 2) | 기본·어두운 테마·큰 글자(textScale 2) |
167
+ | 확정 Notice 발표 | 늘 live region | `announcement`를 지정해야 발표(기본 `none`) |
168
+ | 바깥 틀 | 제품 화면 레이아웃(문서 스크롤) | `ScrollView` > `Container` |
169
+
170
+ ## 함정
171
+
172
+ - 값을 바꾸면 이전 확정 결과(`result`)를 지운다. 남겨 두면 새로 고른 시각과 다른 확정 Notice가 보인다.
173
+ - 스토리의 확정은 로컬 상태만 바꾼다. 서버 저장의 진행·실패·재시도 검증을 대신하지 않는다.
174
+ - 2026-10-06 검수에서 예제도 Container·Section·Text와 Native 확정 Notice의 `announcement="polite"`를 사용하도록 맞췄다. 제목 의미와 iOS 알림을 개별 View/Text 스타일로 다시 만들지 않는다.
@@ -0,0 +1,128 @@
1
+ # 토스트 배치 비교
2
+
3
+ - 단계: 구성
4
+ - 상태: 배포
5
+ - 지원: Web
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: [Toast](../../toast.md), `showcase/web/src/components/ToastLayout.stories.tsx`, `src/component-recipes.ts`(`toastRecipe`), `src/toast.ts`, `packages/react/src/styles.css`(`.hjm-toast-viewport`, `.hjm-toast`), `packages/react/src/toast.tsx`
9
+ - 스토리북: `배포/구성/비교와 검증/토스트 배치 비교`
10
+
11
+ ## 언제 쓰나
12
+
13
+ Toast 카드 한 장의 내부 배치(톤 배지·제목·설명·닫기·실행 버튼)와 화면 위 위치를 좁은 폭·큰 글자·긴 문구·톤별로 확인하는 비교 스토리다.
14
+ 소비자는 이 결과로 "문구를 얼마나 길게 둘지, 실행 버튼을 둘지, 어떤 tone을 쓸지"를 고른다.
15
+
16
+ | 스토리 | 비교하는 것 | 소비자가 고를 기준 |
17
+ | --- | --- | --- |
18
+ | 간결한 배치 | mobile1 viewport에서 설명 한 줄 카드 | 기본 알림은 `description` 한 줄로 끝낸다 |
19
+ | 리퀴드 효과 대체 표시 | `presentation: "liquid"` descriptor를 Web이 표준 카드로 그림 | 공유 descriptor에 `liquid`를 넣어도 Web은 표준 카드 + 실행 버튼을 유지한다 |
20
+ | 긴 문구와 실행 버튼 | 좁은 폭 + textScale 2 + 혼합 언어 긴 문구 + `dismissOnAction: false` | 긴 문구는 줄바꿈되고 잘리지 않는다. 실행 후에도 남아야 하는 행동(다시 시도)은 `dismissOnAction: false` |
21
+ | 상태별 비교 | neutral·success·info·warning·danger 다섯 장, 실행 버튼 유무 | tone은 결과의 의미로 고른다. 실행 버튼은 그 알림이 아니어도 할 수 있는 행동일 때만 둔다 |
22
+
23
+ ## 구성 요소
24
+
25
+ | 컴포넌트 | 역할 | 지침 |
26
+ | --- | --- | --- |
27
+ | `ToastProvider` + `useToast` | 앱 루트에 하나. viewport·큐·타이머 | [Toast](../components/toast.md) |
28
+ | `Toast` | controlled 카드 한 장(스토리가 직접 그림) | [Toast](../components/toast.md) |
29
+ | descriptor `action` | 끝 정렬 알약 버튼 하나 | [Toast](../components/toast.md) |
30
+
31
+ ## 배치
32
+
33
+ ```text
34
+ 화면(좁은 폭, placement="bottom" 기본)
35
+ ┌──────────────────────────────────────┐
36
+ │ │
37
+ │ (콘텐츠, 스크롤) │
38
+ │ │
39
+ │ ┌ Toast ───────────────────────────┐ │ ← 좌우 max(spacing.md 16, 안전 영역)
40
+ │ │ (●) 연결을 확인해 주세요 [×] │ │ 배지 32 · 제목 굵게 · 닫기 44×44
41
+ │ │ Your changes remain … │ │ 설명(보조 색)
42
+ │ │ [ 다시 시도 ] │ │ ← 실행 버튼: 둘째 줄, 끝 정렬, 높이 44
43
+ │ └──────────────────────────────────┘ │
44
+ │ ↕ max(spacing.md 16, 하단 안전 영역) + bottomOffset
45
+ └──────────────────────────────────────┘
46
+ ```
47
+
48
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
49
+ | --- | --- | --- | --- |
50
+ | 바깥 틀 | `ToastProvider` viewport(앱 루트 하나) | 화면 콘텐츠·스크롤 위에 `position: fixed`로 떠 있다. 콘텐츠 스크롤과 무관하고 콘텐츠 여백을 바꾸지 않는다. 안전 영역은 viewport가 `env(safe-area-inset-*)`로 직접 피한다. 하단 고정 바는 `bottomOffset`으로 피한다. 모바일 Web 화면 키보드는 따로 피하지 않는다 | 가장자리 `max(spacing.md 16, safe-area)`, `layer.toast` 1000, viewport는 포인터를 통과시킨다(`pointer-events: none`, 카드만 받음) |
51
+ | viewport | `ToastProvider` | 화면 고정(`position: fixed`), 기본 하단 가운데. `top`·`top-start`·`top-end`·`bottom-start`·`bottom-end` 선택 | 가장자리 `max(spacing.md 16, safe-area)`, 최대 폭 `min(420, 100vw - 32)`, 카드 사이 `spacing.sm` 12. 하단이면 새 카드가 아래부터 쌓인다 |
52
+ | 카드 | `Toast` | viewport 안 | 최소 높이 3.5rem, padding 위아래 `spacing.sm` 12·시작 `spacing.md` 16·끝 `spacing.xs` 8, 열 간격 `spacing.sm` 12, radius `lg` 16 |
53
+ | 배지 | tone 아이콘 | 첫 줄 시작 | 지름 `toastRecipe.icon.badgeDiameter` 32 |
54
+ | 문구 | 제목 + 설명 | 첫 줄 가운데, 남은 폭 전부 | 제목–설명 `spacing.xxs` 4, `word-break: keep-all`, 잘림 없음 |
55
+ | 닫기 | IconButton | 첫 줄 끝 | `control.minTouchTarget` 44 × 44 |
56
+ | 실행 | 알약 버튼 | 둘째 줄, 문구 열부터 끝까지, 끝 정렬 | 최소 높이 44, 좌우 `spacing.md` 16, 줄 간격 `spacing.xs` 8 |
57
+
58
+ - 하단에 고정 바(BottomNavigation·BottomCTA)가 있으면 `bottomOffset`으로 그 높이만큼 띄운다.
59
+ - 한 번에 보이는 카드는 `maxVisible` 1(기본)이다. 상태별 비교 스토리의 다섯 장 겹침은 비교용이며 실제 화면 모습이 아니다.
60
+
61
+ ## 흐름과 상태
62
+
63
+ 1. 행동 결과가 나오면 `useToast().publish(descriptor)`를 부른다. 문구 키는 결과별 상수로 둔다(아래 `toastCopy`).
64
+ 2. 카드가 viewport에 나타나고 기본 3000ms 뒤 닫힌다(포인터·포커스가 올라가 있으면 멈춘다).
65
+ 3. 실행 버튼을 누르면 `onAction` 후 기본으로 닫힌다(`dismissOnAction: false`면 남는다). 닫기 버튼은 즉시 닫는다.
66
+
67
+ | 상태 | 모습 | 포커스·알림 |
68
+ | --- | --- | --- |
69
+ | 기본 | 결과 한 줄(`description`), tone은 결과 의미, 3000ms 뒤 자동 닫힘 | 낭독은 `priority`가 정한다 |
70
+ | 진행 중 | 오래 걸리는 작업의 진행은 같은 `id`로 다시 `publish`해 카드 하나를 갱신한다(새 카드를 쌓지 않는다). 진행률 자체는 화면의 [Progress](../components/progress.md)가 맡는다 | 같은 카드가 갱신된다 |
71
+ | 실패 | `tone: "danger"` + 실행 버튼 "다시 시도"(`dismissOnAction: false`면 다시 시도 후에도 남는다). 실패를 Toast로만 알리면 사라진 뒤 다시 찾을 수 없으므로, 되돌릴 수 없는 실패는 화면 안 [Notice](../components/notice.md)·Result로 남긴다 | `priority`로 낭독 긴급도를 정한다 |
72
+ | neutral | 회색 배지 + 알림 아이콘 | 낭독은 `priority`가 정하고 tone과 무관 |
73
+ | success·info·warning | 각 feedback 배지 색 + 아이콘 | 같음 |
74
+ | danger | danger 배지 + 경고 아이콘, 흔히 "다시 시도" 실행 | 같음 |
75
+ | 실행 버튼 있음 | 둘째 줄 알약 버튼, `durationMs` 기본은 수동 닫기까지 유지 | 포커스가 카드에 있으면 타이머 정지 |
76
+ | 큰 글자·긴 문구 | 문구가 여러 줄로 늘고 카드가 길어진다. 버튼 라벨도 줄바꿈 | — |
77
+ | 닫히는 중 | 투명해지며 `spacing.xs` 8만큼 내려감. 모션 감소면 이동 없음 | — |
78
+
79
+ ## 코드 골격
80
+
81
+ ```tsx
82
+ // Web
83
+ import { ToastProvider, useToast } from "@hjmds/react/toast";
84
+
85
+ // 앱 루트에 한 번
86
+ <ToastProvider label={t("common.notifications")} bottomOffset={dockHeight}>{children}</ToastProvider>;
87
+
88
+ // 결과 → 문구 키. 결과 이름으로 키를 조립하지 않는다.
89
+ const toastCopy = {
90
+ saving: { title: "sync.savingTitle", description: "sync.savingBody" },
91
+ failed: { title: "sync.offlineTitle", description: "sync.offlineBody" },
92
+ } as const;
93
+
94
+ // 화면에서
95
+ const toast = useToast();
96
+ toast.publish({
97
+ id: "sync",
98
+ tone: "danger",
99
+ title: t(toastCopy.failed.title),
100
+ description: t(toastCopy.failed.description),
101
+ closeLabel: t("common.closeNotification"),
102
+ action: { label: t("sync.retry"), onAction: retry, dismissOnAction: false },
103
+ });
104
+ ```
105
+
106
+ ```tsx
107
+ // Native
108
+ // 이 스토리는 Web 전용이다. Native 배치는 ToastRegion(`@hjmds/react-native/feedback`)을 쓰고
109
+ // safeAreaInsets·keyboardOffset을 제품이 넘긴다. 자세한 것은 Toast 컴포넌트 지침을 본다.
110
+ ```
111
+
112
+ 스토리의 문구(번뚝 타이머 등)는 예시다. 문구·id·action은 제품 소유, 카드 모양·큐·타이머는 HJM 소유다.
113
+
114
+ ## 플랫폼 차이
115
+
116
+ | 항목 | Web | Native |
117
+ | --- | --- | --- |
118
+ | 큐·영역 | `ToastProvider`·`useToast` | `ToastRegion`·`useToastRegion` |
119
+ | 하단 고정 바 회피 | `bottomOffset` | `keyboardOffset` |
120
+ | 안전 영역 | `env(safe-area-inset-*)` | `safeAreaInsets` prop(기본 `{}`) |
121
+ | `presentation: "liquid"` | 표준 카드로 대체 | `/toast-liquid`의 `createLiquidToastPresentation`을 등록하면 리퀴드 표현 |
122
+
123
+ ## 함정
124
+
125
+ - `durationMs`를 3000보다 짧게 줘도 3000으로 올라간다.
126
+ - 카드 색·배지·radius를 `className`으로 덮지 않는다. 톤 색은 `toastRecipe.tones`가 정한다.
127
+ - 화면마다 Provider를 새로 만들지 않는다. 같은 진행을 갱신할 때는 같은 `id`로 다시 `publish`한다.
128
+ - 현재 스토리는 `Toast` 카드만 직접 그리고 viewport(`ToastProvider`)를 띄우지 않는다. 화면 위 위치·안전 영역·`bottomOffset`은 스토리로 확인되지 않고 위 표의 값은 `.hjm-toast-viewport` CSS에서 확인했다.