@hjmds/design-contracts 1.30.0 → 1.32.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 (102) hide show
  1. package/dist/behaviors.d.ts +3 -0
  2. package/dist/behaviors.d.ts.map +1 -1
  3. package/dist/bottom-navigation.d.ts +43 -0
  4. package/dist/bottom-navigation.d.ts.map +1 -1
  5. package/dist/bottom-navigation.js +55 -0
  6. package/dist/bottom-navigation.js.map +1 -1
  7. package/dist/carousel.d.ts +30 -0
  8. package/dist/carousel.d.ts.map +1 -1
  9. package/dist/carousel.js +32 -0
  10. package/dist/carousel.js.map +1 -1
  11. package/dist/catalog.d.ts +178 -1
  12. package/dist/catalog.d.ts.map +1 -1
  13. package/dist/catalog.js +16 -6
  14. package/dist/catalog.js.map +1 -1
  15. package/dist/component-recipes.d.ts +126 -0
  16. package/dist/component-recipes.d.ts.map +1 -1
  17. package/dist/component-recipes.js +71 -2
  18. package/dist/component-recipes.js.map +1 -1
  19. package/dist/icon.d.ts +58 -1
  20. package/dist/icon.d.ts.map +1 -1
  21. package/dist/icon.js +69 -0
  22. package/dist/icon.js.map +1 -1
  23. package/dist/image-choice.d.ts +47 -0
  24. package/dist/image-choice.d.ts.map +1 -1
  25. package/dist/image-choice.js +56 -0
  26. package/dist/image-choice.js.map +1 -1
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/pagination.d.ts +53 -0
  32. package/dist/pagination.d.ts.map +1 -1
  33. package/dist/pagination.js +48 -0
  34. package/dist/pagination.js.map +1 -1
  35. package/dist/recipes.d.ts +1 -1
  36. package/dist/recipes.d.ts.map +1 -1
  37. package/dist/recipes.js.map +1 -1
  38. package/dist/screen-patterns.d.ts +152 -2
  39. package/dist/screen-patterns.d.ts.map +1 -1
  40. package/dist/screen-patterns.js +132 -4
  41. package/dist/screen-patterns.js.map +1 -1
  42. package/dist/showcase.d.ts.map +1 -1
  43. package/dist/showcase.js +3 -0
  44. package/dist/showcase.js.map +1 -1
  45. package/dist/steps.d.ts +16 -7
  46. package/dist/steps.d.ts.map +1 -1
  47. package/dist/steps.js +6 -1
  48. package/dist/steps.js.map +1 -1
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/dist/version.js.map +1 -1
  52. package/docs/authoring-tools.md +1 -0
  53. package/docs/bottom-navigation.md +28 -0
  54. package/docs/carousel.md +8 -0
  55. package/docs/data-workflows.md +1 -0
  56. package/docs/dialog.md +23 -0
  57. package/docs/generated/component-maturity.md +7 -4
  58. package/docs/generated/renderer-evidence.json +317 -19
  59. package/docs/generated/renderer-evidence.md +9 -3
  60. package/docs/generated/showcase-manifest.json +164 -6
  61. package/docs/icon.md +9 -0
  62. package/docs/pagination.md +17 -0
  63. package/docs/screen-patterns.md +55 -1
  64. package/docs/steps.md +4 -3
  65. package/docs/toast.md +10 -0
  66. package/docs/usage/README.md +5 -2
  67. package/docs/usage/SELECTION.md +1 -1
  68. package/docs/usage/components/auth-screen-layout.md +15 -5
  69. package/docs/usage/components/bottom-cta.md +11 -0
  70. package/docs/usage/components/bottom-navigation.md +99 -2
  71. package/docs/usage/components/card.md +36 -5
  72. package/docs/usage/components/carousel.md +1 -0
  73. package/docs/usage/components/chat-date-separator.md +86 -0
  74. package/docs/usage/components/chat-message.md +13 -1
  75. package/docs/usage/components/chat-screen.md +3 -2
  76. package/docs/usage/components/comment-thread-row.md +11 -3
  77. package/docs/usage/components/comment-thread-screen.md +1 -1
  78. package/docs/usage/components/dialog.md +69 -0
  79. package/docs/usage/components/editor-screen.md +6 -2
  80. package/docs/usage/components/field.md +13 -0
  81. package/docs/usage/components/icon-tile.md +19 -3
  82. package/docs/usage/components/icon.md +45 -6
  83. package/docs/usage/components/image-choice-group.md +35 -4
  84. package/docs/usage/components/media-selection-screen.md +20 -1
  85. package/docs/usage/components/message-composer.md +4 -1
  86. package/docs/usage/components/moderation-screen.md +107 -2
  87. package/docs/usage/components/page-indicator.md +102 -0
  88. package/docs/usage/components/pagination.md +28 -4
  89. package/docs/usage/components/profile-screen.md +9 -3
  90. package/docs/usage/components/profile-summary.md +3 -3
  91. package/docs/usage/components/screen-layout.md +15 -3
  92. package/docs/usage/components/search-screen.md +5 -3
  93. package/docs/usage/components/steps.md +14 -7
  94. package/docs/usage/components/tabs.md +21 -1
  95. package/docs/usage/components/text.md +30 -1
  96. package/docs/usage/components/toast.md +5 -0
  97. package/docs/usage/components/typing-indicator.md +89 -0
  98. package/docs/usage/screens/common-login.md +3 -1
  99. package/docs/usage/screens/common-profile.md +3 -3
  100. package/docs/usage/screens/flow-editor.md +13 -2
  101. package/docs/usage/screens/flow-moderation.md +31 -3
  102. package/package.json +1 -1
@@ -4,7 +4,7 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.12.1
7
- - 검토일: 2026-10-13
7
+ - 검토일: 2026-10-18
8
8
  - 근거: [BottomNavigation](../../bottom-navigation.md), recipe `bottomNavigationRecipe`
9
9
  - 스토리북: `배포/컴포넌트/탐색/하단 탐색`
10
10
 
@@ -28,6 +28,8 @@
28
28
  | 이름 | 역할 | Web | Native |
29
29
  | --- | --- | --- | --- |
30
30
  | `BottomNavigation` | 기본 | `@hjmds/react`, `/navigation` | `@hjmds/react-native`, `/navigation`, `/top-bar` |
31
+ | `BottomNavigationInsetProvider` | 보조(막대가 덮는 높이를 화면에 전달) | `@hjmds/react`, `/navigation` | `@hjmds/react-native`, `/navigation`, `/top-bar` |
32
+ | `useBottomNavigationInset` | 보조(덮인 높이 읽기) | `@hjmds/react`, `/navigation` | `@hjmds/react-native`, `/navigation`, `/top-bar` |
31
33
 
32
34
  ## 최소 사용 예
33
35
 
@@ -76,6 +78,11 @@ import { BottomNavigation } from "@hjmds/react-native/navigation";
76
78
  | `configuration.labels` | `visible` · `hidden` | `visible` | `hidden`은 아이콘만 남기고 목적지 이름을 화면에서 뺀다. 이름은 항목의 접근성 이름으로 그대로 읽힌다. 아이콘을 칸 가운데에 둔다. 선택 표시는 아이콘 굵기·확대(와 제품 선택 표시)만 남으므로 의도한 아이콘 전용 막대에만 쓴다 |
77
79
  | `configuration.selection` | `emphasis` · `tile` | `emphasis` | `tile`은 선택 목적지 전체를 카드로 채운다. floating·equal·visible labels 전용 |
78
80
  | `configuration.surface` | `flat` · `raised` | `flat` | `raised`는 `selection: "tile"` 전용 두께 있는 막대다. surfaceAlt 바탕·borderControl 옆면(4)·선택 카드 옆면(3) |
81
+ | `configuration.iconSize` | `standard` · `large` · `xlarge` | `standard` | 1.31.0 추가. `standard`는 density 글리프(`regular` 24 · `compact` 20). `large` 44 · `xlarge` 52는 앱 아이콘 같은 그림을 `renderIcon`으로 넣는 자리다(`bottomNavigationRecipe.iconSizes`). `renderIcon`이 받은 `size`로 그린다 |
82
+ | `configuration.shape` | `standard` · `rounded` · `pill` · `compact` · `square` | `standard` | 1.32.0 추가. floating 막대의 틀(모서리·안쪽 여백, `bottomNavigationRecipe.shapes`). `standard`는 floating 그대로(radius.xl, 여백 0). `rounded` radius.xl·8, `pill` radius.full·8, `compact` radius.lg·4, `square` radius.sm·4. `presentation: "floating"`·`selection: "emphasis"`에서만 허용(위반 시 오류) |
83
+ | `configuration.iconEmphasis` | `scale` · `lift` | `scale` | 1.32.0 추가. `scale`은 선택 아이콘 1.06배. `lift`는 그 위에 선택 아이콘을 4 올리고 선택하지 않은 아이콘을 0.82로 흐리게 한다(`bottomNavigationRecipe.icon.liftEmphasis`). 색을 바꿀 수 없는 앱 아이콘 그림에 쓴다. 배지와 `prominent` 원은 흐려지지 않는다 |
84
+ | 항목 `prominent` | `boolean` | `false` | 1.31.0 추가. 가운데 목적지를 막대 위로 솟은 브랜드 원(지름 56, 글리프 28)으로 그린다. 선택·재선택·router 소유는 다른 항목과 같다. 홀수 개 항목의 정가운데 하나만, `distribution: "equal"`·`bar`/`floating`·`selection: "emphasis"`에서만 허용(위반 시 오류) |
85
+ | Native `placement` | `inline` · `overlay` | `inline` | 1.31.0 추가. `overlay`는 막대를 host 아래쪽에 겹쳐 고정하고(본문이 떠 있는 막대 뒤로 스크롤) 덮는 높이를 `BottomNavigationInsetProvider`에 알린다. `inline`은 자기 높이를 차지하므로 알리지 않는다 |
79
86
  | `primaryAction` | `ReactNode`(Button/IconButton) | — | `configuration` 밖의 별도 prop. 생성 같은 비목적지 행동이며 `center-gap` 또는 `capsule`과 짝짓는다 |
80
87
  | 항목 `badge` | `{ count, max?, accessibilityLabel }` | `max` 99 | 숫자만. 점 배지는 없다. `count`가 0이면 그리지 않는다 |
81
88
  | `onActivate` | `(activation: { key: Key; reason: "navigate" \| "reselect" }) => void` | Web 선택 · Native 필수 | 이동 의도만 알린다. 선택은 바꾸지 않는다. `reselect`는 이미 선택된 항목을 다시 누른 경우 |
@@ -114,6 +121,87 @@ bar(기본) center-gap + primaryAction
114
121
  floating·capsule: 바깥 좌우 16 · 위 8 띄운 둥근 표면, 최대 폭 384 · 480
115
122
  ```
116
123
 
124
+ ### 막대가 덮는 높이 (`BottomNavigationInsetProvider`, 1.31.0)
125
+
126
+ 화면 위에 겹치는 막대(Web은 항상 `position: fixed`, Native는 `placement="overlay"`)가 덮는 하단 높이를 화면이 알게 한다.
127
+ 화면들과 막대를 함께 감싸는 자리(탭 navigator·앱 셸)에 `BottomNavigationInsetProvider`를 둔다. 막대는 자기 높이(안전 영역
128
+ padding과 `prominent` 원이 막대 위로 솟은 부분 포함)를 측정해 알리고, 키보드 때문에 숨으면 0을 알린다.
129
+ `ScreenLayout`은 이 값을 스스로 읽어 마지막 영역(footer, 없으면 본문)의 아래 여백을 `max(기존 값, 화면 여백 16 + 덮인 높이)`로
130
+ 둔다. 그래서 탭 화면마다 손으로 둔 하단 빈칸이 필요 없다. **이 여백의 소유자는 ScreenLayout 하나다(1.32.0 patch).**
131
+ `scroll="content"`로 FlatList를 두는 화면도 본문 영역이 이미 막대 위에서 끝나므로 목록의 `contentContainerStyle`에
132
+ `useBottomNavigationInset()`을 더하지 않는다(더하면 두 번 쌓인다). ScreenLayout 위에 만든 화면 구성(NotificationInboxScreen 등)도
133
+ 같은 한 번이다. `contentInset="body-bleed"` 본문은 여백 16 없이 덮인 높이만 비운다. Sheet·Dialog 안에서는 0이다(오버레이가 막대를 덮는다).
134
+ `useBottomNavigationInset()`은 ScreenLayout 밖에서 직접 만든 레이아웃(막대 위 떠 있는 제품 컨트롤 등)이 읽는다.
135
+
136
+ ```tsx
137
+ // Native (Expo Router 탭)
138
+ <BottomNavigationInsetProvider>
139
+ <Tabs tabBar={(props) => <AppTabBar {...props} />} />
140
+ </BottomNavigationInsetProvider>
141
+ // AppTabBar 안: <BottomNavigation placement="overlay" configuration={{ presentation: "floating" }} ... />
142
+ ```
143
+
144
+ ```tsx
145
+ // Web (앱 셸)
146
+ <BottomNavigationInsetProvider>
147
+ {children /* ScreenLayout 화면 */}
148
+ <BottomNavigation descriptor={descriptor} getHref={href} renderIcon={icon} />
149
+ </BottomNavigationInsetProvider>
150
+ ```
151
+
152
+ Web Provider는 같은 값을 `--hjm-bottom-navigation-inset` CSS 변수로도 내려 준다. ScreenLayout이 아닌 제품 레이아웃은
153
+ `padding-block-end: calc(<여백> + var(--hjm-bottom-navigation-inset, 0px))`로 쓸 수 있다.
154
+
155
+ ### 솟은 가운데 목적지 (`prominent`, 1.31.0)
156
+
157
+ 가운데 칸이 쓰기·만들기 같은 제품의 주 진입점이면서 **목적지**일 때 쓴다(선택 상태가 있고 다시 누르면 `reselect`).
158
+ 행동만 하고 화면이 바뀌지 않으면 `primaryAction`이다. 원은 선택 여부와 관계없이 브랜드 면(`action.brand`)으로 칠하고,
159
+ 선택 표시는 다른 항목처럼 라벨 굵기·색이 맡는다. 원은 `지름 − 표시 자리 높이`만큼 위로 올라가 세 라벨이 한 줄에 선다.
160
+
161
+ ```tsx
162
+ // Native
163
+ <BottomNavigation
164
+ descriptor={{ accessibilityLabel: t("tab.label"), selectedKey: route, items: [
165
+ { id: "trail", label: t("tab.sea"), icon: { name: "footprints" } },
166
+ { id: "echo", label: t("tab.write"), icon: { name: "bird" }, prominent: true },
167
+ { id: "nest", label: t("tab.beach"), icon: { name: "birdhouse" } },
168
+ ] }}
169
+ onActivate={({ key }) => navigation.navigate(key)}
170
+ renderIcon={({ name, color, size, strokeWidth }) => <AppIcon name={name} color={color} size={size} strokeWidth={strokeWidth} />}
171
+ />
172
+ ```
173
+
174
+ ### 앱 아이콘 자리 (`iconSize`, 1.31.0)
175
+
176
+ `renderIcon`은 원래 어떤 ReactNode든 그릴 수 있다(이미지 포함). `iconSize: "large" | "xlarge"`는 그 자리를 44 · 52로 키워
177
+ 런처의 앱 아이콘처럼 큰 그림을 넣게 한다. 그림은 받은 `size`로 그리고 크기를 따로 정하지 않는다.
178
+
179
+ ### 테마별 막대 모양 (`shape`·`iconEmphasis`, 1.32.0)
180
+
181
+ 제품 테마마다 막대 틀이 달라야 하면 막대를 직접 조립하지 않고 `configuration`으로 고른다. 틀 값은 recipe가 소유하고
182
+ 모서리는 theme radius token이라 design profile의 radius 척도를 그대로 따른다. 색은 팔레트 역할(`brandPalette`)로 바꾼다.
183
+ 높이는 `density`(`regular` 64 · `compact` 52)와 `iconSize`가 정한다.
184
+
185
+ | 테마 예 | `shape` | `density` | `iconSize` | 틀 |
186
+ | --- | --- | --- | --- | --- |
187
+ | 둥근 독 | `rounded` | `regular` | `xlarge` | radius.xl · 안쪽 여백 8 |
188
+ | 알약 막대 | `pill` | `regular` | `xlarge` | radius.full · 8 |
189
+ | 작은 독 | `compact` | `compact` | `large` | radius.lg · 4 |
190
+ | 각진 작업 표시줄 | `square` | `compact` | `large` | radius.sm · 4 |
191
+
192
+ ```tsx
193
+ <BottomNavigation
194
+ placement="overlay"
195
+ configuration={{ presentation: "floating", shape: frames[theme].shape, density: frames[theme].density, iconSize: frames[theme].iconSize, iconEmphasis: "lift" }}
196
+ descriptor={descriptor}
197
+ onActivate={({ key }) => routes[key]()}
198
+ renderIcon={({ item, size }) => <Image source={art[theme][item.id]} style={{ width: size, height: size }} accessible={false} />}
199
+ />
200
+ ```
201
+
202
+ `iconEmphasis: "lift"`는 선택 아이콘을 올리고 나머지를 흐리게 해 그림 아이콘에서도 색 없이 현재 목적지를 보인다.
203
+ 라벨의 굵기·색 선택 표시는 그대로다. 예제는 Storybook `테마별 막대 모양`(Web·Native)에 있다.
204
+
117
205
  ## 꼭 지킬 것
118
206
 
119
207
  - `selectedKey`는 router가 확정한 값으로 넘긴다. `onActivate`는 의도일 뿐이며 컴포넌트가 선택을 바꾸지 않는다.
@@ -131,13 +219,16 @@ floating·capsule: 바깥 좌우 16 · 위 8 띄운 둥근 표면, 최대 폭 38
131
219
  | `onActivate` | 선택, 수정키 없는 왼쪽 클릭에만 | 필수, 추가로 `onLongActivate` |
132
220
  | 배치 | CSS `position: fixed` 하단 | 제품 레이아웃이 배치 |
133
221
  | 하단 inset | `env(safe-area-inset-bottom)` 자동 | `safeAreaBottom`(recipe 최소 padding에 더함) |
222
+ | 화면 위 겹침 | 항상(`position: fixed`). Provider가 덮인 높이를 알림 | `placement="overlay"`일 때만. 기본 `inline`은 자기 높이를 차지 |
134
223
  | 키보드 감지 | `visualViewport` 높이 | `Keyboard` 이벤트 |
135
224
  | `renderIcon`의 `color` | `"currentColor"` | 해석된 색 문자열 |
136
225
  | 그 밖 | `className`·`style`·HTML 속성·`layoutStyle` | `renderBadge`, `getItemTestID`, `layoutStyle`(`style`·`surfaceStyle`·`listStyle`·`primaryActionStyle`은 제거된 속성) |
137
226
 
138
227
  ## 함정
139
228
 
140
- - Web root가 `position: fixed`라 본문 마지막 내용이 막대 아래로 들어간다. 제품 레이아웃이 하단 여백을 둔다.
229
+ - Web root가 `position: fixed`라 본문 마지막 내용이 막대 아래로 들어간다. `BottomNavigationInsetProvider`로 감싸면 ScreenLayout이 여백을 둔다. 직접 만든 레이아웃은 `--hjm-bottom-navigation-inset`을 쓴다.
230
+ - Native 기본 `inline` 막대는 React Navigation `tabBar`처럼 자기 높이를 차지한다. 이때 탭 화면에 하단 빈칸을 또 두면 두 번 비게 된다. 막대 뒤로 본문을 보이게 하려면 `placement="overlay"`와 Provider를 함께 쓴다.
231
+ - ScreenLayout 안의 목록(`scroll="content"` FlatList 포함)에 `useBottomNavigationInset()`이나 `--hjm-bottom-navigation-inset`을 더하지 않는다. ScreenLayout 마지막 영역이 이미 비웠다.
141
232
  - `renderLink` 없이 쓰면 일반 `<a>`로 그려 SPA 전환이 일어나지 않는다.
142
233
  - Web `onActivate`는 링크 기본 이동을 막지 않는다. `renderLink`의 라우터 Link가 이동하고 `onActivate`는 계측·맨 위로 스크롤 같은 부수 동작에만 쓴다. 수정키·가운데 클릭에는 불리지 않는다.
143
234
  - Native 내비게이션 연구 스토리(`showcase/native/src/reference-navigation-bars.tsx`)는 `configuration`과 [Heading](heading.md)을 사용한다. 과거의 `style`·`listStyle` override는 제거되었으며, 선택 타일과 모서리는 공개 configuration 축으로 정한다.
@@ -168,4 +259,10 @@ floating 그림자는 그리지 않는다. 옆면이 높이를 표현하므로
168
259
 
169
260
  ## 근거
170
261
 
262
+ - 덮인 높이 Provider·`placement`: 2026-10-18 Spint가 떠 있는 막대 높이를 몰라 탭 화면마다 `TAB_BAR_SPACE`(112)를 손으로 두었다.
263
+ - 덮인 높이의 단일 소유(ScreenLayout, 1.32.0 patch): 1.31.0 지침이 `scroll="content"` 화면에 목록 여백으로 다시 더하라고 안내했지만 두 renderer의 ScreenLayout은 그 화면에도 마지막 영역 여백을 이미 적용했다. 지침대로 하면 두 번 쌓인다. 코드는 그대로 두고 지침을 고쳤으며, 지침을 따른 소비 앱은 확인 시점(2026-10-18) 기준 없었다.
264
+ - `prominent`: 2026-10-18 다에리 가운데 "메아리"(쓰기) 탭을 그리려고 HJM 막대 대신 Expo Router 기본 탭바와 제품 원 아이콘을 썼다.
265
+ - `iconSize`: 2026-10-18 Utilverse 독이 38~54pt 앱 아이콘을 보이려고 Surface·Button으로 막대를 직접 조립했다.
266
+ - `shape`·`iconEmphasis`: 1.31.0 채택 뒤에도 Utilverse 다섯 셸(iPhone 둥근 독·Android 알약·macOS 작은 독·Linux/Windows 각진 막대)의 틀과 선택 아이콘 올림(−3·1.06배, 나머지 0.82)을 표현할 공개 축이 없어 모든 셸을 floating 하나로 통일했다(2026-10-18, Utilverse ADR-0074). 모서리는 그 독의 radius token 그대로다. 안쪽 여백은 항목 density padding(8)이 이미 있어 원래 Surface 여백(폰 12·데스크톱 8)에서 한 단계(4)씩 줄였다. 올림은 4pt 격자에 맞춰 4로 했다.
267
+
171
268
  - capsule 선택 알약·tile 선택 카드: 2026-10-17 정렬 실측(Web 402·1280, 실험 내비게이션 바 비교). 첫·끝 아이콘의 막대 끝 거리 차이 17~23px은 선택 항목의 `flex: 2` 알약 때문이며 항목 상자 양끝 여백은 5/5로 같았다. 가운데 항목이 14.5px 솟아 보인 값은 tile 선택 카드 상자를 다른 항목의 라벨과 비교한 측정 오차였고 아이콘 244·라벨 268이 모든 항목에서 같았다. 두 renderer에 이미 같은 규칙이 있어 코드는 바꾸지 않고 배치 표에 의도를 적었다.
@@ -78,7 +78,7 @@ import { Button } from "@hjmds/react-native/actions";
78
78
  | `selected` | `boolean` | `false` | `true`면 tone이 `accent`로 바뀐다(`cardRecipe.selectedTone`) |
79
79
  | `headingLevel`(Web만) | `2` · `3` · `4` | `3` | 제목이 `h3`로 렌더되므로 문서 위계에 맞춰 고른다 |
80
80
  | `title` · `description` · `leading` · `media` · `actions` · `children` | `ReactNode` | — | 슬롯. 순서는 HJM이 고정한다 |
81
- | `onActivate` | `() => void` | — | 카드 전체가 하나의 행동일 때 사용(미게시). Web `aria-label`, Native `accessibilityLabel` 필수 |
81
+ | `onActivate` | `() => void` | — | 카드 전체가 하나의 행동일 때 사용(미게시). Web `aria-label`, Native `accessibilityLabel` 필수. `actions`와 함께 쓰면 행동은 별도 대상으로 남는다(1.31.0 추가) |
82
82
  | `disabled` | `boolean` | `false` | 전체 카드 행동 차단·비활성 피드백(미게시) |
83
83
  | `layoutStyle` | margin·width·flex·`alignSelf` | — | 배치 전용. 두 플랫폼 모두 root Surface에 적용된다 |
84
84
 
@@ -99,8 +99,39 @@ import { Button } from "@hjmds/react-native/actions";
99
99
  // Native는 aria-label 대신 accessibilityLabel을 전달한다.
100
100
  ```
101
101
 
102
- `onActivate`와 `actions`를 함께 쓰지 않는다. children·media·leading에도 입력/링크/버튼을
103
- 넣지 않는다. 내부 행동이 여러 개인 카드에는 기존 `actions` 슬롯을 사용한다.
102
+ children·media·leading에는 입력/링크/버튼을 넣지 않는다. 카드를 여는 행동과 별도 명령(삭제·더보기)이
103
+ 함께 필요하면 명령을 `actions`에 둔다.
104
+
105
+ ### 카드 열기와 별도 행동(1.31.0 추가)
106
+
107
+ 2026-10-18 unairplane 홈 여정 카드는 카드 전체로 상세를 열고 삭제는 따로 해야 했다. 이전에는 `onActivate`와
108
+ `actions`가 상호 배제라 제품이 카드 안에 Pressable을 다시 조립하고 삭제를 별도 footer로 붙였다.
109
+ 이제 둘을 함께 주면 HJM이 버튼 안 버튼 없이 두 대상을 나눈다.
110
+
111
+ ```tsx
112
+ // Native
113
+ <Card accessibilityLabel={flightLabel} accessibilityHint={t("journey.openHint")} title={route} description={flightNumber}
114
+ onActivate={openJourney}
115
+ actions={<IconButton label={t("journey.delete", { flight: flightNumber })} tone="ghost" size="small" edge="start" onPress={confirmDelete}>
116
+ <Icon descriptor={{ name: "delete", tone: "danger" }} renderGlyph={renderSemanticGlyph} />
117
+ </IconButton>}>
118
+ {schedule}
119
+ </Card>
120
+ // Web은 accessibilityLabel 대신 aria-label, onPress 대신 onClick을 쓴다.
121
+ ```
122
+
123
+ | 항목 | Web | Native |
124
+ | --- | --- | --- |
125
+ | 구조 | root는 `<article>`로 남고, 카드 전체를 덮는 투명 `<button>`(`data-slot="activator"`)이 이름·`aria-pressed`·`disabled`를 가진다. 행동 영역만 그 위에 놓인다 | media+본문이 하나의 `Pressable`(버튼 role·이름·선택/비활성 상태), 행동 줄은 그 형제다 |
126
+ | 누르는 영역 | 행동 버튼을 뺀 카드 전체(행동 줄의 빈 자리 포함) | media+본문. 행동 줄의 빈 자리는 누름 대상이 아니다 |
127
+ | 초점·낭독 순서 | 카드 → 행동 | 카드 → 행동(각각 별도 접근성 요소) |
128
+ | 눌림·비활성 피드백 | media·본문만 흐려진다. 행동은 자기 상태를 쓴다 | 같음 |
129
+
130
+ - `disabled`는 카드 열기만 막는다. 행동을 막으려면 그 버튼의 `disabled`를 따로 준다.
131
+ - 행동은 `actions`에 직접 넣는다. Web은 행동 영역의 직계 자식만 누름을 받으므로, 행동을 넓은 묶음으로 감싸면
132
+ 그 묶음의 빈 자리도 카드 열기가 아니라 묶음이 받는다. 행동을 끝 쪽에 두려면 묶음으로 감싸지 말고 첫 행동의
133
+ `layoutStyle`에 Web `{ marginInlineStart: "auto" }`, Native `{ marginStart: "auto" }`를 준다(행동 줄은 시작 쪽부터 놓인다).
134
+ - `actions` 없이 `onActivate`만 주면 기존처럼 root 자체가 버튼(Web `<button>`, Native `Pressable`)이다.
104
135
  기존 passive Card는 콜백 없이 동일하게 유지된다. 이 API는 로컬 source/candidate 단계이며
105
136
  npm 게시 및 소비 제품의 게시 버전 채택 완료를 뜻하지 않는다.
106
137
 
@@ -134,13 +165,13 @@ subtree라 질감을 바꾸거나 host가 실패해도 본문/초안은 유지
134
165
  - 색·radius·padding은 `tone`·`radius`·`padding` 축으로만 바꾼다. 카드마다 브랜드 색을 칠하지 않는다.
135
166
  - 배치는 두 플랫폼 모두 `layoutStyle`로 한다. Native `CardProps`에는 `style`이 없다. Web의 `className`·`style`로 recipe 값을 덮지 않는다.
136
167
  카드 목록 사이 간격은 바깥 [Stack](stack.md)·[Grid](grid.md)이 정한다.
137
- - 카드 전체 행동은 `onActivate`만 사용한다. 카드 안 행동이 있으면 전체 행동을 빼고 `actions`의 Button으로 둔다.
168
+ - 카드 전체 행동은 `onActivate`만 사용한다. 카드 안 별도 명령은 `actions`에 두고 children에 버튼을 넣지 않는다.
138
169
 
139
170
  ## 플랫폼 차이
140
171
 
141
172
  | 항목 | Web | Native |
142
173
  | --- | --- | --- |
143
- | root | 기본 `<article>`, 전체 행동은 `<button type="button">` | 기본 `View`(Surface), 전체 행동은 `Pressable` |
174
+ | root | 기본 `<article>`, 전체 행동은 `<button type="button">`, 전체 행동+`actions`는 `<article>` + 덮개 `<button>` | 기본 `View`(Surface), 전체 행동은 `Pressable`, 전체 행동+`actions`는 media·본문 `Pressable` + 형제 행동 줄 |
144
175
  | 제목 | `h{headingLevel}` | `Text` + `accessibilityRole="header"`, 수준 지정 없음 |
145
176
  | 배치 | `layoutStyle` | `layoutStyle` |
146
177
  | 내용 clip | tone의 `clipsContent`(`raised`는 그림자 때문에 clip 안 함) | 내부 `View`가 항상 clip, 그림자는 바깥에 남는다. frame과 clip이 같은 Provider radius token을 사용 |
@@ -23,6 +23,7 @@
23
23
  | 여러 카드의 독립 입력·행동을 함께 유지하는 유한 가로 탐색 | [CollectionRail](list.md#collectionrail-가로-배치) |
24
24
  | 항목 수가 많거나 끝이 없음 | [VirtualList](virtual-list.md), [LoadMore](load-more.md) |
25
25
  | 같은 자리의 보기 전환 | [Tabs](tabs.md), [SegmentedControl](segmented-control.md) |
26
+ | 페이저와 페이지 내용을 제품이 직접 소유하고 위치 점만 필요 | [PageIndicator](page-indicator.md)(이 컴포넌트의 점 줄과 같은 부품) |
26
27
 
27
28
  ## 공개 이름과 import
28
29
 
@@ -0,0 +1,86 @@
1
+ # ChatDateSeparator
2
+
3
+ - 단계: 컴포넌트
4
+ - 상태: 실험
5
+ - 지원: Web · Native
6
+ - 적용: 미게시(1.30.0 이후)
7
+ - 검토일: 2026-10-18
8
+ - 근거: [반복 화면 조합](../../screen-patterns.md), `src/screen-patterns.ts`의 `chatDateSeparatorRecipe`; Web·Native `src/screens.tsx`
9
+ - 스토리북: `실험/컴포넌트/데이터 표시/대화 날짜 구분`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 대화 타임라인에서 날짜(또는 오래 쉰 뒤의 시각)가 바뀌는 자리에 가운데 정렬 구분 줄을 둘 때 쓴다. 문구 포맷("오늘", "10월 18일 토요일", "오후 3:04")과
14
+ 구분을 넣을 위치(날짜 변경·간격 기준)는 제품이 정하고, HJM은 줄의 배치·글자·간격을 맡는다.
15
+
16
+ ## 쓰지 않을 때
17
+
18
+ | 상황 | 대신 쓸 것 |
19
+ | --- | --- |
20
+ | 일반 목록의 구획 제목 | [Section](section.md), [List](list.md) 묶음 제목 |
21
+ | 선만 필요한 구분 | [Divider](divider.md) |
22
+ | 메시지마다 붙는 시각 | [ChatMessage](chat-message.md) `timestamp` |
23
+
24
+ ## 공개 이름과 import
25
+
26
+ | 이름 | 역할 | Web | Native |
27
+ | --- | --- | --- | --- |
28
+ | `ChatDateSeparator` | 기본 | `@hjmds/react/screens` | `@hjmds/react-native/screens` |
29
+
30
+ 루트 barrel에는 없다.
31
+
32
+ ## 최소 사용 예
33
+
34
+ ```tsx
35
+ // Web
36
+ import { ChatDateSeparator, ChatMessage } from "@hjmds/react/screens";
37
+
38
+ {rows.map(row => <Fragment key={row.message.id}>
39
+ {row.dayChanged ? <ChatDateSeparator label={formatDay(row.message.sentAt)} dateTime={row.message.sentAt.slice(0, 10)} /> : null}
40
+ <ChatMessage {...toMessageProps(row.message)}>{row.message.text}</ChatMessage>
41
+ </Fragment>)}
42
+ ```
43
+
44
+ ```tsx
45
+ // Native
46
+ import { ChatDateSeparator, ChatMessage } from "@hjmds/react-native/screens";
47
+
48
+ const renderItem = ({ item }: { item: Row }) => <>
49
+ {item.dayChanged ? <ChatDateSeparator label={formatDay(item.message.sentAt)} /> : null}
50
+ <ChatMessage {...toMessageProps(item.message)}><Text>{item.message.text}</Text></ChatMessage>
51
+ </>;
52
+ ```
53
+
54
+ ## 축과 기본값
55
+
56
+ | prop | 값 | 기본값 | 설명 |
57
+ | --- | --- | --- | --- |
58
+ | `label` | `string` | 필수 | 제품이 포맷한 날짜·시각. 빈 문자열은 `TypeError` |
59
+ | `dateTime` | `string` | 없음 | Web `<time dateTime>` 값(예: `2026-10-18`). Native는 쓰지 않는다 |
60
+ | `layoutStyle` | `HjmCompositionStyleProp` | 없음 | 행 배치 전용. 색·여백을 덮지 않는다 |
61
+
62
+ ## 배치
63
+
64
+ | 항목 | 값 | 근거 |
65
+ | --- | --- | --- |
66
+ | 크기 | 타임라인 폭 전체 행, 글자 `textVariant` caption · `tone` muted | `chatDateSeparatorRecipe` |
67
+ | 간격 | 위 `paddingTop`(`spacing.sm`) 12, 아래 `paddingBottom`(`spacing.xxs`) 4. 다음 메시지는 자기 `groupPosition` 위 여백(새 묶음 12)을 그대로 가진다 | `chatDateSeparatorRecipe`, `resolveChatMessageGroupDisplay` |
68
+ | 순서·정렬 | 구분할 메시지 바로 앞, 가로 가운데 | Web·Native `ChatDateSeparator` |
69
+ | 고정·스크롤 | 자체 고정 없음. 타임라인과 함께 스크롤한다 | — |
70
+ | 좁은 폭·큰 글자 | 글자는 줄바꿈되고 자르지 않는다 | Web·Native `ChatDateSeparator` |
71
+
72
+ ## 꼭 지킬 것
73
+
74
+ - 구분 줄 뒤의 메시지는 새 묶음으로 시작한다(`resolveChatMessageGroups`가 같은 시각 문자열이면 묶으므로, 날짜가 바뀌면 제품이 묶음을 끊는다).
75
+ - 문구는 제품이 지역화·포맷한다. HJM은 날짜를 해석하지 않는다.
76
+ - 읽기 순서상 메시지 사이 일반 글자로 읽힌다. 따로 live region이나 heading 역할을 주지 않는다.
77
+
78
+ ## 플랫폼 차이
79
+
80
+ | 항목 | Web | Native |
81
+ | --- | --- | --- |
82
+ | 시각 의미 | `dateTime`이 있으면 `<time>` | 글자만 |
83
+
84
+ ## 근거
85
+
86
+ - 공통 날짜 구분: 2026-10-18 유틸버스 채팅. ChatScreen에 시각 구분 줄이 없어 가운데 흐린 caption을 제품이 직접 그렸다(위 12·아래 8 여백). 아래 여백은 다음 메시지의 묶음 여백 12와 겹치지 않도록 4로 정했다.
@@ -30,6 +30,7 @@ DM·대화 타임라인의 메시지 한 개에 쓴다. 발신/수신 정렬,
30
30
  | --- | --- | --- | --- |
31
31
  | `ChatMessage` | 메시지 한 개 | `/screens` | `/screens` |
32
32
  | `ReactionPicker` | `reactions` prop이 받는 타입(`ReactionPickerProps`)의 원본 | `/reaction-picker` | `/reaction-picker` |
33
+ | `MediaAlbumPageStatus` | `mediaAlbum.renderPage`의 불러오는 중·실패 장(미게시, CommentThreadRow도 같다) | `/screens` | `/screens` |
33
34
 
34
35
  루트 barrel에는 없다. `/screens`는 optional native peer를 요구하지 않는다(Native 반응 메뉴는 core `Modal`).
35
36
 
@@ -85,8 +86,12 @@ import { Text } from "@hjmds/react-native/primitives";
85
86
  | `replyQuote` | `{ title, excerpt }` | 없음 | 미게시. 말풍선 맨 위에 HJM이 인용을 그린다: 제목(예: "서연에게 답장", 본문 글자) → 원문 한 줄(흐린 글자, 넘치면 말줄임) → 구분선 1 → 답장 본문. 자유 노드 `reply`보다 우선 사용한다 |
86
87
  | `replyLink` | `{ label, onPress() }` | 없음 | 인용과 같이 주면 말풍선 안 인용을 눌러 원문으로 이동한다(Native는 말풍선의 "원문으로 이동" 접근성 동작도 제공). 이전에는 버블 밖 ghost 버튼이었다 |
87
88
  | `media` | `{ id, label, preview, aspectRatio?, onPress? }[]` | 없음 | 미게시. 보낸 사진. 폭 `chatMessageRecipe.mediaWidth` 210, 한 장은 `aspectRatio`(기본 1), 여러 장은 정사각 칸(`resolveChatMediaColumns`: 2·4장 2열, 그 외 3열, 간격 2), 바깥 radius 14. 글 없이 사진만 보내면 말풍선 표면·꼬리를 그리지 않는다. `onPress`가 있으면 사진마다 버튼(크게 보기는 제품) |
89
+ | `mediaAlbum` | `{ count, renderPage(index), pageLabel(index, count), previousLabel, nextLabel, aspectRatio?, onPress?(index), index?·onIndexChange?·defaultIndex? }` | 없음 | 미게시. 한 장씩 넘기는 사진 묶음. `renderPage`는 화면에 보이는 장에만 호출되어 제품이 그 사진만 조회·디코딩한다(이미지 원본·권한·캐시는 제품). 폭 `mediaWidth` 210, 사진 칸 `aspectRatio`(기본 1)·radius 14, 그 아래 이전 아이콘 · 위치(예: 2/5) · 다음 아이콘 줄(작은 ghost IconButton, `mediaAlbumRecipe`). 글 없이 앨범만 보내면 `media`처럼 말풍선 표면·꼬리를 그리지 않는다. 넘김 버튼이 있어 말풍선은 `interactiveContent`로 다뤄진다. 넘긴 장은 길게 누르기 미리보기에서도 같다. 사진을 아직 못 그리는 장은 `renderPage`(또는 그 안 컴포넌트)에서 `MediaAlbumPageStatus`를 돌려준다(아래). `media`와 함께 주면 `TypeError` |
90
+ | `MediaAlbumPageStatus` | `{ kind: "loading", label }` 또는 `{ kind: "error", label, retry?: { label, onPress } }` | 없음 | 미게시, `@hjmds/react(-native)/screens`. 앨범 장의 불러오는 중(스피너, `label`은 접근성 이름)·실패(caption 문구는 alert로 알림 + 작은 secondary 다시 시도 Button) 표시. 칸 가운데에 한 묶음으로 놓인다(`mediaAlbumRecipe.pageStatus` 간격 4·안쪽 여백 8). 표시되는 동안 사진 칸은 접근성 이미지 이름과 `onPress` 열기를 내려놓아 안쪽 내용이 낭독기·포인터에 닿는다. 제품 장 컴포넌트는 상태가 바뀌어도 다시 마운트되지 않는다. 빈 `label`은 `TypeError` |
88
91
  | `replyAction` | `{ label, onPress(), disabled?, icon? }` | 없음 | `replySwipeDistance` 60px 이상이고 세로 이동의 2배를 넘는 가로 스와이프로 실행(`isReplySwipe`). `reactions`가 있으면 길게 누른 메뉴의 첫 항목으로도 보인다(미게시) |
89
92
  | `reactions` | `ReactionPickerProps & { closeLabel, menuActions?, reportAction?, menuAction? }` | 없음 | `reactionHoldMs` 450ms 길게 누르면 메뉴가 열린다: 반응 줄(이모지 `typography.title` 18/26, `more`면 +) → 메시지 미리보기 → 카드(맨 위 `timestamp` 정확한 시각 → `replyAction` → `menuActions` → `menuAction` → `reportAction` 빨간 글자). 항목은 높이 44, 아이콘 24 칸. 배치는 `holdMenuRecipe`(인스타그램 DM 실측): 화면 좌우 10 안쪽 전체 폭 반응 줄(높이 60, 48 원형 칸, 이모지 28, 넘칠 때만 가로 스크롤) → 12 → 누른 말풍선 그대로 미리보기(제 쪽 정렬) → 12 → 폭 200·radius 18 동작 카드(행 56, 아이콘 24, `submenu` 행은 ›)를 말풍선 쪽에 붙인다. 보이는 ×는 없고 배경 탭·Escape·VoiceOver 닫기로 닫는다(보조기술 첫 초점용 닫기 대상만 둔다). 같은 반응 재선택은 `null`. `label`은 Web에서 말풍선 이름·Popover 제목, Native에서 접근성 힌트·동작 이름. 시각·동작 카드와 `reportAction`·`menuActions`는 미게시(2026-10-16 인스타그램 DM 길게 누르기 실측 반영) |
93
+ | `reactions.menuActions[].tone` | `"neutral"` · `"danger"` | `"neutral"` | 미게시(1.30.0 이후). 길게 누른 메뉴 행의 색. 삭제처럼 되돌릴 수 없는 행은 `danger`(빨간 글자). [Menu](menu.md) 항목 `tone`과 같은 어휘(`MenuItemTone`). `reportAction`은 항상 `danger`이고 맨 끝이다. 댓글(CommentThreadRow) 메뉴도 같다 |
94
+ | `reactions.summary` | `{ label, items: { id, emoji, count }[] }` | 없음 | 미게시. 현재 반응 집계를 시각 옆 알약 버튼 하나로 그리고, 누르면 길게 누른 것과 같은 메뉴를 연다. `label`은 버튼의 지역화 접근성 이름(예: "반응 ❤️ 2개, 😂 1개. 반응 메뉴 열기"), `items`는 표시 순서. `count`가 `chatMessageRecipe.reactionSummary.minCount` 2 미만이면 이모지만 보인다. 내 반응(`value`)이 들어 있으면 brand 테두리. 높이 24(Native는 hitSlop으로 44 터치), 좌우 8, 항목 사이 4. 빈 `label`·중복 id·빈 이모지는 `TypeError`, 양의 정수가 아닌 count는 `RangeError`(`resolveChatReactionSummary`). `reactions.disabled`면 버튼도 비활성. 메뉴가 닫히면 Web 초점은 말풍선으로 돌아간다. `readOnly: true`(미게시)면 반응할 수 없는 독자(게스트)용으로 같은 자리에 알약 표면 없는 caption 글자로 그리고 누를 수 없다. 접근성 이름은 `label` 하나(Web 시각적으로 숨긴 글자, Native 단일 text 요소)이므로 "메뉴 열기" 같은 행동 문구를 넣지 않는다 |
90
95
  | `interactiveContent` | `boolean` | `false` | 사진 앨범·링크처럼 자식이 버튼을 가지면 `true`. 안쪽 버튼·링크의 누름과 Web Enter/Space는 그 컨트롤이 받는다 |
91
96
  | `actions` | `ReactNode` | 없음 | 시각 옆 슬롯. 반응 집계 배지·재시도는 제품이 넣는다 |
92
97
  | `children` | `ReactNode` | 필수 | 메시지 본문. Native는 말풍선이 이 텍스트로 읽힌다 |
@@ -111,7 +116,8 @@ import { Text } from "@hjmds/react-native/primitives";
111
116
  답장·반응은 접근성 동작(`reply`·`activate`)으로 남는다. 2026-10-06 리뷰에서 행 이름(`author`)과 반응 이름(`picker.label`)이
112
117
  본문을 가려 화면 낭독기가 메시지를 읽지 못했다.
113
118
  - 타임라인 전체를 live region으로 감싸지 않는다(과거 메시지 로딩 때 읽기 순서를 가로챈다).
114
- - 반응 **선택 메뉴**와 현재 반응 **집계 버튼**은 다른 행동이다. 집계를 눌러 메시지 메뉴를 열 때는
119
+ - 반응 **선택 메뉴**와 현재 반응 **집계 버튼**은 다른 행동이다. 집계를 눌러 같은 메뉴를 열 때는
120
+ `reactions.summary`를 쓴다(미게시). 집계를 상태 표시용 Tag로 `actions`에 직접 그리지 않는다. 다른 행동(예: 반응한 사람 목록)을 여는 버튼은
115
121
  `actions` 슬롯에 Button을 공급할 수 있다. 본문 안에 집계 버튼을 넣는다면 사진·링크가 없어도
116
122
  `interactiveContent=true`가 필요하다. 2026-10-07 Utilverse 소스 대조에서 사진/도구만 검사해
117
123
  집계 버튼이 부모 접근성 그룹에 들어갈 수 있는 경로를 발견했다. `actions`에 둘 때도 긴 집계와
@@ -129,6 +135,8 @@ import { Text } from "@hjmds/react-native/primitives";
129
135
 
130
136
  ## 함정
131
137
 
138
+ - 사진이 여러 장이고 장마다 권한 확인·조회가 필요하면 `media`에 모든 `preview`를 넘기지 말고 `mediaAlbum`을 쓴다. `media`의 미리보기는 한 번에 모두 마운트된다(2026-10-18 Utilverse 소셜 사진: 대화 행마다 원본 10장을 받지 않으려고 제품 앨범을 따로 만들었다).
139
+
132
140
  - Native는 반응 모달 안에서 `children`을 미리보기로 한 번 더 렌더한다. 자식은 표시용 콘텐츠로 준다
133
141
  (부작용·고유 ref를 두지 않는다).
134
142
  - `reactions`의 `options`와 `more.options`는 id가 합쳐서 유일하고 emoji·label이 비어 있지 않아야 한다. `value`가
@@ -147,4 +155,8 @@ import { Text } from "@hjmds/react-native/primitives";
147
155
 
148
156
  ## 근거
149
157
 
158
+ - `reactions.summary`: 2026-10-18 번뚝 DM. 메뉴를 밖에서 열 수 없어 집계를 상태용 Tag로만 `actions`에 그렸다(제품 docs/DESIGN.md에 격차로 기록). 제어형 `open` 대신 HJM이 집계 버튼까지 그려 위치·접근성 이름·메뉴 연결을 공통으로 맡는다.
159
+ - `reactions.summary.readOnly`·`MediaAlbumPageStatus`: 2026-10-18 HJM 1.31.0 유틸버스 채택. 게스트는 반응할 수 없는데 집계가 항상 메뉴 버튼이라 제품이 `actions`에 caption을 따로 그렸고, 앨범 장 안의 불러오는 중·다시 시도가 사진 칸의 단일 접근성 이미지에 삼켜져 낭독기로 닿지 않았다.
160
+ - `menuActions[].tone`: 2026-10-18 유틸버스 채팅 길게 누르기 메뉴의 삭제 행. 빨간 글자가 `reportAction`에만 있어 삭제가 일반 행으로 보였다.
161
+
150
162
  - 아바타 32: 2026-10-17 정렬 실측. recipe·문서는 36이었지만 Web Avatar 단계(32/40/48/64)에 36이 없어 Web은 36 칸에 32 아바타를, Native는 36 아바타를 그렸다. 카카오톡 참고값 약 34에 가장 가까운 Avatar 단계 `small` 32를 recipe가 직접 읽게 해 두 플랫폼이 같은 값을 쓴다. 작성자 첫 줄과의 위쪽 맞춤(카카오톡 방식)은 유지한다.
@@ -29,6 +29,7 @@ DM·대화방처럼 헤더, 메시지 타임라인, 하단 작성창으로 이
29
29
  | --- | --- | --- | --- |
30
30
  | `ChatScreen` | 화면 조합 | `/screens` | `/screens` |
31
31
  | `ChatMessage`, `MessageComposer`, `ScreenLayout` | 함께 쓰는 조각 | `/screens` | `/screens` |
32
+ | [`TypingIndicator`](typing-indicator.md), [`ChatDateSeparator`](chat-date-separator.md) | 타임라인 조각(입력 중 표시, 날짜 구분). 미게시(1.30.0 이후) | `/screens` | `/screens` |
32
33
 
33
34
  루트 barrel에는 없다. `/screens`는 optional native peer를 요구하지 않는다.
34
35
 
@@ -68,11 +69,11 @@ import { ChatScreen, MessageComposer } from "@hjmds/react-native/screens";
68
69
  | --- | --- | --- |
69
70
  | `title`(필수) | 지역화한 대화 제목 | `header`를 주면 Web은 `aria-label`로만 쓴다 |
70
71
  | `header` / `exit` / `actions` / `description` | 기존 navigation 헤더, 또는 헤더 시작 쪽 화면 나가기 아이콘(`exit`: 뒤로·닫기)·도구 슬롯 | `header`를 주면 기본 헤더를 그리지 않는다 |
71
- | `children` | 타임라인(가상화 목록 + `ChatMessage`) | 자동 스크롤·새 메시지 배지·이전 메시지 위치는 제품 |
72
+ | `children` | 타임라인(가상화 목록 + `ChatMessage`) | 자동 스크롤·새 메시지 배지·이전 메시지 위치는 제품. 날짜·시각이 바뀌는 자리에는 `ChatDateSeparator`, 상대가 입력 중이면 마지막 메시지 뒤에 `TypingIndicator`를 둔다. 둘을 제품 caption·점 표시로 만들지 않는다 |
72
73
  | `composer`(필수) | `MessageComposer` 등 작성창 | `ready`와 `empty`에서 보인다(빈 대화의 첫 메시지 입력) |
73
74
  | `state` / `stateAction` | `loading`·`empty`·`error`·`restricted` + 지역화 `title` | ready 외에는 본문을 교체한다 |
74
75
  | `notice` | 새로고침 실패 같은 비차단 안내 | 초안 유지가 필요하면 `state` 대신 이것 |
75
- | `avatar` | 상대·방 사진 노드 | 미게시. 가운데 정렬 제목 앞에 놓는다. 단톡방은 인원 수를 `titleBadge`(흐린 글자)로 넘긴다 |
76
+ | `avatar` | 상대·방 사진 노드 | 미게시. 가운데 정렬 제목 앞에 놓는다. 단톡방은 인원 수를 `titleBadge`(흐린 글자)로 넘긴다. ScreenLayout `titleLeading`과 같은 자리라 ChatScreen은 `titleLeading`을 따로 받지 않는다. 장식이라 보조기술에서 숨는다(제목이 이름) |
76
77
  | `jumpToLatest` | `{ label, icon, onPress() }` | 미게시. 독자가 최신 메시지에서 벗어났을 때만 넘긴다. 작성창 위 끝 쪽에 원형 `IconButton size="small"`(secondary)로 떠 있고, 스크롤 위치 판단은 제품이 한다 |
77
78
 
78
79
  ## 축과 기본값
@@ -64,6 +64,9 @@ import { CommentThreadRow } from "@hjmds/react-native/screen-flows";
64
64
  | --- | --- | --- |
65
65
  | `id`·`parentId`·`author`·`body`·`timeLabel`·`likeCountLabel`·`likeIcon`·`likeLabel` | 필수 | `parentId`가 `null`이면 최상위 댓글 |
66
66
  | `bodyText` | 없음 | 주면 `author`와 본문을 한 문장으로, 시각은 아래 행동 줄로 옮긴다 |
67
+ | `timePlacement` | `"auto"` | 미게시. `"auto"`는 `bodyText`가 없으면 시각을 작성자 옆에 둔다. `"actions"`는 글이 아닌 본문(꽃 도장·카드)에서도 시각을 본문 아래 반응 요약·답글과 같은 줄 맨 앞으로 옮긴다 |
68
+ | `panel` | 없음 | 미게시. 댓글별 인라인 패널(수정·신고·차단 양식). 답글 줄 아래, 길게 누르기 대상 밖에 `panelGap` 8 간격으로 놓인다. 열림 상태·초점·제출·닫기는 제품이 소유한다 |
69
+ | `mediaAlbum` | 없음 | 미게시. 한 장씩 넘기는 사진 묶음 `{ count, renderPage(index), pageLabel(index, count), previousLabel, nextLabel, aspectRatio?, onPress?(index), index?·onIndexChange?·defaultIndex? }`. `renderPage`는 화면에 보이는 장에만 호출되어 제품이 그 사진만 조회·디코딩한다. 불러오는 중·실패 장은 `MediaAlbumPageStatus`(`@hjmds/react(-native)/screens`, [ChatMessage](chat-message.md#축과-기본값))로 그려 안쪽 내용이 낭독기에 닿게 한다. `media`와 함께 주면 `TypeError`. 본문 아래, 길게 누르기 대상 밖에 놓인다 |
67
70
  | `avatar` | 없음 | 제품 아바타. 최상위 32·답글 24 폭 칸에 둔다 |
68
71
  | `canReply` | `parentId === null` | 답글 행동 표시 여부 |
69
72
  | `replyDisabled` | false | 답글 행동 비활성 |
@@ -75,9 +78,9 @@ import { CommentThreadRow } from "@hjmds/react-native/screen-flows";
75
78
 
76
79
  | 항목 | 값 | 근거 |
77
80
  | --- | --- | --- |
78
- | 크기 | 아바타 칸 최상위 32·답글 24, 좋아요 수 칸 폭 44, `media` 최대 폭 160·radius `radius.md` | `commentThreadRecipe` |
81
+ | 크기 | 아바타 칸 최상위 32·답글 24, 좋아요 수 칸 폭 44, `media`·`mediaAlbum` 최대 폭 160·radius `radius.md`(앨범은 사진 칸에만 radius, 그 아래 이전 아이콘 · 위치(예: 2/5) · 다음 아이콘 줄, 작은 ghost IconButton) | `commentThreadRecipe`, `mediaAlbumRecipe` |
79
82
  | 간격 | 아바타·본문·좋아요 열 사이 `spacing.xs` 8, 본문 줄 사이 2, 행동 줄 항목 사이 `spacing.sm` 12, 작성자–시각 `spacing.xxs` 4, `media` 위 4 | `commentThreadRecipe.lineGap`, `Stack gap` |
80
- | 순서·정렬 | 아바타 → (작성자·시각 → 본문 → media → 시각/반응 요약/답글 행동) → 좋아요 버튼·`actions` 아래 좋아요 수. 위 정렬 단, 오른쪽 좋아요·`actions`·더보기 열은 44 터치 영역을 첫 줄(작성자 caption, `bodyText`면 본문 줄)의 가운데에 맞춘다: 열을 (첫 줄 높이 − 44) / 2만큼 올린다(`commentThreadRecipe.actionAnchor`). HJM의 좋아요·더보기가 마지막이면 열 전체가 IconButton 가장자리 오버행(10)만큼 끝으로 나가 아이콘 칸이 행 끝 여백 선(아바타 시작과 같은 선)에 온다. 제품 `actions`가 마지막이면 그 버튼에 `edge="end"`를 준다. 측정 전후(Web 402): 아바타 168 / 작성자 줄 162 / 아이콘 174 → 아이콘 160 = 작성자 줄, 더보기 칸 끝 28 → 16(2026-10-17 실측 감사, [IconButton `edge`](icon-button.md#배치)) | 양 renderer |
83
+ | 순서·정렬 | 아바타 → (작성자·시각 → 본문 → media 또는 mediaAlbum → 시각/반응 요약/답글 행동 → panel) → 좋아요 버튼·`actions` 아래 좋아요 수. 위 정렬 단, 오른쪽 좋아요·`actions`·더보기 열은 44 터치 영역을 첫 줄(작성자 caption, `bodyText`면 본문 줄)의 가운데에 맞춘다: 열을 (첫 줄 높이 − 44) / 2만큼 올린다(`commentThreadRecipe.actionAnchor`). HJM의 좋아요·더보기가 마지막이면 열 전체가 IconButton 가장자리 오버행(10)만큼 끝으로 나가 아이콘 칸이 행 끝 여백 선(아바타 시작과 같은 선)에 온다. 제품 `actions`가 마지막이면 그 버튼에 `edge="end"`를 준다. 측정 전후(Web 402): 아바타 168 / 작성자 줄 162 / 아이콘 174 → 아이콘 160 = 작성자 줄, 더보기 칸 끝 28 → 16(2026-10-17 실측 감사, [IconButton `edge`](icon-button.md#배치)) | 양 renderer |
81
84
  | 고정·스크롤 | 행은 스크롤하지 않는다. 목록 스크롤·행 간격(`rowGap` 12)·답글 들여쓰기(`replyIndent` 40)는 host가 준다 | `CommentThreadScreen` |
82
85
  | 좁은 폭·큰 글자 | 본문 열이 `min-width: 0`으로 줄며 줄바꿈되고 작성자·행동 줄은 `flexWrap`. 좋아요 열은 줄지 않는다 | `.hjm-comment-row__text`, `flexShrink: 0` |
83
86
 
@@ -85,7 +88,8 @@ import { CommentThreadRow } from "@hjmds/react-native/screen-flows";
85
88
 
86
89
  - 가상화 host에서 행 사이 간격(`commentThreadRecipe.rowGap`)과 답글 들여쓰기(`replyIndent`)를 직접 줄 때도 recipe 값을 쓴다. 제품 숫자로 다시 정하지 않는다.
87
90
  - 답글 펼치기·정렬·권한·좋아요 저장은 제품이 소유한다. 행은 콜백만 호출한다.
88
- - 행 안에 두 번째 레이아웃을 만들지 말고 `actions`·`likeAction`·`media` 슬롯으로 추가한다.
91
+ - 행 안에 두 번째 레이아웃을 만들지 말고 `actions`·`likeAction`·`media`·`mediaAlbum`·`panel` 슬롯으로 추가한다. 시각·답글 줄을 본문 노드 안에 다시 그리지 말고 `timePlacement: "actions"`와 `canReply`를 쓴다.
92
+ - `mediaAlbum`의 사진 조회·권한 확인·캐시 수명은 제품이 `renderPage` 안에서 소유한다. HJM은 보이는 장만 마운트하고 넘김 상태를 행 안에 둔다(제어형은 `index`+`onIndexChange`).
89
93
 
90
94
  ## 상태와 상호작용
91
95
 
@@ -93,6 +97,10 @@ import { CommentThreadRow } from "@hjmds/react-native/screen-flows";
93
97
  `reactions`를 주면 작성자·본문만 길게 누르기 대상이 되고, 답글 가능하면 메뉴에 답글 항목이 자동으로 붙는다.
94
98
  답글 행동 버튼은 길게 누르기 대상 밖에 남아 자기 버튼 의미를 유지한다.
95
99
 
100
+ ## 근거
101
+
102
+ - `timePlacement`·`panel`·`mediaAlbum`: 2026-10-18 HJM 1.30.0 채택 감사. Spint는 꽃 도장 본문에서 시각을 답글 줄에 두려고 시각·답글 행을 본문 안에 다시 그렸고, Utilverse는 수정·신고·차단 양식을 본문 노드 안(길게 누르기 대상 안)에 넣었으며, 사진 묶음은 선택한 장만 조회하는 제품 앨범을 따로 만들었다.
103
+
96
104
  ## 플랫폼 차이
97
105
 
98
106
  | 항목 | Web | Native |
@@ -93,7 +93,7 @@ import { MessageComposer } from "@hjmds/react-native/screens";
93
93
  `likeIcon`, `likeLabel`은 필수. 선택은 `bodyText`(작성자와 본문을 한 줄 흐름으로), `avatar`, `likeAction`(기본
94
94
  좋아요 버튼 교체, `null`이면 생략), `actions`(하트 바로 옆의 더보기 메뉴 등), `canReply`(기본: 최상위만 true), `replyDisabled`.
95
95
  `reactions`(미게시)를 주면 작성자·본문을 길게 눌러 ChatMessage와 같은 메뉴(반응 줄 → 미리보기 → 시각 → 답글 달기 → `menuActions` → `reportAction` 신고)가 열린다. 답글 달기는 `canReply`일 때 `onReply`로 자동 연결되고, 아이콘은 `reactions.replyIcon`으로 넘긴다. `reactionSummary`는 답글 달기 앞에 놓는 선택된 반응 표시 슬롯이다. 댓글의 기본은 반응 옵션을 비운(`options: []`) 동작 목록 전용 메뉴다(답글 달기 → `menuActions` → 신고, 인스타그램 댓글 길게 누르기 실측). 댓글 이모지는 길게 누르기가 아니라 작성창의 `quickInserts` 이모지 줄로 입력한다. 반응 줄은 제품이 명시적으로 댓글 반응을 운영할 때만 옵션을 넣는다. 댓글 메뉴에는 시각 제목을 두지 않는다(행에 이미 보인다).
96
- `media`(미게시, 선택)는 본문 아래 사진·스티커이며 최대 폭 `commentThreadRecipe.mediaMaxWidth` 160, radius 12로 감싼다. `likeCountLabel`은 하트 아래 숫자만(예: "15") 넣고 0이면 빈 문자열로 숨긴다. 개수를 포함한 전체 문장은 `likeLabel`에 둔다.
96
+ `media`(미게시, 선택)는 본문 아래 사진·스티커이며 최대 폭 `commentThreadRecipe.mediaMaxWidth` 160, radius 12로 감싼다. 사진 여러 장을 한 장씩 불러와야 하면 `media` 대신 `mediaAlbum`, 시각을 답글 줄로 옮기려면 `timePlacement: "actions"`, 수정·신고·차단 양식은 `panel`을 쓴다(미게시, [CommentThreadRow](comment-thread-row.md#축과-기본값)). `likeCountLabel`은 하트 아래 숫자만(예: "15") 넣고 0이면 빈 문자열로 숨긴다. 개수를 포함한 전체 문장은 `likeLabel`에 둔다.
97
97
 
98
98
  ## 배치
99
99
 
@@ -38,6 +38,7 @@
38
38
  | --- | --- | --- | --- |
39
39
  | `Dialog` | 기본 | `@hjmds/react`, `/overlays` | `@hjmds/react-native`, `/overlays` |
40
40
  | `OverlayStackProvider` | 확장 — 명령형 열기(`useDialog`·`useSheet`·`useOverlayStack`) | `@hjmds/react`, `/overlay-stack` | 없음 |
41
+ | `ImmersiveOverlay` | 확장 — 카드·scrim·머리 없이 화면 전체를 채우는 표현(1.31.0). 아래 [전체 화면 표현](#전체-화면-표현-immersiveoverlay) | `@hjmds/react`, `/overlays` | `@hjmds/react-native`, `/overlays` |
41
42
 
42
43
  ## 최소 사용 예
43
44
 
@@ -127,6 +128,8 @@ await handle.closed; // portal 제거·초점 복구 뒤. 다음 오버레이는
127
128
  | `onActionError`(Native) | `(error: unknown) => void` | — | action 실패를 본문 오류·재시도로 연결한다. 오류를 처리하는 동안 초안을 보존한다. 없으면 개발 빌드에서 실패마다 `console.error`를 남긴다(중복 제거 없음). 1.13.0 변경 |
128
129
  | `children`(Native) | `ReactNode` | — | 고정된 제목 줄 아래 스크롤 본문(`ScrollView`) 안에 들어간다. `FlatList`·`SectionList`를 넣지 않는다 |
129
130
  | `contentStyle`(Native) | 배치 key만(`HjmCompositionStyleProp`) | — | 대화상자 상자의 배치. 높이는 `size`로 |
131
+ | `overlayLayer` | `ReactNode` | 없음 | 1.31.0. 카드 **밖** 화면 전체 층에 그리는 장식(끌기 미리보기·특정 위치 강조). 카드보다 위에 그려지고 Web viewport/Native window 좌표(`clientX`·`pageX`·`measureInWindow`)를 그대로 쓴다. 누름을 받지 않고 보조기술에서 숨긴다(Web `inert`·`aria-hidden`, Native `pointerEvents="none"`·`no-hide-descendants`). 조작할 내용은 `children`에 둔다 |
132
+ | `scrollEnabled` | `boolean` | `true` | 1.31.0. `false`면 사용자가 대화상자를 스크롤하지 못한다(현재 위치 유지). Web은 카드 `overflow: hidden`, Native는 본문 ScrollView `scrollEnabled`. 본문 안 끌기 제스처가 진행되는 동안만 끄고 끝나면 되돌린다. 내용을 자르는 용도로 쓰지 않는다 |
130
133
  | `useDialog()`(Web) | `(request) => { closed: Promise<void>, close(): void }` | — | request는 `open`·`defaultOpen`·`onOpenChange`·`trigger`를 뺀 DialogProps. `OverlayStackProvider` 안에서만 |
131
134
 
132
135
  - Dialog는 `layoutStyle`을 받지 않는다(Web 제외 15개 중 하나 — 포털로 가운데 그려져 배치할 흐름 안 루트가 없다).
@@ -178,6 +181,66 @@ Native, 큰 글자 또는 폭 < 480: [ 취소 ]
178
181
  - Native에서 저장처럼 실패할 수 있는 action은 `onActionError`를 넘긴다. 단발성 작업에 Promise를 반환하면 완료까지 닫히지 않으므로, 즉시 닫으려면 아무것도 반환하지 않는다.
179
182
  - 배치 변경은 Native `contentStyle`(layout 전용)만 쓴다. 높이는 `size`로 정한다. Web은 `className`만 받으며 시각 override에 쓰지 않는다.
180
183
 
184
+ ### 카드 밖 레이어 (`overlayLayer`)
185
+
186
+ 폴더 안 아이콘을 끌 때처럼 손가락·포인터를 따라가는 미리보기는 카드 안에 그리면 카드의 안쪽 여백·스크롤·잘림·열림
187
+ 변형 때문에 좌표가 어긋나고 카드에 갇힌다. `overlayLayer`는 카드와 같은 모달 안이지만 카드의 형제 층이라 이 문제가 없다.
188
+ 끌기 제스처와 드롭 대상 측정은 카드 안 `children`이 소유하고, 층에는 현재 좌표의 그림만 넘긴다.
189
+ Native 입력 본문의 키보드 회피는 Dialog가 이미 맡는다(위 키보드 설명). 제품이 `KeyboardAvoidingView`를 다시 감싸지 않는다.
190
+ 끌기 중에는 `scrollEnabled={!dragging}`으로 본문 스크롤을 잠근다. 잠그지 않으면 Native ScrollView가 세로 움직임을 가져가 끌기가 끊기고, Web은 카드가 함께 스크롤된다.
191
+
192
+ ```tsx
193
+ // Native
194
+ <Dialog open={open} onOpenChange={setOpen} title={folder.name} closeLabel={t("common.close")}
195
+ scrollEnabled={drag === null}
196
+ overlayLayer={drag ? <DragGhost x={drag.pageX} y={drag.pageY} item={drag.item} /> : null}>
197
+ {grid /* 끌기 responder와 measureInWindow 등록은 여기 */}
198
+ </Dialog>
199
+ ```
200
+
201
+ <!-- usage-example: Web ../../../../../showcase/web/src/components/dialog-overlay-examples.tsx -->
202
+ - [Web 실행 예제](https://github.com/jim1286/hjm-design-system/blob/8afe62bcf44b4ced063998bc9669b55704265f2d/showcase/web/src/components/dialog-overlay-examples.tsx): `DialogOverlayLayerPreview`·`ImmersiveOverlayPreview`(`배포/컴포넌트/오버레이/대화상자`의 카드 밖 레이어·전체 화면 표현).
203
+ <!-- usage-example: Native ../../../../../showcase/native/src/dialog-overlay-preview.tsx -->
204
+ - [Native 실행 예제](https://github.com/jim1286/hjm-design-system/blob/8afe62bcf44b4ced063998bc9669b55704265f2d/showcase/native/src/dialog-overlay-preview.tsx): 같은 이름의 Native preview.
205
+
206
+ ### 전체 화면 표현 (`ImmersiveOverlay`)
207
+
208
+ 전광판·발표 화면처럼 **화면 자체가 내용**인 표현에 쓴다. 카드·scrim·머리·안쪽 여백이 없고, 제품이 그린 무대가 화면 끝까지
209
+ 채운다. 아래에는 HJM 행동 줄(상태 문구 → 도구 행동 → 나가기 행동)이 safe area 안에 붙는다. 모달 스택·포커스 가두기·
210
+ 배경 격리·닫힌 뒤 포커스 복귀·아래 화면의 진입 포커스 대기는 Dialog와 같다.
211
+
212
+ | prop | 값 | 기본값 | 설명 |
213
+ | --- | --- | --- | --- |
214
+ | `open`·`onOpenChange` | `boolean`, `(open, { reason }) => void` | — | 제어형만. Web `reason`: `"exit-action"`·`"escape"`. Native: `"exit-action"`·`"back"` |
215
+ | `label` | 현지화 문자열 | — | 표현 전체의 접근성 이름 |
216
+ | `exitLabel` | 현지화 문자열 | — | 나가기 버튼에 보이는 문구(제품 문구) |
217
+ | `children` | `ReactNode` | — | 무대. 남은 높이 전체를 채우고 넘치는 부분은 잘린다 |
218
+ | `tools` | `ReactNode` | 없음 | 나가기 앞에 놓는 도구 행동(멈춤 등 `IconButton`) |
219
+ | `status` | `ReactNode` | 없음 | 행동 줄 위 상태 문구·`Notice` |
220
+ | `returnFocusRef` | ref | 없음 | 닫힌 뒤 돌아갈 대상 |
221
+ | `initialFocusRef`(Web) | ref | 나가기 버튼 | 열릴 때 포커스 |
222
+ | `modalPriority`·`portalContainer`·`className`(Web) | — | — | Dialog와 같다 |
223
+ | `onShow`(Native) | RN `Modal` `onShow` | — | 표시 완료 |
224
+
225
+ - Native는 `presentationStyle="fullScreen"` Modal에서 상태 표시줄을 숨기고 세로·가로 회전을 허용한다(`dialogRecipe.immersive.orientations`, 앱이 세로 고정이어도 이 표현은 회전한다). 무대는 화면 끝까지 그려지므로 읽어야 할 내용은 가운데 두거나 `useHjmNativeSafeAreaInsets`로 가장자리 절개를 피한다.
226
+ - Web은 viewport 전체를 덮는 고정 층이다. 브라우저 Fullscreen API는 요청하지 않는다. 새 사용자 활성화가 필요하고, 그 Escape는 페이지가 받기 전에 전체 화면만 끝내 오버레이가 창 안에 남으며, iOS Safari는 요소 전체 화면을 지원하지 않는다.
227
+ - 행동 줄 간격: 안쪽 `spacing.md` 16(아래·좌우는 safe area와 큰 값), 상태–행동 `spacing.xs` 8, 행동 사이 `spacing.sm` 12. 나가기 버튼은 `size="large"`로 남은 폭을 채운다.
228
+ - 화면 꺼짐 방지·움직임 같은 무대 동작은 제품 소유다.
229
+
230
+ ```tsx
231
+ // Native
232
+ import { ImmersiveOverlay } from "@hjmds/react-native/overlays";
233
+
234
+ <ImmersiveOverlay open={presenting} onOpenChange={(next) => { if (!next) setPresenting(false); }}
235
+ label={t("display.title")} exitLabel={t("displayClose")}
236
+ status={<Text variant="caption" tone="muted">{t("displayWakeActive")}</Text>}
237
+ tools={<IconButton tone="secondary" size="large" label={t("displayPause")} onPress={togglePause}><PauseGlyph /></IconButton>}>
238
+ {sign /* 제품이 그린 전광판 */}
239
+ </ImmersiveOverlay>
240
+ ```
241
+
242
+ 실행 예제: 위 같은 파일의 `ImmersiveOverlayPreview`(`배포/컴포넌트/오버레이/대화상자` 전체 화면 표현).
243
+
181
244
  ## 플랫폼 차이
182
245
 
183
246
  | 항목 | Web | Native |
@@ -255,3 +318,9 @@ function RecordDetail() {
255
318
  실제 View의 `measureInWindow`로 활성화 시점의 영역을 얻고, 좌표를 얻지 못해도 일반 Dialog를 연다.
256
319
  측정 callback만 무기한 기다리면서 열기 행동을 막지 않는다. 버튼의 접근성 이름과 초점 복귀 대상을
257
320
  유지하고, 카드 전체를 누름 가능한 비의미적 View/div로 바꾸지 않는다.
321
+
322
+ ## 근거
323
+
324
+ - `overlayLayer`: 2026-10-18 번뚝 폴더 팝업. 끌기 유령 아이콘을 창 좌표로 카드 밖에 그려야 해서 Dialog 대신 raw RN `Modal`과 `KeyboardAvoidingView`를 썼다. 키보드 회피는 2026-10-13부터 Dialog가 이미 갖고 있었고, 카드 밖 층만 없었다.
325
+ - `scrollEnabled`: 2026-10-18 번뚝 폴더 팝업. 아이콘을 끄는 동안 본문 스크롤을 꺼야 해서(`scrollEnabled={!touchingIcon && !draggingId}`) raw `Modal`의 ScrollView를 유지했다.
326
+ - `ImmersiveOverlay`: 2026-10-18 utilverse 전광판. Dialog·Sheet는 항상 카드·scrim(`full` Sheet도 위 safe area·모서리·머리)을 그려 상태 표시줄을 숨긴 가로 전광판을 표현하지 못해 raw RN `Modal`을 썼다. 표현만 다르고 모달 스택·포커스·닫기 계약은 Dialog와 같아 Dialog의 확장으로 둔다.
@@ -4,7 +4,7 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.17.1
7
- - 검토일: 2026-10-17
7
+ - 검토일: 2026-10-18
8
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
9
  - 스토리북: `배포/화면/콘텐츠/작성과 수정`, `배포/화면/계정/프로필`
10
10
 
@@ -95,6 +95,7 @@ import { TextField } from "@hjmds/react-native/inputs";
95
95
  | `dirty` | `boolean` | 필수 | `true`에서 닫기를 누르면 `discard` 확인창을 띄우고, 확인하면 `cancel.onAction`을 부른다. `false`면 바로 부른다 |
96
96
  | `submit` | `ScreenFlowAction`(`{ label, onAction(), disabled?, pending? }`) | 필수 | `pending`이면 loading·비활성. 그동안 닫기 버튼도 비활성. 본문 `state`가 `ready`가 아니면 저장 비활성 |
97
97
  | `cancel` | `ScreenFlowAction` | 필수 | 헤더 시작 쪽 × 아이콘 닫기 버튼(`ScreenLayout` `exit` `close`). `label`은 보조기술 이름이다. 2026-10-17부터 글자 버튼 대신 아이콘 |
98
+ | `stepBack` | `ScreenFlowAction` | 없음 | 1.31.0. 단계형 작성에서 주는 동안 헤더 시작 쪽이 뒤로 화살표(`exit` `back`)가 되고 누르면 `stepBack.onAction`(앞 단계로)을 부른다. 단계를 되돌리는 일이라 `dirty`여도 `discard` 확인을 띄우지 않고, 이미 열린 버리기 확인도 닫는다. `disabled`·`pending` 또는 `submit.pending`이면 잠긴다. 첫 단계에서는 빼서 × 닫기로 돌아간다 |
98
99
  | `discard` | AlertDialog confirm 요청에서 `onConfirm`을 뺀 것 + `fallbackErrorMessage: string` | 필수 | `onConfirm`은 EditorScreen이 채운다 |
99
100
  | `submitPlacement` | `"footer"` · `"header"` | `"footer"` | `header`면 저장이 상단 `actions` 자리로 가고 footer에는 `draftStatus`만 남는다 |
100
101
  | `draftStatus` | `ReactNode` | 없음 | 초안 저장 상태 안내. footer에 놓인다 |
@@ -107,7 +108,7 @@ import { TextField } from "@hjmds/react-native/inputs";
107
108
  | --- | --- | --- |
108
109
  | 크기 | ScreenLayout 폭(최대 720) 안에 입력 본문; 저장은 `Button` 기본 크기, 닫기는 `IconButton` | `ScreenLayout`, `screen-flows.tsx` `Action` |
109
110
  | 간격 | 화면 padding `spacing.md` 16; footer 안 `draftStatus`–저장 `spacing.sm` 12(`Stack gap="sm"`); 본문 안 입력 간격은 제품(Form 등) 소유 | Web·Native `EditorScreen` |
110
- | 순서·정렬 | 헤더(닫기 → 제목 → [저장: header 배치]) → 본문 → footer(`draftStatus` → 저장) | `EditorScreen` 렌더 순서 |
111
+ | 순서·정렬 | 헤더(닫기 또는 `stepBack` 뒤로 → 제목 → [저장: header 배치]) → 본문 → footer(`draftStatus` → 저장) | `EditorScreen` 렌더 순서 |
111
112
  | 고정·스크롤 | 헤더·footer 고정, 본문 스크롤(`scroll` 기본 `"screen"`); 이탈 확인은 AlertDialog 오버레이 | `ScreenLayout`, `AlertDialog` |
112
113
  | 좁은 폭·큰 글자 | 제목 열 최소 폭 120 × 글자 배율, 모자라면 header 저장 버튼이 다음 줄로 내려간다; 키보드·safe area는 host | `screenPatternRecipe.headerMinWidth` |
113
114
 
@@ -119,6 +120,8 @@ import { TextField } from "@hjmds/react-native/inputs";
119
120
  - 저장 시작 또는 `cancel.disabled`/`cancel.pending` 전환은 이미 열린 초안 버리기 확인도 종료한다. 잠금이 풀렸다고 확인창이 다시 열리지는 않는다.
120
121
  - 최초 조회·오류·권한 제한 등 본문 대체 상태에서는 저장을 잠근다. 새 문서의 빈 초안은 `state="empty"`가 아니라 `ready`와 빈 입력으로 표현하고 제품 validation은 `submit.disabled`에 연결한다.
121
122
  - 라우터 뒤로가기·Android back·브라우저 이탈은 이 컴포넌트가 막지 않는다. 제품이 `dirty`로 guard를 건다.
123
+ - 단계형 작성의 헤더 뒤로는 `header`에 TopBar를 넣어 만들지 않고 `stepBack`을 쓴다. 지난 단계를 눌러 돌아가는 표시는 [Steps](steps.md) `onStepPress`다([단계형 작성](../screens/flow-editor.md#단계형-작성1310)).
124
+ - 첫 단계에서 이탈하는 길은 `cancel`이다. `stepBack`으로 화면을 떠나게 만들면 `discard` 확인을 건너뛴다.
122
125
  - 저장 성공 판정·오류 표시는 제품 상태(`notice`, Toast 등)로 한다.
123
126
 
124
127
  ## 플랫폼 차이
@@ -130,4 +133,5 @@ import { TextField } from "@hjmds/react-native/inputs";
130
133
 
131
134
  ## 근거
132
135
 
136
+ - `stepBack`(1.31.0): 2026-10-18 spint 작성(색 → 이모지 → 글)은 헤더 시작 쪽이 앞 단계로 가야 했는데 EditorScreen은 × 닫기만 있고 `exit`를 받지 않아, 제품이 `header`에 TopBar를 넣어 머리를 통째로 대체했다.
133
137
  - 저장 시작 시 열린 버리기 확인 종료: 2026-10-13 이전 확인창으로 저장 중 이탈 잠금을 우회할 수 있던 경로를 수정했다.