@hjmds/design-contracts 1.18.0 → 1.20.0

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 (251) hide show
  1. package/dist/action-session.d.ts +16 -0
  2. package/dist/action-session.d.ts.map +1 -1
  3. package/dist/action-session.js +18 -0
  4. package/dist/action-session.js.map +1 -1
  5. package/dist/behaviors.d.ts +8 -6
  6. package/dist/behaviors.d.ts.map +1 -1
  7. package/dist/behaviors.js +4 -3
  8. package/dist/behaviors.js.map +1 -1
  9. package/dist/bottom-navigation.d.ts +14 -0
  10. package/dist/bottom-navigation.d.ts.map +1 -1
  11. package/dist/bottom-navigation.js +21 -0
  12. package/dist/bottom-navigation.js.map +1 -1
  13. package/dist/carousel.d.ts +3 -0
  14. package/dist/carousel.d.ts.map +1 -1
  15. package/dist/carousel.js.map +1 -1
  16. package/dist/catalog.d.ts +61 -0
  17. package/dist/catalog.d.ts.map +1 -1
  18. package/dist/component-recipes.d.ts +67 -0
  19. package/dist/component-recipes.d.ts.map +1 -1
  20. package/dist/component-recipes.js +27 -1
  21. package/dist/component-recipes.js.map +1 -1
  22. package/dist/field-group.d.ts +14 -0
  23. package/dist/field-group.d.ts.map +1 -1
  24. package/dist/field-group.js +9 -0
  25. package/dist/field-group.js.map +1 -1
  26. package/dist/form.d.ts.map +1 -1
  27. package/dist/form.js +10 -3
  28. package/dist/form.js.map +1 -1
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/screen-patterns.d.ts +4 -0
  33. package/dist/screen-patterns.d.ts.map +1 -1
  34. package/dist/screen-patterns.js +12 -3
  35. package/dist/screen-patterns.js.map +1 -1
  36. package/dist/semantic-colors.d.ts +9 -0
  37. package/dist/semantic-colors.d.ts.map +1 -1
  38. package/dist/semantic-colors.js +5 -0
  39. package/dist/semantic-colors.js.map +1 -1
  40. package/dist/toast.d.ts +10 -1
  41. package/dist/toast.d.ts.map +1 -1
  42. package/dist/toast.js +43 -2
  43. package/dist/toast.js.map +1 -1
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/dist/version.js.map +1 -1
  47. package/docs/action-session.md +2 -0
  48. package/docs/asset.md +7 -0
  49. package/docs/bottom-navigation.md +22 -0
  50. package/docs/brand-boundary.md +3 -3
  51. package/docs/collection-rail.md +4 -0
  52. package/docs/compound-controls.md +8 -3
  53. package/docs/consumer-policy.md +39 -120
  54. package/docs/generated/component-maturity.md +1 -1
  55. package/docs/generated/renderer-evidence.json +3 -3
  56. package/docs/generated/renderer-evidence.md +1 -1
  57. package/docs/generated/showcase-manifest.json +1 -1
  58. package/docs/gravity-letters.md +2 -0
  59. package/docs/grid-reveal.md +2 -0
  60. package/docs/layout.md +2 -2
  61. package/docs/load-more.md +5 -0
  62. package/docs/migration-native-legacy-removal.md +30 -17
  63. package/docs/optional-adapters.md +4 -0
  64. package/docs/screen-patterns.md +23 -1
  65. package/docs/toast.md +41 -2
  66. package/docs/usage/README.md +248 -245
  67. package/docs/usage/SELECTION.md +43 -0
  68. package/docs/usage/STANDARD.md +22 -10
  69. package/docs/usage/components/accordion.md +5 -3
  70. package/docs/usage/components/agreement.md +6 -9
  71. package/docs/usage/components/alert-dialog.md +12 -6
  72. package/docs/usage/components/asset.md +10 -4
  73. package/docs/usage/components/auth-provider-button.md +2 -2
  74. package/docs/usage/components/avatar.md +8 -6
  75. package/docs/usage/components/badge.md +4 -4
  76. package/docs/usage/components/bottom-cta.md +4 -4
  77. package/docs/usage/components/bottom-info.md +2 -2
  78. package/docs/usage/components/bottom-navigation.md +30 -6
  79. package/docs/usage/components/button.md +2 -2
  80. package/docs/usage/components/card.md +1 -1
  81. package/docs/usage/components/carousel.md +12 -4
  82. package/docs/usage/components/chat-message.md +6 -2
  83. package/docs/usage/components/chat-screen.md +5 -5
  84. package/docs/usage/components/checkbox-group.md +1 -1
  85. package/docs/usage/components/checkbox.md +2 -2
  86. package/docs/usage/components/chip.md +2 -2
  87. package/docs/usage/components/code-block.md +3 -4
  88. package/docs/usage/components/collapsible.md +6 -4
  89. package/docs/usage/components/combobox.md +2 -2
  90. package/docs/usage/components/command-palette.md +1 -1
  91. package/docs/usage/components/comment-thread-screen.md +7 -4
  92. package/docs/usage/components/container.md +1 -1
  93. package/docs/usage/components/content-transition.md +2 -2
  94. package/docs/usage/components/context-menu.md +2 -2
  95. package/docs/usage/components/counter-badge.md +3 -3
  96. package/docs/usage/components/data-table.md +1 -1
  97. package/docs/usage/components/date-picker.md +4 -4
  98. package/docs/usage/components/description-list.md +3 -3
  99. package/docs/usage/components/design-system-provider.md +2 -2
  100. package/docs/usage/components/dialog.md +14 -3
  101. package/docs/usage/components/divider.md +2 -2
  102. package/docs/usage/components/editor-screen.md +5 -3
  103. package/docs/usage/components/effect-surface.md +1 -1
  104. package/docs/usage/components/empty-state.md +6 -6
  105. package/docs/usage/components/field.md +5 -3
  106. package/docs/usage/components/file-picker.md +6 -3
  107. package/docs/usage/components/form.md +1 -1
  108. package/docs/usage/components/heading.md +3 -3
  109. package/docs/usage/components/icon-button.md +3 -3
  110. package/docs/usage/components/icon.md +1 -1
  111. package/docs/usage/components/image.md +5 -4
  112. package/docs/usage/components/keyboard-dock.md +1 -1
  113. package/docs/usage/components/layout.md +1 -1
  114. package/docs/usage/components/link.md +2 -2
  115. package/docs/usage/components/list-detail-screen.md +6 -6
  116. package/docs/usage/components/list-row.md +13 -4
  117. package/docs/usage/components/list.md +5 -4
  118. package/docs/usage/components/masonry.md +2 -1
  119. package/docs/usage/components/media-selection-screen.md +11 -5
  120. package/docs/usage/components/mentions.md +2 -2
  121. package/docs/usage/components/menu.md +3 -2
  122. package/docs/usage/components/menubar.md +2 -2
  123. package/docs/usage/components/message-composer.md +3 -3
  124. package/docs/usage/components/moderation-screen.md +3 -2
  125. package/docs/usage/components/notice.md +2 -2
  126. package/docs/usage/components/notification-inbox-screen.md +1 -1
  127. package/docs/usage/components/notification-item.md +2 -2
  128. package/docs/usage/components/number-field.md +3 -3
  129. package/docs/usage/components/otp-field.md +1 -1
  130. package/docs/usage/components/overview-screen.md +1 -1
  131. package/docs/usage/components/permission-screen.md +1 -1
  132. package/docs/usage/components/photo-source-sheet.md +1 -1
  133. package/docs/usage/components/popover.md +2 -2
  134. package/docs/usage/components/profile-screen.md +1 -1
  135. package/docs/usage/components/progress.md +2 -2
  136. package/docs/usage/components/qr-code.md +2 -2
  137. package/docs/usage/components/radio-group.md +1 -2
  138. package/docs/usage/components/radio.md +1 -2
  139. package/docs/usage/components/result.md +1 -1
  140. package/docs/usage/components/saved-items-screen.md +4 -2
  141. package/docs/usage/components/screen-layout.md +4 -3
  142. package/docs/usage/components/search-field.md +1 -1
  143. package/docs/usage/components/search-screen.md +4 -4
  144. package/docs/usage/components/section.md +3 -3
  145. package/docs/usage/components/segmented-control.md +4 -4
  146. package/docs/usage/components/select.md +2 -3
  147. package/docs/usage/components/settings-screen.md +11 -49
  148. package/docs/usage/components/sheet.md +10 -5
  149. package/docs/usage/components/sidebar.md +1 -1
  150. package/docs/usage/components/skeleton.md +1 -1
  151. package/docs/usage/components/skip-nav.md +1 -1
  152. package/docs/usage/components/slider.md +2 -2
  153. package/docs/usage/components/spinner.md +2 -2
  154. package/docs/usage/components/splitter.md +1 -1
  155. package/docs/usage/components/statistic.md +5 -4
  156. package/docs/usage/components/steps.md +2 -2
  157. package/docs/usage/components/surface.md +2 -3
  158. package/docs/usage/components/switch.md +1 -1
  159. package/docs/usage/components/tabs.md +4 -4
  160. package/docs/usage/components/tag.md +2 -2
  161. package/docs/usage/components/tags-input.md +4 -4
  162. package/docs/usage/components/text-area.md +2 -3
  163. package/docs/usage/components/text.md +85 -4
  164. package/docs/usage/components/timeline.md +2 -2
  165. package/docs/usage/components/toast.md +17 -7
  166. package/docs/usage/components/toggle-group.md +3 -3
  167. package/docs/usage/components/tooltip.md +1 -1
  168. package/docs/usage/components/top-bar.md +5 -4
  169. package/docs/usage/components/top.md +3 -3
  170. package/docs/usage/components/transfer-list.md +5 -4
  171. package/docs/usage/components/upload-item.md +3 -3
  172. package/docs/usage/components/virtual-list.md +1 -0
  173. package/docs/usage/compositions/action-feedback.md +42 -6
  174. package/docs/usage/compositions/action-recovery-optimistic.md +6 -1
  175. package/docs/usage/compositions/action-recovery-save.md +19 -23
  176. package/docs/usage/compositions/action-recovery-undo.md +6 -1
  177. package/docs/usage/compositions/activity-status.md +11 -5
  178. package/docs/usage/compositions/collection-detail.md +2 -0
  179. package/docs/usage/compositions/command-records.md +15 -5
  180. package/docs/usage/compositions/common-message.md +4 -2
  181. package/docs/usage/compositions/common-notification.md +5 -3
  182. package/docs/usage/compositions/compound-controls.md +13 -5
  183. package/docs/usage/compositions/content-transition-comparison.md +2 -2
  184. package/docs/usage/compositions/date-entry.md +10 -5
  185. package/docs/usage/compositions/date-time-selection.md +3 -3
  186. package/docs/usage/compositions/design-profile-comparison.md +7 -5
  187. package/docs/usage/compositions/disclosure.md +1 -1
  188. package/docs/usage/compositions/document-resource.md +10 -3
  189. package/docs/usage/compositions/expo-interactions.md +3 -2
  190. package/docs/usage/compositions/family-drawer.md +9 -4
  191. package/docs/usage/compositions/field-group.md +7 -1
  192. package/docs/usage/compositions/floating-action-button.md +18 -18
  193. package/docs/usage/compositions/illustrated-outcome.md +3 -2
  194. package/docs/usage/compositions/input-sheet.md +6 -2
  195. package/docs/usage/compositions/interaction-adapters.md +1 -1
  196. package/docs/usage/compositions/interaction-flow-apply.md +6 -3
  197. package/docs/usage/compositions/interaction-flow-draft.md +5 -2
  198. package/docs/usage/compositions/interaction-flow-search.md +5 -2
  199. package/docs/usage/compositions/live-list.md +7 -5
  200. package/docs/usage/compositions/navigation-bar-collection.md +8 -7
  201. package/docs/usage/compositions/optional-adapters.md +1 -1
  202. package/docs/usage/compositions/origin-dialog.md +5 -5
  203. package/docs/usage/compositions/paper-surface.md +1 -1
  204. package/docs/usage/compositions/photo-source.md +1 -1
  205. package/docs/usage/compositions/purpose-input-comment.md +5 -3
  206. package/docs/usage/compositions/purpose-input-message.md +5 -3
  207. package/docs/usage/compositions/reference-first.md +63 -42
  208. package/docs/usage/compositions/reference-review.md +60 -50
  209. package/docs/usage/compositions/reference-settings.md +60 -50
  210. package/docs/usage/compositions/selection-scope.md +48 -35
  211. package/docs/usage/compositions/stea-order-progress.md +1 -0
  212. package/docs/usage/compositions/stea-otp-verify.md +5 -3
  213. package/docs/usage/compositions/stea-pixel-empty.md +3 -3
  214. package/docs/usage/compositions/time-selection.md +6 -5
  215. package/docs/usage/compositions/upload-recovery.md +10 -1
  216. package/docs/usage/compositions/video-dialog.md +10 -3
  217. package/docs/usage/compositions/web-additions.md +5 -5
  218. package/docs/usage/compositions/web-navigation.md +1 -1
  219. package/docs/usage/screens/common-chat.md +21 -8
  220. package/docs/usage/screens/common-comments.md +13 -1
  221. package/docs/usage/screens/common-inbox.md +19 -6
  222. package/docs/usage/screens/common-login.md +19 -7
  223. package/docs/usage/screens/common-profile.md +22 -9
  224. package/docs/usage/screens/common-saved.md +16 -2
  225. package/docs/usage/screens/common-search.md +24 -4
  226. package/docs/usage/screens/common-settings.md +22 -50
  227. package/docs/usage/screens/common-shell.md +14 -2
  228. package/docs/usage/screens/dashboard.md +52 -109
  229. package/docs/usage/screens/discovery-gallery.md +57 -148
  230. package/docs/usage/screens/flow-collection.md +38 -9
  231. package/docs/usage/screens/flow-editor.md +25 -7
  232. package/docs/usage/screens/flow-media.md +23 -7
  233. package/docs/usage/screens/flow-moderation.md +15 -2
  234. package/docs/usage/screens/flow-onboarding.md +3 -3
  235. package/docs/usage/screens/flow-permission.md +17 -3
  236. package/docs/usage/screens/landing.md +37 -20
  237. package/docs/usage/screens/mockup-studio.md +22 -7
  238. package/docs/usage/screens/notification-settings.md +18 -4
  239. package/docs/usage/screens/product-bento.md +18 -9
  240. package/docs/usage/screens/reference-comparison.md +29 -10
  241. package/docs/usage/tokens/color.md +6 -3
  242. package/docs/usage/tokens/elevation-opacity.md +4 -2
  243. package/docs/usage/tokens/font-roles.md +2 -2
  244. package/docs/usage/tokens/layers.md +3 -3
  245. package/docs/usage/tokens/motion.md +2 -2
  246. package/docs/usage/tokens/radius.md +4 -2
  247. package/docs/usage/tokens/spacing.md +3 -3
  248. package/docs/usage/tokens/theme-studio.md +2 -2
  249. package/docs/usage/tokens/typography-studio.md +14 -8
  250. package/docs/usage/tokens/typography.md +15 -5
  251. package/package.json +1 -1
@@ -3,7 +3,7 @@
3
3
  - 단계: 구성
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
- - 적용: 미게시(1.12.1 이후)
6
+ - 적용: 구성 API 지침 참조
7
7
  - 검토일: 2026-10-06
8
8
  - 근거: [반복 화면 조합](../../screen-patterns.md); 공통 API와 실제 Web·Native 예제의 슬롯·상태를 대조해 중복 조립 방지. 2026-10-06 사용자 승인으로 스토리북 배포(이전 `실험/구성/시작하기/첫 작업을 만들고 이어하기`, [승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정))
9
9
  - 스토리북: `배포/구성/입력과 작성/첫 작업을 만들고 이어하기`
@@ -28,8 +28,8 @@
28
28
 
29
29
  | 영역 | 컴포넌트 | 위치 | 크기·간격 |
30
30
  | --- | --- | --- | --- |
31
- | 바깥 틀 | Steps 또는 포함 Surface | 부모 화면의 해당 슬롯 | [배치](../components/steps.md#배치)에 따른다 |
32
- | 내용 | Steps 내부 슬롯 | 주제 → 입력 → 검토 → 결과 | spacing 토큰과 포함 컴포넌트 recipe |
31
+ | 바깥 틀 | 부모 화면 또는 포함 Surface | 부모 화면의 해당 슬롯 | [배치](../components/steps.md#배치)에 따른다 |
32
+ | 내용 | Steps와 분리한 입력·요약 슬롯 | 주제 → 입력 → 검토 → 결과 | spacing 토큰과 포함 컴포넌트 recipe |
33
33
  | 행동 | Button 또는 공개 콜백 | 내용과 가까운 명시 진입점 | 주 행동 하나, 보조 행동과 구분 |
34
34
 
35
35
  ## 흐름과 상태
@@ -46,49 +46,70 @@
46
46
 
47
47
  ## 코드 골격
48
48
 
49
- ```tsx
50
- // Web
51
- import { Steps } from "@hjmds/react/steps";
52
-
53
- <Steps
54
- descriptor={{
55
- currentStepId: "team",
56
- steps: [
57
- { id: "welcome", label: t("onboarding.welcome") },
58
- { id: "team", label: t("onboarding.team") },
59
- { id: "alerts", label: t("onboarding.alerts") },
60
- ],
61
- }}
62
- statusLabels={{
63
- pending: t("steps.pending"), current: t("steps.current"),
64
- complete: t("steps.complete"), error: t("steps.error"),
65
- }}
66
- composeAccessibleName={({ position, total, label }) => t("steps.name", { position, total, label })}
67
- />
68
- ```
49
+ <!-- usage-example: Web ../../../../../showcase/web/src/patterns/reference-flow-previews.tsx -->
50
+ <!-- usage-example: Native ../../../../../showcase/native/src/reference-flow-previews.tsx -->
51
+
52
+ 이 이름의 독립 공개 컴포넌트는 없다. 아래는 공개 `createActionSession`과 입력·Steps·Sheet를 조합하는 예제다. Storybook 예제 파일을 제품에서 import하지 않는다.
53
+
54
+ - [Web 실행 예제](../../../../../showcase/web/src/patterns/reference-flow-previews.tsx)의 `Editor(kind="first")`
55
+ - [Native 실행 예제](../../../../../showcase/native/src/reference-flow-previews.tsx)의 `Editor(kind="first")`
56
+ - [공통 검증·초안 비교](../../../../../showcase/shared/reference-flows.ts)의 `draftError`, `sameDraft`는 예제 데이터 규칙이며 제품 규칙으로 교체한다.
57
+
58
+ 주제 선택(0) → 입력(1) → 검토(2) → 저장 결과 순서다. 중단은 paused 표시만 바꾸고 draft와 stage를 보존한다. 이어하기는 이를 다시 보여 준다.
69
59
 
70
60
  ```tsx
71
- // Native
72
- import { Steps } from "@hjmds/react-native/steps";
73
-
74
- <Steps
75
- descriptor={{
76
- currentStepId: "team",
77
- steps: [
78
- { id: "welcome", label: t("onboarding.welcome") },
79
- { id: "team", label: t("onboarding.team") },
80
- { id: "alerts", label: t("onboarding.alerts") },
81
- ],
82
- }}
83
- statusLabels={{
84
- pending: t("steps.pending"), current: t("steps.current"),
85
- complete: t("steps.complete"), error: t("steps.error"),
86
- }}
87
- composeAccessibleName={({ position, total, label }) => t("steps.name", { position, total, label })}
88
- />
61
+ // Web — Native도 같은 상태 골격을 사용한다. JSX/문구/영속 저장은 각 제품의 기존 화면 슬롯에 연결한다.
62
+ import { useState, useSyncExternalStore } from "react";
63
+ import { createActionSession } from "@hjmds/design-contracts/action-session";
64
+
65
+ const [session] = useState(() => createActionSession(initialValue));
66
+ const saved = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
67
+ const [draft, setDraft] = useState(initialValue);
68
+ const [stage, setStage] = useState(0);
69
+ const [paused, setPaused] = useState(false);
70
+ const busy = saved.status === "pending";
71
+ const dirty = !sameDraft(draft, saved.value);
72
+
73
+ function review() {
74
+ if (busy || draftError(draft)) return; // 오류 문구를 입력에 연결하고 첫 오류 입력으로 이동
75
+ setStage(2);
76
+ }
77
+ async function save() {
78
+ if (draftError(draft)) return;
79
+ const submitted = { ...draft };
80
+ // pending 중 재호출은 계약이 차단한다. 실패하면 saved.value와 draft를 보존한다.
81
+ const result = await session.run(() => saveToServer(submitted));
82
+ if (result.status === "success") setDraft(result.value);
83
+ }
84
+ function discard() {
85
+ if (session.getSnapshot().status === "pending") return;
86
+ setDraft(saved.value);
87
+ session.reset(saved.value);
88
+ setPaused(true);
89
+ }
89
90
  ```
90
91
 
91
- 제품 데이터·콜백은 주입한다. 위 공개 API 지침에 Web·Native 차이를 유지한다.
92
+ | 표시/행동 | 연결 |
93
+ | --- | --- |
94
+ | 진행 단계 | `Steps.descriptor.currentStepId`를 `stage`에서 도출, 저장 실패 시 `currentStepStatus="error"` |
95
+ | 편집 | `TextField`/선택 컨트롤은 draft를 편집. pending 동안 disabled이며 이미 큐에 든 입력·이탈·되돌리기 콜백도 최신 session 상태로 차단. 변경 시 오래된 실패 표시를 해제 |
96
+ | 검토 | 같은 draft의 요약과 수정 버튼. 요약에서 값을 따로 보관하지 않음 |
97
+ | 저장 | `Button.loading=busy`, 성공 결과는 서버 응답에서, 실패는 Notice와 같은 저장 버튼 재시도 |
98
+ | 되돌리기 | dirty이며 pending이 아닐 때 저장값 복원. 서버 요청 취소로 설명하지 않음 |
99
+ | 중단/이탈 | first/review는 메모리 draft·stage를 유지해 이어하기. settings는 dirty이면 확인 Sheet |
100
+ | 확정 이후 | 결과 확인과 새 편집 진입을 구분. 새 편집은 마지막 확정값으로 시작 |
101
+
102
+ ## 플랫폼 차이
103
+
104
+ | 항목 | Web | Native |
105
+ | --- | --- | --- |
106
+ | 입력/단계 포커스 | DOM 입력·제목 ref | Native 입력 ref와 접근성 포커스 |
107
+ | 결과 알림 | role=status | Android live region + iOS 접근성 알림 |
108
+ | 이탈 확인 | 공개 Sheet의 dismiss/focus 계약 | 공개 Sheet의 dismiss/키보드/복귀 계약 |
109
+
110
+ 입력 오류는 입력 ref로 복귀한다. 설정 Sheet를 취소하면 실제 이탈 버튼, 변경을 버려 버튼이 사라지면 유지되는 단계 제목으로 복귀한다. Web은 안정적인 단계 제목/입력 ref에 포커스를 옮기고 `role="status"`로 결과를 알린다. Native는 공개 입력 ref와 단계 제목의 접근성 포커스·알림을 연결하며, unmount 때 예약된 포커스 이동을 취소한다. Sheet의 포커스·키보드·dismiss 정책은 기존 계약에 맡긴다. 제품은 validation 결과, 저장 API, 라우터 이탈 이벤트와 확인 Sheet의 연결, i18n 키를 제공한다.
111
+
112
+ 메모리 초안은 이 예제의 mounted 화면 안에서만 유지된다. 새로고침·프로세스 종료·다른 기기 복구와 서버 중복 처리 방지는 제품 저장소와 API 계약이 있어야 한다. `session.reset`은 서버 요청 취소가 아니다.
92
113
 
93
114
  ## 함정
94
115
 
@@ -3,20 +3,20 @@
3
3
  - 단계: 구성
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
- - 적용: 미게시(1.12.1 이후)
6
+ - 적용: 구성 API 지침 참조
7
7
  - 검토일: 2026-10-06
8
8
  - 근거: [반복 화면 조합](../../screen-patterns.md); 공통 API와 실제 Web·Native 예제의 슬롯·상태를 대조해 중복 조립 방지. 2026-10-06 사용자 승인으로 스토리북 배포(이전 `실험/구성/확인/선택 내용 검토와 수정`, [승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정))
9
9
  - 스토리북: `배포/구성/입력과 작성/선택 내용 검토와 수정`
10
10
 
11
11
  ## 언제 쓰나
12
12
 
13
- 선택 내용을 검토하고 수정 후 명시적으로 확정 흐름이 필요할 때 쓴다. Form의 상태·콜백을 제품 로직에 연결하며 새 데이터 엔진을 만들지 않는다.
13
+ 선택 내용을 검토하고 수정 후 명시적으로 확정 흐름이 필요할 때 쓴다. 입력·단계·저장 세션을 제품 로직에 연결하며 새 데이터 엔진을 만들지 않는다.
14
14
 
15
15
  ## 구성 요소
16
16
 
17
17
  | 컴포넌트 | 역할 | 지침 |
18
18
  | --- | --- | --- |
19
- | Form | 선택 내용을 검토하고 수정 후 명시적으로 확정 | [공개 계약](../components/form.md) |
19
+ | TextField · createActionSession | 선택 내용을 검토하고 수정 후 명시적으로 확정 | [공개 계약](../components/form.md) |
20
20
  | Button | 명시 행동·재시도 | [Button](../components/button.md) |
21
21
 
22
22
  ## 배치
@@ -28,8 +28,8 @@
28
28
 
29
29
  | 영역 | 컴포넌트 | 위치 | 크기·간격 |
30
30
  | --- | --- | --- | --- |
31
- | 바깥 틀 | Form 또는 포함 Surface | 부모 화면의 해당 슬롯 | [배치](../components/form.md#배치)에 따른다 |
32
- | 내용 | Form 내부 슬롯 | 입력 → 요약 → 수정·저장 | spacing 토큰과 포함 컴포넌트 recipe |
31
+ | 바깥 틀 | 부모 화면 또는 포함 Surface | 부모 화면의 해당 슬롯 | [배치](../components/form.md#배치)에 따른다 |
32
+ | 내용 | 부모 화면의 입력·요약 슬롯 | 입력 → 요약 → 수정·저장 | spacing 토큰과 포함 컴포넌트 recipe |
33
33
  | 행동 | Button 또는 공개 콜백 | 내용과 가까운 명시 진입점 | 주 행동 하나, 보조 행동과 구분 |
34
34
 
35
35
  ## 흐름과 상태
@@ -46,60 +46,70 @@
46
46
 
47
47
  ## 코드 골격
48
48
 
49
- ```tsx
50
- // Web
51
- import { useRef } from "react";
52
- import { Form, TextField } from "@hjmds/react/forms";
53
- import { Button } from "@hjmds/react/actions";
54
-
55
- const nicknameInputRef = useRef<HTMLInputElement>(null);
56
-
57
- <Form
58
- aria-label={t("profile.formLabel")}
59
- onSubmit={async () => {
60
- setServerError(null);
61
- if (nicknameError) { nicknameInputRef.current?.focus(); return; } // Web은 첫 오류 포커스를 제품이 한다
62
- try { await save(values); } catch { setServerError(t("profile.saveFailed")); }
63
- }}
64
- formError={serverError}
65
- actions={<Button type="submit">{t("profile.save")}</Button>}
66
- >
67
- <TextField ref={nicknameInputRef} label={t("profile.nickname")} value={values.nickname}
68
- onValueChange={setNickname} error={nicknameError ? t(nicknameError) : undefined} />
69
- </Form>
70
- ```
49
+ <!-- usage-example: Web ../../../../../showcase/web/src/patterns/reference-flow-previews.tsx -->
50
+ <!-- usage-example: Native ../../../../../showcase/native/src/reference-flow-previews.tsx -->
51
+
52
+ 이 이름의 독립 공개 컴포넌트는 없다. 아래는 공개 `createActionSession`과 입력·Steps·Sheet를 조합하는 예제다. Storybook 예제 파일을 제품에서 import하지 않는다.
53
+
54
+ - [Web 실행 예제](../../../../../showcase/web/src/patterns/reference-flow-previews.tsx)의 `Editor(kind="review")`
55
+ - [Native 실행 예제](../../../../../showcase/native/src/reference-flow-previews.tsx)의 `Editor(kind="review")`
56
+ - [공통 검증·초안 비교](../../../../../showcase/shared/reference-flows.ts)의 `draftError`, `sameDraft`는 예제 데이터 규칙이며 제품 규칙으로 교체한다.
57
+
58
+ 입력(1) → 검토(2) → 저장 결과 순서다. 검토에서 수정을 누르면 같은 draft로 입력 단계에 돌아간다. 저장은 검토한 draft의 복사본을 캡처한다.
71
59
 
72
60
  ```tsx
73
- // Native
74
- import { useRef } from "react";
75
- import type { TextInput } from "react-native";
76
- import { Form } from "@hjmds/react-native/forms";
77
- import { TextField } from "@hjmds/react-native/inputs";
78
-
79
- const nicknameInputRef = useRef<TextInput>(null);
80
-
81
- <Form
82
- label={t("profile.formLabel")}
83
- values={values}
84
- onSubmit={save} // 거절되면 Form이 오류를 표시한다(메시지가 없으면 fallbackErrorMessage)
85
- submitLabel={t("profile.save")}
86
- fallbackErrorMessage={t("profile.saveFailed")}
87
- {...(nicknameError ? { firstInvalidFieldRef: nicknameInputRef } : {})} // Native는 Form이 첫 오류로 포커스를 옮긴다
88
- >
89
- <TextField ref={nicknameInputRef} label={t("profile.nickname")} value={values.nickname}
90
- onValueChange={setNickname} {...(nicknameError ? { error: t(nicknameError) } : {})} />
91
- </Form>
61
+ // Web — Native도 같은 상태 골격을 사용한다. JSX/문구/영속 저장은 각 제품의 기존 화면 슬롯에 연결한다.
62
+ import { useState, useSyncExternalStore } from "react";
63
+ import { createActionSession } from "@hjmds/design-contracts/action-session";
64
+
65
+ const [session] = useState(() => createActionSession(initialValue));
66
+ const saved = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
67
+ const [draft, setDraft] = useState(initialValue);
68
+ const [stage, setStage] = useState(1);
69
+ const [paused, setPaused] = useState(false);
70
+ const busy = saved.status === "pending";
71
+ const dirty = !sameDraft(draft, saved.value);
72
+
73
+ function review() {
74
+ if (busy || draftError(draft)) return; // 오류 문구를 입력에 연결하고 첫 오류 입력으로 이동
75
+ setStage(2);
76
+ }
77
+ async function save() {
78
+ if (draftError(draft)) return;
79
+ const submitted = { ...draft };
80
+ // pending 중 재호출은 계약이 차단한다. 실패하면 saved.value와 draft를 보존한다.
81
+ const result = await session.run(() => saveToServer(submitted));
82
+ if (result.status === "success") setDraft(result.value);
83
+ }
84
+ function discard() {
85
+ if (session.getSnapshot().status === "pending") return;
86
+ setDraft(saved.value);
87
+ session.reset(saved.value);
88
+ setPaused(true);
89
+ }
92
90
  ```
93
91
 
94
- 제품 데이터·콜백은 주입한다. 위 공개 API 지침에 Web·Native 차이를 유지한다.
92
+ | 표시/행동 | 연결 |
93
+ | --- | --- |
94
+ | 진행 단계 | `Steps.descriptor.currentStepId`를 `stage`에서 도출, 저장 실패 시 `currentStepStatus="error"` |
95
+ | 편집 | `TextField`/선택 컨트롤은 draft를 편집. pending 동안 disabled이며 이미 큐에 든 입력·이탈·되돌리기 콜백도 최신 session 상태로 차단. 변경 시 오래된 실패 표시를 해제 |
96
+ | 검토 | 같은 draft의 요약과 수정 버튼. 요약에서 값을 따로 보관하지 않음 |
97
+ | 저장 | `Button.loading=busy`, 성공 결과는 서버 응답에서, 실패는 Notice와 같은 저장 버튼 재시도 |
98
+ | 되돌리기 | dirty이며 pending이 아닐 때 저장값 복원. 서버 요청 취소로 설명하지 않음 |
99
+ | 중단/이탈 | first/review는 메모리 draft·stage를 유지해 이어하기. settings는 dirty이면 확인 Sheet |
100
+ | 확정 이후 | 결과 확인과 새 편집 진입을 구분. 새 편집은 마지막 확정값으로 시작 |
95
101
 
96
102
  ## 플랫폼 차이
97
103
 
98
104
  | 항목 | Web | Native |
99
105
  | --- | --- | --- |
100
- | 첫 오류 포커스 | 제품이 `onSubmit`에서 해당 입력 `ref.focus()` | `firstInvalidFieldRef`를 주면 Form이 그 입력에 포커스·보조공학 포커스를 옮기고 제출을 멈춘다 |
101
- | 저장 실패 표시 | 제품이 `formError`로 넘긴다 | `onSubmit`이 거절되면 Form이 오류 메시지(없으면 `fallbackErrorMessage`)를 표시한다 |
102
- | 제출 버튼 | `actions`에 `type="submit"` Button | `submitLabel`로 내장 버튼, `actions`로 교체 가능 |
106
+ | 입력/단계 포커스 | DOM 입력·제목 ref | Native 입력 ref와 접근성 포커스 |
107
+ | 결과 알림 | role=status | Android live region + iOS 접근성 알림 |
108
+ | 이탈 확인 | 공개 Sheet의 dismiss/focus 계약 | 공개 Sheet의 dismiss/키보드/복귀 계약 |
109
+
110
+ 입력 오류는 입력 ref로 복귀한다. 설정 Sheet를 취소하면 실제 이탈 버튼, 변경을 버려 버튼이 사라지면 유지되는 단계 제목으로 복귀한다. Web은 안정적인 단계 제목/입력 ref에 포커스를 옮기고 `role="status"`로 결과를 알린다. Native는 공개 입력 ref와 단계 제목의 접근성 포커스·알림을 연결하며, unmount 때 예약된 포커스 이동을 취소한다. Sheet의 포커스·키보드·dismiss 정책은 기존 계약에 맡긴다. 제품은 validation 결과, 저장 API, 라우터 이탈 이벤트와 확인 Sheet의 연결, i18n 키를 제공한다.
111
+
112
+ 메모리 초안은 이 예제의 mounted 화면 안에서만 유지된다. 새로고침·프로세스 종료·다른 기기 복구와 서버 중복 처리 방지는 제품 저장소와 API 계약이 있어야 한다. `session.reset`은 서버 요청 취소가 아니다.
103
113
 
104
114
  ## 함정
105
115
 
@@ -3,20 +3,20 @@
3
3
  - 단계: 구성
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
- - 적용: 미게시(1.12.1 이후)
6
+ - 적용: 구성 API 지침 참조
7
7
  - 검토일: 2026-10-06
8
8
  - 근거: [반복 화면 조합](../../screen-patterns.md); 공통 API와 실제 Web·Native 예제의 슬롯·상태를 대조해 중복 조립 방지. 2026-10-06 사용자 승인으로 스토리북 배포(이전 `실험/구성/설정/변경 저장과 이탈 확인`, [승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정))
9
9
  - 스토리북: `배포/구성/피드백과 복구/변경 저장과 이탈 확인`
10
10
 
11
11
  ## 언제 쓰나
12
12
 
13
- 저장값과 편집 초안을 비교해 이탈 확인 흐름이 필요할 때 쓴다. Form의 상태·콜백을 제품 로직에 연결하며 새 데이터 엔진을 만들지 않는다.
13
+ 저장값과 편집 초안을 비교해 이탈 확인 흐름이 필요할 때 쓴다. 입력·단계·저장 세션을 제품 로직에 연결하며 새 데이터 엔진을 만들지 않는다.
14
14
 
15
15
  ## 구성 요소
16
16
 
17
17
  | 컴포넌트 | 역할 | 지침 |
18
18
  | --- | --- | --- |
19
- | Form | 저장값과 편집 초안을 비교해 이탈 확인 | [공개 계약](../components/form.md) |
19
+ | TextField · createActionSession | 저장값과 편집 초안을 비교해 이탈 확인 | [공개 계약](../components/form.md) |
20
20
  | Button | 명시 행동·재시도 | [Button](../components/button.md) |
21
21
 
22
22
  ## 배치
@@ -28,8 +28,8 @@
28
28
 
29
29
  | 영역 | 컴포넌트 | 위치 | 크기·간격 |
30
30
  | --- | --- | --- | --- |
31
- | 바깥 틀 | Form 또는 포함 Surface | 부모 화면의 해당 슬롯 | [배치](../components/form.md#배치)에 따른다 |
32
- | 내용 | Form 내부 슬롯 | 입력 → 저장·되돌리기 → 이탈 확인 | spacing 토큰과 포함 컴포넌트 recipe |
31
+ | 바깥 틀 | 부모 화면 또는 포함 Surface | 부모 화면의 해당 슬롯 | [배치](../components/form.md#배치)에 따른다 |
32
+ | 내용 | 부모 화면의 입력·요약 슬롯 | 입력 → 저장·되돌리기 → 이탈 확인 | spacing 토큰과 포함 컴포넌트 recipe |
33
33
  | 행동 | Button 또는 공개 콜백 | 내용과 가까운 명시 진입점 | 주 행동 하나, 보조 행동과 구분 |
34
34
 
35
35
  ## 흐름과 상태
@@ -46,60 +46,70 @@
46
46
 
47
47
  ## 코드 골격
48
48
 
49
- ```tsx
50
- // Web
51
- import { useRef } from "react";
52
- import { Form, TextField } from "@hjmds/react/forms";
53
- import { Button } from "@hjmds/react/actions";
54
-
55
- const nicknameInputRef = useRef<HTMLInputElement>(null);
56
-
57
- <Form
58
- aria-label={t("profile.formLabel")}
59
- onSubmit={async () => {
60
- setServerError(null);
61
- if (nicknameError) { nicknameInputRef.current?.focus(); return; } // Web은 첫 오류 포커스를 제품이 한다
62
- try { await save(values); } catch { setServerError(t("profile.saveFailed")); }
63
- }}
64
- formError={serverError}
65
- actions={<Button type="submit">{t("profile.save")}</Button>}
66
- >
67
- <TextField ref={nicknameInputRef} label={t("profile.nickname")} value={values.nickname}
68
- onValueChange={setNickname} error={nicknameError ? t(nicknameError) : undefined} />
69
- </Form>
70
- ```
49
+ <!-- usage-example: Web ../../../../../showcase/web/src/patterns/reference-flow-previews.tsx -->
50
+ <!-- usage-example: Native ../../../../../showcase/native/src/reference-flow-previews.tsx -->
51
+
52
+ 이 이름의 독립 공개 컴포넌트는 없다. 아래는 공개 `createActionSession`과 입력·Steps·Sheet를 조합하는 예제다. Storybook 예제 파일을 제품에서 import하지 않는다.
53
+
54
+ - [Web 실행 예제](../../../../../showcase/web/src/patterns/reference-flow-previews.tsx)의 `Editor(kind="settings")`
55
+ - [Native 실행 예제](../../../../../showcase/native/src/reference-flow-previews.tsx)의 `Editor(kind="settings")`
56
+ - [공통 검증·초안 비교](../../../../../showcase/shared/reference-flows.ts)의 `draftError`, `sameDraft`는 예제 데이터 규칙이며 제품 규칙으로 교체한다.
57
+
58
+ 저장값 session.value와 편집 draft를 비교한다. dirty일 때만 저장·되돌리기를 켜고, 이탈은 확인 Sheet를 연다. 계속 수정은 Sheet만 닫고, 버리기는 마지막 저장값으로 draft를 복원한다.
71
59
 
72
60
  ```tsx
73
- // Native
74
- import { useRef } from "react";
75
- import type { TextInput } from "react-native";
76
- import { Form } from "@hjmds/react-native/forms";
77
- import { TextField } from "@hjmds/react-native/inputs";
78
-
79
- const nicknameInputRef = useRef<TextInput>(null);
80
-
81
- <Form
82
- label={t("profile.formLabel")}
83
- values={values}
84
- onSubmit={save} // 거절되면 Form이 오류를 표시한다(메시지가 없으면 fallbackErrorMessage)
85
- submitLabel={t("profile.save")}
86
- fallbackErrorMessage={t("profile.saveFailed")}
87
- {...(nicknameError ? { firstInvalidFieldRef: nicknameInputRef } : {})} // Native는 Form이 첫 오류로 포커스를 옮긴다
88
- >
89
- <TextField ref={nicknameInputRef} label={t("profile.nickname")} value={values.nickname}
90
- onValueChange={setNickname} {...(nicknameError ? { error: t(nicknameError) } : {})} />
91
- </Form>
61
+ // Web — Native도 같은 상태 골격을 사용한다. JSX/문구/영속 저장은 각 제품의 기존 화면 슬롯에 연결한다.
62
+ import { useState, useSyncExternalStore } from "react";
63
+ import { createActionSession } from "@hjmds/design-contracts/action-session";
64
+
65
+ const [session] = useState(() => createActionSession(initialValue));
66
+ const saved = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
67
+ const [draft, setDraft] = useState(initialValue);
68
+ const [stage, setStage] = useState(1);
69
+ const [paused, setPaused] = useState(false);
70
+ const busy = saved.status === "pending";
71
+ const dirty = !sameDraft(draft, saved.value);
72
+
73
+ function review() {
74
+ if (busy || draftError(draft)) return; // 오류 문구를 입력에 연결하고 첫 오류 입력으로 이동
75
+ setStage(2);
76
+ }
77
+ async function save() {
78
+ if (draftError(draft)) return;
79
+ const submitted = { ...draft };
80
+ // pending 중 재호출은 계약이 차단한다. 실패하면 saved.value와 draft를 보존한다.
81
+ const result = await session.run(() => saveToServer(submitted));
82
+ if (result.status === "success") setDraft(result.value);
83
+ }
84
+ function discard() {
85
+ if (session.getSnapshot().status === "pending") return;
86
+ setDraft(saved.value);
87
+ session.reset(saved.value);
88
+ setPaused(true);
89
+ }
92
90
  ```
93
91
 
94
- 제품 데이터·콜백은 주입한다. 위 공개 API 지침에 Web·Native 차이를 유지한다.
92
+ | 표시/행동 | 연결 |
93
+ | --- | --- |
94
+ | 진행 단계 | `Steps.descriptor.currentStepId`를 `stage`에서 도출, 저장 실패 시 `currentStepStatus="error"` |
95
+ | 편집 | `TextField`/선택 컨트롤은 draft를 편집. pending 동안 disabled이며 이미 큐에 든 입력·이탈·되돌리기 콜백도 최신 session 상태로 차단. 변경 시 오래된 실패 표시를 해제 |
96
+ | 검토 | 같은 draft의 요약과 수정 버튼. 요약에서 값을 따로 보관하지 않음 |
97
+ | 저장 | `Button.loading=busy`, 성공 결과는 서버 응답에서, 실패는 Notice와 같은 저장 버튼 재시도 |
98
+ | 되돌리기 | dirty이며 pending이 아닐 때 저장값 복원. 서버 요청 취소로 설명하지 않음 |
99
+ | 중단/이탈 | first/review는 메모리 draft·stage를 유지해 이어하기. settings는 dirty이면 확인 Sheet |
100
+ | 확정 이후 | 결과 확인과 새 편집 진입을 구분. 새 편집은 마지막 확정값으로 시작 |
95
101
 
96
102
  ## 플랫폼 차이
97
103
 
98
104
  | 항목 | Web | Native |
99
105
  | --- | --- | --- |
100
- | 첫 오류 포커스 | 제품이 `onSubmit`에서 해당 입력 `ref.focus()` | `firstInvalidFieldRef`를 주면 Form이 그 입력에 포커스·보조공학 포커스를 옮기고 제출을 멈춘다 |
101
- | 저장 실패 표시 | 제품이 `formError`로 넘긴다 | `onSubmit`이 거절되면 Form이 오류 메시지(없으면 `fallbackErrorMessage`)를 표시한다 |
102
- | 제출 버튼 | `actions`에 `type="submit"` Button | `submitLabel`로 내장 버튼, `actions`로 교체 가능 |
106
+ | 입력/단계 포커스 | DOM 입력·제목 ref | Native 입력 ref와 접근성 포커스 |
107
+ | 결과 알림 | role=status | Android live region + iOS 접근성 알림 |
108
+ | 이탈 확인 | 공개 Sheet의 dismiss/focus 계약 | 공개 Sheet의 dismiss/키보드/복귀 계약 |
109
+
110
+ 입력 오류는 입력 ref로 복귀한다. 설정 Sheet를 취소하면 실제 이탈 버튼, 변경을 버려 버튼이 사라지면 유지되는 단계 제목으로 복귀한다. Web은 안정적인 단계 제목/입력 ref에 포커스를 옮기고 `role="status"`로 결과를 알린다. Native는 공개 입력 ref와 단계 제목의 접근성 포커스·알림을 연결하며, unmount 때 예약된 포커스 이동을 취소한다. Sheet의 포커스·키보드·dismiss 정책은 기존 계약에 맡긴다. 제품은 validation 결과, 저장 API, 라우터 이탈 이벤트와 확인 Sheet의 연결, i18n 키를 제공한다.
111
+
112
+ 메모리 초안은 이 예제의 mounted 화면 안에서만 유지된다. 새로고침·프로세스 종료·다른 기기 복구와 서버 중복 처리 방지는 제품 저장소와 API 계약이 있어야 한다. `session.reset`은 서버 요청 취소가 아니다.
103
113
 
104
114
  ## 함정
105
115
 
@@ -24,11 +24,11 @@
24
24
  | --- | --- | --- |
25
25
  | `Heading level="level3" semanticLevel={2}` | 질문형 제목 `t("share.scopeQuestion")` | [Heading](../components/heading.md) |
26
26
  | `Text` | 묶음 설명: 대표 1개 포함 전체 N개 | [Text](../components/text.md) |
27
- | `Button selected`(secondary) × 2 | 범위 선택: "대표만 · 1개" / "묶음 전체 · N개". 라벨에 개수를 넣는다 | [Button](../components/button.md) |
27
+ | `SegmentedControl` | 범위 선택: "대표만 · 1개" / "묶음 전체 · N개". 라벨에 개수를 넣는다 | [SegmentedControl](../components/segmented-control.md) |
28
28
  | `Text` | 선택 범위 요약(범위 이름 + 개수) | [Text](../components/text.md) |
29
29
  | `Button`(primary) | 적용. 라벨에 개수를 넣는다 `t("share.apply", { count })`. 진행 중은 `loading` | [Button](../components/button.md) |
30
30
  | `Text` 상태 | 적용 결과·안내·실패 | [Text](../components/text.md) |
31
- | `Container`·`Stack` | 바깥 틀, 바깥 `gap="md"`, 범위 버튼 묶음 `gap="sm"` | [Container](../components/container.md), [Stack](../components/stack.md) |
31
+ | `Container`·`Stack` | 바깥 틀, 바깥 `gap="md"`, 선택지 내부는 SegmentedControl recipe | [Container](../components/container.md), [Stack](../components/stack.md) |
32
32
 
33
33
  ## 배치
34
34
 
@@ -53,14 +53,14 @@
53
53
  | --- | --- | --- | --- |
54
54
  | 바깥 틀 | Web: `Container size="reading"` → `Stack gap="md"`. Native: `ScrollView` → `Container` → `Stack gap="md"` | 화면 본문 스크롤. 안전 영역은 화면 host(SafeArea·navigation header)가 준다. 입력이 없어 키보드 처리는 없다. 적용 버튼을 하단에 고정하면 [BottomCTA](../components/bottom-cta.md)가 하단 안전 영역을 맡는다 | Native `ScrollView` 위아래 `spacing.md` 16, 좌우는 `Container` `gutter`(폭 600 미만 `compact` 16, 이상 `regular` 20). 최대 폭 `layout.readingMaxWidth` 720 |
55
55
  | 질문·설명 | `Heading` + `Text` | 맨 위 | 사이 `spacing.md` 16 |
56
- | 범위 선택 | `Button selected` × 2 | 설명 아래, 세로 묶음 | 각 `medium` 44(`control.buttonHeight`), 서로 `spacing.sm` 12 |
56
+ | 범위 선택 | `SegmentedControl` 단일 선택 | 설명 아래, 세로 묶음 | 각 `medium` 44(`control.buttonHeight`), 서로 `spacing.sm` 12 |
57
57
  | 범위 요약 | `Text` | 범위 선택 아래 | `spacing.md` 16 |
58
58
  | 주 행동 | `Button` 적용 | 요약 아래 | `medium` 44, `spacing.md` 16 |
59
59
  | 결과 | `Text` 상태 | 맨 아래 | `spacing.md` 16 |
60
60
 
61
- - 범위 버튼은 서로 가깝게(`spacing.sm` 12), 다른 덩어리와는 `spacing.md` 16으로 띄워 한 묶음으로 읽히게 한다.
61
+ - 범위는 이름 있는 하나의 선택 그룹이다. 다른 덩어리와 `spacing.md` 16으로 띄우고 내부 간격은 SegmentedControl recipe를 따른다.
62
62
  - 순서는 좁은 범위(대표) → 넓은 범위(전체)다. 기본 선택은 스토리에서 대표 항목이지만 제품이 정한다.
63
- - 범위 버튼은 `selected` 처리로 칠해져 primary로 세지 않는다. 화면의 primary는 적용 하나다([Button](../components/button.md) "한 화면 primary 하나"의 예외).
63
+ - 범위 선택은 SegmentedControl의 선택 의미와 외형을 사용한다. 화면의 primary 행동은 적용 하나다.
64
64
  - 적용 버튼이 화면 하단 고정이면 [BottomCTA](../components/bottom-cta.md)에 두고 라벨의 개수는 유지한다.
65
65
 
66
66
  근거: `src/component-recipes.ts`(`stackRecipe.gaps` = `spacing`, `buttonRecipe.states.selected`), `src/foundations.ts`(`control`·`layout`)
@@ -68,15 +68,15 @@
68
68
  ## 흐름과 상태
69
69
 
70
70
  1. 화면은 묶음 설명과 기본 범위(대표)를 보여 준다.
71
- 2. 사용자가 범위 버튼을 누르면 `selected`가 옮겨 가고, 요약과 적용 버튼 라벨의 개수가 바뀐다. 이전 결과 문구는 지운다.
72
- 3. 적용을 누르면 그 범위의 항목 ID로 제품 작업(공유 대상 확정 등)을 실행한다. 끝날 때까지 적용 버튼은 `loading`이다.
71
+ 2. 사용자가 범위를 고르면 `value`가 바뀌고, 요약과 적용 버튼 라벨의 개수가 바뀐다. 이전 결과 문구는 지운다.
72
+ 3. 적용을 누르면 그 범위의 항목 ID로 제품 작업(공유 대상 확정 등)을 실행한다. 끝날 때까지 적용 버튼은 `loading`이고 선택한 개수의 처리 중 상태를 표시한다. 비활성 렌더 전에 도착한 선택 callback도 현재 세션이 pending이면 거부한다.
73
73
  4. 결과 문구로 몇 개에 적용했는지 알린다. 실패하면 범위는 그대로 두고 실패 문구를 알린다. 적용을 다시 누르면 같은 범위로 재요청한다.
74
74
 
75
75
  | 상태 | 모습 | 포커스·알림 |
76
76
  | --- | --- | --- |
77
- | 기본 | 기본 범위 버튼 `selected`, 요약에 개수, 안내 문구 `share.chooseScope` | `selected`는 Web `aria-pressed`·Native 접근성 state로 알림 |
77
+ | 기본 | 기본 범위를 `value`로 지정, 요약에 개수, 안내 문구 `share.chooseScope` | 그룹 이름과 선택 항목을 Web radio/Native radio 접근성 의미로 알림 |
78
78
  | 범위 변경 | 요약·적용 라벨의 개수 갱신, 결과 문구는 안내로 돌아감 | 같음 |
79
- | 진행 중 | 적용 버튼 `loading`(누름 막힘·스피너), 범위 버튼 `disabled` | 포커스는 적용 버튼 유지 |
79
+ | 진행 중 | 적용 버튼 `loading`(누름 막힘·스피너), 선택 그룹 `disabled`, 제출 개수의 처리 중 문구 | 포커스는 적용 버튼 유지 |
80
80
  | 성공 | 결과 문구 `t("share.applied", { count })` | 결과 알림, 포커스는 적용 버튼 유지 |
81
81
  | 실패 | 네트워크·서버 실패: 범위 유지, 결과 문구 `share.applyFailed`, 적용 버튼 다시 활성. 재요청도 실패하면 같은 문구를 다시 알린다 | 결과 알림(Web `role="status"`, Native live region + iOS 알림) |
82
82
 
@@ -87,46 +87,49 @@
87
87
 
88
88
  ```tsx
89
89
  // Web
90
- import { useState } from "react";
90
+ import { useState, useSyncExternalStore } from "react";
91
+ import { createActionSession } from "@hjmds/design-contracts/action-session";
91
92
  import { Button } from "@hjmds/react/actions";
93
+ import { SegmentedControl } from "@hjmds/react/selection";
92
94
  import { Heading } from "@hjmds/react/heading";
93
95
  import { Container, Stack, Text } from "@hjmds/react/layout";
94
96
 
95
97
  const scopeKey = { cover: "share.scope.cover", group: "share.scope.group" } as const;
96
- const resultKey = { idle: "share.chooseScope", applied: "share.applied", failed: "share.applyFailed" } as const;
98
+ const resultKey = { idle: "share.chooseScope", applying: "share.applying", applied: "share.applied", failed: "share.applyFailed" } as const;
97
99
 
98
100
  const [scope, setScope] = useState<"cover" | "group">("cover");
99
- const [result, setResult] = useState<"idle" | "applying" | "applied" | "failed">("idle");
101
+ const [session] = useState(() => createActionSession<"cover" | "group" | null>(null));
102
+ const snapshot = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
103
+ const result = snapshot.status === "pending" ? "applying" : snapshot.status === "error" ? "failed" : snapshot.status === "success" ? "applied" : "idle";
100
104
  const count = scope === "cover" ? 1 : group.items.length;
101
- const choose = (next: "cover" | "group") => { setScope(next); setResult("idle"); };
105
+ // The owner also guards queued callbacks that arrive before disabled renders.
106
+ const choose = (next: "cover" | "group") => { if (session.getSnapshot().status === "pending") return; setScope(next); session.reset(null); };
102
107
  const apply = async () => {
103
- setResult("applying");
104
- try { await onApply(scope); setResult("applied"); } catch { setResult("failed"); }
108
+ const submitted = scope;
109
+ await session.run(async () => { await onApply(submitted); return submitted; });
105
110
  };
106
111
 
107
112
  <Container size="reading">
108
113
  <Stack gap="md">
109
114
  <Heading level="level3" semanticLevel={2}>{t("share.scopeQuestion")}</Heading>
110
115
  <Text as="p">{t("share.groupSummary", { total: group.items.length })}</Text>
111
- <Stack gap="sm">
112
- <Button tone="secondary" selected={scope === "cover"} disabled={result === "applying"} onClick={() => choose("cover")}>
113
- {t("share.coverOnly", { count: 1 })}
114
- </Button>
115
- <Button tone="secondary" selected={scope === "group"} disabled={result === "applying"} onClick={() => choose("group")}>
116
- {t("share.wholeGroup", { count: group.items.length })}
117
- </Button>
118
- </Stack>
116
+ <SegmentedControl label={t("share.scopeQuestion")} value={scope} onValueChange={value => { if (value === "cover" || value === "group") choose(value); }}
117
+ presentation="pills" disabled={result === "applying"}
118
+ items={[{ value: "cover", label: t("share.coverOnly", { count: 1 }) },
119
+ { value: "group", label: t("share.wholeGroup", { count: group.items.length }) }]} />
119
120
  <Text as="p">{t("share.scopeSummary", { scope: t(scopeKey[scope]), count })}</Text>
120
121
  <Button loading={result === "applying"} onClick={() => void apply()}>{t("share.apply", { count })}</Button>
121
- <Text as="p" role="status">{t(resultKey[result === "applying" ? "idle" : result], { count })}</Text>
122
+ <Text as="p" role="status">{t(resultKey[result], { count })}</Text>
122
123
  </Stack>
123
124
  </Container>
124
125
  ```
125
126
 
126
127
  ```tsx
127
128
  // Native
129
+ import { StatusText } from "@hjmds/react-native/status-text";
128
130
  import { ScrollView, useWindowDimensions } from "react-native";
129
131
  import { Button } from "@hjmds/react-native/actions";
132
+ import { SegmentedControl } from "@hjmds/react-native/inputs";
130
133
  import { Heading } from "@hjmds/react-native/heading";
131
134
  import { Container, Stack, Text } from "@hjmds/react-native/primitives";
132
135
  import { useHjmNativeTheme } from "@hjmds/react-native/provider";
@@ -141,23 +144,19 @@ const gutter = resolveWindowClass(useWindowDimensions().width) === "compact" ? "
141
144
  <Stack gap="md">
142
145
  <Heading level="level3" semanticLevel={2}>{t("share.scopeQuestion")}</Heading>
143
146
  <Text>{t("share.groupSummary", { total: group.items.length })}</Text>
144
- <Stack gap="sm">
145
- <Button tone="secondary" selected={scope === "cover"} disabled={result === "applying"} onPress={() => choose("cover")}>
146
- {t("share.coverOnly", { count: 1 })}
147
- </Button>
148
- <Button tone="secondary" selected={scope === "group"} disabled={result === "applying"} onPress={() => choose("group")}>
149
- {t("share.wholeGroup", { count: group.items.length })}
150
- </Button>
151
- </Stack>
147
+ <SegmentedControl label={t("share.scopeQuestion")} value={scope} onValueChange={value => { if (value === "cover" || value === "group") choose(value); }}
148
+ presentation="pills" disabled={result === "applying"}
149
+ items={[{ value: "cover", label: t("share.coverOnly", { count: 1 }) },
150
+ { value: "group", label: t("share.wholeGroup", { count: group.items.length }) }]} />
152
151
  <Text>{t("share.scopeSummary", { scope: t(scopeKey[scope]), count })}</Text>
153
152
  <Button loading={result === "applying"} onPress={() => void apply()}>{t("share.apply", { count })}</Button>
154
- <StatusText>{t(resultKey[result === "applying" ? "idle" : result], { count })}</StatusText>
153
+ <StatusText>{t(resultKey[result], { count })}</StatusText>
155
154
  </Stack>
156
155
  </Container>
157
156
  </ScrollView>
158
157
  ```
159
158
 
160
- `group`·`onApply`는 제품 소유다. `StatusText`는 [저장과 재시도의 공통 절](action-recovery-save.md#공통-세션과-상태-알림)의 helper(Android live region + iOS 알림)다.
159
+ `group`·`onApply`는 제품 소유다. `StatusText`는 [저장과 재시도의 공통 절](action-recovery-save.md#공통-세션과-상태-알림)에서 사용하는 공개 Text companion이다.
161
160
 
162
161
  ## 플랫폼 차이
163
162
 
@@ -165,10 +164,24 @@ const gutter = resolveWindowClass(useWindowDimensions().width) === "compact" ? "
165
164
  | --- | --- | --- |
166
165
  | 바깥 틀 | 문서 스크롤 + `Container` | `ScrollView`(위아래 `spacing.md`) + `Container` |
167
166
  | 이벤트 | `onClick` | `onPress` |
168
- | `selected` 알림 | `aria-pressed` | 접근성 state |
167
+ | 단일 선택 의미 | 이름 있는 radiogroup, radio checked | 이름 있는 그룹과 radio checked |
169
168
  | 상태 알림 | `role="status"` | live region(Android) + iOS 알림 호출(`StatusText`) |
170
169
 
171
170
  ## 함정
172
171
 
173
172
  - 구획 제목은 Heading으로 표시한다. 이전 Text heading 예제는 2026-10-06 제목 의미 구조를 맞추면서 수정했다.
174
173
  - 스토리의 "다음 적용 실패시키기"(ghost)와 350ms 지연은 실패·진행 중을 확인하는 데모 전용이다. 제품에 넣지 않는다.
174
+
175
+ ## 단일 선택 계약
176
+
177
+ 2026-10-13 실행 예제는 대표/전체를 두 독립 토글 대신 `SegmentedControl presentation="pills"`로 묶었다. 둘 중 하나인 선택은 그룹 이름과 checked 의미를 전달한다. 위 골격과 같은 공개 선택 계약을 유지한다. 저장·실패·초안은 기존 세션을 유지한다.
178
+
179
+ ```tsx
180
+ // Web: @hjmds/react/selection, Native: @hjmds/react-native/inputs
181
+ <SegmentedControl label={t("share.scopeQuestion")} value={scope} onValueChange={value => { if (value === "cover" || value === "group") choose(value); }}
182
+ presentation="pills" disabled={result === "applying"}
183
+ items={[{ value: "cover", label: t("share.coverOnly", { count: 1 }) },
184
+ { value: "group", label: t("share.wholeGroup", { count: group.items.length }) }]} />
185
+ ```
186
+
187
+ 2026-10-13 실제 Story 검사에서 Native의 늦은 선택 callback이 pending 요청을 reset하는 경우를 재현했다. Web·Native 예제 모두 세션 snapshot으로 선택 변경과 실패 예약을 즉시 거부한다. 새 범위 선택은 이전 성공 문구를 지우고, 실패 후 재적용은 기존 선택 범위로 요청한다. 이 예제에는 별도 텍스트 초안이 없으며 선택값 자체가 적용 전 초안이다.