@hjmds/design-contracts 1.12.0 → 1.13.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 (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 +25 -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 +17 -0
  16. package/dist/component-recipes.d.ts.map +1 -1
  17. package/dist/component-recipes.js +5 -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 +376 -0
  67. package/docs/sheet.md +12 -0
  68. package/docs/splitter.md +8 -2
  69. package/docs/theming.md +36 -29
  70. package/docs/toggle-group.md +7 -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 +104 -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 +215 -0
  169. package/docs/usage/components/section.md +111 -0
  170. package/docs/usage/components/segmented-control.md +138 -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 +151 -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 +274 -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,171 @@
1
+ # 늦은 응답보다 최신 검색 유지
2
+
3
+ - 단계: 구성
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: [공통 실행과 실패 복구](../../action-session.md), [제품 상호작용 품질](../../../../../docs/INTERACTION_QUALITY.md), `src/action-session.ts`, `showcase/web/src/patterns/interaction-flow-previews.tsx`, `showcase/native/src/interaction-flow-previews.tsx`. 2026-10-06 사용자 승인으로 스토리북 배포(이전 `실험/구성/상호작용 예제/늦은 응답보다 최신 검색 유지`, [승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정))
9
+ - 스토리북: `배포/구성/입력과 작성/늦은 응답보다 최신 검색 유지`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 검색어를 바꿔 다시 검색했을 때 먼저 보낸 요청이 늦게 도착해도 최신 검색 결과를 덮어쓰지 않게 할 때 쓴다.
14
+
15
+ 검색처럼 **읽기 전용** 요청에만 쓴다. 이전 결과를 버려도 서버에 남는 것이 없기 때문이다. 저장·수정 요청이 겹치는 경우는
16
+ 같은 세션의 중복 실행 차단([저장과 재시도](action-recovery-save.md))으로 다룬다. 이 방식은 서버 요청 취소가 아니다.
17
+
18
+ 같은 `상호작용 예제` 묶음: [선택 후 적용·취소](interaction-flow-apply.md) · [닫았다 열고 초안 이어쓰기](interaction-flow-draft.md) ·
19
+ [대표 항목과 묶음 전체 선택](selection-scope.md). 세션 연결과 상태 알림은 [저장과 재시도의 공통 절](action-recovery-save.md#공통-세션과-상태-알림)을 따른다.
20
+
21
+ ## 구성 요소
22
+
23
+ | 컴포넌트 | 역할 | 지침 |
24
+ | --- | --- | --- |
25
+ | `Container`·`Stack` | 바깥 틀. 구획 사이 `gap="xl"`, 묶음 안 `gap="md"` | [Container](../components/container.md), [Stack](../components/stack.md) |
26
+ | `Heading` | 묶음 제목 | [Heading](../components/heading.md) |
27
+ | `createActionSession` | 검색 결과 값. 새 검색 전에 `reset`으로 세대를 바꿔 이전 응답을 무시 | [계약](../../action-session.md) |
28
+ | `SearchField` | 검색어와 지우기(×). 진행 중에는 Web `loading`, Native `busy`+`busyLabel`로 뒤쪽 자리만 진행 표시로 바뀌고 입력은 그대로 쓸 수 있다. 빈 검색어면 검색 버튼 `disabled`. Web·Native 모두 `onValueChange` | [SearchField](../components/search-field.md) |
29
+ | `Button` 검색 / `Button` 다시 검색(secondary) | 검색 실행(primary 하나), 실패했을 때만 보이는 재시도 | [Button](../components/button.md) |
30
+ | `Text` | 진행·결과·실패 문구 | [Text](../components/text.md) |
31
+
32
+ 스토리의 "느린 응답으로 검색"(900ms)·"빠른 응답으로 검색"(150ms) 두 버튼은 역순 응답을 재현하는 데모다. 제품은 검색 버튼 하나(또는 입력 중 검색)로 둔다.
33
+ `다음 검색 실패시키기`는 실패 경로를 여는 데모 도구다. 스토리는 기본·어두운 테마·큰 글자, `검색 중`, `실패와 다시 검색`, 제품 팔레트 두 가지(`보라 테마`, `초록 테마 · 어둡게`)다.
34
+ 2026-10-06 같은 SearchField 진행 상태와 팔레트를 보이던 `입력과 작성/검색 입력`을 이 항목으로 합쳤다([옛 링크](../../../../../docs/STORYBOOK_NAVIGATION.md#실험-정리와-합친-항목-2026-10-06)).
35
+ 검색 입력 칸·결과 목록이 필요한 화면이면 [SearchField](../components/search-field.md)·[List](../components/list.md)에 같은 세대 규칙을 연결한다.
36
+
37
+ ## 배치
38
+
39
+ ```text
40
+ ┌ 화면 ────────────────────────────────┐
41
+ │ Native ScrollView 위아래 16 │
42
+ │ (keyboardShouldPersistTaps=handled) │
43
+ │ └ Container gutter 16/20 │
44
+ │ Heading 제목 │
45
+ │ ↕ spacing.md 16 │
46
+ │ 검색어 │
47
+ │ [🔍 입력 (×)] │ SearchField 높이 44, 진행 중 × 자리 = 진행 표시
48
+ │ ↕ spacing.md 16 │
49
+ │ [ 검색 (primary) ] │ ← 주 행동
50
+ │ ↕ spacing.md 16 │
51
+ │ 검색 중이에요 / 결과 / 실패 (status)│
52
+ │ [ 다시 검색 ] (secondary, 실패 때만)│
53
+ └──────────────────────────────────────┘
54
+ ```
55
+
56
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
57
+ | --- | --- | --- | --- |
58
+ | 바깥 틀 | Native: `ScrollView keyboardShouldPersistTaps="handled"` → `Container` → `Stack gap="xl"`. Web: `Container` → `Stack gap="xl"` | 화면 본문. 안전 영역은 화면 셸이 준다. 키보드가 열린 채 검색 버튼을 누를 수 있게 Native 스크롤은 탭을 통과시킨다. 입력이 화면 아래쪽에 있으면 [KeyboardFormScrollView](../components/keyboard-form-scroll-view.md)를 쓴다 | Native `ScrollView` 위아래 `spacing.md` 16, 좌우 `Container` `gutter`(폭 600 미만 `compact` 16, 이상 `regular` 20). 구획 사이 `layout.sectionGap` 24 |
59
+ | 제목 | `Heading level="level3"` | 묶음 맨 위 | 24/32(`heading.level3`) |
60
+ | 입력 | `SearchField` | 제목 아래 | 높이 `control.fieldHeight` 44, `layout.contentGap` 16 |
61
+ | 주 행동 | `Button` 검색 | 입력 아래 | `medium` 44(`control.buttonHeight`), `spacing.md` 16 |
62
+ | 결과·상태 | `Text` | 행동 아래 | `spacing.md` 16 |
63
+ | 재시도 | `Button tone="secondary"` | 실패 문구 아래, 실패일 때만 | `medium` 44, `spacing.md` 16 |
64
+
65
+ 근거: `src/foundations.ts`(`spacing`, `control`, `layout`, `heading`), `src/action-session.ts`
66
+
67
+ ## 흐름과 상태
68
+
69
+ 1. 사용자가 검색어를 입력하고 검색한다. 앞뒤 공백을 지운 값을 캡처한다.
70
+ 2. 새 검색 직전에 `session.reset(state.value)`를 부른다. 진행 중이던 이전 요청의 세대가 끝나 그 응답과 재시도는 무시된다.
71
+ 3. `session.run(() => api.search(submitted), { retryable: true })`. pending 중 상태 문구는 `t("search.pending")`이다.
72
+ 4. 검색어를 바꿔 다시 검색하면 2~3을 반복한다. 늦게 온 이전 결과는 화면을 바꾸지 않는다.
73
+ 5. 최신 요청이 끝나면 결과를 보인다. 실패하면 "다시 검색"으로 같은 검색어를 `session.retry()`한다.
74
+
75
+ | 상태 | 모습 | 포커스·알림 |
76
+ | --- | --- | --- |
77
+ | 기본 | 아직 검색 전이면 `t("search.notYet")`, `reset` 뒤(idle)에는 직전 결과(`state.value`) | — |
78
+ | 진행 중 | 이전 결과 대신 `t("search.pending")`. 검색 버튼은 그대로 눌린다(새 검색이 `reset`으로 이전 요청을 떼어 낸다) | 포커스는 입력·버튼에 유지, 진행 알림 |
79
+ | 성공 | 최신 검색어의 결과 | 결과 문구 알림 |
80
+ | 실패 | 네트워크·서버 실패: `t("search.failed")` + "다시 검색"(secondary). `state.value`는 직전 값이라 이전 결과를 실패 문구 대신 보이지 않는다. 재시도도 실패하면 같은 문구로 다시 error가 된다. 새 검색어로 검색하면 `reset`이 실패 시도를 버린다 | 실패 알림 |
81
+
82
+ `reset` 없이 run을 다시 부르면 pending 중이라 `blocked`가 되어 새 검색이 실행되지 않는다.
83
+ 상태→문구 키는 상수 표(`searchStatusKey`)로 둔다. 템플릿 문자열 키는 키 추출·누락 검사가 찾지 못한다.
84
+
85
+ ## 코드 골격
86
+
87
+ ```tsx
88
+ // Web
89
+ import { useState, useSyncExternalStore } from "react";
90
+ import { createActionSession } from "@hjmds/design-contracts/action-session";
91
+ import { Button } from "@hjmds/react/actions";
92
+ import { SearchField } from "@hjmds/react/forms";
93
+ import { Heading } from "@hjmds/react/heading";
94
+ import { Container, Stack, Text } from "@hjmds/react/layout";
95
+
96
+ const searchStatusKey = { idle: "search.notYet", pending: "search.pending", error: "search.failed" } as const;
97
+
98
+ const [session] = useState(() => createActionSession<Result | null>(null));
99
+ const state = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
100
+ const [query, setQuery] = useState("");
101
+ const search = () => {
102
+ const submitted = query.trim();
103
+ if (!submitted) return;
104
+ session.reset(state.value); // 이전 응답만 버린다. 서버 요청 취소가 아니다
105
+ void session.run(() => api.search(submitted), { retryable: true });
106
+ };
107
+ const message = state.status === "pending" || state.status === "error" ? t(searchStatusKey[state.status])
108
+ : state.value ? renderResult(state.value) : t(searchStatusKey.idle);
109
+
110
+ <Container gutter={gutter}>
111
+ <Stack gap="xl">
112
+ <Stack gap="md">
113
+ <Heading level="level3" semanticLevel={2}>{t("search.title")}</Heading>
114
+ <SearchField label={t("search.label")} clearLabel={t("common.clearSearch")} value={query} onValueChange={setQuery}
115
+ loading={state.status === "pending"} />
116
+ <Button disabled={!query.trim()} onClick={search}>{t("search.action")}</Button>
117
+ <Text role="status">{message}</Text>
118
+ {state.status === "error" ? <Button tone="secondary" onClick={() => void session.retry()}>{t("search.retry")}</Button> : null}
119
+ </Stack>
120
+ </Stack>
121
+ </Container>
122
+ ```
123
+
124
+ ```tsx
125
+ // Native
126
+ import { ScrollView, useWindowDimensions } from "react-native";
127
+ import { Button } from "@hjmds/react-native/actions";
128
+ import { Heading } from "@hjmds/react-native/heading";
129
+ import { SearchField } from "@hjmds/react-native/inputs";
130
+ import { Container, Stack } from "@hjmds/react-native/primitives";
131
+ import { useHjmNativeTheme } from "@hjmds/react-native/provider";
132
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
133
+
134
+ const { spacing } = useHjmNativeTheme().tokens;
135
+ const gutter = resolveWindowClass(useWindowDimensions().width) === "compact" ? "compact" : "regular";
136
+
137
+ <ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ paddingVertical: spacing.md }}>
138
+ <Container gutter={gutter}>
139
+ <Stack gap="xl">
140
+ <Stack gap="md">
141
+ <Heading level="level3" semanticLevel={2}>{t("search.title")}</Heading>
142
+ <SearchField label={t("search.label")} clearLabel={t("common.clearSearch")} value={query} onValueChange={setQuery}
143
+ busy={state.status === "pending"} busyLabel={t("search.pending")} />
144
+ <Button disabled={!query.trim()} onPress={search}>{t("search.action")}</Button>
145
+ <StatusText>{message}</StatusText>
146
+ {state.status === "error" ? <Button tone="secondary" onPress={() => void session.retry()}>{t("search.retry")}</Button> : null}
147
+ </Stack>
148
+ </Stack>
149
+ </Container>
150
+ </ScrollView>
151
+ ```
152
+
153
+ `api.search`·`renderResult`·`Result`·문구 키는 제품 소유다. `StatusText`는 [저장과 재시도의 공통 절](action-recovery-save.md#공통-세션과-상태-알림)의 Native 상태 알림 helper다.
154
+ 세션·`search`·`message`는 Web과 같다. 네트워크 요청을 실제로 끊어야 하면 제품이 `AbortController` 등을 따로 쓴다.
155
+
156
+ ## 플랫폼 차이
157
+
158
+ | 항목 | Web | Native |
159
+ | --- | --- | --- |
160
+ | 입력 이벤트 | `onValueChange`(문자열 값). DOM 이벤트가 필요할 때만 `onChange`(둘 다 호출된다) | `onValueChange` |
161
+ | 상태 알림 | `role="status"` | live region(Android) + iOS 알림 호출 |
162
+ | 바깥 틀 | `Container` | `ScrollView`(세로 여백, `keyboardShouldPersistTaps="handled"`) 안 `Container` |
163
+ | 검색 중 표시 | SearchField `loading` | SearchField `busy` + `busyLabel`(필수) |
164
+
165
+ ## 함정
166
+
167
+ - 데모의 인위 지연(150·900ms)을 제품에 최소 대기 시간으로 복사하지 않는다.
168
+ - TanStack Query처럼 키별 캐시가 최신 요청을 이미 고르는 앱은 세션을 겹치지 않는다.
169
+ - 구획 제목은 Heading으로 표시한다. 이전 Text heading 예제는 2026-10-06 제목 의미 구조를 맞추면서 수정했다.
170
+ - 진행 표시를 결과 영역에 따로 두지 않는다. SearchField의 진행 표시와 상태 문구 하나면 된다(흡수한 `검색 입력` 예제의 규칙).
171
+ - 실패와 결과 없음은 다른 상태다. 실패는 `다시 검색`(`session.retry()`), 결과 없음은 검색어를 바꾸는 경로를 준다.
@@ -0,0 +1,106 @@
1
+ # 네이티브 컴포넌트 기기 확인
2
+
3
+ - 단계: 구성
4
+ - 상태: 배포
5
+ - 지원: Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: `showcase/native/src/NativeRenderers.stories.tsx`, `showcase/native/src/story-registry.ts` `nativeRendererStoryGroups`
9
+ - 스토리북: `배포/구성/비교와 검증/네이티브 컴포넌트 기기 확인`
10
+
11
+ ## 언제 쓰나
12
+
13
+ Native 공개 컴포넌트가 실제 기기·시뮬레이터에서 그려지고 눌리는지 범주별로 한 화면에서 확인할 때 쓴다. 화면 설계의 본보기가 아니라
14
+ 렌더 확인용 모음이다. 여기서 범주를 찾은 뒤 배치·문구 규칙은 각 컴포넌트 지침에서 가져온다.
15
+
16
+ ## 구성 요소
17
+
18
+ 여덟 스토리가 아래 범주를 하나씩 그린다. 달력·떠 있는 버튼·데이터 배치·생각 구슬은 이 모음이 아니라 각자의 스토리에 있다.
19
+
20
+ | 컴포넌트 | 역할 | 지침 |
21
+ | --- | --- | --- |
22
+ | 디자인 기초 | Provider·글자·표면·레이아웃·아이콘·제목, 로그인 화면 골격 | [DesignSystemProvider](../components/design-system-provider.md), [Text](../components/text.md), [Surface](../components/surface.md), [Stack](../components/stack.md), [Container](../components/container.md), [AspectRatio](../components/aspect-ratio.md), [Grid](../components/grid.md), [Layout](../components/layout.md), [Icon](../components/icon.md), [Section](../components/section.md), [Divider](../components/divider.md), [Top](../components/top.md), [Heading](../components/heading.md), [AuthScreenLayout](../components/auth-screen-layout.md) |
23
+ | 동작 | 버튼·링크·하단 고정 행동·소셜 로그인 버튼 | [Button](../components/button.md), [IconButton](../components/icon-button.md), [Link](../components/link.md), [BottomCTA](../components/bottom-cta.md), [AuthProviderButton](../components/auth-provider-button.md) |
24
+ | 약관 동의 | 전체 동의·필수/선택 항목·상세 보기 | [Agreement](../components/agreement.md) |
25
+ | 입력 | 텍스트·숫자·날짜·파일·선택 입력 | [Field](../components/field.md), [SearchField](../components/search-field.md), [TextArea](../components/text-area.md), [PasswordField](../components/password-field.md), [OtpField](../components/otp-field.md), [NumberField](../components/number-field.md), [Slider](../components/slider.md), [Form](../components/form.md), [DatePicker](../components/date-picker.md), [FilePicker](../components/file-picker.md), [Checkbox](../components/checkbox.md), [Radio](../components/radio.md), [CheckboxGroup](../components/checkbox-group.md), [RadioGroup](../components/radio-group.md), [Switch](../components/switch.md), [SegmentedControl](../components/segmented-control.md), [Select](../components/select.md), [Combobox](../components/combobox.md), [Chip](../components/chip.md), [ToggleGroup](../components/toggle-group.md), [TagsInput](../components/tags-input.md), [DateRangePicker](../components/date-range-picker.md), [Mentions](../components/mentions.md), [TransferList](../components/transfer-list.md) |
26
+ | 탐색 | 상단 막대·탭·단계·메뉴·하단 탭·더 보기 | [Tabs](../components/tabs.md), [Steps](../components/steps.md), [TopBar](../components/top-bar.md), [Menu](../components/menu.md), [BottomNavigation](../components/bottom-navigation.md), [LoadMore](../components/load-more.md) |
27
+ | 데이터 표시 | 목록·카드·배지·수치·미디어 | [Badge](../components/badge.md), [Avatar](../components/avatar.md), [Card](../components/card.md), [ListRow](../components/list-row.md), [Tag](../components/tag.md), [Timeline](../components/timeline.md), [DescriptionList](../components/description-list.md), [Image](../components/image.md), [CounterBadge](../components/counter-badge.md), [List](../components/list.md), [Carousel](../components/carousel.md), [Statistic](../components/statistic.md), [UploadItem](../components/upload-item.md), [Accordion](../components/accordion.md), [Collapsible](../components/collapsible.md), [Asset](../components/asset.md) |
28
+ | 상태와 알림 | 빈 상태·결과·알림·진행·토스트 | [EmptyState](../components/empty-state.md), [Result](../components/result.md), [Notice](../components/notice.md), [Progress](../components/progress.md), [Skeleton](../components/skeleton.md), [Spinner](../components/spinner.md), [Toast](../components/toast.md), [BottomInfo](../components/bottom-info.md) |
29
+ | 오버레이 | 대화상자·확인 대화상자·하단 시트 | [Dialog](../components/dialog.md), [AlertDialog](../components/alert-dialog.md), [Sheet](../components/sheet.md) |
30
+
31
+ ## 배치
32
+
33
+ ```text
34
+ ┌──────── 안전 영역 안(제품 화면 host) ────┐
35
+ │ ScrollView 위아래 spacing.md 16 │ ← 스크롤 영역, 탭 유지(handled)
36
+ │ └ Container gutter 16/20 │
37
+ │ Section 범주 제목 (header 역할) │
38
+ │ 설명 │
39
+ │ ↕ contentGap 16 (Stack gap="md") │
40
+ │ 컴포넌트 A │
41
+ │ 컴포넌트 B │
42
+ │ ... 공개 import 순서대로 세로로 쌓음 │
43
+ │ 오버레이 트리거 → 열면 화면 위에 뜬다 │
44
+ └──────────────────────────────────────────┘
45
+ ```
46
+
47
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
48
+ | --- | --- | --- | --- |
49
+ | 바깥 틀 | `ScrollView keyboardShouldPersistTaps="handled"` → `Container` → `Section` | 화면 전체. 안전 영역은 제품 화면 host(또는 Provider `safeAreaInsets`)가 소유한다. 입력 범주는 키보드가 필드를 가리지 않게 `automaticallyAdjustKeyboardInsets`를 주거나, keyboard-controller가 있는 앱은 [KeyboardFormScrollView](../components/keyboard-form-scroll-view.md)로 바꾼다 | `ScrollView` 위아래 `spacing.md` 16, 좌우 `Container` `gutter`(폭 600 미만 `compact` 16, 이상 `regular` 20). 범주를 여럿 이으면 범주 사이 `layout.sectionGap` 24(`Stack gap="xl"`) |
50
+ | 범주 제목 | [Section](../components/section.md) `title`·`description` | 맨 위 | 제목과 본문 사이 `spacing.xs` 8, 제목과 설명 사이 `spacing.xxs` 4(`sectionRecipe`) |
51
+ | 컴포넌트 | 범주의 공개 컴포넌트, `Stack gap="md"` | 제목 아래 세로 | 요소 사이 `layout.contentGap` 16. 각 컴포넌트는 기본 크기 그대로 |
52
+ | 오버레이 | Dialog·AlertDialog·Sheet | 트리거를 누르면 화면 위 | 각 recipe |
53
+
54
+ ## 흐름과 상태
55
+
56
+ 1. 범주 스토리를 연다.
57
+ 2. 각 컴포넌트를 눌러 보고, 아래 "Last action"·"Pressed" 같은 상태 문구로 이벤트가 왔는지 확인한다.
58
+ 3. 같은 범주를 어두운 테마·큰 글자 전역 설정으로 다시 본다.
59
+ 4. 문제가 있으면 해당 컴포넌트 지침과 계약에서 원인을 찾는다. 이 모음에서 배치를 베끼지 않는다.
60
+
61
+ | 상태 | 모습 | 포커스·알림 |
62
+ | --- | --- | --- |
63
+ | 기본 | 범주의 컴포넌트가 기본 축으로 그려진다 | 키보드가 열려도 탭이 먹힌다(`handled`) |
64
+ | 진행 중 | 업로드 64%(UploadItem `uploading`·Progress), LoadMore 불러오기, AuthScreenLayout `pendingLabel`(카드 크기를 유지한 가운데 로딩) 같은 예시 상태 | 각 컴포넌트 지침의 진행 알림 |
65
+ | 실패 | — (렌더 확인 모음이라 네트워크 요청이 없다). 실패 표현은 상태와 알림 범주의 Result·Notice로 확인하고, 실제 실패·재시도 흐름은 [저장과 재시도](action-recovery-save.md) 같은 구성 지침을 따른다 | — |
66
+ | 비활성 | 동작 범주의 `disabled` Button | — |
67
+ | 다크·큰 글자 | 전역 설정으로 같은 화면을 다시 확인 | — |
68
+
69
+ ## 코드 골격
70
+
71
+ ```tsx
72
+ // Web
73
+ // 없음. Web은 각 컴포넌트 스토리(`배포/컴포넌트/...`)에서 확인한다.
74
+ ```
75
+
76
+ ```tsx
77
+ // Native
78
+ import { ScrollView, useWindowDimensions } from "react-native";
79
+ import { Container, Section, Stack } from "@hjmds/react-native/primitives";
80
+ import { useHjmNativeTheme } from "@hjmds/react-native/provider";
81
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
82
+
83
+ // 제품의 개발용 확인 화면에서 범주 하나를 같은 틀로 모을 때
84
+ const { spacing } = useHjmNativeTheme().tokens;
85
+ const gutter = resolveWindowClass(useWindowDimensions().width) === "compact" ? "compact" : "regular";
86
+
87
+ <ScrollView keyboardShouldPersistTaps="handled" automaticallyAdjustKeyboardInsets
88
+ contentContainerStyle={{ paddingVertical: spacing.md }}>
89
+ <Container gutter={gutter}>
90
+ <Section title={t("dev.inputs.title")} description={t("dev.inputs.description")}>
91
+ <Stack gap="md">
92
+ {/* 범주의 컴포넌트를 공개 import에서 가져와 순서대로 둔다 */}
93
+ </Stack>
94
+ </Section>
95
+ </Container>
96
+ </ScrollView>
97
+ ```
98
+
99
+ 스토리의 영어 예시 문구, `Glyph`(첫 글자 아이콘), 직접 만든 입력의 `#667085` 테두리는 확인용이며 제품에 가져오지 않는다.
100
+
101
+ ## 함정
102
+
103
+ - 이 모음의 순서·간격은 확인용이다. 실제 화면은 화면 지침과 각 구성 지침의 배치를 따른다.
104
+ - 구획 제목은 Heading으로 표시한다. 이전 Text heading 예제는 2026-10-06 제목 의미 구조를 맞추면서 수정했다.
105
+ - 스토리의 직접 만든 입력 스타일(`#667085`, radius 12)은 토큰이 아니다. 제품 코드에 숫자로 옮기지 않는다.
106
+ - StoryFrame은 Container compact·Stack md와 위아래 spacing.md를 사용하며 `automaticallyAdjustKeyboardInsets`를 켠다. Android 키보드 회피·큰 입력 폼은 해당 제품 기기에서 별도로 확인한다.
@@ -0,0 +1,164 @@
1
+ # 내비게이션 바 비교
2
+
3
+ - 단계: 구성
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: [BottomNavigation](../../bottom-navigation.md), `src/component-recipes.ts` `bottomNavigationRecipe`, `packages/react/src/styles.css` `.hjm-bottom-navigation`, `showcase/shared/navigation-bar-references.ts`
9
+ - 스토리북: `배포/구성/비교와 검증/내비게이션 바 비교`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 하단 탭에 목적지 이동과 별개의 행동(작성·전원·기록 추가)을 함께 둘지, 선택한 목적지를 어떻게 보여 줄지 고를 때 이 비교를 본다.
14
+ 스토리는 BottomNavigation 하나를 `configuration`과 `primaryAction`만 바꿔 네 가지 동작으로 보여 준다.
15
+ 열 가지 색·라벨은 참고 예시이고 동작은 네 가지뿐이다.
16
+
17
+ ## 구성 요소
18
+
19
+ | 컴포넌트 | 역할 | 지침 |
20
+ | --- | --- | --- |
21
+ | BottomNavigation | 목적지 2~6개, 선택 상태, 배지 | [BottomNavigation](../components/bottom-navigation.md) |
22
+ | IconButton `tone="primary" size="large" shape="circle"` | `primaryAction`: 목적지가 아닌 행동 | [IconButton](../components/icon-button.md) |
23
+ | HjmProvider / HjmNativeProvider `brandPalette` | 막대 범위의 제품 색(스토리 색은 예시) | [DesignSystemProvider](../components/design-system-provider.md) |
24
+ | Button `secondary` `selected` | 스토리에서 참고 표현을 고르는 선택기(제품에는 필요 없음) | [Button](../components/button.md) |
25
+
26
+ ## 배치
27
+
28
+ 네 가지 동작과 고르는 기준이다.
29
+
30
+ | 동작 | `configuration` | `primaryAction` 위치 | 고를 때 |
31
+ | --- | --- | --- | --- |
32
+ | 중앙에서 작업 실행 | `presentation: "floating"`, `distribution: "center-gap"` | 막대 정중앙(목적지 사이 빈칸 위) | 생성이 앱의 핵심 행동이고 목적지가 짝수(2·4·6)일 때 |
33
+ | 탐색 옆에서 작업 실행 | `presentation: "capsule"`, `distribution: "equal"` | 캡슐 밖 끝 쪽, 캡슐과 `spacing.xs` 8 간격 | 행동이 목적지와 섞여 보이면 안 될 때, 목적지가 홀수일 때 |
34
+ | 선택한 목적지 이름 표시 | `presentation: "capsule"`, 행동 없음 | — | 아이콘만으로 목적지가 모호해 선택 항목의 이름을 보여야 할 때 |
35
+ | 사각 영역으로 현재 위치 표시 | `capsule` + 목록·항목 radius `lg` 16 | — | 둥근 캡슐이 제품 표현과 맞지 않을 때(radius 변경은 제품 스타일) |
36
+
37
+ ```text
38
+ 중앙에서 작업 실행 (floating + center-gap) 탐색 옆에서 작업 실행 (capsule)
39
+ ┌──── 스크롤 본문 (하단 여백 = 막대 높이) ────┐ ┌───────────────────────────────────────┐
40
+ │ ... │ │ ... │
41
+ ├──────── 고정 막대, outer 좌우 16 ──────────┤ ├───────────────────────────────────────┤
42
+ │ ╭───────────────────────────────────────╮ │ │ ╭─────────────────────────────╮ 8 (●) │
43
+ │ │ [홈] [공간] (●) [스토어] [전력] │ │ │ │ [●홈 ] [달력] [통계] [기록] │ ↑ │
44
+ │ ╰──────── center gap 68 ────────────────╯ │ │ ╰───── radius full ───────────╯ 주 행동│
45
+ │ ↑ 주 행동 IconButton 52 │ │ 선택 항목만 아이콘+이름(가로) │
46
+ ├──── 안전 영역 + spacing.xs 8 ─────────────┤ ├──── 안전 영역 + spacing.xs 8 ─────────┤
47
+ └────────────────────────────────────────────┘ └───────────────────────────────────────┘
48
+ radius xl 24, 최대 폭 384 최대 폭 480
49
+ ```
50
+
51
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
52
+ | --- | --- | --- | --- |
53
+ | 바깥 틀 | Web: 문서 스크롤 본문(`Container`) + 화면 아래 고정 막대. Native: 화면 루트 세로 flex(`flex: 1`) — 위 `ScrollView`(flex 1) → `Container`, 아래 막대(탭 navigator의 tabBar 자리) | 막대는 스크롤하지 않는다. 하단 안전 영역은 막대가 받는다(Web `env(safe-area-inset-bottom)`, Native `safeAreaBottom`). 상단 안전 영역은 화면 header가 받는다. 키보드가 열리면 막대가 숨는다(`keyboardBehavior: "hide"` 기본) | 본문 좌우는 `Container` `gutter`(폭 600 미만 `compact` 16, 이상 `regular` 20), Native `ScrollView` 위아래 `spacing.md` 16. Web 본문 하단 여백 = 잰 막대 높이(막대가 `position: fixed`라 내용이 아래로 들어간다) |
54
+ | 막대 | BottomNavigation | Web `position: fixed` 하단, Native는 제품 navigator가 하단에 둔다 | outer 위 `spacing.xs` 8, 좌우 `spacing.md` 16(floating·capsule), `bar`는 0 |
55
+ | 표면 | floating / capsule | 막대 가운데 | floating 최대 폭 384·radius `xl` 24, capsule 최대 폭 480·radius `full` |
56
+ | 항목 | 목적지 | 표면 안 균등 분배 | `compact` 최소 52×52·padding `spacing.xxs` 4, `regular` 56×64·padding `spacing.xs` 8 |
57
+ | 중앙 빈칸 | `center-gap` | 목적지 가운데 | `control.buttonHeight.large` 52 + `spacing.md` 16 = 68 |
58
+ | 주 행동 | IconButton | center-gap이면 정중앙, capsule이면 목록 뒤 끝 쪽 | `large` 지름 52, capsule 목록과 `spacing.xs` 8 |
59
+ | 하단 안전 영역 | — | 막대 아래 | Web `env(safe-area-inset-bottom)` + `spacing.xs` 8, Native `safeAreaBottom` |
60
+ | 스크롤 본문 | 제품 화면 | 막대 위 | Web은 하단 여백을 막대 높이만큼 제품이 둔다. Native는 막대가 flex 형제라 겹치지 않는다 |
61
+
62
+ capsule은 기본 크기에서 선택되지 않은 항목의 이름을 접는다. 큰 글자(계약 기준 1.5 이상)이거나 목적지가 5~6개면
63
+ 모든 이름을 세로로 보인다. 접힌 항목도 접근성 이름은 유지된다.
64
+
65
+ ## 흐름과 상태
66
+
67
+ 1. 목적지를 누르면 `onActivate`로 이동 의도만 받는다. 선택은 제품 router가 확정해 `selectedKey`로 다시 넘긴다.
68
+ 2. `primaryAction`을 누르면 작성 화면·시트를 연다. `selectedKey`는 바뀌지 않는다.
69
+ 3. 같은 탭을 다시 누르면 Native는 `reason: "reselect"`로 오므로 목록 맨 위로 스크롤하는 데 쓴다.
70
+ 4. 소프트 키보드가 열리면 기본(`keyboardBehavior: "hide"`)으로 막대를 숨긴다.
71
+
72
+ | 상태 | 모습 | 포커스·알림 |
73
+ | --- | --- | --- |
74
+ | 기본 | 선택 목적지가 `content.brand` 아이콘·라벨, capsule은 `surfaceAccent` 배경 | Web `aria-current`, Native 선택 state |
75
+ | 진행 중 | — (막대는 즉시 바뀐다. 목적지 화면을 불러오는 동안의 로딩은 그 화면이 표시하고, 막대는 `selectedKey`가 바뀐 상태로 남는다) | — |
76
+ | 실패 | — (막대 자체는 실패하지 않는다. 목적지 화면의 불러오기 실패·재요청 실패는 그 화면의 [Result](../components/result.md)·[Notice](../components/notice.md)가 표시한다. `primaryAction`이 연 작성 화면의 저장 실패는 [저장과 재시도](action-recovery-save.md)를 따른다) | — |
77
+ | 배지 | 숫자 배지(`count`·`max`) | 배지 `accessibilityLabel`을 항목 이름과 함께 읽는다 |
78
+ | 키보드 열림 | 막대가 숨는다(`remain`이면 유지) | — |
79
+ | 큰 글자 | capsule의 모든 이름이 세로로 보인다 | 같다 |
80
+
81
+ ## 코드 골격
82
+
83
+ ```tsx
84
+ // Web
85
+ import { BottomNavigation } from "@hjmds/react/navigation";
86
+ import { IconButton } from "@hjmds/react/actions";
87
+ import { Container } from "@hjmds/react/layout";
88
+
89
+ <>
90
+ {/* 막대가 position: fixed라 본문 끝이 가려진다. 잰 막대 높이만큼 아래 여백을 둔다. */}
91
+ <main style={{ paddingBlockEnd: barHeight }}>
92
+ <Container>{page}</Container>
93
+ </main>
94
+ <BottomNavigation ref={barRef}
95
+ descriptor={{ accessibilityLabel: t("nav.main"), selectedKey: route, items }}
96
+ configuration={{ presentation: "floating", density: "compact", distribution: "center-gap" }}
97
+ getHref={(item) => routes[item.id]}
98
+ renderLink={(props) => <Link {...props} />}
99
+ renderIcon={renderGlyph}
100
+ primaryAction={
101
+ <IconButton label={t("post.create")} tone="primary" size="large" shape="circle" onClick={openComposer}>
102
+ <PlusGlyph aria-hidden="true" />
103
+ </IconButton>
104
+ }
105
+ />
106
+ </>
107
+ ```
108
+
109
+ ```tsx
110
+ // Native
111
+ import { ScrollView, View, useWindowDimensions } from "react-native";
112
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
113
+ import { BottomNavigation } from "@hjmds/react-native/navigation";
114
+ import { IconButton } from "@hjmds/react-native/actions";
115
+ import { Container } from "@hjmds/react-native/primitives";
116
+ import { useHjmNativeTheme } from "@hjmds/react-native/provider";
117
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
118
+
119
+ const { colors, tokens } = useHjmNativeTheme();
120
+ const insets = useSafeAreaInsets();
121
+ const gutter = resolveWindowClass(useWindowDimensions().width) === "compact" ? "compact" : "regular";
122
+
123
+ <View style={{ flex: 1 }}>
124
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingVertical: tokens.spacing.md }}>
125
+ <Container gutter={gutter}>{page}</Container>
126
+ </ScrollView>
127
+ <BottomNavigation
128
+ descriptor={{ accessibilityLabel: t("nav.main"), selectedKey: route, items }}
129
+ configuration={{ presentation: "capsule", density: "compact", distribution: "equal" }}
130
+ safeAreaBottom={insets.bottom}
131
+ renderIcon={renderGlyph}
132
+ onActivate={({ key }) => navigation.navigate(key)}
133
+ primaryAction={
134
+ <IconButton label={t("record.add")} tone="primary" size="large" shape="circle" onPress={openRecord}>
135
+ <PlusGlyph color={colors.onPrimary} />
136
+ </IconButton>
137
+ }
138
+ />
139
+ </View>
140
+ ```
141
+
142
+ `route`·`items`·`routes`·`Link`·`renderGlyph`·`PlusGlyph`·`barRef`/`barHeight`(막대 높이 측정)·`navigation`은 제품 소유다.
143
+ 탭 navigator를 쓰면 `View` 대신 navigator의 `tabBar`에 `BottomNavigation`을 넘기고 본문은 각 탭 화면이 그린다.
144
+
145
+ 스토리의 참고 색(`accent`·`soft`), 아이콘 세트, 막대를 감싼 테두리 카드와 `padding-inline: 0`(320 폭 갤러리용) 덮어쓰기는
146
+ 제품에 가져오지 않는다. 제품 색은 앱 루트 Provider의 `brandPalette`로 준다.
147
+
148
+ ## 플랫폼 차이
149
+
150
+ | 항목 | Web | Native |
151
+ | --- | --- | --- |
152
+ | 배치 | CSS `position: fixed`(스토리는 갤러리용으로 `relative`로 덮음), 본문 하단 여백은 제품 | 화면 루트 세로 flex의 마지막 자식 또는 navigator `tabBar` |
153
+ | 목적지 | 링크(`getHref` 필수, `renderLink`) | tab 역할 + `onActivate` 필수 |
154
+ | 사각 선택 표현 | 공개 축이 없다. 스토리는 CSS로 목록·항목 radius를 `lg`로 덮는다 | 공개 축이 없다. 스토리는 deprecated `listStyle={{ borderRadius: radius.lg }}`로 덮는다 |
155
+ | 행동 아이콘 색 | `currentColor` | `colors.onPrimary`를 직접 넘김 |
156
+
157
+ ## 함정
158
+
159
+ - `center-gap`은 홀수 목적지에서, `capsule`과 함께 쓰면 오류다.
160
+ - 생성 행동을 목적지 항목으로 넣지 않는다. 선택 상태가 생성 화면으로 옮겨 가 탭 의미가 깨진다.
161
+ - "사각 영역으로 현재 위치 표시"는 `configuration` 축이 아니라 radius 덮어쓰기다. Native `listStyle`·`style`은 deprecated(다음 major에서 제거)라 제품에 옮기면 그때 깨진다. 필요하면 공개 축 추가를 먼저 요청한다.
162
+ - 구획 제목은 Heading으로 표시한다. 이전 Text heading 예제는 2026-10-06 제목 의미 구조를 맞추면서 수정했다.
163
+ - 현재 스토리는 Native 막대에 deprecated `style={{ paddingHorizontal: 0 }}`를 준다(320 폭 갤러리용). 제품은 바깥 좌우 `spacing.md` 16을 그대로 둔다.
164
+ - 현재 스토리는 바깥 틀(본문 스크롤·하단 여백)이 없고 막대를 갤러리 카드 안에 둔다. 제품은 바깥 틀 행대로 둔다.
@@ -0,0 +1,169 @@
1
+ # 이미지·시트·키보드 조작
2
+
3
+ - 단계: 구성
4
+ - 상태: 배포
5
+ - 지원: Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: [Optional presentation adapters](../../optional-adapters.md#behavior-boundaries), `packages/react-native/src/keyboard-controller.tsx`, `packages/react-native/src/sheet-gesture.tsx`, `showcase/native/src/OptionalAdapters.stories.tsx`. 2026-10-06 사용자 승인으로 스토리북 배포(이전 `실험/구성/이미지·시트·키보드 조작`, [승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정))
9
+ - 스토리북: `배포/구성/직접 조작과 모션/이미지·시트·키보드 조작`
10
+
11
+ ## 언제 쓰나
12
+
13
+ Native 앱 한 화면에서 이미지 확대 보기, 끌어서 높이를 바꾸는 시트, OS 길게 누르기 메뉴, 키보드를 따라 올라가는 하단 행동을 함께 쓸 때 provider 중첩 순서와 각 요소의 자리를 확인하는 구성이다.
14
+
15
+ 네 어댑터(`image-viewer`·`sheet-gesture`·`context-menu-native`·`keyboard-controller`)는 모두 experimental이고 Native 전용이다. 링크된 네이티브 모듈이 필요하므로
16
+ Expo Go가 아니라 호환 개발 클라이언트에서만 확인된다. 이 기능이 필요 없으면 기본 [Sheet](../components/sheet.md)·[ContextMenu](../components/context-menu.md)·
17
+ [KeyboardAvoiding](../components/keyboard-avoiding.md)을 쓴다.
18
+
19
+ ## 구성 요소
20
+
21
+ | 컴포넌트 | 역할 | 지침 |
22
+ | --- | --- | --- |
23
+ | `GestureHandlerRootView` | 제스처 host. 화면 루트 하나 | — |
24
+ | `KeyboardMotionProvider` | 키보드 추적. 앱(화면) 루트에 하나, 필드마다 두지 않는다 | [KeyboardMotionProvider](../components/keyboard-motion-provider.md) |
25
+ | `GestureSheetProvider` | GestureSheet host. `GestureHandlerRootView` 안 | [Sheet](../components/sheet.md) |
26
+ | `KeyboardFormScrollView` | 입력이 있는 본문 스크롤. 초점 필드를 키보드 위로 | [KeyboardFormScrollView](../components/keyboard-form-scroll-view.md) |
27
+ | `KeyboardDock` | 하단 행동(완료 등)이 키보드를 따라 올라감 | [KeyboardDock](../components/keyboard-dock.md) |
28
+ | `GestureSheet` + `GestureSheetInput` | 끌어서 snap(기본 `["50%", "90%"]`)·닫기, 시트 안 입력 | [Sheet](../components/sheet.md) |
29
+ | `ImageViewer` | 전체 화면 이미지 확대·넘기기, 실패 시 다시 시도 | [Image](../components/image.md) |
30
+ | `NativeContextMenu` | 길게 눌러 OS 메뉴. 자식은 접근 가능한 native 요소 하나 | [ContextMenu](../components/context-menu.md) |
31
+ | `Button`·`Text` | 트리거·결과 문구 | [Button](../components/button.md), [Text](../components/text.md) |
32
+
33
+ ## 배치
34
+
35
+ ```text
36
+ ┌ GestureHandlerRootView ▸ KeyboardMotionProvider ▸ GestureSheetProvider ┐
37
+ │ ░ 상단 안전 영역(insets.top) ░ │
38
+ │ 상단 바 영역 (Container gutter 16/20) │ ← 고정
39
+ ├─────────────────────────────────────────────────────────────────────────┤
40
+ │ KeyboardFormScrollView 위아래 spacing.md 16 │ ↑ 스크롤
41
+ │ └ Container gutter 16/20 ▸ Stack gap="md" 16 │ │
42
+ │ [ 이미지 보기 ] (secondary) │ │
43
+ │ [ 시트 열기 ] (secondary) │ │
44
+ │ (길게 눌러 메뉴 열기) ← NativeContextMenu 자식 │ │
45
+ │ 결과 문구 (live region) │ │
46
+ │ [ 입력 TextField ] │ ↓
47
+ ├─────────────────────────────────────────────────────────────────────────┤
48
+ │ KeyboardDock: [ 완료 (primary) ] Container gutter │ ← 주 행동, 키보드 위로 이동
49
+ │ ░ 하단 안전 영역: 키보드 닫힘일 때만 insets.bottom ░ │
50
+ └─────────────────────────────────────────────────────────────────────────┘
51
+ 위에 겹침: GestureSheet(하단, 50% ↔ 90%) / ImageViewer(전체 화면, safeAreaInsets)
52
+ ```
53
+
54
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
55
+ | --- | --- | --- | --- |
56
+ | 바깥 틀 | `GestureHandlerRootView` ▸ `KeyboardMotionProvider` ▸ `GestureSheetProvider`(이 순서로 화면 루트에 하나씩) → 본문 `KeyboardFormScrollView` → `Container` → `Stack gap="md"` | 화면 전체. 위 안전 영역은 상단 바 영역이 `paddingTop: insets.top`으로, 아래 안전 영역은 하단 행동 영역이 받는다. 키보드: 본문은 `KeyboardFormScrollView`가 초점 필드를 올리고 하단 행동은 `KeyboardDock`이 따라 올라간다 | 본문 위아래 `spacing.md` 16, 좌우 `Container` `gutter`(폭 600 미만 `compact` 16, 이상 `regular` 20), 요소 사이 `layout.contentGap` 16 |
57
+ | 상단 | 제품 상단 바([TopBar](../components/top-bar.md) 등) | 고정, `paddingTop: insets.top` | 좌우 `Container` gutter |
58
+ | 트리거 | `Button tone="secondary"` × 2 | 본문 맨 위 | 높이 44. 화면의 primary는 하단 완료 하나 |
59
+ | 메뉴 트리거 | `NativeContextMenu` 자식(`Pressable` + 지역화된 `accessibilityLabel`) | 본문 안 | 터치 영역 최소 44(`control.minTouchTarget`) 권장 |
60
+ | 결과 문구 | `Text accessibilityLiveRegion="polite"` | 메뉴 트리거 아래 | — |
61
+ | 입력 | [TextField](../components/field.md) | 본문 끝 | 높이 44 |
62
+ | 하단 행동 | `KeyboardDock` + `Button`(primary) | 하단 고정, 키보드가 열리면 그 위로 | 좌우 `Container` gutter, 아래 여백은 키보드 닫힘 시 `insets.bottom`·열림 시 0, 추가 간격은 `clearance`(기본 0) |
63
+ | 시트 | `GestureSheet` + `GestureSheetInput` | 하단 오버레이 | snap 기본 50%·90%, 닫기 버튼이 본문 끝에 자동 |
64
+ | 이미지 | `ImageViewer` | 전체 화면 오버레이 | `safeAreaInsets` 필수(가장자리까지 그릴 때) |
65
+
66
+ - 하단 안전 영역은 host(제품)가 소유한다. `KeyboardDock`은 키보드만 따라가므로, 키보드가 닫혀 있을 때만 `insets.bottom`을 더한다.
67
+ - `KeyboardDock`을 다른 키보드 회피 wrapper와 같은 내용에 겹쳐 쓰지 않는다.
68
+ - 시트 안 입력은 `GestureSheetInput`이다. 시트의 키보드 여백은 시트가 소유하므로 시트 안에 `KeyboardFormScrollView`를 또 두지 않는다.
69
+
70
+ 근거: `showcase/native/src/OptionalAdapters.stories.tsx`, `packages/react-native/src/keyboard-controller.tsx`(`clearance`), `packages/react-native/src/sheet-gesture.tsx`(`snapPoints`·`busy`), `packages/react-native/src/image-viewer.tsx`(로딩·실패·다시 시도), `src/foundations.ts`(`layout`)
71
+
72
+ ## 흐름과 상태
73
+
74
+ 1. 이미지 보기를 누르면 `ImageViewer`가 `open`으로 열린다. 닫으면 unmount되어 확대 상태가 초기화된다.
75
+ 2. 시트 열기를 누르면 `GestureSheet`가 첫 snap(50%)으로 열린다. 끌어서 90%로 올리거나 아래로 끌어 닫는다.
76
+ 3. 메뉴 트리거를 길게 누르면 OS 메뉴가 열리고, 고른 항목 id가 `onAction`으로 온다. 제품은 id를 결과 문구 키로 바꿔 보인다.
77
+ 4. 입력에 초점이 가면 본문이 초점 필드를 키보드 위로 올리고, 완료 버튼이 키보드 위에 붙는다. 완료는 `Keyboard.dismiss()`다.
78
+ 5. Android back: 열린 GestureSheet가 있으면 그것부터 닫는다.
79
+
80
+ | 상태 | 모습 | 포커스·알림 |
81
+ | --- | --- | --- |
82
+ | 기본 | 트리거 두 개·메뉴 트리거·안내 결과 문구(`memo.menuHint`)·입력, 하단에 완료 | — |
83
+ | 진행 중 | 이미지: 이미지마다 `loadingLabel` 문구. 시트 `busy`: 끌어 닫기·배경 닫기·Android back이 막힌다(코드로는 닫을 수 있다) | 이미지 로딩 문구는 live region(polite) |
84
+ | 실패 | 이미지 로드 실패: `errorLabel` + `retryLabel` 버튼. 다시 시도는 같은 이미지를 새로 요청하고, 다시 실패하면 같은 실패 문구와 버튼으로 돌아온다. 시트·메뉴는 요청이 없다. 시트 안 저장처럼 제품 요청이 실패하면 `busy`를 풀고 시트를 연 채 실패 문구를 둔다 | 실패 문구는 live region이 아니다(함정) |
85
+ | 시트 열림 | 하단 시트, 배경 눌러 닫기 | 열었던 트리거로 포커스 복귀는 제품이 처리 |
86
+ | 키보드 열림 | 완료 버튼이 키보드 위, 아래 안전 영역 0 | — |
87
+ | 메뉴 항목 disabled·danger | OS가 비활성·파괴 표시 | OS 메뉴 접근성 |
88
+
89
+ 메뉴 결과처럼 상태→문구 키는 상수 표로 둔다. 템플릿 문자열 키는 키 추출·누락 검사가 찾지 못한다.
90
+
91
+ ## 코드 골격
92
+
93
+ ```tsx
94
+ // Web
95
+ // 없음: 네 어댑터 모두 Native 전용이다. Web은 기본 Sheet·ContextMenu와 브라우저 키보드 동작을 쓴다.
96
+ ```
97
+
98
+ ```tsx
99
+ // Native
100
+ import { Keyboard, Pressable, View, useWindowDimensions } from "react-native";
101
+ import { GestureHandlerRootView } from "react-native-gesture-handler";
102
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
103
+ import { Button } from "@hjmds/react-native/actions";
104
+ import { Container, Stack, Text } from "@hjmds/react-native/primitives";
105
+ import { TextField } from "@hjmds/react-native/inputs";
106
+ import { useHjmNativeTheme } from "@hjmds/react-native/provider";
107
+ import { KeyboardMotionProvider, KeyboardDock, KeyboardFormScrollView } from "@hjmds/react-native/keyboard-controller";
108
+ import { GestureSheet, GestureSheetProvider, GestureSheetInput } from "@hjmds/react-native/sheet-gesture";
109
+ import { ImageViewer } from "@hjmds/react-native/image-viewer";
110
+ import { NativeContextMenu } from "@hjmds/react-native/context-menu-native";
111
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
112
+
113
+ const menuResultKey = { none: "memo.menuHint", save: "memo.saved", delete: "memo.deleted" } as const;
114
+ const { spacing } = useHjmNativeTheme().tokens;
115
+ const insets = useSafeAreaInsets();
116
+ const gutter = resolveWindowClass(useWindowDimensions().width) === "compact" ? "compact" : "regular";
117
+
118
+ <GestureHandlerRootView style={{ flex: 1 }}>
119
+ <KeyboardMotionProvider>
120
+ <GestureSheetProvider>
121
+ <View style={{ paddingTop: insets.top }}><Container gutter={gutter}>{topBar}</Container></View>
122
+ <KeyboardFormScrollView contentContainerStyle={{ paddingVertical: spacing.md }}>
123
+ <Container gutter={gutter}>
124
+ <Stack gap="md">
125
+ <Button tone="secondary" onPress={() => setViewer(true)}>{t("photo.view")}</Button>
126
+ <Button tone="secondary" onPress={() => setSheet(true)}>{t("detail.open")}</Button>
127
+ <NativeContextMenu items={menuItems} onAction={(id) => setMenuResult(id === "delete" ? "delete" : "save")}>
128
+ <Pressable accessibilityRole="button" accessibilityLabel={t("memo.moreActions")}>{card}</Pressable>
129
+ </NativeContextMenu>
130
+ <Text accessibilityLiveRegion="polite">{t(menuResultKey[menuResult])}</Text>
131
+ <TextField label={t("memo.label")} value={memo} onValueChange={setMemo} />
132
+ </Stack>
133
+ </Container>
134
+ </KeyboardFormScrollView>
135
+ <KeyboardDock>
136
+ <View style={{ paddingBottom: keyboardOpen ? 0 : insets.bottom }}>
137
+ <Container gutter={gutter}>
138
+ <Button onPress={() => Keyboard.dismiss()}>{t("common.done")}</Button>
139
+ </Container>
140
+ </View>
141
+ </KeyboardDock>
142
+ <GestureSheet open={sheet} onOpenChange={setSheet} busy={sheetBusy} title={t("detail.title")} closeLabel={t("common.close")}>
143
+ <GestureSheetInput accessibilityLabel={t("detail.memo")} />
144
+ </GestureSheet>
145
+ <ImageViewer open={viewer} onClose={() => setViewer(false)} safeAreaInsets={insets} items={images}
146
+ closeLabel={t("common.close")} previousLabel={t("common.previous")} nextLabel={t("common.next")}
147
+ loadingLabel={t("common.loading")} errorLabel={t("photo.loadFailed")} retryLabel={t("photo.retryLoad")} />
148
+ </GestureSheetProvider>
149
+ </KeyboardMotionProvider>
150
+ </GestureHandlerRootView>
151
+ ```
152
+
153
+ `keyboardOpen`은 `Keyboard`의 `keyboardDidShow`·`keyboardDidHide` 구독으로 제품이 만든다. `images`·`menuItems`·`card`·`topBar`·문구는 제품 소유다.
154
+
155
+ ## 플랫폼 차이
156
+
157
+ | 항목 | Web | Native |
158
+ | --- | --- | --- |
159
+ | 지원 | 없음(기본 Sheet·ContextMenu 사용) | 네 어댑터 experimental |
160
+ | 실행 환경 | — | 호환 개발 클라이언트(Expo Go 불가) |
161
+
162
+ ## 함정
163
+
164
+ - RN `Modal` 안에 GestureSheet를 두면 Android back이 `Modal`의 `onRequestClose`로만 간다. `dismissTopGestureSheet()`를 먼저 부르고 `false`일 때만 host를 닫는다.
165
+ - KeyboardDock은 window 기준 좌표를 쓴다. Storybook 캔버스처럼 아래에 다른 영역이 남는 host에서는 위치가 어긋나 스토리가 전체 화면 `Modal`로 띄운다. 제품도 앱 크기 host에서 쓴다.
166
+ - NativeContextMenu 패치(Zeego 3.0.6 관련)는 HJM 설치로 적용되지 않는다. `@hjmds/react-native/docs/patches/`를 제품에 복사해 등록한다.
167
+ - 필요한 optional peer(`react-native-zoom-toolkit` 5.1.1, `react-native-keyboard-controller` 1.22.5, `@gorhom/bottom-sheet` 5.2.14, `zeego` 3.0.6 등)가 없으면 기기 Metro 번들이 실패한다.
168
+ - 현재 `ImageViewer`는 로딩 문구만 live region이고 실패 문구(`errorLabel`)는 알리지 않는다. 실패를 낭독해야 하면 제품이 따로 알린다(renderer 수정 후보).
169
+ - 2026-10-06 예제 검수에서 머리·본문·하단의 좌우 여백을 Container compact로 맞추고 본문 간격은 Stack md로 옮겼다. 안전 영역·키보드 좌표는 바깥 host가 유지한다. 메뉴 결과는 상태 문구와 iOS/Android 알림을 함께 갱신한다.