@hjmds/design-contracts 1.12.1 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (288) hide show
  1. package/dist/avatar-fallback.d.ts +11 -0
  2. package/dist/avatar-fallback.d.ts.map +1 -1
  3. package/dist/avatar-fallback.js +21 -0
  4. package/dist/avatar-fallback.js.map +1 -1
  5. package/dist/base-recipes.d.ts +17 -0
  6. package/dist/base-recipes.d.ts.map +1 -1
  7. package/dist/base-recipes.js +17 -0
  8. package/dist/base-recipes.js.map +1 -1
  9. package/dist/catalog.d.ts +26 -0
  10. package/dist/catalog.d.ts.map +1 -1
  11. package/dist/command-palette.d.ts +14 -9
  12. package/dist/command-palette.d.ts.map +1 -1
  13. package/dist/command-palette.js +8 -9
  14. package/dist/command-palette.js.map +1 -1
  15. package/dist/component-recipes.d.ts +31 -0
  16. package/dist/component-recipes.d.ts.map +1 -1
  17. package/dist/component-recipes.js +20 -0
  18. package/dist/component-recipes.js.map +1 -1
  19. package/dist/provider-button.d.ts.map +1 -1
  20. package/dist/provider-button.js +3 -0
  21. package/dist/provider-button.js.map +1 -1
  22. package/dist/reactions.d.ts +10 -0
  23. package/dist/reactions.d.ts.map +1 -1
  24. package/dist/reactions.js +7 -0
  25. package/dist/reactions.js.map +1 -1
  26. package/dist/screen-patterns.d.ts +147 -0
  27. package/dist/screen-patterns.d.ts.map +1 -0
  28. package/dist/screen-patterns.js +149 -0
  29. package/dist/screen-patterns.js.map +1 -0
  30. package/dist/slider.d.ts +8 -0
  31. package/dist/slider.d.ts.map +1 -1
  32. package/dist/slider.js +6 -1
  33. package/dist/slider.js.map +1 -1
  34. package/dist/upload-item.d.ts +5 -0
  35. package/dist/upload-item.d.ts.map +1 -1
  36. package/dist/upload-item.js +5 -0
  37. package/dist/upload-item.js.map +1 -1
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/dist/version.js.map +1 -1
  41. package/docs/action-session.md +3 -3
  42. package/docs/agreement.md +5 -0
  43. package/docs/avatar-fallback.md +7 -0
  44. package/docs/bottom-navigation.md +6 -0
  45. package/docs/brand-boundary.md +1 -1
  46. package/docs/button-label.md +6 -0
  47. package/docs/clipboard.md +3 -0
  48. package/docs/command-palette.md +45 -2
  49. package/docs/consumer-policy.md +5 -1
  50. package/docs/data-table.md +6 -4
  51. package/docs/dialog.md +8 -2
  52. package/docs/form.md +51 -0
  53. package/docs/generated/component-maturity.md +1 -1
  54. package/docs/generated/renderer-evidence.json +3 -3
  55. package/docs/generated/renderer-evidence.md +1 -1
  56. package/docs/generated/showcase-manifest.json +1 -1
  57. package/docs/link.md +8 -0
  58. package/docs/migration-native-legacy-removal.md +45 -1
  59. package/docs/optional-adapters.md +1 -1
  60. package/docs/password-field.md +5 -0
  61. package/docs/product-composition-adoption.md +40 -0
  62. package/docs/progress.md +19 -1
  63. package/docs/provider-button.md +13 -0
  64. package/docs/result.md +3 -0
  65. package/docs/screen-chrome.md +10 -0
  66. package/docs/screen-patterns.md +378 -0
  67. package/docs/sheet.md +21 -0
  68. package/docs/splitter.md +8 -2
  69. package/docs/theming.md +36 -29
  70. package/docs/toggle-group.md +13 -0
  71. package/docs/tour.md +7 -1
  72. package/docs/tree.md +5 -2
  73. package/docs/upload-item.md +7 -0
  74. package/docs/usage/README.md +236 -0
  75. package/docs/usage/STANDARD.md +108 -0
  76. package/docs/usage/components/accordion.md +107 -0
  77. package/docs/usage/components/activity-heatmap.md +104 -0
  78. package/docs/usage/components/affix.md +86 -0
  79. package/docs/usage/components/agreement.md +129 -0
  80. package/docs/usage/components/alert-dialog.md +130 -0
  81. package/docs/usage/components/anchor.md +96 -0
  82. package/docs/usage/components/aspect-ratio.md +89 -0
  83. package/docs/usage/components/asset.md +126 -0
  84. package/docs/usage/components/auth-provider-button.md +116 -0
  85. package/docs/usage/components/auth-screen-layout.md +129 -0
  86. package/docs/usage/components/avatar.md +114 -0
  87. package/docs/usage/components/badge.md +84 -0
  88. package/docs/usage/components/bottom-cta.md +125 -0
  89. package/docs/usage/components/bottom-info.md +99 -0
  90. package/docs/usage/components/bottom-navigation.md +136 -0
  91. package/docs/usage/components/breadcrumb.md +81 -0
  92. package/docs/usage/components/button.md +118 -0
  93. package/docs/usage/components/calendar.md +122 -0
  94. package/docs/usage/components/card.md +110 -0
  95. package/docs/usage/components/carousel.md +113 -0
  96. package/docs/usage/components/celebration.md +96 -0
  97. package/docs/usage/components/chat-message.md +122 -0
  98. package/docs/usage/components/chat-screen.md +112 -0
  99. package/docs/usage/components/checkbox-group.md +104 -0
  100. package/docs/usage/components/checkbox.md +103 -0
  101. package/docs/usage/components/chip.md +107 -0
  102. package/docs/usage/components/code-block.md +111 -0
  103. package/docs/usage/components/collapsible.md +112 -0
  104. package/docs/usage/components/color-picker.md +86 -0
  105. package/docs/usage/components/combobox.md +137 -0
  106. package/docs/usage/components/command-palette.md +125 -0
  107. package/docs/usage/components/comment-thread-screen.md +125 -0
  108. package/docs/usage/components/container.md +98 -0
  109. package/docs/usage/components/content-transition.md +101 -0
  110. package/docs/usage/components/context-menu.md +136 -0
  111. package/docs/usage/components/counter-badge.md +107 -0
  112. package/docs/usage/components/data-table.md +122 -0
  113. package/docs/usage/components/date-picker.md +142 -0
  114. package/docs/usage/components/date-range-picker.md +111 -0
  115. package/docs/usage/components/description-list.md +103 -0
  116. package/docs/usage/components/design-system-provider.md +124 -0
  117. package/docs/usage/components/dialog.md +176 -0
  118. package/docs/usage/components/divider.md +89 -0
  119. package/docs/usage/components/editor-screen.md +126 -0
  120. package/docs/usage/components/effect-surface.md +120 -0
  121. package/docs/usage/components/empty-state.md +114 -0
  122. package/docs/usage/components/field.md +129 -0
  123. package/docs/usage/components/file-picker.md +114 -0
  124. package/docs/usage/components/floating-action-button.md +138 -0
  125. package/docs/usage/components/form.md +162 -0
  126. package/docs/usage/components/grid.md +99 -0
  127. package/docs/usage/components/heading.md +87 -0
  128. package/docs/usage/components/icon-button.md +126 -0
  129. package/docs/usage/components/icon.md +105 -0
  130. package/docs/usage/components/image.md +122 -0
  131. package/docs/usage/components/keyboard-avoiding.md +93 -0
  132. package/docs/usage/components/keyboard-dock.md +110 -0
  133. package/docs/usage/components/keyboard-form-scroll-view.md +95 -0
  134. package/docs/usage/components/keyboard-motion-provider.md +86 -0
  135. package/docs/usage/components/layout.md +117 -0
  136. package/docs/usage/components/link.md +121 -0
  137. package/docs/usage/components/list-detail-screen.md +103 -0
  138. package/docs/usage/components/list-row.md +124 -0
  139. package/docs/usage/components/list.md +119 -0
  140. package/docs/usage/components/load-more.md +115 -0
  141. package/docs/usage/components/masonry.md +109 -0
  142. package/docs/usage/components/media-selection-screen.md +119 -0
  143. package/docs/usage/components/mentions.md +119 -0
  144. package/docs/usage/components/menu.md +129 -0
  145. package/docs/usage/components/menubar.md +93 -0
  146. package/docs/usage/components/message-composer.md +124 -0
  147. package/docs/usage/components/moderation-screen.md +113 -0
  148. package/docs/usage/components/notice.md +106 -0
  149. package/docs/usage/components/notification-inbox-screen.md +97 -0
  150. package/docs/usage/components/notification-item.md +98 -0
  151. package/docs/usage/components/number-field.md +131 -0
  152. package/docs/usage/components/onboarding-screen.md +106 -0
  153. package/docs/usage/components/otp-field.md +101 -0
  154. package/docs/usage/components/pagination.md +82 -0
  155. package/docs/usage/components/password-field.md +137 -0
  156. package/docs/usage/components/permission-screen.md +107 -0
  157. package/docs/usage/components/photo-source-sheet.md +119 -0
  158. package/docs/usage/components/popover.md +108 -0
  159. package/docs/usage/components/profile-screen.md +89 -0
  160. package/docs/usage/components/progress.md +122 -0
  161. package/docs/usage/components/qr-code.md +122 -0
  162. package/docs/usage/components/radio-group.md +124 -0
  163. package/docs/usage/components/radio.md +104 -0
  164. package/docs/usage/components/result.md +116 -0
  165. package/docs/usage/components/saved-items-screen.md +126 -0
  166. package/docs/usage/components/screen-layout.md +119 -0
  167. package/docs/usage/components/search-field.md +120 -0
  168. package/docs/usage/components/search-screen.md +221 -0
  169. package/docs/usage/components/section.md +111 -0
  170. package/docs/usage/components/segmented-control.md +146 -0
  171. package/docs/usage/components/select.md +142 -0
  172. package/docs/usage/components/settings-screen.md +126 -0
  173. package/docs/usage/components/shared-transition-element.md +111 -0
  174. package/docs/usage/components/shared-transition-screen.md +86 -0
  175. package/docs/usage/components/sheet.md +157 -0
  176. package/docs/usage/components/side-panel.md +104 -0
  177. package/docs/usage/components/sidebar.md +107 -0
  178. package/docs/usage/components/skeleton.md +105 -0
  179. package/docs/usage/components/skip-nav.md +76 -0
  180. package/docs/usage/components/slider.md +121 -0
  181. package/docs/usage/components/sortable-collection.md +127 -0
  182. package/docs/usage/components/spinner.md +86 -0
  183. package/docs/usage/components/splitter.md +103 -0
  184. package/docs/usage/components/stack.md +93 -0
  185. package/docs/usage/components/statistic.md +123 -0
  186. package/docs/usage/components/steps.md +110 -0
  187. package/docs/usage/components/surface.md +91 -0
  188. package/docs/usage/components/swipe-actions.md +124 -0
  189. package/docs/usage/components/switch.md +120 -0
  190. package/docs/usage/components/tabs.md +134 -0
  191. package/docs/usage/components/tag.md +84 -0
  192. package/docs/usage/components/tags-input.md +111 -0
  193. package/docs/usage/components/text-area.md +112 -0
  194. package/docs/usage/components/text-format.md +75 -0
  195. package/docs/usage/components/text-transition.md +104 -0
  196. package/docs/usage/components/text.md +101 -0
  197. package/docs/usage/components/thinking-orb.md +105 -0
  198. package/docs/usage/components/timeline.md +105 -0
  199. package/docs/usage/components/toast.md +145 -0
  200. package/docs/usage/components/toggle-group.md +95 -0
  201. package/docs/usage/components/tooltip.md +103 -0
  202. package/docs/usage/components/top-bar.md +124 -0
  203. package/docs/usage/components/top.md +89 -0
  204. package/docs/usage/components/tour.md +118 -0
  205. package/docs/usage/components/transfer-list.md +115 -0
  206. package/docs/usage/components/tree.md +91 -0
  207. package/docs/usage/components/upload-item.md +99 -0
  208. package/docs/usage/components/virtual-list.md +105 -0
  209. package/docs/usage/components/visually-hidden.md +72 -0
  210. package/docs/usage/components/watermark.md +78 -0
  211. package/docs/usage/compositions/action-recovery-optimistic.md +180 -0
  212. package/docs/usage/compositions/action-recovery-save.md +235 -0
  213. package/docs/usage/compositions/action-recovery-undo.md +193 -0
  214. package/docs/usage/compositions/common-message.md +132 -0
  215. package/docs/usage/compositions/common-notification.md +101 -0
  216. package/docs/usage/compositions/compound-controls.md +186 -0
  217. package/docs/usage/compositions/data-layouts.md +157 -0
  218. package/docs/usage/compositions/disclosure.md +144 -0
  219. package/docs/usage/compositions/environment-matrix.md +139 -0
  220. package/docs/usage/compositions/expo-interactions.md +149 -0
  221. package/docs/usage/compositions/family-drawer.md +201 -0
  222. package/docs/usage/compositions/floating-action-button.md +197 -0
  223. package/docs/usage/compositions/input-sheet.md +148 -0
  224. package/docs/usage/compositions/interaction-adapters.md +190 -0
  225. package/docs/usage/compositions/interaction-flow-apply.md +205 -0
  226. package/docs/usage/compositions/interaction-flow-draft.md +188 -0
  227. package/docs/usage/compositions/interaction-flow-search.md +171 -0
  228. package/docs/usage/compositions/native-renderers.md +106 -0
  229. package/docs/usage/compositions/navigation-bar-collection.md +164 -0
  230. package/docs/usage/compositions/optional-adapters.md +169 -0
  231. package/docs/usage/compositions/optional-motion.md +109 -0
  232. package/docs/usage/compositions/photo-source.md +104 -0
  233. package/docs/usage/compositions/purpose-input-comment.md +110 -0
  234. package/docs/usage/compositions/purpose-input-message.md +119 -0
  235. package/docs/usage/compositions/reference-first.md +96 -0
  236. package/docs/usage/compositions/reference-review.md +107 -0
  237. package/docs/usage/compositions/reference-settings.md +107 -0
  238. package/docs/usage/compositions/selection-scope.md +174 -0
  239. package/docs/usage/compositions/stea-event-ticket.md +166 -0
  240. package/docs/usage/compositions/stea-flip-card.md +162 -0
  241. package/docs/usage/compositions/stea-order-progress.md +184 -0
  242. package/docs/usage/compositions/stea-otp-verify.md +215 -0
  243. package/docs/usage/compositions/stea-pixel-empty.md +140 -0
  244. package/docs/usage/compositions/stea-schedule-card.md +169 -0
  245. package/docs/usage/compositions/stea-stat-summary.md +154 -0
  246. package/docs/usage/compositions/time-selection.md +174 -0
  247. package/docs/usage/compositions/toast-layout.md +128 -0
  248. package/docs/usage/compositions/visual-foundations.md +185 -0
  249. package/docs/usage/compositions/web-additions.md +146 -0
  250. package/docs/usage/compositions/web-navigation.md +143 -0
  251. package/docs/usage/screens/common-chat.md +127 -0
  252. package/docs/usage/screens/common-comments.md +108 -0
  253. package/docs/usage/screens/common-inbox.md +110 -0
  254. package/docs/usage/screens/common-login.md +98 -0
  255. package/docs/usage/screens/common-profile.md +221 -0
  256. package/docs/usage/screens/common-saved.md +127 -0
  257. package/docs/usage/screens/common-search.md +279 -0
  258. package/docs/usage/screens/common-settings.md +126 -0
  259. package/docs/usage/screens/common-shell.md +108 -0
  260. package/docs/usage/screens/dashboard.md +245 -0
  261. package/docs/usage/screens/discovery-gallery.md +306 -0
  262. package/docs/usage/screens/flow-collection.md +96 -0
  263. package/docs/usage/screens/flow-editor.md +120 -0
  264. package/docs/usage/screens/flow-media.md +111 -0
  265. package/docs/usage/screens/flow-moderation.md +120 -0
  266. package/docs/usage/screens/flow-onboarding.md +193 -0
  267. package/docs/usage/screens/flow-permission.md +103 -0
  268. package/docs/usage/screens/landing.md +347 -0
  269. package/docs/usage/screens/mockup-studio.md +190 -0
  270. package/docs/usage/screens/notification-settings.md +206 -0
  271. package/docs/usage/screens/reference-comparison.md +159 -0
  272. package/docs/usage/templates/component.md +61 -0
  273. package/docs/usage/templates/composition.md +47 -0
  274. package/docs/usage/templates/screen.md +56 -0
  275. package/docs/usage/templates/token.md +32 -0
  276. package/docs/usage/tokens/color.md +142 -0
  277. package/docs/usage/tokens/elevation-opacity.md +86 -0
  278. package/docs/usage/tokens/layers.md +98 -0
  279. package/docs/usage/tokens/layout.md +114 -0
  280. package/docs/usage/tokens/motion.md +88 -0
  281. package/docs/usage/tokens/radius.md +53 -0
  282. package/docs/usage/tokens/size.md +74 -0
  283. package/docs/usage/tokens/spacing.md +73 -0
  284. package/docs/usage/tokens/stroke.md +50 -0
  285. package/docs/usage/tokens/theme-studio.md +70 -0
  286. package/docs/usage/tokens/typography-studio.md +70 -0
  287. package/docs/usage/tokens/typography.md +89 -0
  288. package/package.json +7 -1
@@ -0,0 +1,115 @@
1
+ # LoadMore
2
+
3
+ - 단계: 컴포넌트
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: [LoadMore](../../load-more.md), `src/component-recipes.ts`(`loadMoreRecipe`), `src/load-more.ts`(상태·controller)
9
+ - 스토리북: `배포/컴포넌트/탐색/더 보기`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 이미 그린 항목을 그대로 둔 채 목록 끝에서 다음 페이지를 요청하는 footer에 쓴다. 피드, 댓글, 검색 결과처럼
14
+ 끝까지 이어 읽는 목록이다. LoadMore는 데이터·cursor를 갖지 않고 footer 상태 표시와 같은 cursor의
15
+ 중복 요청 방지만 맡는다.
16
+
17
+ - 목록 본체(한 번에 그림): [List](list.md) + [ListRow](list-row.md)
18
+ - 목록 본체(고정 높이 행이 매우 많음): [VirtualList](virtual-list.md)
19
+ - 목록 아래 다음 페이지 footer: `LoadMore`
20
+
21
+ ## 쓰지 않을 때
22
+
23
+ | 상황 | 대신 쓸 것 |
24
+ | --- | --- |
25
+ | 페이지 번호로 이동 | [Pagination](pagination.md) |
26
+ | 처음 화면을 채우는 로딩 | [Skeleton](skeleton.md), [ListRow](list-row.md) `loading`(Web) |
27
+ | 목록이 비었음 | [EmptyState](empty-state.md) |
28
+
29
+ ## 공개 이름과 import
30
+
31
+ | 이름 | 역할 | Web | Native |
32
+ | --- | --- | --- | --- |
33
+ | `LoadMore` | 기본 | `@hjmds/react`, `/navigation` | `@hjmds/react-native`, `/navigation`, `/top-bar` |
34
+
35
+ ## 최소 사용 예
36
+
37
+ ```tsx
38
+ // Web — 화면에 보이면 자동 요청(IntersectionObserver)
39
+ import { LoadMore } from "@hjmds/react/navigation";
40
+ <LoadMore
41
+ descriptor={{ state: footerState, labels: {
42
+ loadMore: t("feed.more"), loading: t("common.loading"),
43
+ retry: t("common.retry"), complete: t("feed.end") } }}
44
+ onLoadMore={({ requestKey }) => fetchNextPage(requestKey)} // query가 끝날 때 settle하는 Promise
45
+ />
46
+ ```
47
+
48
+ ```tsx
49
+ // Native — FlatList의 끝 도달을 ref로 연결
50
+ import { useRef } from "react";
51
+ import { FlatList } from "react-native";
52
+ import { LoadMore, type LoadMoreHandle } from "@hjmds/react-native/navigation";
53
+ const loadMore = useRef<LoadMoreHandle>(null);
54
+ <FlatList data={items} renderItem={renderRow}
55
+ onEndReached={() => { void loadMore.current?.onEndReached(); }}
56
+ ListFooterComponent={<LoadMore ref={loadMore} descriptor={footer} onLoadMore={fetchNext} />} />
57
+ ```
58
+
59
+ ## 축과 기본값
60
+
61
+ 표의 prop은 Web·Native 공통이다(`(Web)` 표시만 Web 전용). 타입은 `@hjmds/design-contracts/components/load-more`에서 가져온다.
62
+
63
+ | prop | 값 | 기본값 | 설명 |
64
+ | --- | --- | --- | --- |
65
+ | `descriptor.state` | `LoadMoreState`: `{ status: "ready", requestKey }` · `{ status: "loading", requestKey }` · `{ status: "error", requestKey, message }` · `{ status: "complete" }` | 필수 | `requestKey`는 제품이 cursor·offset으로 만든 안정된 문자열. `message`는 현지화된 오류 문구 |
66
+ | `descriptor.labels` | `{ loadMore, loading, retry, complete }`(모두 `string`) | 필수 | 네 문구 모두 필수. renderer는 영어 대체 문구를 만들지 않는다 |
67
+ | `onLoadMore` | `(request: { requestKey: string; reason: "viewport" \| "manual" \| "retry" }) => Promise<void>` | 필수 | 실제 요청이 끝날 때 settle해야 같은 `requestKey`의 중복 요청을 막는다 |
68
+ | `mode` | `automatic` · `manual` | `automatic` | `automatic`은 화면 도달로도 요청하고 ready 상태엔 수동 버튼도 함께 나온다. `manual`은 버튼만 |
69
+ | `density` | `regular` · `compact` | `regular` | 위아래 여백·간격(아래 배치) |
70
+ | `onRequestOutcome` | `(outcome: "started" \| "blocked-by-mode" \| "blocked-by-state" \| "already-requesting", reason) => void` | — | 요청 시도 결과 관찰(분석·디버그용) |
71
+ | `onRequestError` | `(error: unknown, reason) => void` | — | `onLoadMore`가 reject했을 때. 화면 상태는 여전히 제품이 `error`로 바꾼다 |
72
+ | `rootMargin`(Web) | 문자열 | `"200px 0px"` | 자동 요청 감지 여백 |
73
+ | `intersectionRoot`(Web) | `Element \| Document \| null` | viewport | 자동 요청 감지 기준 |
74
+ | `ref` | Web `HTMLDivElement` · Native `LoadMoreHandle` `{ onEndReached(): Promise<outcome> }` | — | Native는 FlatList `onEndReached`에서 `ref.current?.onEndReached()`를 부른다 |
75
+ | `layoutStyle` | 배치 전용 style 객체 | — | 바깥 여백·폭 같은 배치만. 시각 값은 받지 않는다 |
76
+
77
+ ## 배치
78
+
79
+ | 항목 | 값 | 근거 |
80
+ | --- | --- | --- |
81
+ | 크기 | 더 보기·다시 시도 버튼은 최소 높이 `control.minTouchTarget` 44, 좌우 안쪽 `spacing.md` 16, radius `radius.md` 12의 텍스트 버튼. 끝 문구는 `caption` | `loadMoreRecipe.trigger`, `.hjm-load-more__trigger` |
82
+ | 간격 | 위아래 여백·줄 간격: `regular` `spacing.lg` 20 · `spacing.sm` 12, `compact` `spacing.sm` 12 · `spacing.xs` 8 | `loadMoreRecipe.density` |
83
+ | 순서·정렬 | 목록 마지막 항목 바로 아래, 한 목록에 하나. 내용은 가로 가운데 정렬한 한 열. 오류일 때 Web은 문구와 다시 시도를 한 줄에 두고(모자라면 줄바꿈), Native는 문구 아래에 다시 시도를 쌓는다 | `.hjm-load-more`, `.hjm-load-more__error`, Native `LoadMore` |
84
+ | 고정·스크롤 | 목록과 같은 스크롤 영역 안에 둔다. 화면 하단에 고정하지 않는다. Native는 FlatList `ListFooterComponent`, Web은 목록 요소 다음 형제 | — |
85
+ | 좁은 폭·큰 글자 | Web 오류 줄은 `flex-wrap`으로 줄을 바꾼다. 버튼은 최소 높이만 있어 큰 글자에서 높이가 늘어난다 | `.hjm-load-more__status`, `.hjm-load-more__error` |
86
+
87
+ ```text
88
+ ┌─ 스크롤 영역 ─────────────────┐
89
+ │ [ListRow] │
90
+ │ [ListRow] │
91
+ │ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │ ← padding spacing.lg 20
92
+ │ [ 더 보기 ] │ ← ready: 높이 ≥ 44
93
+ │ 오류 문구 [ 다시 시도 ] │ ← error(Web 한 줄, Native 두 줄)
94
+ │ t("feed.end") │ ← complete: caption
95
+ └───────────────────────────────┘
96
+ ```
97
+
98
+ ## 꼭 지킬 것
99
+
100
+ - `onLoadMore`는 실제 query가 끝날 때 settle되는 Promise를 반환한다. `void fetchNextPage()`처럼 바로 끝나면 같은 cursor가 두 번 돈다.
101
+ - 상태는 제품이 바꾼다. 요청 성공 뒤 다음 `requestKey`의 `ready` 또는 `complete`, 실패 뒤 `error`(현지화 message)를 넘긴다.
102
+ - 오류가 나도 이미 그린 항목을 숨기지 않는다.
103
+ - 로딩 중에는 스피너만 보이고 문구는 접근성 이름으로만 쓴다. 옆에 문구를 따로 붙이지 않는다.
104
+
105
+ ## 플랫폼 차이
106
+
107
+ | 항목 | Web | Native |
108
+ | --- | --- | --- |
109
+ | 자동 감지 | 내부 sentinel + IntersectionObserver | 제품 FlatList `onEndReached` → `ref.onEndReached()` |
110
+ | 배치 | `className`, `layoutStyle`(HTML `style`도 받음) | `layoutStyle` |
111
+
112
+ ## 함정
113
+
114
+ - [VirtualList](virtual-list.md)는 끝 도달 callback을 노출하지 않는다. Native에서 VirtualList 아래 자동 요청은 연결할 수 없으니
115
+ `mode="manual"`로 두거나 제품 FlatList를 쓴다.
@@ -0,0 +1,109 @@
1
+ # Masonry
2
+
3
+ - 단계: 컴포넌트
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: [Masonry](../../masonry.md), `src/masonry.ts`(`resolveMasonryLayout`, `masonryRecipe`)
9
+ - 스토리북: `배포/컴포넌트/레이아웃/높이가 다른 카드 배치`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 높이가 서로 다른 카드(사진 피드, 핀보드, 갤러리)를 여러 열에 빈틈없이 쌓을 때 쓴다. 각 항목 높이를
14
+ 제품이 계산해 넘기면 가장 짧은 열에 입력 순서대로 놓는다. 읽기 순서는 입력 순서 그대로다.
15
+
16
+ ## 쓰지 않을 때
17
+
18
+ | 상황 | 대신 쓸 것 |
19
+ | --- | --- |
20
+ | 칸 높이가 같은 격자 | [Grid](grid.md) |
21
+ | 한 줄씩 쌓는 목록 | [List](list.md), [ListRow](list-row.md) |
22
+ | 고정 높이 행이 매우 많음 | [VirtualList](virtual-list.md) (Masonry는 가상화하지 않는다) |
23
+ | 사진을 넘겨 보기 | [Carousel](carousel.md) |
24
+
25
+ ## 공개 이름과 import
26
+
27
+ | 이름 | 역할 | Web | Native |
28
+ | --- | --- | --- | --- |
29
+ | `Masonry` | 기본 | `/masonry` | `/masonry` |
30
+
31
+ 루트 entry에는 없다. 추가 peer는 없다.
32
+
33
+ ## 최소 사용 예
34
+
35
+ ```tsx
36
+ // Web
37
+ import { Masonry } from "@hjmds/react/masonry";
38
+ import { EmptyState } from "@hjmds/react/feedback";
39
+
40
+ <Masonry label={t("gallery.title")} items={photos} keyExtractor={(p) => p.id}
41
+ width={containerWidth} columns={3}
42
+ getItemHeight={(p, itemWidth) => itemWidth * (p.height / p.width)}
43
+ renderItem={(p) => <PhotoCard photo={p} />}
44
+ empty={<EmptyState title={t("gallery.empty.title")} />} />
45
+ ```
46
+
47
+ ```tsx
48
+ // Native
49
+ import { Masonry } from "@hjmds/react-native/masonry";
50
+
51
+ <Masonry label={t("gallery.title")} items={photos} keyExtractor={(p) => p.id}
52
+ width={layoutWidth} getItemHeight={(p, itemWidth) => itemWidth * (p.height / p.width)}
53
+ renderItem={(p) => <PhotoCard photo={p} />} />
54
+ ```
55
+
56
+ ## 축과 기본값
57
+
58
+ 표의 prop은 Web·Native 공통이다.
59
+
60
+ | prop | 값 | 기본값 | 설명 |
61
+ | --- | --- | --- | --- |
62
+ | `items` | `readonly T[]` | 필수 | 입력 순서가 읽기·포커스 순서다 |
63
+ | `keyExtractor` | `(item: T) => string` | 필수 | 유일한 키. 중복이면 `TypeError` |
64
+ | `renderItem` | `(item: T, index: number) => ReactNode` | 필수 | 열 폭은 넘기지 않는다. 항목 래퍼가 `getItemHeight` 높이·열 폭으로 고정돼 있어 Web은 일반 레이아웃 div에 `height: "100%", display: "flex"`를 주고 그 안의 Surface에는 `layoutStyle={{ flex: 1 }}`을 준다. Native Surface는 `layoutStyle={{ flex: 1 }}`로 채운다. HJM layoutStyle에 height를 넣지 않는다 |
65
+ | `getItemHeight` | `(item: T, itemWidth: number) => number` | 필수 | 그 열 폭에서 항목의 확정 높이(양의 유한수). 루트 높이는 가장 긴 열로 정해진다 |
66
+ | `width` | 숫자(> 0) | 필수 | 컨테이너 폭. 측정은 소비 화면이 한다(Web ResizeObserver, Native `onLayout` 등) |
67
+ | `columns` | 1~12 정수 | 2 | 열 수 |
68
+ | `gap` | 0 이상 숫자 | 12 | 가로·세로 간격 |
69
+ | `label` | 문자열 | 필수 | 현지화 |
70
+ | `empty` | 노드 | — | 항목이 없을 때 보일 내용 |
71
+ | `layoutStyle` | 배치 전용 style 객체 | — | 루트 바깥 배치만. 측정한 `width`·높이가 우선한다 |
72
+
73
+ ## 배치
74
+
75
+ | 항목 | 값 | 근거 |
76
+ | --- | --- | --- |
77
+ | 크기 | 열 폭 = (`width` − `gap` × (`columns` − 1)) ÷ `columns`. 루트 높이는 가장 긴 열의 끝. `columns` 1~12 | `resolveMasonryLayout`, `masonryRecipe.maxColumns` |
78
+ | 간격 | `gap` 기본 12(`spacing.sm`과 같은 값), 가로·세로 같은 값. `width`는 화면 좌우 여백(`layout.pagePadding` compact 16 · regular 20 · spacious 24)을 뺀 본문 폭 | `src/masonry.ts`, `layout.pagePadding` |
79
+ | 순서·정렬 | 원본 순서대로 가장 짧은 열에 채운다. 읽기·포커스 순서는 열이 아니라 원본 순서. RTL은 오른쪽 열부터 | `masonryRecipe.readingOrder`, `insetInlineStart`(Web)·`right`(Native) |
80
+ | 고정·스크롤 | 본문 스크롤 영역 안에 놓는다. Masonry는 스크롤을 갖지 않는다 | `src/masonry.tsx`(Web·Native) |
81
+ | 좁은 폭·큰 글자 | 열 수는 제품이 폭으로 정한다(좁은 폭 2열, `breakpoint.medium` 600 이상에서 늘림). 큰 글자로 카드 높이가 바뀌면 `getItemHeight`를 다시 계산하고, 폭이 바뀌면 `width`를 다시 넘긴다 | `breakpoint` |
82
+
83
+ ## 꼭 지킬 것
84
+
85
+ - 높이는 추정하지 않고 확정 값을 준다. 긴 문구·큰 글자로 카드 높이가 바뀌면 `getItemHeight`도 다시 계산한다.
86
+ 모자라게 주면 항목이 겹친다(절대 위치 배치).
87
+ - `keyExtractor`는 유일한 키를 돌려준다. 중복 키는 `TypeError`다.
88
+ - `width`가 0 이하이거나 열 폭이 0 이하, `getItemHeight`가 양의 유한수가 아니면 `TypeError`가 난다. 항목이 비어
89
+ `empty`만 그릴 때도 기하 검사를 먼저 하므로 같다. 측정 전(폭 0)에는 Masonry를 렌더하지 않는다.
90
+ - 화면 폭이 바뀌면 `width`를 다시 넘긴다.
91
+
92
+ ## 플랫폼 차이
93
+
94
+ | 항목 | Web | Native |
95
+ | --- | --- | --- |
96
+ | 목록 의미 | `role="list"`·`listitem` | `accessibilityLabel`만(목록 role 없음) |
97
+ | RTL | `insetInlineStart`로 자동 반전 | provider direction이 rtl이면 `right` 기준 |
98
+ | 루트 배치 | `layoutStyle`(측정 `width`·높이가 우선) | `layoutStyle`(측정 `width`·높이가 우선) |
99
+
100
+ ## 함정
101
+
102
+ - 모든 항목을 한 번에 그린다. 항목이 많은 무한 피드에서는 렌더 비용이 쌓인다.
103
+ - 항목이 없으면 `empty`만 그리고 목록 role이 붙지 않는다. Web 빈 분기는 `role="group"` + `label` 이름이다(미게시(1.12.1 이후).
104
+ 1.12.1은 role 없는 `<div aria-label>`이라 이름이 노출되지 않았다). 빈 상태 문구는 `empty` 안에 보이는 글로 둔다.
105
+ - 다음 페이지는 목록 다음 형제로 [LoadMore](load-more.md)를 둔다. Native에서 FlatList 밖(ScrollView 안 Masonry)이면
106
+ `onEndReached`가 없으므로 `mode="manual"`로 둔다. Web `automatic`은 sentinel로 동작하지만 비가상화라 항목이
107
+ 누적되는 비용을 감안해 제품이 고른다.
108
+ - 첫 로딩 자리를 Masonry + aria-hidden Skeleton으로 채우면 Web은 "빈 항목 목록"으로 읽힌다. 로딩 동안은 Masonry 대신
109
+ [Skeleton](skeleton.md)의 영역 단위 알림을 쓴다.
@@ -0,0 +1,119 @@
1
+ # MediaSelectionScreen
2
+
3
+ - 단계: 컴포넌트
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 미게시(1.12.1 이후)
7
+ - 검토일: 2026-10-06
8
+ - 근거: [반복 화면 조합](../../screen-patterns.md), Web·Native `src/screens.tsx`·`src/screen-flows.tsx`; 기존 개별 지침을 새 규격으로 통합. 예제 스토리는 2026-10-06 사용자 승인으로 스토리북 배포([승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정)). 스토리북 배포는 API 게시가 아니다(`적용` 참고)
9
+ - 스토리북: `배포/화면/콘텐츠/사진 선택과 업로드`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 고른 사진·영상을 큰 썸네일 격자로 보여 주고, 각 항목의 업로드 상태·재시도·취소·순서 이동·삭제와
14
+ "추가"·"완료" 행동을 한 화면에 묶을 때 쓴다. 제품이 사진 라이브러리 picker를 직접 그리려면 `library` 슬롯을 쓴다.
15
+ 실제 picker 실행·권한·이미지 URI 수명·업로드는 제품이 소유한다.
16
+
17
+ ## 쓰지 않을 때
18
+
19
+ | 상황 | 대신 쓸 것 |
20
+ | --- | --- |
21
+ | 앨범·촬영 중 고르는 첫 단계 | [PhotoSourceSheet](photo-source-sheet.md) |
22
+ | 파일 선택 컨트롤 하나 | [FilePicker](file-picker.md) |
23
+ | 업로드 한 건의 상태 행 | [UploadItem](upload-item.md) |
24
+ | 사진 권한 요청·거부 안내 | [PermissionScreen](permission-screen.md) |
25
+ | 높이가 다른 사진 피드 보기 | [Masonry](masonry.md) |
26
+ | 끌어서 순서 바꾸기 | [SortableCollection](sortable-collection.md) (이 화면은 위·아래 버튼으로 옮긴다) |
27
+
28
+ ## 공개 이름과 import
29
+
30
+ | 이름 | 역할 | Web | Native |
31
+ | --- | --- | --- | --- |
32
+ | `MediaSelectionScreen` | 미디어 선택 검토 화면 | `/screen-flows` | `/screen-flows` |
33
+
34
+ granular subpath로만 import 된다(루트 entry에 없음). 추가 peer는 없다.
35
+
36
+ ## 최소 사용 예
37
+
38
+ ```tsx
39
+ // Web
40
+ import { MediaSelectionScreen } from "@hjmds/react/screen-flows";
41
+
42
+ <MediaSelectionScreen
43
+ title={t("media.title")}
44
+ items={picked.map((p) => ({ descriptor: { id: p.id, name: p.fileName, state: p.upload },
45
+ preview: <img src={p.uri} alt="" width={p.width} height={p.height} /> }))}
46
+ add={{ label: t("media.add"), onAction: openPicker }}
47
+ done={{ label: t("media.done"), onAction: submit, disabled: uploading }}
48
+ labels={{ pending: t("upload.pending"), uploading: t("upload.uploading"),
49
+ success: t("upload.success"), cancel: t("upload.cancel"), retry: t("upload.retry") }}
50
+ actionLabels={{ remove: t("media.remove"), moveUp: t("media.moveUp"), moveDown: t("media.moveDown") }}
51
+ removeLabel={(item) => t("media.removeNamed", { name: item.descriptor.name })}
52
+ moveUpLabel={(item) => t("media.moveUpNamed", { name: item.descriptor.name })}
53
+ moveDownLabel={(item) => t("media.moveDownNamed", { name: item.descriptor.name })}
54
+ onRemove={remove} onMove={(id, direction) => move(id, direction)}
55
+ onRetry={retryUpload} onCancel={cancelUpload}
56
+ />
57
+ ```
58
+
59
+ ```tsx
60
+ // Native
61
+ import { MediaSelectionScreen } from "@hjmds/react-native/screen-flows";
62
+ import { Image } from "react-native";
63
+
64
+ <MediaSelectionScreen
65
+ title={t("media.title")}
66
+ items={picked.map((p) => ({ descriptor: { id: p.id, name: p.fileName, state: p.upload },
67
+ preview: <Image src={p.uri} width={p.width} height={p.height} /> }))}
68
+ add={{ label: t("media.add"), onAction: openPicker }}
69
+ done={{ label: t("media.done"), onAction: submit, disabled: uploading }}
70
+ labels={{ pending: t("upload.pending"), uploading: t("upload.uploading"),
71
+ success: t("upload.success"), cancel: t("upload.cancel"), retry: t("upload.retry") }}
72
+ actionLabels={{ remove: t("media.remove"), moveUp: t("media.moveUp"), moveDown: t("media.moveDown") }}
73
+ removeLabel={(item) => t("media.removeNamed", { name: item.descriptor.name })}
74
+ moveUpLabel={(item) => t("media.moveUpNamed", { name: item.descriptor.name })}
75
+ moveDownLabel={(item) => t("media.moveDownNamed", { name: item.descriptor.name })}
76
+ onRemove={remove} onMove={(id, direction) => move(id, direction)}
77
+ onRetry={retryUpload} onCancel={cancelUpload}
78
+ />
79
+ ```
80
+
81
+ Web의 `<img alt="">`는 장식용 미리보기다. 사진 이름은 `removeLabel` 등 접근성 이름과 UploadItem 행이 읽는다.
82
+
83
+ ## 축과 기본값
84
+
85
+ | prop | 값 | 기본값 | 설명 |
86
+ | --- | --- | --- | --- |
87
+ | `items` | `readonly { descriptor: UploadItemDescriptor; preview?: ReactNode }[]` | 필수 | descriptor는 `id`·`name`·선택 `sizeLabel`·`state`(`pending` · `uploading` · `success` · `error`) |
88
+ | `library` | `ReactNode` | 없음 | 주면 `items` 격자 대신 이 슬롯을 그린다 |
89
+ | `selectionSummary` | `ReactNode` | 없음 | footer에서 `done` 위 |
90
+ | `add` | `ScreenFlowAction`(`{ label, onAction(), disabled?, pending? }`) | 필수 | 상단 `actions` 자리의 ghost 버튼 |
91
+ | `done` | `ScreenFlowAction` | 필수 | footer의 primary 버튼 |
92
+ | `labels` | `UploadItemLabels`(`{ pending, uploading, success, cancel, retry }`) | 필수 | 각 항목 UploadItem 문구 |
93
+ | `actionLabels` | `{ remove, moveUp, moveDown }` | 필수 | 항목 아래 버튼의 짧은 표시 문구 |
94
+ | `removeLabel` · `moveUpLabel` · `moveDownLabel` | `(item: MediaSelectionItem) => string` | 필수 | 사진 이름을 포함한 접근성 이름 |
95
+ | `onMove` | `(id: string, direction: -1 \| 1) => void` | 필수 | 첫 항목의 위로·마지막 항목의 아래로는 비활성 |
96
+ | `onRemove` · `onRetry` · `onCancel` | `(id: string) => void` | 필수 | 삭제, UploadItem 재시도·취소 |
97
+ | 나머지 | `ScreenLayout`과 같음(`children`·`footer` 제외) | — | `actions`는 `add`가 덮는다 |
98
+
99
+ ## 배치
100
+
101
+ | 항목 | 값 | 근거 |
102
+ | --- | --- | --- |
103
+ | 크기 | 격자 열 compact(창 0~959) 2열 · expanded(창 960 이상) 3열, 열 폭은 ScreenLayout 폭(최대 720)을 나눈다; 미리보기는 열 폭, 항목 버튼은 `Button size="small"` | `Grid columns={{ compact: 2, expanded: 3 }}`, `breakpoint` |
104
+ | 간격 | 화면 padding `spacing.md` 16; 격자 간격 `spacing.md` 16; 항목 안 미리보기–UploadItem–버튼 줄 `spacing.xs` 8; 버튼 사이 `spacing.xxs` 4; footer 요약–완료 `spacing.sm` 12 | Web·Native `MediaSelectionScreen` |
105
+ | 순서·정렬 | 헤더(제목 → 추가) → 격자(항목마다 미리보기 → UploadItem → 위로·아래로·삭제) → footer(`selectionSummary` → 완료) | 렌더 순서 |
106
+ | 고정·스크롤 | 헤더·footer 고정, 격자는 본문 스크롤(`scroll` 기본 `"screen"`) | `ScreenLayout` |
107
+ | 좁은 폭·큰 글자 | 좁은 폭에서도 2열 유지; 항목 버튼 줄은 줄바꿈(`flexWrap: "wrap"`)해 버튼을 자르지 않는다 | `screen-flows.tsx` |
108
+
109
+ ## 꼭 지킬 것
110
+
111
+ - `actionLabels`는 짧은 표시 문구, `removeLabel`·`moveUpLabel`·`moveDownLabel`은 사진 이름을 포함한 접근성 이름이다.
112
+ 둘 다 현지화한다.
113
+ - preview는 작은 leading 아이콘으로 줄이지 않고 큰 썸네일로 둔다.
114
+ - 업로드 진행·재시도·취소의 실제 동작과 개수·크기 제한, EXIF 처리는 제품이 한다.
115
+
116
+ ## 함정
117
+
118
+ - `actions`를 넘겨도 `add` 버튼이 그 자리를 덮는다. 다른 상단 행동은 `leading`이나 `notice`로 둔다.
119
+ - `library`를 주면 `items` 격자와 업로드 상태 표시는 그려지지 않는다. 선택 결과는 `selectionSummary`로 보여 준다.
@@ -0,0 +1,119 @@
1
+ # Mentions
2
+
3
+ - 단계: 컴포넌트
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: [Mentions](../../mentions.md), 트리거 판정 `src/mentions.ts`
9
+ - 스토리북: `배포/컴포넌트/입력/사용자 언급`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 여러 줄 입력 중 `@`(사람)·`#`(해시태그) 같은 트리거를 치면 후보를 띄우고, 고른 후보를 트리거부터 커서까지
14
+ 자리에 넣고 공백 하나를 붙이는 입력에 쓴다. 댓글·게시글 본문 같은 자유 텍스트다.
15
+ 트리거는 단어의 시작에서만 열리고(`user@example.com`은 열리지 않음), 공백을 치면 닫힌다.
16
+
17
+ ## 쓰지 않을 때
18
+
19
+ | 상황 | 대신 쓸 것 |
20
+ | --- | --- |
21
+ | 입력창 전체가 검색어인 자동완성 | [Combobox](combobox.md) |
22
+ | 태그를 칩으로 하나씩 추가 | [TagsInput](tags-input.md) |
23
+ | 트리거 없는 여러 줄 입력 | [TextArea](text-area.md) |
24
+
25
+ ## 공개 이름과 import
26
+
27
+ | 이름 | 역할 | Web | Native |
28
+ | --- | --- | --- | --- |
29
+ | `Mentions` | 기본 | `@hjmds/react`, `/mentions` | `@hjmds/react-native`, `/mentions` |
30
+
31
+ ## 최소 사용 예
32
+
33
+ ```tsx
34
+ // Web
35
+ import { Mentions } from "@hjmds/react/mentions";
36
+
37
+ <Mentions
38
+ label={t("post.body")}
39
+ value={body}
40
+ onValueChange={setBody}
41
+ triggers={[{ id: "user", trigger: "@" }, { id: "tag", trigger: "#" }]}
42
+ candidates={candidates}
43
+ onMentionQueryChange={(match) => setQuery(match)}
44
+ emptyMessage={t("mention.noMatch")}
45
+ listLabel={t("mention.candidates")}
46
+ />
47
+ ```
48
+
49
+ ```tsx
50
+ // Native
51
+ import { Mentions } from "@hjmds/react-native/mentions";
52
+
53
+ <Mentions label={t("comment.body")} value={body} onValueChange={setBody}
54
+ triggers={[{ id: "user", trigger: "@" }]} candidates={candidates}
55
+ onMentionQueryChange={setQuery} emptyMessage={t("mention.noMatch")} listLabel={t("mention.candidates")} />
56
+ ```
57
+
58
+ ## 축과 기본값
59
+
60
+ 표의 prop은 Web·Native 공통이다. 타입 `MentionMatch`·`MentionTriggerConfig`는 `@hjmds/design-contracts/components/mentions`,
61
+ `MentionCandidate`는 각 `/mentions` entry에서 가져온다.
62
+
63
+ | prop | 값 | 기본값 | 설명 |
64
+ | --- | --- | --- | --- |
65
+ | `value` | 문자열 | 필수 | 제어 값. 비제어(`defaultValue`)는 받지 않는다 |
66
+ | `onValueChange` | `(value: string) => void` | 필수 | 입력·후보 확정 때마다 전체 본문을 넘긴다 |
67
+ | `triggers` | `readonly { id: TriggerId; trigger: string }[]` | 필수 | trigger는 공백 아닌 한 글자이고 글자·id 모두 중복 불가 |
68
+ | `candidates` | `readonly { id, label, insertText?, description? }[]` | 필수 | 넣는 글자는 `insertText`, 없으면 `label` |
69
+ | `onMentionQueryChange` | `(match: { triggerId, trigger, triggerStart, query } \| null) => void` | — | 활성 트리거가 바뀔 때마다(닫힐 때 `null`) 호출. 후보 필터링·로딩은 제품이 한다 |
70
+ | `renderCandidate` | `(candidate: MentionCandidate) => ReactNode` | `label`(Web은 + `description`) | 후보 한 줄의 내용 |
71
+ | `emptyMessage` · `listLabel` | 문자열 | 필수 | 현지화 |
72
+ | `layoutStyle` | 배치 전용 style 객체 | — | Web은 입력+후보 기준 블록 전체, Native는 입력 영역만 배치한다 |
73
+ | 나머지 입력 prop | `label`, `description`, `error` 등 | — | [TextArea](text-area.md)를 따른다 |
74
+
75
+ ## 배치
76
+
77
+ | 항목 | 값 | 근거 |
78
+ | --- | --- | --- |
79
+ | 크기 | 후보 한 줄 최소 높이 44(`control.minTouchTarget`). Web 목록 최대 높이 `14rem`, 입력 폭에 맞춤 | `.hjm-mentions__option`·`__list`, Native `minHeight: 44` |
80
+ | 간격 | 라벨·입력·설명·오류 사이 `spacing.xs` 8. Web 목록은 입력과 8, 화면 가장자리 8, 안쪽 8, radius `radius.md` 12 — 모두 Combobox 목록과 같은 `comboboxRecipe.popover`(sideOffset·collisionPadding·padding `spacing.xs`). Native 목록 안쪽 8(같은 recipe)·항목 사이 4, radius `radius.md` 12. 후보 좌우 여백 8. 2026-10-06까지 Web 가장자리 16·안쪽 4, Native 안쪽 4였다(1.12.1 이후 미게시) | `comboboxRecipe.popover`, `.hjm-mentions__*`, `react-native/src/mentions.tsx` |
81
+ | 순서·정렬 | 후보 목록은 입력 **바로 아래**. Web은 아래 공간이 모자라면 위로 뒤집는다. Web 후보는 `label` 옆에 `description`, 좁으면 줄바꿈 | `useAnchoredPopup`, `.hjm-mentions__option` |
82
+ | 고정·스크롤 | Web 목록은 떠 있는 portal(z-index `layer.dropdown` 400)이고 넘치면 목록 안 스크롤. Native 목록은 문서 흐름 안에 끼어들어 아래 내용을 민다 | `src/mentions.tsx`(Web·Native) |
83
+ | 좁은 폭·큰 글자 | 키보드 위 입력이면 Native 목록이 가려질 수 있으니 [KeyboardFormScrollView](keyboard-form-scroll-view.md) 안에 둔다 | — |
84
+
85
+ ```text
86
+ Web Native
87
+ ┌ 본문 ──────────────────────┐ ┌ 본문 ──────────────────────┐
88
+ │ 라벨 │ │ 라벨 │
89
+ │ ┌─────────────────────────┐ │ │ ┌─────────────────────────┐ │
90
+ │ │ 안녕 @ji| │ │ │ │ 안녕 @ji| │ │
91
+ │ └─────────────────────────┘ │ │ └─────────────────────────┘ │
92
+ │ ┌─────────────────────────┐ │ ← 8 │ ┌─────────────────────────┐ │ ← 흐름 안
93
+ │ │ jimin 설명 │ │ 떠 있음│ │ jimin │ │ (아래 내용이 밀림)
94
+ │ │ jiho │ │ ≤14rem │ │ jiho │ │
95
+ │ └─────────────────────────┘ │ │ └─────────────────────────┘ │
96
+ └─────────────────────────────┘ └─────────────────────────────┘
97
+ ```
98
+
99
+ ## 꼭 지킬 것
100
+
101
+ - `value`/`onValueChange`로 제어한다. 후보를 고르면 새 문자열이 `onValueChange`로 온다.
102
+ - 트리거 문자를 후보 `insertText`에 넣지 않는다. 컴포넌트가 정확히 한 번 붙인다.
103
+ - `triggerId`로 후보 출처(사람·태그)를 나눈다. 후보 조회 경합·취소는 제품이 처리한다.
104
+ - 문구는 모두 i18n 키로 넣는다.
105
+
106
+ ## 플랫폼 차이
107
+
108
+ | 항목 | Web | Native |
109
+ | --- | --- | --- |
110
+ | 후보 표시 | 입력 아래 떠 있는 listbox(portal) | 입력 아래 문서 흐름 안 목록 |
111
+ | 키보드 | ↑↓ 이동, Enter 확정, Esc 닫기(입력은 유지) | 해당 없음(누름으로 확정) |
112
+ | 기본 후보 렌더 | `label` + `description` | `label`만 |
113
+ | 추가 prop | `className`, ref(`HTMLTextAreaElement`). `style`은 안쪽 textarea에 붙는다 | `listStyle`은 deprecated(다음 major 제거, 대체 없음 — 후보 목록 recipe가 외형을 가진다) |
114
+
115
+ ## 함정
116
+
117
+ - 결과는 평문 문자열이다. 어느 후보를 골랐는지(id)는 돌려주지 않으므로 서버에 구조화된 멘션이 필요하면
118
+ 제품이 따로 기록하거나 본문을 파싱한다.
119
+ - Native는 커서를 `onSelectionChange`로 추적하므로 이 prop을 넘겨도 컴포넌트 것으로 덮인다.
@@ -0,0 +1,129 @@
1
+ # Menu
2
+
3
+ - 단계: 컴포넌트
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: [Dropdown 판정](../../dropdown.md), [Popover 경계](../../popover.md), `src/component-recipes.ts`(`menuRecipe`)
9
+ - 스토리북: `배포/컴포넌트/탐색/메뉴`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 트리거 버튼을 누르면 뜨는 **항목 목록**에 쓴다. 더보기(⋯) 행동, 정렬 기준 고르기,
14
+ 보기 옵션 켜고 끄기처럼 안정적인 id를 가진 action·단일 선택·다중 선택 목록이 여기에 속한다.
15
+ 트리거·떠 있는 표면·항목 목록을 Menu 하나가 소유하므로 별도 Dropdown은 없다.
16
+
17
+ ## 쓰지 않을 때
18
+
19
+ | 상황 | 대신 쓸 것 |
20
+ | --- | --- |
21
+ | 목록이 아닌 임의 콘텐츠(작은 폼, 링크 섞인 본문) | [Popover](popover.md) (Web) |
22
+ | 우클릭·키보드 메뉴 키로 포인터 위치에서 여는 메뉴 | [ContextMenu](context-menu.md) |
23
+ | 데스크톱 앱의 파일·편집·보기 가로 막대 | [Menubar](menubar.md) (Web) |
24
+ | 폼 값 하나를 고르는 입력 | [Select](select.md), [Combobox](combobox.md) |
25
+ | 하단에서 올라오는 큰 선택 화면 | [Sheet](sheet.md) |
26
+ | 명령 검색 | [CommandPalette](command-palette.md) |
27
+
28
+ ## 공개 이름과 import
29
+
30
+ | 이름 | 역할 | Web | Native |
31
+ | --- | --- | --- | --- |
32
+ | `Menu` | 기본 | `@hjmds/react`, `/overlays` | `@hjmds/react-native`, `/navigation`, `/top-bar` |
33
+ | `MorphingMenu` | action 전용 morph 표현(optional peer `bloom-menu` 0.1.0 필요) | `/menu-morph` | — |
34
+
35
+ ## 최소 사용 예
36
+
37
+ ```tsx
38
+ // Web
39
+ import { IconButton } from "@hjmds/react/actions";
40
+ import { Menu } from "@hjmds/react/overlays";
41
+
42
+ <Menu
43
+ label={t("post.more")}
44
+ trigger={<IconButton label={t("post.more")}>⋯</IconButton>}
45
+ items={[
46
+ { id: "edit", label: t("post.edit") },
47
+ { id: "delete", label: t("post.delete"), tone: "danger" },
48
+ ]}
49
+ onActionAfterDismiss={(id) => handle(id)}
50
+ />
51
+ ```
52
+
53
+ ```tsx
54
+ // Native
55
+ import { Menu } from "@hjmds/react-native/navigation";
56
+
57
+ <Menu
58
+ triggerLabel={t("post.more")}
59
+ dismissLabel={t("common.close")}
60
+ items={[
61
+ { id: "edit", label: t("post.edit") },
62
+ { id: "delete", label: t("post.delete"), tone: "danger" },
63
+ ]}
64
+ onActionAfterDismiss={(id) => handle(id)}
65
+ />
66
+ ```
67
+
68
+ ## 축과 기본값
69
+
70
+ | prop | 값 | 기본값 | 설명 |
71
+ | --- | --- | --- | --- |
72
+ | `items` · `sections` | 둘 중 정확히 하나(Native는 `source`도 가능) | 필수 | 섹션마다 `label` 또는 `accessibilityLabel`이 필요하다. 활성 항목이 하나도 없으면 `TypeError` |
73
+ | 항목 모양 | Web `{ id, label: ReactNode, textValue?, description?, leading?, trailing?, tone?, disabled? }` · Native `{ id, label: string, textValue?, description?, shortcut?, tone?, disabled? }` | — | Web `label`이 글이 아니면 `textValue` 필수(typeahead) |
74
+ | 항목 `tone` | `neutral` · `danger` | `neutral` | |
75
+ | `onAction` | Web `(id: string) => void` · Native `(value) => void \| Promise<void>` | — | 고르는 즉시. 메뉴가 닫히기 전이다 |
76
+ | `onActionAfterDismiss` | Web `(id: string) => void` · Native `(value) => void \| Promise<void>` | — | 메뉴가 실제로 닫힌 뒤 한 번. Dialog·Sheet 열기·화면 이동은 여기서 한다 |
77
+ | `open` · `defaultOpen` · `onOpenChange` | `onOpenChange: (open: boolean, detail) => void` — Web `detail = { reason }`, Native 두 번째 인자가 `reason` | 비제어 `false` | reason: Web `trigger` · `selection` · `escape` · `outside` · `tab`, Native `trigger` · `selection` · `escape` · `outside` · `programmatic`. 제어하면 `onOpenChange` 필수(Web) |
78
+ | 선택(Web) | `selectionMode`: `action` · `single`(`value: string \| null`, `onValueChange(value: string)`) · `multiple`(`value: ReadonlySet<string>`, `onValueChange(value: ReadonlySet<string>)`) | `action` | `defaultValue`로 비제어 |
79
+ | 선택(Native) | `selection`: `{ mode: "none" }` · `{ mode: "single", selectedKey, onSelectionChange(key \| null) }` · `{ mode: "multiple", selectedKeys: ReadonlySet, onSelectionChange(keys) }` | `{ mode: "none" }` | `defaultSelectedKey(s)`로 비제어 |
80
+ | `density` | `comfortable` · `compact` | `comfortable`(recipe) | Web은 생략하면 Provider density를 따른다 |
81
+ | `asyncState` | `{ status: "idle" }` · `{ status: "loading" \| "loadingMore" \| "empty" \| "error", message }` | `{ status: "idle" }` | `message`는 현지화(Web `ReactNode`, Native `string`) |
82
+ | `align`(Web) | `start` · `end` | `start` | RTL에서 자동 반전 |
83
+ | `layoutStyle` | 배치 전용 style 객체 | — | Web은 트리거를 감싼 흐름 안 wrapper, Native는 트리거 host. 표면(popup·Modal)은 따라 움직이지 않는다 |
84
+
85
+ ## 배치
86
+
87
+ | 항목 | 값 | 근거 |
88
+ | --- | --- | --- |
89
+ | 크기 | 항목 높이 `comfortable` 56(`layout.rowHeight.singleLine`) · `compact` 44(`control.minTouchTarget`). Web 표면 폭 `13.75rem`~`min(24rem, 90vw)`, 높이 최대 `100dvh − 2 × spacing.md`. Native 표면 폭 100%·최대 520, 높이 최대 75% | `menuRecipe.density`, `.hjm-menu__content`, `react-native/src/navigation.tsx` |
90
+ | 간격 | 항목 좌우 `spacing.sm` 12, leading·문구·단축키 사이 12. 섹션 제목 좌우 12 · 위아래 `spacing.xs` 8. Web: 트리거와 8(`menuRecipe.sideOffset`), 화면 가장자리 8(`collisionPadding`), 안쪽 `spacing.xs` 8(`menuRecipe.surface.padding`; 2026-10-06까지 4, 1.12.1 이후 미게시), 표면·항목 radius `radius.md` 12, 구분선 위아래 4 · 좌우 8. Native: 바깥 여백·안쪽 `spacing.md` 16, 제목·목록·닫기 사이 12, radius `radius.lg` 16 | `collectionItemContract`, `menuRecipe.sectionLabel`·`surface`, `.hjm-menu__*`, `useAnchoredPopup` |
91
+ | 순서·정렬 | 일반 행동을 위에, `danger` 행동은 맨 아래. Web `align="start"`(기본)는 트리거 시작 끝, `end`는 끝 끝에 맞춘다(행 끝 ⋯은 `end`). Native는 위→아래 제목 → 항목 → 닫기 버튼(`secondary`) | `menuRecipe`, `src/overlays.tsx` |
92
+ | 고정·스크롤 | Web: 트리거에 붙는 portal(z-index `layer.dropdown` 400), 아래가 모자라면 위로 뒤집고 가로는 화면 안으로 민다. 넘치면 표면 안 스크롤. Native: `Modal`로 화면 **가운데**, scrim을 누르면 닫히고 항목 목록만 스크롤 | `useAnchoredPopup`, `react-native/src/navigation.tsx`(`Menu`) |
93
+ | 좁은 폭·큰 글자 | Web 폭 상한 `90vw`, 항목 문구는 줄바꿈된다. Native 높이 상한 75%를 넘으면 목록이 스크롤된다 | `.hjm-menu__content`, `maxHeight: "75%"` |
94
+
95
+ ```text
96
+ Web (트리거 아래, 앵커) Native (Modal, 화면 가운데)
97
+ ┌ 행 ───────────────── [⋯] ┐ ┌──────────── 화면 ─────────────┐
98
+ └──────────────────────────┘ │░░░░░░░░ scrim(누르면 닫힘) ░░░│
99
+ ↓ 8 │░ ┌─────────────────────────┐ ░│
100
+ ┌─────────────────┐ │░ │ 제목 │ ░│
101
+ │ 편집 │ ← 56 │░ │ 편집 │ ░│ ← 항목 56
102
+ │ 공유 │ │░ │ 공유 │ ░│ (스크롤)
103
+ │─────────────────│ │░ │ 삭제 (danger) │ ░│
104
+ │ 삭제 (danger) │ │░ │ [ 닫기 ] │ ░│ ← secondary
105
+ └─────────────────┘ │░ └─────────────────────────┘ ░│
106
+ 13.75rem ~ 24rem │░ 폭 ≤520 · 높이 ≤75% · 여백 16│
107
+ └───────────────────────────────┘
108
+ ```
109
+
110
+ ## 꼭 지킬 것
111
+
112
+ - 항목 id는 비어 있지 않고 유일해야 하며, 활성 항목이 하나 이상 있어야 한다. 어기면 렌더 중 던진다.
113
+ - Web에서 `label`이 문자열이 아닌 항목은 typeahead용 `textValue`를 준다. 없으면 던진다.
114
+ - 다른 모달·시트·화면 이동을 여는 action은 `onActionAfterDismiss`로 실행한다. 이 콜백은 메뉴가 실제로 닫힌 뒤(Native는 Modal teardown 뒤)에만 실행되므로 두 표면이 겹치지 않는다.
115
+ - 항목 문구·아이콘(제품 소유)은 i18n 키와 제품 아이콘으로 넣고, 색은 `tone`으로만 바꾼다.
116
+ - `MorphingMenu`는 action 전용이다. 선택·async가 필요하면 `Menu`를 쓴다. reduce-motion·RTL에서는 스스로 `Menu`로 돌아간다.
117
+
118
+ ## 플랫폼 차이
119
+
120
+ | 항목 | Web | Native |
121
+ | --- | --- | --- |
122
+ | 트리거 | `trigger`(element 필수), `label`은 메뉴 이름 | `triggerLabel` 필수, `trigger`/`renderTrigger` 선택 |
123
+ | 닫기 버튼 이름 | 없음 | `dismissLabel` 필수 |
124
+ | 선택 | `selectionMode="single"\|"multiple"` + `value`/`onValueChange` | `selection={{ mode, selectedKey(s), onSelectionChange }}` |
125
+ | 선택 후 실행 | 없음 | `onSelectionAfterDismiss` |
126
+ | 상태 | `disabled` | `disabled`, `readOnly`(+`readOnlyLabel`), `busy`, `onRetry`/`retryLabel` |
127
+ | 표면 | 트리거에 붙는 portal(`portalContainer`) | `Modal` |
128
+ | 항목 label | `ReactNode`(+`textValue`) | `string` |
129
+ | 배치 | `className`, `layoutStyle` | `layoutStyle`(`style`은 deprecated — 개발 모드 경고, 다음 major 제거) |