@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,193 @@
1
+ # 보관과 실행 취소
2
+
3
+ - 단계: 구성
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: [공통 실행과 실패 복구 · 초안과 실행 취소](../../action-session.md#초안과-실행-취소), `src/action-session.ts`, `showcase/web/src/patterns/action-recovery-previews.tsx`, `showcase/native/src/action-recovery-previews.tsx`. 2026-10-06 사용자 승인으로 스토리북 배포(이전 `실험/구성/공통 동작/보관과 실행 취소`, [승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정))
9
+ - 스토리북: `배포/구성/피드백과 복구/보관과 실행 취소`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 보관·숨기기·목록에서 빼기처럼 제품이 역연산을 제공하는 작업 뒤에, 같은 자리에서 실행 취소를 주고 그 복구 요청이 성공해야 화면을 되돌릴 때 쓴다.
14
+
15
+ 금융 거래·영구 삭제에는 가짜 Undo를 넣지 않는다.
16
+
17
+ 같은 `공통 동작` 묶음: [저장과 재시도](action-recovery-save.md) · [즉시 반영과 복구](action-recovery-optimistic.md).
18
+ 세션 생성·구독·상태 알림은 [저장과 재시도의 공통 절](action-recovery-save.md#공통-세션과-상태-알림)을 따른다.
19
+
20
+ ## 구성 요소
21
+
22
+ | 컴포넌트 | 역할 | 지침 |
23
+ | --- | --- | --- |
24
+ | `createActionSession` | 보관·복구 요청의 진행·실패 상태. 값은 "보관됨" 여부 | [계약](../../action-session.md) |
25
+ | `Heading` | 묶음 제목(선택). 크기 `level3`(24), 문서 단계는 화면 구조에 맞춰 `semanticLevel` | [Heading](../components/heading.md) |
26
+ | `Text` | 대상의 현재 위치(목록·보관함) | [Text](../components/text.md) |
27
+ | `Button` 보관/실행 취소 | 같은 자리의 한 버튼, 라벨이 바뀜. pending 중 `loading` | [Button](../components/button.md) |
28
+ | `Button` 다시 시도 | error일 때만 나타나는 보조 행동, `tone="secondary"` | [Button](../components/button.md) |
29
+ | `Text` | 상태 문구. Web `role="status"`, Native 접근성 알림 | [Text](../components/text.md) |
30
+ | `Stack` | 세로 쌓기 `gap="md"` | [Stack](../components/stack.md) |
31
+ | `Toast` `action`(선택) | 행이 사라지는 목록에서 실행 취소 진입점 | [Toast](../components/toast.md) |
32
+ | `Container` · `ScrollView` | 바깥 틀. 제품 화면이 소유한다 | [Container](../components/container.md), [화면 여백](../tokens/layout.md) |
33
+
34
+ Toast의 `action`을 진입점으로 쓰면 토스트는 진입점만, 세션은 진행·실패 상태만 소유한다. Toast 지침대로 그 알림에서만 할 수 있는
35
+ 행동은 두지 않으므로 보관함 화면 등 다른 곳에도 복구 경로를 둔다. 스토리의 "다음 요청 실패시키기"·350ms 지연은 데모 전용이고,
36
+ 스토리에는 자동 만료가 없다(만료 시간은 제품이 정한다).
37
+
38
+ ## 배치
39
+
40
+ ```text
41
+ ┌ 화면 바깥 틀(제품 소유) ─────────────────┐
42
+ │ Web: 문서 스크롤 · Native: ScrollView │
43
+ │ ↕ Native 위아래 spacing.lg 20 │
44
+ │ ←gutter 16|20→ Container ←gutter 16|20→ │
45
+ │ ┌ Stack gap="md" ──────────────────────┐ │
46
+ │ │ 제목 Heading level3 (선택) │ │
47
+ │ │ ↕ spacing.md 16 │ │
48
+ │ │ 대상: 목록 / 보관함 + 항목 이름 │ │
49
+ │ │ ↕ spacing.md 16 │ │
50
+ │ │ [ 보관 ] → [ 실행 취소 ] │ │ ← 같은 자리, loading 중 스피너
51
+ │ │ ↕ spacing.md 16 │ │
52
+ │ │ [ 실패한 작업 다시 시도 (secondary)] │ │ ← error일 때만
53
+ │ │ ↕ spacing.md 16 │ │
54
+ │ │ 상태 문구 (role=status) │ │
55
+ │ └──────────────────────────────────────┘ │
56
+ └──────────────────────────────────────────┘
57
+ 토스트(선택): 화면 단위 오버레이, action = 실행 취소
58
+ ```
59
+
60
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
61
+ | --- | --- | --- | --- |
62
+ | 바깥 틀 | Web 문서 스크롤 > `Container`. Native `ScrollView` > `Container` | 이 구성을 감싸는 제품 화면. 상단 안전 영역은 내비게이션 헤더(또는 [TopBar](../components/top-bar.md))가 맡는다 | 좌우 `Container gutter`: 폭 600 미만 `compact` 16, 이상 `regular` 20([화면 여백](../tokens/layout.md)). Native 위아래는 `contentContainerStyle` `paddingVertical: spacing.lg` 20 |
63
+ | 제목(선택) | `Heading level="level3"` | Stack 맨 위 | 아래 `spacing.md` 16 |
64
+ | 대상 표시 | `Text` | 행동 위, 스크롤 | 아래 `spacing.md` 16 |
65
+ | 행동 | `Button` 보관/실행 취소(primary) | 대상 아래, 스크롤 | `medium` 44(`control.buttonHeight`). Stack 기본 `align="stretch"`로 꽉 찬 폭 |
66
+ | 재시도 | `Button tone="secondary"` | 행동 아래, error일 때만 | `medium` 44, `spacing.md` 16. 라벨은 무엇을 다시 하는지 적는다(스토리 "실패한 작업 다시 시도") |
67
+ | 상태 | `Text` | 맨 아래 | `spacing.md` 16 |
68
+ | 토스트(선택) | `Toast` `action` | 화면 단위 오버레이 | action 높이 `control.minTouchTarget` 44 |
69
+
70
+ - 보관과 실행 취소를 같은 자리의 한 버튼으로 둬서 방금 누른 위치에서 바로 되돌리게 한다.
71
+ - 목록 행에서 보관을 시작하면 행 끝 `size="small"` 버튼이나 [SwipeActions](../components/swipe-actions.md)를 쓰고,
72
+ 실행 취소는 화면 단위 Toast `action`으로 옮긴다(행이 사라지므로 행 안에 둘 수 없다).
73
+ - 한 화면의 primary는 하나다([Button](../components/button.md)). 재시도는 `secondary`로 둔다.
74
+
75
+ 근거: `src/foundations.ts`(`spacing`, `control`, `layout`), [Toast 지침](../components/toast.md)
76
+
77
+ ## 흐름과 상태
78
+
79
+ 1. 대상이 목록에 있을 때 버튼은 `t("archive.action")`이다.
80
+ 2. 보관을 누르면 `session.run(() => api.archive(id), { retryable: true })`를 부르고 버튼은 `loading`이다. pending 동안 같은 세션의 새 run·retry는 `blocked`다.
81
+ 3. 성공하면 대상 표시가 보관함으로, 버튼이 `t("archive.undo")`로 바뀐다.
82
+ 4. 실행 취소를 누르면 같은 버튼으로 `session.run(() => api.unarchive(id), { retryable: true })`를 부른다. 성공해야 목록으로 돌아온다.
83
+ 5. 어느 쪽이든 실패(네트워크·서버 오류, 역연산 거부)하면 `state.value`는 마지막으로 확인한 상태를 유지하고 재시도 버튼을 보인다.
84
+ 재시도도 실패하면 다시 error가 되고 재시도 버튼이 남는다. error 중 같은 자리 버튼을 누르면 새 run이 되고 이전 실패 작업은 버려진다.
85
+
86
+ | 상태 | 모습 | 포커스·알림 |
87
+ | --- | --- | --- |
88
+ | 기본 | idle(목록). 대상 "목록", 버튼 보관, 상태 문구 `t("archive.listed")` | 알림 없음(첫 렌더는 알리지 않는다) |
89
+ | 진행 중 | pending. 버튼 `loading`(라벨 자리 유지, 중앙 스피너 하나), `t("archive.pending")` | 포커스는 버튼에 유지, 상태 문구 알림 |
90
+ | 보관됨 | success, `state.value === true`. 대상 "보관함", 버튼 실행 취소, `t("archive.done")`(되돌릴 수 있음을 함께) | 상태 문구 알림 |
91
+ | 복구됨 | success, `state.value === false`. 대상 "목록", 버튼 보관, `t("archive.listed")` | 상태 문구 알림 |
92
+ | 실패 | error. 마지막 확인 상태 유지, 재시도 버튼, `t("archive.failed")` | 상태 문구 알림, 포커스 이동 없음 |
93
+ | 재시도 실패 | error 유지. 재시도 → pending → error로 문구가 바뀌므로 다시 알린다 | 상태 문구 알림, 포커스 이동 없음 |
94
+
95
+ - 상태 문구는 status와 값을 함께 본다. 키는 상수 표(`archiveStatusKey`)에서 고른다. 템플릿 문자열 키(`` `archive.${status}` ``)는
96
+ 키 추출·누락 검사가 찾지 못하고 위 표의 키와 어긋난다.
97
+ - 권한·만료·대상 버전·복구 데이터 검증은 제품이 한다. UI를 되돌렸다는 이유로 서버 취소 완료를 선언하지 않는다.
98
+ - 오류 문구는 제품이 지역화한다. raw exception(`state.error`)을 그대로 보이지 않는다.
99
+
100
+ ## 코드 골격
101
+
102
+ ```tsx
103
+ // Web
104
+ import { useState, useSyncExternalStore } from "react";
105
+ import { createActionSession, type ActionSnapshot } from "@hjmds/design-contracts/action-session";
106
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
107
+ import { Button } from "@hjmds/react/actions";
108
+ import { Container, Stack, Text } from "@hjmds/react/layout";
109
+
110
+ const archiveStatusKey = { pending: "archive.pending", error: "archive.failed", archived: "archive.done", listed: "archive.listed" } as const;
111
+ const statusKeyOf = (state: ActionSnapshot<boolean>) =>
112
+ state.status === "pending" || state.status === "error" ? archiveStatusKey[state.status] : state.value ? archiveStatusKey.archived : archiveStatusKey.listed;
113
+
114
+ type ArchiveApi = { archive(id: string): Promise<boolean>; unarchive(id: string): Promise<boolean> };
115
+
116
+ function ArchiveItem({ id, name, api }: { id: string; name: string; api: ArchiveApi }) {
117
+ const [session] = useState(() => createActionSession(false)); // false = 목록에 있음
118
+ const state = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
119
+ const toggle = () => {
120
+ const next = !state.value;
121
+ void session.run(() => (next ? api.archive(id) : api.unarchive(id)), { retryable: true });
122
+ };
123
+ const gutter = resolveWindowClass(window.innerWidth) === "compact" ? "compact" : "regular";
124
+ return (
125
+ <Container gutter={gutter}>
126
+ <Stack gap="md">
127
+ <Text as="p">{state.value ? t("archive.inArchive", { name }) : t("archive.inList", { name })}</Text>
128
+ <Button loading={state.status === "pending"} onClick={toggle}>
129
+ {state.value ? t("archive.undo") : t("archive.action")}
130
+ </Button>
131
+ {state.status === "error" && <Button tone="secondary" onClick={() => void session.retry()}>{t("archive.retry")}</Button>}
132
+ <Text as="p" role="status">{t(statusKeyOf(state))}</Text>
133
+ </Stack>
134
+ </Container>
135
+ );
136
+ }
137
+ ```
138
+
139
+ ```tsx
140
+ // Native
141
+ import { useState, useSyncExternalStore } from "react";
142
+ import { ScrollView, useWindowDimensions } from "react-native";
143
+ import { createActionSession, type ActionSnapshot } from "@hjmds/design-contracts/action-session";
144
+ import { spacing } from "@hjmds/design-contracts/foundations";
145
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
146
+ import { Button } from "@hjmds/react-native/actions";
147
+ import { Container, Stack, Text } from "@hjmds/react-native/primitives";
148
+
149
+ const archiveStatusKey = { pending: "archive.pending", error: "archive.failed", archived: "archive.done", listed: "archive.listed" } as const;
150
+ const statusKeyOf = (state: ActionSnapshot<boolean>) =>
151
+ state.status === "pending" || state.status === "error" ? archiveStatusKey[state.status] : state.value ? archiveStatusKey.archived : archiveStatusKey.listed;
152
+
153
+ type ArchiveApi = { archive(id: string): Promise<boolean>; unarchive(id: string): Promise<boolean> };
154
+
155
+ function ArchiveItem({ id, name, api }: { id: string; name: string; api: ArchiveApi }) {
156
+ const [session] = useState(() => createActionSession(false));
157
+ const state = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
158
+ const { width } = useWindowDimensions();
159
+ const toggle = () => {
160
+ const next = !state.value;
161
+ void session.run(() => (next ? api.archive(id) : api.unarchive(id)), { retryable: true });
162
+ };
163
+ return (
164
+ <ScrollView contentContainerStyle={{ paddingVertical: spacing.lg }}>
165
+ <Container gutter={resolveWindowClass(width) === "compact" ? "compact" : "regular"}>
166
+ <Stack gap="md">
167
+ <Text>{state.value ? t("archive.inArchive", { name }) : t("archive.inList", { name })}</Text>
168
+ <Button loading={state.status === "pending"} onPress={toggle}>
169
+ {state.value ? t("archive.undo") : t("archive.action")}
170
+ </Button>
171
+ {state.status === "error" && <Button tone="secondary" onPress={() => void session.retry()}>{t("archive.retry")}</Button>}
172
+ <StatusText>{t(statusKeyOf(state))}</StatusText>
173
+ </Stack>
174
+ </Container>
175
+ </ScrollView>
176
+ );
177
+ }
178
+ ```
179
+
180
+ `api.archive`·`api.unarchive`(서버가 확정한 boolean을 돌려줌)와 `StatusText`는 제품 소유다([공통 절](action-recovery-save.md#공통-세션과-상태-알림)의 helper).
181
+
182
+ ## 플랫폼 차이
183
+
184
+ | 항목 | Web | Native |
185
+ | --- | --- | --- |
186
+ | 이벤트 | `onClick` | `onPress` |
187
+ | 바깥 스크롤 | 문서 스크롤 | `ScrollView`(위아래 `spacing.lg`) |
188
+ | 상태 알림 | `role="status"` | live region(Android) + iOS 알림 호출 |
189
+
190
+ ## 함정
191
+
192
+ - 구획 제목은 Heading으로 표시한다. 이전 Text heading 예제는 2026-10-06 제목 의미 구조를 맞추면서 수정했다.
193
+ - 재시도는 secondary로 두어 저장과 경쟁하는 primary를 만들지 않는다(2026-10-06 예제 반영).
@@ -0,0 +1,132 @@
1
+ # 대화 메시지
2
+
3
+ - 단계: 구성
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 미게시(1.12.1 이후)
7
+ - 검토일: 2026-10-06
8
+ - 근거: [ChatMessage](../components/chat-message.md), [공통 실행과 실패 복구](../../action-session.md), `showcase/web/src/patterns/conversation-previews.tsx`, `showcase/native/src/conversation-previews.tsx`. 2026-10-06 사용자 승인으로 스토리북 배포(이전 `실험/구성/공통 화면/대화 메시지`, [승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정))
9
+ - 스토리북: `배포/구성/정보 표시/대화 메시지`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 말풍선 하나하나에 반응·답장·원문 이동·전송 실패 후 다시 보내기를 붙일 때 쓴다. ChatMessage의 슬롯과 콜백을 제품 로직에 연결하며 새 데이터 엔진을 만들지 않는다.
14
+
15
+ 2026-10-06 정리 전에는 이 스토리가 [채팅 화면](../screens/common-chat.md)과 같은 화면 전체를 그렸다. 지금은 말풍선 단위 상호작용만 보이고,
16
+ 대화 목록·하단 입력·불러오는 중·빈·오류·로그인 필요 상태는 채팅 화면이 소유한다. 입력은 [메시지 작성](purpose-input-message.md)을 쓴다.
17
+
18
+ ## 구성 요소
19
+
20
+ | 컴포넌트 | 역할 | 지침 |
21
+ | --- | --- | --- |
22
+ | `ChatMessage` | 수신·발신 말풍선, 작성자·시각·전송 상태 | [ChatMessage](../components/chat-message.md) |
23
+ | `reactions` | 길게 누르기·우클릭·메뉴로 여는 반응 선택 | [ChatMessage](../components/chat-message.md) |
24
+ | `reply` + `replyLink` | 답장 인용과 원문 이동 | [ChatMessage](../components/chat-message.md) |
25
+ | `actions` + `Button size="small"` secondary | 전송 실패 메시지의 "다시 보내기", 고른 반응 표시 | [Button](../components/button.md) |
26
+ | `Avatar` | 수신 메시지 작성자 | [Avatar](../components/avatar.md) |
27
+ | `createActionSession` | 다시 보내기 진행·실패 | [계약](../../action-session.md) |
28
+
29
+ ## 배치
30
+
31
+ ```text
32
+ ┌ 바깥 틀: 채팅 화면 본문(타임라인 스크롤). 단독 예제는 Container ┐
33
+ │ (아바타) 서연 │
34
+ │ ┌ 수신 말풍선 ───────────┐ ← 시작 쪽, 최대 폭 84% │
35
+ │ └────────────────────────┘ │
36
+ │ 오후 2:30 ❤️ ← 시각 · actions(고른 반응) │
37
+ │ [서연 · 원문 발췌] ← replyLink(ghost) │
38
+ │ ┌ 발신 말풍선 ───────────┐ ← 끝 쪽 │
39
+ │ └────────────────────────┘ │
40
+ │ 오후 2:32 · 읽음 │
41
+ │ ┌ 발신 말풍선 ───────────┐ │
42
+ │ └────────────────────────┘ │
43
+ │ 오후 2:33 · 전송 실패 [다시 보내기] ← actions │
44
+ │ 상태 문구(Web role=status, Native live region) │
45
+ └──────────────────────────────────────────────────────────────────┘
46
+ ```
47
+
48
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
49
+ | --- | --- | --- | --- |
50
+ | 바깥 틀 | [ChatScreen](../components/chat-screen.md) 본문 타임라인. 단독 예제는 Web `Container`, Native `ScrollView` > `Container` | 화면 본문 스크롤 | 좌우 `Container gutter` compact 16 / regular 20, 메시지 사이 `spacing.lg` 20 |
51
+ | 말풍선 | `ChatMessage` | incoming 시작 쪽, outgoing 끝 쪽 | 최대 폭 `messageMaxWidth` 84%, 버블 padding `spacing.sm` 12([배치](../components/chat-message.md#배치)) |
52
+ | 반응 | `reactions` | 말풍선 위 Popover(Web)·Modal 메뉴(Native) | 길게 누르기 450ms(`reactionHoldMs`) |
53
+ | 다시 보내기 | `actions` 슬롯의 `Button size="small"` secondary | 실패한 발신 메시지의 시각 옆 | 높이 36(`control.buttonHitSlop.small` 4로 터치 44), 시각과 `spacing.xs` 8 |
54
+ | 상태 | `Text` 상태 문구 | 목록 아래(단독 예제). 화면에서는 Toast 등 화면 단위로 한 번 알린다 | `spacing.lg` 20 |
55
+
56
+ ## 흐름과 상태
57
+
58
+ 1. 말풍선을 길게 누르거나(Web은 우클릭·Enter도) 메뉴를 열어 반응을 고른다. 같은 반응을 다시 고르면 `null`로 해제된다.
59
+ 2. 가로 스와이프(60px 이상)나 접근성 동작으로 답장을 시작하면 제품이 composer의 답장 대상을 채운다.
60
+ 3. 답장 인용(`replyLink`)을 누르면 제품이 원문 메시지로 스크롤하고 잠시 강조한다.
61
+ 4. 전송에 실패한 발신 메시지는 `deliveryLabel`로 실패를 적고 `actions`에 "다시 보내기"를 둔다. 누르면 진행 중 → 전송됨 또는 다시 실패.
62
+
63
+ | 상태 | 모습 | 포커스·알림 |
64
+ | --- | --- | --- |
65
+ | 기본 | 작성자 → 말풍선 → 시각·전송 상태. 반응은 길게 누르기·메뉴로 연 말풍선 위 메뉴에서 고른다 | 말풍선 이름(Web `author`·Native 본문), 반응 이름은 `reactions.label` |
66
+ | 진행 중 | 다시 보내기 `loading`, `deliveryLabel` "보내는 중" | 포커스는 버튼에 유지 |
67
+ | 실패 | `deliveryLabel` "전송 실패" + 다시 보내기 유지, 메시지 본문 유지 | 상태 문구로 알림, 포커스 이동 없음 |
68
+ | 성공 | `deliveryLabel` "전송됨", 다시 보내기 사라짐 | 상태 문구 |
69
+
70
+ - 전송 상태·반응 문구는 id→문구 키 상수 표로 둔다. 원문 이동·답장 준비·실패 알림은 제품 알림 계층(Toast 등)으로 화면 단위로 한 번 알린다.
71
+
72
+ ## 코드 골격
73
+
74
+ ```tsx
75
+ // Web
76
+ import { Avatar } from "@hjmds/react/display";
77
+ import { ChatMessage } from "@hjmds/react/screens";
78
+
79
+ <ChatMessage
80
+ direction={mine ? "outgoing" : "incoming"}
81
+ author={message.authorName}
82
+ timestamp={formatTime(message.sentAt)}
83
+ {...(mine ? { deliveryLabel: t("chat.delivered") } : {})}
84
+ avatar={mine ? undefined : <Avatar name={message.authorName} />}
85
+ replyAction={{ label: t("chat.reply"), onPress: () => setReplyTo(message.id) }}
86
+ reactions={{
87
+ label: t("chat.react"), closeLabel: t("common.close"),
88
+ options: reactionOptions, value: message.myReaction, onValueChange: v => react(message.id, v),
89
+ }}
90
+ >
91
+ {message.text}
92
+ </ChatMessage>
93
+ ```
94
+
95
+ ```tsx
96
+ // Native
97
+ import { Avatar } from "@hjmds/react-native/data-display";
98
+ import { ChatMessage } from "@hjmds/react-native/screens";
99
+
100
+ <ChatMessage
101
+ direction={mine ? "outgoing" : "incoming"}
102
+ author={message.authorName}
103
+ timestamp={formatTime(message.sentAt)}
104
+ {...(mine ? { deliveryLabel: t("chat.delivered") } : {})}
105
+ avatar={mine ? undefined : <Avatar name={message.authorName} decorative />}
106
+ replyAction={{ label: t("chat.reply"), onPress: () => setReplyTo(message.id) }}
107
+ reactions={{
108
+ label: t("chat.react"), closeLabel: t("common.close"),
109
+ options: reactionOptions, value: message.myReaction, onValueChange: v => react(message.id, v),
110
+ }}
111
+ >
112
+ {message.text}
113
+ </ChatMessage>
114
+ ```
115
+
116
+ 제품 데이터·콜백은 주입한다. 위 공개 API 지침에 Web·Native 차이를 유지한다.
117
+
118
+ ## 플랫폼 차이
119
+
120
+ | 항목 | Web | Native |
121
+ | --- | --- | --- |
122
+ | 반응 메뉴 여는 법 | 말풍선 길게 누르기 450ms(`reactionHoldMs`, 10px 넘게 움직이면 취소), 우클릭(`contextmenu`), 말풍선 자체에 포커스 후 Enter·Space(`interactiveContent` 안 버튼·링크의 Enter·Space는 그 컨트롤이 받는다) | 말풍선 길게 누르기 450ms(`delayLongPress`), 접근성 동작 `activate`. `interactiveContent`이면 말풍선 옆 `···` IconButton이 메뉴를 연다 |
123
+ | 반응 메뉴 표면 | `Popover`(`placement: "top"`, `align: "start"`) — 충돌 회피·포커스·Escape·바깥 닫기는 Popover 소유. 시각적으로 숨긴 닫기 버튼은 포커스를 받으면 보인다 | 투명 `Modal`, 누른 위치 위쪽에 띄우고 안전 영역 안으로 맞춘다. 너비 최대 320(`reactionMenuWidth`). 닫기 대상은 표준 `activate` 동작에 답해 TalkBack에서도 닫힌다 |
124
+ | 메시지 낭독 | 행 이름 `author`, 반응이 있으면 말풍선 이름 `reactions.label` | 말풍선이 본문 텍스트로 읽히고 `reactions.label`은 힌트, 작성자·시각은 별도 요소. 답장·반응은 접근성 동작 |
125
+ | Avatar 이름 | `name`만으로 이름을 읽는다 | 작성자 caption이 이미 이름을 읽으므로 `decorative`. 아니면 `accessibilityLabel` 필수 |
126
+
127
+ ## 함정
128
+
129
+ - 전송 실패를 말풍선 색만으로 알리지 않는다. `deliveryLabel` 글자와 다시 보내기를 함께 둔다.
130
+ - 답장은 스와이프만으로 숨기지 않는다. Native 접근성 동작과 Web 키보드·메뉴 경로가 같은 `replyAction`을 부른다.
131
+ - 화면 상태(불러오는 중·빈·오류·로그인 필요)를 말풍선 예제에 다시 만들지 않는다. [채팅 화면](../screens/common-chat.md)의 `state`가 소유한다.
132
+ - 스토리의 "다음 전송 실패시키기"·350ms 지연은 데모 전용이다.
@@ -0,0 +1,101 @@
1
+ # 알림 항목
2
+
3
+ - 단계: 구성
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 미게시(1.12.1 이후)
7
+ - 검토일: 2026-10-06
8
+ - 근거: [NotificationItem](../components/notification-item.md), [공통 실행과 실패 복구](../../action-session.md), `showcase/web/src/patterns/conversation-previews.tsx`, `showcase/native/src/conversation-previews.tsx`. 2026-10-06 사용자 승인으로 스토리북 배포(이전 `실험/구성/공통 화면/알림 항목`, [승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정))
9
+ - 스토리북: `배포/구성/정보 표시/알림 항목`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 알림 한 행을 누르면 바로 읽음으로 바꾸고, 서버가 실패하면 읽지 않음으로 되돌릴 때 쓴다. NotificationItem과 낙관 반영 세션을 연결하며 새 데이터 엔진을 만들지 않는다.
14
+
15
+ 2026-10-06 정리 전에는 이 스토리가 [알림함 화면](../screens/common-inbox.md)과 같은 화면 전체를 그렸다. 지금은 행 단위의 읽음 처리와 복구만 보이고,
16
+ 필터·묶음 제목·불러오는 중·빈·오류·로그인 필요 상태는 알림함 화면이 소유한다.
17
+
18
+ ## 구성 요소
19
+
20
+ | 컴포넌트 | 역할 | 지침 |
21
+ | --- | --- | --- |
22
+ | `NotificationItem` | 알림 한 행. `read`면 제목 굵기가 풀리고 `statusLabel`이 글로도 상태를 알린다 | [NotificationItem](../components/notification-item.md) |
23
+ | `Avatar` | 보낸 사람(`leading`) | [Avatar](../components/avatar.md) |
24
+ | `createActionSession` + `optimisticValue` | 읽음 즉시 반영, 실패하면 이전 확인 값으로 복구 | [계약](../../action-session.md), [즉시 반영과 복구](action-recovery-optimistic.md) |
25
+ | `Text` 상태 | 읽음 처리 진행·실패 알림 | [Text](../components/text.md) |
26
+
27
+ ## 배치
28
+
29
+ ```text
30
+ ┌ 바깥 틀: 알림함 본문 스크롤. 단독 예제는 Container ────────┐
31
+ │ ┌ NotificationItem ─────────────────────────────────────┐ │
32
+ │ │ (아바타) 서연님이 답글을 남겼어요 ← 안 읽음은 굵게 │ │
33
+ │ │ 저도 그 산책길 좋아해요… │ │
34
+ │ │ 새 알림 · 5분 전 ← statusLabel·시각│ │
35
+ │ └───────────────────────────────────────────────────────┘ │
36
+ │ ↕ spacing.sm 12 │
37
+ │ ┌ NotificationItem(확인함) ─────────────────────────────┐ │
38
+ │ └───────────────────────────────────────────────────────┘ │
39
+ │ 상태 문구(Web role=status, Native live region) │
40
+ └───────────────────────────────────────────────────────────┘
41
+ ```
42
+
43
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
44
+ | --- | --- | --- | --- |
45
+ | 바깥 틀 | [NotificationInboxScreen](../components/notification-inbox-screen.md) 본문. 단독 예제는 Web `Container`, Native `ScrollView` > `Container` | 화면 본문 스크롤 | 좌우 `Container gutter` compact 16 / regular 20 |
46
+ | 행 | `NotificationItem` | 세로 목록 | ListRow 크기, 행 사이 `Stack gap="sm"` 12. 행을 카드로 다시 감싸지 않는다 |
47
+ | 상태 | `Text` | 목록 아래(단독 예제). 화면에서는 화면 단위로 한 번 알린다 | `spacing.lg` 20 |
48
+
49
+ ## 흐름과 상태
50
+
51
+ 1. 안 읽은 알림을 누르면 `optimisticValue`로 곧바로 읽음 표시가 되고 제품이 상세로 이동한다.
52
+ 2. 진행 중에는 다른 행을 `disabled`로 막아 같은 세션의 겹친 요청을 만들지 않는다(제품은 행마다 세션을 둘 수 있다).
53
+ 3. 서버가 실패하면 그 행만 읽지 않음으로 돌아오고 상태 문구가 알린다.
54
+
55
+ | 상태 | 모습 | 포커스·알림 |
56
+ | --- | --- | --- |
57
+ | 기본 | 안 읽음: 제목 굵게, `statusLabel` "새 알림" | 행 이름에 제목·상태·시각 |
58
+ | 진행 중 | 누른 행이 읽음으로 바뀜(낙관), 다른 행 `disabled` | 포커스 유지, 상태 문구 |
59
+ | 실패 | 읽지 않음으로 복구 | 상태 문구 알림, 포커스 이동 없음 |
60
+ | 성공 | 읽음 확정, `statusLabel` "확인함" | 상태 문구 |
61
+
62
+ ## 코드 골격
63
+
64
+ ```tsx
65
+ // Web
66
+ import { NotificationItem } from "@hjmds/react/screens";
67
+
68
+ <NotificationItem
69
+ read={n.read}
70
+ statusLabel={n.read ? t("inbox.read") : t("inbox.unread")}
71
+ timestamp={formatRelative(n.createdAt)}
72
+ title={n.title}
73
+ description={n.body}
74
+ leading={senderAvatar}
75
+ href={n.url}
76
+ />
77
+ ```
78
+
79
+ ```tsx
80
+ // Native
81
+ import { NotificationItem } from "@hjmds/react-native/screens";
82
+
83
+ <NotificationItem
84
+ read={n.read}
85
+ statusLabel={n.read ? t("inbox.read") : t("inbox.unread")}
86
+ timestamp={formatRelative(n.createdAt)}
87
+ title={n.title}
88
+ description={n.body}
89
+ leading={senderAvatar}
90
+ onPress={() => openNotification(n.id)}
91
+ />
92
+ ```
93
+
94
+ 제품 데이터·콜백은 주입한다. 위 공개 API 지침에 Web·Native 차이를 유지한다.
95
+
96
+ ## 함정
97
+
98
+ - 읽음 상태를 제목 굵기만으로 알리지 않는다. `statusLabel`을 반드시 준다.
99
+ - 읽음 처리 실패를 조용히 삼키지 않는다. 이전 상태로 되돌리고 알린다.
100
+ - 필터·묶음 제목·화면 상태를 행 예제에 다시 만들지 않는다. [알림함 화면](../screens/common-inbox.md)이 소유한다.
101
+ - 스토리의 "다음 읽음 처리 실패시키기"·350ms 지연은 데모 전용이다.
@@ -0,0 +1,186 @@
1
+ # 복합 입력 모음
2
+
3
+ - 단계: 구성
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: [복합 입력 계약](../../compound-controls.md), `showcase/shared/compound-controls.ts`, `showcase/{web/src/patterns,native/src}/compound-previews.tsx`
9
+ - 스토리북: `배포/구성/비교와 검증/복합 입력 모음`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 기존 컨트롤을 묶은 네 가지 복합 입력(소요 시간, 버튼 자리 확인, 이모지 반응, 알림 종)을 화면 안 한 블록으로 둘 때 쓴다.
14
+ 스토리 네 개(소요 시간 선택·버튼 안에서 확인·이모지 반응·알림 상태)가 각각 "제목 → 설명 → 컨트롤 → 결과" 블록 하나를 보인다.
15
+
16
+ ## 구성 요소
17
+
18
+ | 컴포넌트 | 역할 | 지침 |
19
+ | --- | --- | --- |
20
+ | `Heading level="level3"` | 블록 제목(24). 문서 단계는 화면 구조에 맞춰 `semanticLevel` | [Heading](../components/heading.md) |
21
+ | Text `tone="muted"` | 설명·안내 | [Text](../components/text.md) |
22
+ | DurationField (`/duration-field`) | 시·분·초로 정수 초를 고른다 | [NumberField](../components/number-field.md) |
23
+ | InlineConfirm (`/inline-confirm`) | 오버레이 없이 버튼 자리에서 한 번 더 확인(트리거·확인 `danger`, 취소 `ghost`) | [Button](../components/button.md) |
24
+ | ReactionPicker (`/reaction-picker`) | 반응 하나를 고르거나 해제(`ghost` `pill` 토글 버튼 묶음) | [Button](../components/button.md) |
25
+ | NotificationBell (`/notification-bell`) | 읽지 않은 수가 있는 종 아이콘 버튼 | [IconButton](../components/icon-button.md) |
26
+ | `Stack` | 블록 안 세로 쌓기 `gap="xl"` | [Stack](../components/stack.md) |
27
+ | `Container` · `ScrollView` | 바깥 틀. 제품 화면이 소유한다 | [Container](../components/container.md), [화면 여백](../tokens/layout.md) |
28
+ | Switch, Button `ghost`·`primary` | 스토리의 실패 응답 토글·다시 시작·알림 추가(데모 전용, 제품에는 넣지 않는다) | [Switch](../components/switch.md), [Button](../components/button.md) |
29
+
30
+ ## 배치
31
+
32
+ ```text
33
+ ┌ 화면 바깥 틀(제품 소유) ───────────────────────────┐
34
+ │ Web: 문서 스크롤 · Native: ScrollView │
35
+ │ ↕ Native 위아래 spacing.lg 20 │
36
+ │ ←gutter 16|20→ Container ←gutter 16|20→ │
37
+ └────────────────────────────────────────────────────┘
38
+
39
+ 소요 시간 선택 버튼 안에서 확인
40
+ ┌── Stack gap spacing.xl 24 ──────┐ ┌── Stack gap spacing.xl 24 ──────────┐
41
+ │ 제목 (Heading level3) │ │ 제목 │
42
+ │ 설명 (muted) │ │ 설명 (muted) │
43
+ │ 집중할 시간 │ │ [ 초안 삭제 ] danger ← 처음 트리거 │
44
+ │ [− 시 +] [− 분 +] [− 초 +] │ │ ↓ 누르면 같은 자리에서 │
45
+ │ ↑ 칸 사이 spacing.md 16, │ │ 이 초안을 삭제할까요? │
46
+ │ 좁거나 큰 글자면 줄바꿈 │ │ (오류 문구, 실패 때만) │
47
+ │ 총 1,500초 ← 결과 │ │ [유지하기] [삭제하기] ← 취소 → 확인 │
48
+ └─────────────────────────────────┘ │ ghost danger, 사이 spacing.sm 12│
49
+ └──────────────────────────────────────┘
50
+ 이모지 반응 알림 상태
51
+ ┌─────────────────────────────────┐ ┌──────────────────────────────────────┐
52
+ │ 제목 │ │ (🔔 3) ← NotificationBell │
53
+ │ [👍] [💜] [🎉] [✨] ← 하나 선택 │ │ 안내 (muted) │
54
+ │ ghost pill, 사이 spacing.xs 8 │ └──────────────────────────────────────┘
55
+ └─────────────────────────────────┘
56
+ ```
57
+
58
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
59
+ | --- | --- | --- | --- |
60
+ | 바깥 틀 | Web 문서 스크롤 > `Container`. Native `ScrollView` > `Container` | 블록을 감싸는 제품 화면. 상단 안전 영역은 내비게이션 헤더(또는 [TopBar](../components/top-bar.md))가 맡는다 | 좌우 `Container gutter`: 폭 600 미만 `compact` 16, 이상 `regular` 20([화면 여백](../tokens/layout.md)). Native 위아래는 `contentContainerStyle` `paddingVertical: spacing.lg` 20 |
61
+ | 블록 | `Stack gap="xl"` | 바깥 틀 안, 본문 흐름 | 자식 사이 `spacing.xl` 24 |
62
+ | 소요 시간 | DurationField | 설명 아래 | NumberField 세 칸, 칸 사이 `spacing.md` 16. Web은 칸 최소 10ch로 자동 줄바꿈, Native는 글자 배율에 비례한 칸 폭으로 줄바꿈 |
63
+ | 확인 | InlineConfirm | 트리거 자리 그대로 | 트리거와 확인 행동이 같은 자리, Button 높이 44, 질문·버튼 줄 사이와 두 버튼 사이 `spacing.sm` 12 |
64
+ | 반응 | ReactionPicker | 제목 아래 | `ghost` `pill` Button 묶음, 사이 `spacing.xs` 8, 넘치면 줄바꿈 |
65
+ | 알림 종 | NotificationBell | 블록 맨 위, 시작 쪽 정렬 | IconButton(기본 `medium` 44) + CounterBadge `floating`(끝·위 모서리) |
66
+
67
+ ## 흐름과 상태
68
+
69
+ 1. 소요 시간: 증감 버튼은 즉시, 타이핑은 blur에서 확정된다. 합계가 `min`~`max`로 clamp된다. `max`가 1시간 미만이면 시 칸이 비활성이다.
70
+ 2. 버튼 안에서 확인: 트리거를 누르면 같은 자리에 질문과 [취소][확인]이 나온다. 확인하면 `onConfirm`이 돌고 성공·오류를 같은 자리에 보인다.
71
+ 3. 확인이 실패(`onConfirm`의 throw·reject: 네트워크·서버 오류)하면 질문 아래 `errorLabel`이 나오고, 같은 확인 버튼이 다시 시도가 된다.
72
+ 다시 시도도 실패하면 같은 오류 문구가 남는다. 취소하면 트리거로 돌아간다.
73
+ 4. 이모지 반응: 하나를 고르면 선택, 같은 것을 다시 누르면 해제, 다른 것을 누르면 바뀐다. 개수·저장은 제품 데이터다.
74
+ 서버에 저장하다 실패하면 값을 되돌리는 것은 [즉시 반영과 복구](action-recovery-optimistic.md)대로 제품이 한다.
75
+ 5. 알림 종: 누르면 `onPress`만 온다. 읽음 처리는 제품이 한다.
76
+
77
+ | 상태 | 모습 | 포커스·알림 |
78
+ | --- | --- | --- |
79
+ | 기본 | 각 블록의 초기 값. InlineConfirm은 트리거 버튼 하나(`danger`) | — |
80
+ | 확인 대기 | 질문 + [유지하기][삭제하기] | Web은 취소에 처음 포커스, Escape는 취소하고 트리거로 포커스 복귀. Native는 질문이 live region, iOS 한 번 알림 |
81
+ | 진행 중 | InlineConfirm busy. 두 행동 비활성, 확인 버튼 `loading` + `pendingLabel`. DurationField·ReactionPicker·NotificationBell은 진행 상태가 없다 | Native iOS는 상태마다 한 번 알림, Web Escape 무시 |
82
+ | 실패 | InlineConfirm error. 질문 아래 `errorLabel`(Native `tone="danger"`), 확인 버튼으로 다시 시도 | Web `role="alert"`, Native `accessibilityRole="alert"` + iOS는 대기 중 음성을 끊고 알린다 |
83
+ | 재시도 실패 | 같은 `errorLabel`이 다시 나온다(busy → error로 바뀌므로 다시 알림) | 실패와 같다 |
84
+ | 확인 성공 | `successLabel`이 남는다 | Web `role="status"`, Native live region. 초기화는 새 `key`로 remount |
85
+ | 알림 수 증가 | 종이 400ms 한 번 흔들린다 | 종 그림·배지는 숨기고 `label` 한 번만 읽는다 |
86
+ | reduced motion·배경·`active={false}` | 종이 흔들리지 않는다 | — |
87
+
88
+ - 문구(`labels`·확인 문구·반응 `label`·종 `label`)는 모두 제품이 i18n 키로 넣는다. 개수가 들어가는 문구는 `t(key, { count })`로 만든다.
89
+
90
+ ## 코드 골격
91
+
92
+ ```tsx
93
+ // Web
94
+ import { useState } from "react";
95
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
96
+ import { Icon } from "@hjmds/react/display";
97
+ import { DurationField } from "@hjmds/react/duration-field";
98
+ import { Heading } from "@hjmds/react/heading";
99
+ import { InlineConfirm } from "@hjmds/react/inline-confirm";
100
+ import { Container, Stack, Text } from "@hjmds/react/layout";
101
+ import { NotificationBell } from "@hjmds/react/notification-bell";
102
+ import { ReactionPicker } from "@hjmds/react/reaction-picker";
103
+
104
+ function CompoundBlocks() {
105
+ const [seconds, setSeconds] = useState(1500);
106
+ const [reaction, setReaction] = useState<string | null>(null);
107
+ const gutter = resolveWindowClass(window.innerWidth) === "compact" ? "compact" : "regular";
108
+ return (
109
+ <Container gutter={gutter}>
110
+ <Stack gap="xl">
111
+ <Heading level="level3" semanticLevel={2}>{t("focus.title")}</Heading>
112
+ <Text as="p" tone="muted">{t("focus.description")}</Text>
113
+ <DurationField value={seconds} onValueChange={setSeconds} max={86399} labels={durationLabels} />
114
+ <InlineConfirm key={draft.id} label={t("draft.delete")} prompt={t("draft.delete.prompt")}
115
+ confirmLabel={t("draft.delete.confirm")} cancelLabel={t("draft.delete.cancel")}
116
+ pendingLabel={t("draft.delete.pending")} successLabel={t("draft.delete.done")}
117
+ errorLabel={t("draft.delete.error")} onConfirm={() => deleteDraft(draft.id)} />
118
+ <ReactionPicker label={t("reaction.group")} options={reactions} value={reaction} onValueChange={setReaction} />
119
+ <NotificationBell count={unread} label={t("inbox.unread", { count: unread })}
120
+ icon={<Icon name="notifications" />} onPress={openInbox} />
121
+ </Stack>
122
+ </Container>
123
+ );
124
+ }
125
+ ```
126
+
127
+ ```tsx
128
+ // Native
129
+ import { useState } from "react";
130
+ import { ScrollView, useWindowDimensions } from "react-native";
131
+ import { spacing } from "@hjmds/design-contracts/foundations";
132
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
133
+ import { DurationField } from "@hjmds/react-native/duration-field";
134
+ import { Heading } from "@hjmds/react-native/heading";
135
+ import { InlineConfirm } from "@hjmds/react-native/inline-confirm";
136
+ import { NotificationBell } from "@hjmds/react-native/notification-bell";
137
+ import { Container, Icon, Stack, Text } from "@hjmds/react-native/primitives";
138
+ import { ReactionPicker } from "@hjmds/react-native/reaction-picker";
139
+
140
+ function CompoundBlocks() {
141
+ const [seconds, setSeconds] = useState(1500);
142
+ const [reaction, setReaction] = useState<string | null>(null);
143
+ const { width } = useWindowDimensions();
144
+ return (
145
+ <ScrollView contentContainerStyle={{ paddingVertical: spacing.lg }}>
146
+ <Container gutter={resolveWindowClass(width) === "compact" ? "compact" : "regular"}>
147
+ <Stack gap="xl">
148
+ <Heading level="level3" semanticLevel={2}>{t("focus.title")}</Heading>
149
+ <Text tone="muted">{t("focus.description")}</Text>
150
+ <DurationField value={seconds} onValueChange={setSeconds} max={86399} labels={durationLabels} />
151
+ <InlineConfirm key={draft.id} {...confirmLabels} onConfirm={() => deleteDraft(draft.id)} />
152
+ <ReactionPicker label={t("reaction.group")} options={reactions} value={reaction} onValueChange={setReaction} />
153
+ <NotificationBell count={unread} label={t("inbox.unread", { count: unread })}
154
+ icon={<Icon descriptor={{ name: "notifications" }} renderGlyph={renderGlyph} />} onPress={openInbox} />
155
+ </Stack>
156
+ </Container>
157
+ </ScrollView>
158
+ );
159
+ }
160
+ ```
161
+
162
+ `durationLabels`는 `{ label, hours, minutes, seconds, increment(unit), decrement(unit) }`를 제품이 현지화한다.
163
+ `confirmLabels`는 Web 예의 일곱 문구(`label`·`prompt`·`confirmLabel`·`cancelLabel`·`pendingLabel`·`successLabel`·`errorLabel`) 묶음이다.
164
+ 반응 목록·이모지·개수, 삭제 동작과 서버 멱등성, `renderGlyph`(예: `createLucideGlyph`)는 제품 소유다.
165
+
166
+ ## 플랫폼 차이
167
+
168
+ | 항목 | Web | Native |
169
+ | --- | --- | --- |
170
+ | 바깥 스크롤 | 문서 스크롤 | `ScrollView`(위아래 `spacing.lg`) |
171
+ | 종 아이콘 | `<Icon name>` | `<Icon descriptor renderGlyph>` |
172
+ | 확인 알림 | 포커스 이동 + Escape, 오류 `role="alert"` | iOS 명시 알림, Android live region, 오류 `accessibilityRole="alert"` |
173
+ | 소요 시간 칸 줄바꿈 | CSS grid `auto-fit`(칸 최소 10ch) | 글자 배율에 비례한 칸 폭 + `flexWrap` |
174
+
175
+ ## 함정
176
+
177
+ - 반응 개수를 화면낭독기에 알려야 하면 옵션 `label` 문구 안에 개수를 넣는다. 보이는 이모지·개수 그림은 장식으로 숨겨진다.
178
+ - NotificationBell 누름이 읽음 처리를 하지 않는다. 제품이 count를 갱신해야 배지가 줄어든다.
179
+ - DurationField 범위 밖 `value`는 조용히 고치지 않고 오류를 던진다.
180
+ - InlineConfirm의 성공 상태는 다시 트리거로 돌아가지 않는다. 같은 자리에서 다시 쓰려면 새 `key`로 remount한다.
181
+ - 현재 Native 스토리는 블록 제목을 `Text variant="heading"`으로 그리고, Web 스토리는 `Heading level="level2"`(32)를 쓴다.
182
+ 플랫폼별 크기 차이에 근거가 없으므로 제품은 두 플랫폼 모두 `Heading level="level3"`(24)과 화면 구조에 맞는 `semanticLevel`을 쓴다.
183
+ - 현재 Native 스토리는 `ScrollView contentContainerStyle={{ padding: spacing.xl, gap: spacing.xl }}`로 좌우 여백과 간격을 직접 준다.
184
+ 좌우 여백은 `Container gutter`, 위아래는 `paddingVertical: spacing.lg`, 자식 간격은 `Stack gap="xl"`로 둔다([화면 여백](../tokens/layout.md)).
185
+ - 현재 스토리의 Switch "실패 응답 보기"·"예제 다시 시작"·"새 알림 추가" 버튼은 데모 조작이다. 알림 상태 스토리의 "새 알림 추가"는 primary라
186
+ 제품 화면에 옮기면 그 화면의 주 행동과 겹친다.