@hjmds/design-contracts 1.30.0 → 1.31.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 (90) hide show
  1. package/dist/behaviors.d.ts +1 -0
  2. package/dist/behaviors.d.ts.map +1 -1
  3. package/dist/bottom-navigation.d.ts +24 -0
  4. package/dist/bottom-navigation.d.ts.map +1 -1
  5. package/dist/bottom-navigation.js +34 -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 +141 -1
  12. package/dist/catalog.d.ts.map +1 -1
  13. package/dist/catalog.js +14 -5
  14. package/dist/catalog.js.map +1 -1
  15. package/dist/component-recipes.d.ts +87 -0
  16. package/dist/component-recipes.d.ts.map +1 -1
  17. package/dist/component-recipes.js +44 -1
  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 +7 -0
  24. package/dist/image-choice.d.ts.map +1 -1
  25. package/dist/image-choice.js +7 -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/recipes.d.ts +1 -1
  32. package/dist/recipes.d.ts.map +1 -1
  33. package/dist/recipes.js.map +1 -1
  34. package/dist/screen-patterns.d.ts +100 -0
  35. package/dist/screen-patterns.d.ts.map +1 -1
  36. package/dist/screen-patterns.js +98 -3
  37. package/dist/screen-patterns.js.map +1 -1
  38. package/dist/showcase.d.ts.map +1 -1
  39. package/dist/showcase.js +3 -0
  40. package/dist/showcase.js.map +1 -1
  41. package/dist/steps.d.ts +16 -7
  42. package/dist/steps.d.ts.map +1 -1
  43. package/dist/steps.js +6 -1
  44. package/dist/steps.js.map +1 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/dist/version.js.map +1 -1
  48. package/docs/bottom-navigation.md +10 -0
  49. package/docs/carousel.md +8 -0
  50. package/docs/dialog.md +23 -0
  51. package/docs/generated/component-maturity.md +7 -4
  52. package/docs/generated/renderer-evidence.json +313 -17
  53. package/docs/generated/renderer-evidence.md +9 -3
  54. package/docs/generated/showcase-manifest.json +161 -5
  55. package/docs/icon.md +9 -0
  56. package/docs/screen-patterns.md +18 -0
  57. package/docs/steps.md +4 -3
  58. package/docs/usage/README.md +5 -2
  59. package/docs/usage/SELECTION.md +1 -1
  60. package/docs/usage/components/auth-screen-layout.md +15 -5
  61. package/docs/usage/components/bottom-navigation.md +65 -2
  62. package/docs/usage/components/card.md +36 -5
  63. package/docs/usage/components/carousel.md +1 -0
  64. package/docs/usage/components/chat-date-separator.md +86 -0
  65. package/docs/usage/components/chat-message.md +10 -1
  66. package/docs/usage/components/chat-screen.md +3 -2
  67. package/docs/usage/components/comment-thread-row.md +11 -3
  68. package/docs/usage/components/comment-thread-screen.md +1 -1
  69. package/docs/usage/components/dialog.md +69 -0
  70. package/docs/usage/components/editor-screen.md +6 -2
  71. package/docs/usage/components/field.md +13 -0
  72. package/docs/usage/components/icon-tile.md +10 -1
  73. package/docs/usage/components/icon.md +45 -6
  74. package/docs/usage/components/image-choice-group.md +3 -1
  75. package/docs/usage/components/media-selection-screen.md +17 -0
  76. package/docs/usage/components/message-composer.md +4 -1
  77. package/docs/usage/components/moderation-screen.md +88 -2
  78. package/docs/usage/components/page-indicator.md +102 -0
  79. package/docs/usage/components/profile-screen.md +9 -3
  80. package/docs/usage/components/profile-summary.md +3 -3
  81. package/docs/usage/components/screen-layout.md +9 -0
  82. package/docs/usage/components/steps.md +14 -7
  83. package/docs/usage/components/tabs.md +21 -1
  84. package/docs/usage/components/text.md +30 -1
  85. package/docs/usage/components/typing-indicator.md +89 -0
  86. package/docs/usage/screens/common-login.md +3 -1
  87. package/docs/usage/screens/common-profile.md +3 -3
  88. package/docs/usage/screens/flow-editor.md +13 -2
  89. package/docs/usage/screens/flow-moderation.md +31 -3
  90. package/package.json +1 -1
@@ -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`는 화면에 보이는 장에만 호출되어 제품이 그 사진만 조회·디코딩한다. `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 이전 확인창으로 저장 중 이탈 잠금을 우회할 수 있던 경로를 수정했다.
@@ -81,6 +81,19 @@ import { Field } from "@hjmds/react-native/forms";
81
81
  - 상태 테두리는 recipe가 정한다: 기본 `borderControl`, 포커스 `contentBrand`, 오류 `danger`.
82
82
  - 도움말은 오류가 생겨도 사라지지 않고 둘 다 보인다(Web은 `aria-describedby`로 둘 다 연결).
83
83
  - 비활성(`disabled`)은 라벨과 컨트롤만 흐리고 도움말·오류는 원래 대비 그대로 둔다(`fieldRecipe.disabledScope`).
84
+
85
+ ### 오류 알림
86
+
87
+ `error`가 나타나거나 바뀌면 즉시(assertive) 한 번 알린다. Field·TextField·TextArea·Select·Combobox·NumberField·
88
+ CheckboxGroup·RadioGroup과 그 위에 만든 컨트롤(MessageComposer 등)이 같은 계약을 쓴다(1.30.0 이후).
89
+
90
+ - Web: 오류 전에 미리 mount된 `role="alert"` 영역 하나가 오류 id를 갖고, 컨트롤의 `aria-describedby`가 그 영역을 가리킨다.
91
+ 보이는 오류 문구는 `aria-hidden`이라 탐색 모드에서도 한 번만 읽힌다. 영역은 시각적으로 숨겨져 칸 간격을 바꾸지 않는다.
92
+ - Native: 오류 문구가 assertive live region(Android)이고, live region을 읽지 않는 iOS는 새 오류마다
93
+ `announceForAccessibility`로 한 번 알린다.
94
+ - 제품은 같은 문구를 Notice나 별도 `role="alert"`로 다시 그리지 않는다.
95
+
96
+ 근거: 2026-10-18 번뚝 DM에서 Web 필드 오류가 describedby로만 연결돼 나타날 때 낭독되지 않았고, Native iOS도 작성창·FieldGroup 외 필드 오류를 알리지 않았다. 작성창에만 숨긴 alert를 더한 첫 수정은 같은 문구를 두 번 읽게 해 공용 계약(`react/src/internal/field-error.tsx`, `react-native/src/internal/field-frame.tsx`)으로 옮겼다.
84
97
  흐림 정도는 컴포넌트 recipe에 `states.disabledOpacity`가 있으면 그 값(SearchField·PasswordField·OtpField·NumberField·Select
85
98
  0.5), 없으면 `fieldRecipe.disabledOpacity` 0.6(TextField·TextArea·Mentions·Field·NativeSelect·Combobox·DatePicker·TagsInput)이다.
86
99
  2026-10-06 전에는 틀 전체가 흐려져 잠긴 이유를 적은 도움말까지 대비가 떨어졌다.
@@ -54,6 +54,9 @@ import { IconTile } from "@hjmds/react-native/screens";
54
54
  | overlay | ReactNode | 없음 | 제품 배지 |
55
55
  | labelLines | 1 또는 2 | 1 | 제목 줄 수 |
56
56
  | ghost | boolean | false | 이동 대상 표시 |
57
+ | frame | `"surface"` · `"none"` | `"surface"` | 미게시(1.30.0 이후). `surface`는 공통 틀(`surfaceAlt` 채움 + 테두리 1), `none`은 틀을 그리지 않는다. 아이콘 원본이 이미 완성된 런처 아이콘(모양·배경 포함)일 때 `none` |
58
+ | shape | `"squircle"` · `"square"` · `"circle"` | `"squircle"` | 미게시. 틀(과 ghost 점선)의 모서리. 크기 대비 비율 `iconTileRecipe.shapes`(squircle 0.22 · square 0 · circle 0.5). 제품 아이콘 테마(아이폰 셸 squircle, Windows·Linux 셸 square)를 따른다 |
59
+ | size | `"fixed"` · `"fill"` | `"fixed"` | 미게시. `fixed`는 64×64, `fill`은 격자 칸 폭을 채우되 `fillMinSize` 48 ~ `fillMaxSize` 68 사이로 제한한 정사각형. 열 수·칸 폭은 host Grid가 정하고 제품이 칸 크기를 계산해 넘기지 않는다 |
57
60
 
58
61
  `icon: ReactNode`, `label: string`, `overlay?: ReactNode`, `labelLines?: 1 | 2`(기본1), `ghost?: boolean`(기본false). 두 줄은 런처 제목 높이44를 예약하고 ghost는 이동 대상의 점선/반투명 표현이다. 아이콘 64, 제목 최대 폭72, 간격6을 공유 recipe가 정한다. overlay는 아이콘 기준 배지를 위한 슬롯이며 넘침을 자르지 않는다.
59
62
 
@@ -61,7 +64,7 @@ import { IconTile } from "@hjmds/react-native/screens";
61
64
 
62
65
  | 항목 | 값 | 근거 |
63
66
  | --- | --- | --- |
64
- | 크기 | 아이콘 64×64(radius 22%), 제목 최대 폭 72 · 두 줄이면 84와 최소 높이 44 | `iconTileRecipe` |
67
+ | 크기 | 아이콘 64×64(radius 22%, `shape`에 따라 0%·50%), `size="fill"`이면 칸 폭 48~68 정사각형; 제목 최대 폭 72 · 두 줄이면 84와 최소 높이 44 | `iconTileRecipe` |
65
68
  | 간격 | 아이콘과 제목 사이 6 | `iconTileRecipe.gap` |
66
69
  | 순서·정렬 | 아이콘(overlay 포함) 위 → 제목 아래, 가로 가운데, 제목 가운데 정렬 | 양 renderer |
67
70
  | 고정·스크롤 | Grid나 가상 목록의 host가 열 수·외부 여백·스크롤을 정한다 | 별도 scroller 없음 |
@@ -73,6 +76,8 @@ import { IconTile } from "@hjmds/react-native/screens";
73
76
 
74
77
  아이콘 원본과 제품 배지만 전달한다. 같은 배치를 별도 View/div로 다시 만들지 않는다.
75
78
 
79
+ - 완성된 아이콘 아트워크 위에 틀이 두 겹으로 그려지면 `frame="none"`, 아이콘 모양이 테마마다 다르면 `shape`, 칸 크기를 따라 커져야 하면 `size="fill"`을 쓴다. 이 이유로 런처 항목을 직접 만들지 않는다.
80
+
76
81
  ## 상태와 상호작용
77
82
 
78
83
  이 구성은 표시 전용이다. 링크/버튼/드래그 host가 disabled·포커스·키보드·길게 누르기와 라우팅을 소유하므로 기존 앱의 drag engine을 교체하지 않는다.
@@ -88,3 +93,7 @@ host에 전체 행동의 현지화 이름을 제공한다. 아이콘 내부 장
88
93
  ## 관련 지침
89
94
 
90
95
  [선택 기준](../SELECTION.md), [Grid](grid.md), [Card](card.md).
96
+
97
+ ## 근거
98
+
99
+ - `frame`·`shape`·`size`: 2026-10-18 HJM 1.30.0 감사. 유틸버스 런처는 도구 아트워크가 완성된 아이콘(셸에 따라 iPhone squircle, Windows·Linux square)이고 크기가 격자를 따라 48~68로 변해, 고정 64 틀을 그리는 IconTile 대신 자체 타일을 유지했다. 범위 48~68은 그 런처의 실제 칸 계산값이다.
@@ -10,8 +10,8 @@
10
10
 
11
11
  ## 언제 쓰나
12
12
 
13
- HJM semantic 이름(`search`, `back`, `chevronEnd`, `notifications` 등 43개)으로 고르는 그림 기호에 쓴다.
14
- 크기·색·선 굵기·RTL 반전은 HJM이 정하고, 그림 자체는 내장 경로(Web)나 제품이 넘긴 glyph가 그린다.
13
+ HJM semantic 이름(`search`, `back`, `chevronEnd`, `notifications` 등 47개)으로 고르는 그림 기호에 쓴다.
14
+ 크기·색·선 굵기·RTL 반전은 HJM이 정하고, 그림은 계약의 공용 경로(Web 내장, Native `/icon-svg`)나 제품이 넘긴 glyph가 그린다.
15
15
 
16
16
  ## 쓰지 않을 때
17
17
 
@@ -29,14 +29,17 @@ HJM semantic 이름(`search`, `back`, `chevronEnd`, `notifications` 등 43개)
29
29
  | --- | --- | --- | --- |
30
30
  | `Icon` | 기본 | `@hjmds/react`, `/display` | `@hjmds/react-native`, `/primitives` |
31
31
  | `createLucideGlyph` | 보조 — Lucide glyph 연결 helper(컴포넌트 아님) | `/icon-lucide` | `/icon-lucide` |
32
+ | `renderSemanticGlyph` | 보조 — HJM 공용 경로를 그리는 `renderGlyph`(컴포넌트 아님, 1.31.0) | — (내장) | `/icon-svg` |
33
+ | `semanticIconPaths` · `isSemanticIconName` | 공용 24 격자 경로 데이터와 이름 판별 | `@hjmds/design-contracts/components/icon` | 같음 |
32
34
 
33
35
  `/icon-lucide`는 optional peer가 필요하다. Web `lucide-react` 1.49.0,
34
- Native `lucide-react-native` 1.49.0. 루트 entry에서는 나오지 않는다.
36
+ Native `lucide-react-native` 1.49.0. Native `/icon-svg`는 optional peer `react-native-svg` 15.15.5가 필요하다.
37
+ 둘 다 루트 entry에서는 나오지 않는다.
35
38
 
36
39
  ## 최소 사용 예
37
40
 
38
41
  ```tsx
39
- // Web — 내장 glyph
42
+ // Web — built-in glyph
40
43
  import { Icon } from "@hjmds/react/display";
41
44
 
42
45
  <Icon name="search" />
@@ -54,6 +57,42 @@ const renderGlyph = createLucideGlyph({ search: Search, back: ArrowLeft });
54
57
  <Icon descriptor={{ name: "search" }} renderGlyph={renderGlyph} />
55
58
  ```
56
59
 
60
+ ```tsx
61
+ // Native — Web과 같은 HJM 공용 그림(react-native-svg 필요)
62
+ import { renderSemanticGlyph } from "@hjmds/react-native/icon-svg";
63
+
64
+ <Icon descriptor={{ name: "arrowUp" }} renderGlyph={renderSemanticGlyph} />
65
+ ```
66
+
67
+ ### 공용 그림의 Native 제공 방식(1.31.0 추가)
68
+
69
+ 2026-10-18 utilverse가 사진 순서 변경·회전 IconButton을 위해 `arrowUp`·`arrowDown`·`rotate` 경로를 앱 adapter에 직접
70
+ 그렸다. Web만 HJM 경로를 내장하고 그 경로가 renderer 내부에 있어 Native는 같은 그림을 받을 수 없었기 때문이다.
71
+ 경로는 외형 값이므로 계약 `semanticIconPaths`로 옮겨 두 플랫폼이 같은 데이터를 읽는다.
72
+
73
+ Native Icon은 `renderGlyph`를 필수로 유지한다. core 컨트롤이 `react-native-svg` 없이 동작하도록 SVG는 optional peer이고,
74
+ 내장 컨트롤의 몇 기호(셰브런·달력·삭제 등)는 내부에서 View로 그린다. 그래서 공용 그림은 `/icon-lucide`와 같은
75
+ 선택 entry `/icon-svg`의 `renderSemanticGlyph`로 연다. 앱 고유 이름을 함께 쓰면 판별 후 넘긴다.
76
+
77
+ ```tsx
78
+ // Native — app glyph names mixed with HJM names
79
+ import { isSemanticIconName } from "@hjmds/design-contracts/components/icon";
80
+
81
+ const renderGlyph = (props: NativeIconRenderProps<AppGlyphName>) =>
82
+ isSemanticIconName(props.name) ? renderSemanticGlyph(props) : renderAppGlyph(props);
83
+ ```
84
+
85
+ `react-native-svg`를 설치하지 않는 앱은 자기 adapter에서 `semanticIconPaths[name]`을 읽어 같은 경로를 그린다.
86
+
87
+ ### 이름 고르기
88
+
89
+ | 목적 | 이름 | 비고 |
90
+ | --- | --- | --- |
91
+ | 순서를 위/아래로 옮기기 | `arrowUp` · `arrowDown` | 1.31.0 추가. 위아래는 물리 방향이라 RTL에서 반전하지 않는다 |
92
+ | 이미지·페이지 90° 회전 | `rotate` | 1.31.0 추가. 시계 방향. RTL에서 반전하지 않는다. 다시 불러오기는 `refresh` |
93
+ | 차단·잠금 해제 | `unblock` | 1.31.0 추가. `lock`의 고리가 열린 모양 |
94
+ | 비밀번호 보기/숨기기(눈/가린 눈) | `visibility` · `visibilityOff` | 이미 있는 이름이다. `eye`라는 이름은 없다. 숨김 상태에서 `visibility`, 보임 상태에서 `visibilityOff`(누르면 될 상태). 비밀번호 입력 자체는 이 짝을 이미 내장한 [PasswordField](password-field.md)를 쓴다 |
95
+
57
96
  ## 축과 기본값
58
97
 
59
98
  | prop | 값 | 기본값 | 설명 |
@@ -93,7 +132,7 @@ const renderGlyph = createLucideGlyph({ search: Search, back: ArrowLeft });
93
132
  | 항목 | Web | Native |
94
133
  | --- | --- | --- |
95
134
  | API 모양 | 평평한 prop(`name`, `size`, `tone` …) | `descriptor` 객체 하나 |
96
- | glyph | 내장 SVG 경로, `renderGlyph`는 선택 | `renderGlyph` 필수(내장 그림 없음) |
135
+ | glyph | 내장 SVG 경로(`semanticIconPaths`), `renderGlyph`는 선택 | `renderGlyph` 필수. 공용 경로는 `/icon-svg`의 `renderSemanticGlyph` |
97
136
  | 사용자 정의 이름 | `SemanticIconName`만 | `Icon<Name>` 제네릭으로 제품 이름 허용 |
98
137
  | 출력 | `<svg>`(장식이면 `aria-hidden`) | `View` 프레임(장식이면 `accessible={false}`) |
99
138
  | 배치 | `layoutStyle` | `layoutStyle`. `style`은 제거된 속성 — `layoutStyle` 또는 descriptor `size`·`tone` |
@@ -102,4 +141,4 @@ const renderGlyph = createLucideGlyph({ search: Search, back: ArrowLeft });
102
141
 
103
142
  - 아이콘은 자기 `tone` 색을 직접 칠한다. Web CSS가 `data-tone`으로 색을 지정하므로 부모 색을 상속하지 않는다.
104
143
  primary·danger IconButton 안에서 기본 tone(`secondary`)을 쓰면 채워진 면 위에 회색이 된다. 이때는 `tone="inverse"`.
105
- - `createLucideGlyph` 맵에 없는 이름을 그리면 실행 중 `TypeError`가 난다.
144
+ - `createLucideGlyph` 맵에 없는 이름을 그리면 실행 중 `TypeError`가 난다. `renderSemanticGlyph`도 HJM 이름이 아니면 `TypeError`를 낸다.
@@ -52,6 +52,7 @@ import { ImageChoiceGroup } from "@hjmds/react-native/image-choice";
52
52
  | --- | --- | --- | --- |
53
53
  | `label` | `string` | 필수 | 묶음의 접근성 이름 |
54
54
  | `showLabel` | `boolean` | `false` | 타일 위에 `label`을 보이는 글자로 둔다 |
55
+ | `showItemLabels` | `boolean` | `false` | 미게시. 타일마다 항목 `label`을 그림 아래 한 줄(caption, 넘치면 말줄임)로 보인다. 같은 누름 영역 안에 있고 보조기술은 기존처럼 타일 이름 하나만 읽는다. 사람을 이름으로 알아봐야 하는 받는 사람 선택에 쓴다 |
55
56
  | `items` | `{ id, label, image, disabled? }[]` | 필수 | id 중복·빈 이름이면 `TypeError`. `image`는 칸을 채우는 그림 노드 |
56
57
  | `selectionMode` | `"single"` · `"multiple"` | 필수 | 단일은 radio, 다중은 checkbox로 읽힌다 |
57
58
  | `value`·`onValueChange` | 단일 `string \| null` / 다중 `string[]` | 필수 | 제어형. 다중은 누른 순서대로 추가·제거 |
@@ -62,7 +63,7 @@ import { ImageChoiceGroup } from "@hjmds/react-native/image-choice";
62
63
  | 항목 | 값 | 근거 |
63
64
  | --- | --- | --- |
64
65
  | 크기 | 그림 72 정사각, 바깥 고리 2 + 간격 2(터치 영역 80), radius `radius.md` 12, 체크 원 20 | `imageChoiceRecipe` |
65
- | 간격 | 타일 사이 `spacing.xs` 8, 라벨–타일 8 | `imageChoiceRecipe.gap` |
66
+ | 간격 | 타일 사이 `spacing.xs` 8, 라벨–타일 8, 그림–항목 라벨 `spacing.xxs` 4 | `imageChoiceRecipe.gap`, `itemLabelGap` |
66
67
  | 순서·정렬 | `items` 순서대로 시작 쪽부터 줄바꿈 배치, 체크는 타일 끝 쪽 위 모서리 | 두 렌더러 |
67
68
  | 고정·스크롤 | 자체 스크롤 없음. 바깥 목록·화면이 스크롤 | 두 렌더러 |
68
69
  | 좁은 폭·큰 글자 | 남은 폭에서 줄바꿈, 타일 크기는 유지 | flex wrap |
@@ -78,4 +79,5 @@ import { ImageChoiceGroup } from "@hjmds/react-native/image-choice";
78
79
 
79
80
  ## 근거
80
81
 
82
+ - `showItemLabels`: 2026-10-18 번뚝 공유 받는 사람 선택이 이름을 보이려고 링크 버튼에 아바타·체크·이름을 직접 조립했다.
81
83
  - 도입 계기: 2026-10-16 구성 예제 점검 — 제품들이 `selected`를 준 ghost 버튼으로 그림 선택을 흉내 내 선택 상태가 일관되지 않았다.
@@ -30,6 +30,7 @@
30
30
  | 이름 | 역할 | Web | Native |
31
31
  | --- | --- | --- | --- |
32
32
  | `MediaSelectionScreen` | 미디어 선택 검토 화면 | `/screen-flows` | `/screen-flows` |
33
+ | `MediaSelectionCell` | 화면 격자가 그리는 항목 칸(미리보기 → UploadItem → 위로·아래로·회전·삭제). 가상화 목록에서 같은 칸을 그릴 때 쓴다. 미게시 | `/screen-flows` | `/screen-flows` |
33
34
 
34
35
  granular subpath로만 import 된다(루트 entry에 없음). 추가 peer는 없다.
35
36
 
@@ -96,6 +97,7 @@ Web의 `<img alt="">`는 장식용 미리보기다. 사진 이름은 `removeLabe
96
97
  | `removeLabel` · `moveUpLabel` · `moveDownLabel` | `(item: MediaSelectionItem) => string` | 필수 | 사진 이름을 포함한 접근성 이름 |
97
98
  | `onMove` | `(id: string, direction: -1 \| 1) => void` | 필수 | 첫 항목의 위로·마지막 항목의 아래로는 비활성 |
98
99
  | `onRemove` · `onRetry` · `onCancel` | `(id: string) => void` | 필수 | 삭제, UploadItem 재시도·취소 |
100
+ | `rotate` | `{ label, itemLabel(item), onRotate(id) }` | 없음 | 미게시. 주면 아래로와 삭제 사이에 회전 버튼(`label` 표시 문구, `itemLabel` 사진 이름을 포함한 접근성 이름). 회전 값 저장과 `preview`·결과물 반영은 제품이 한다 |
99
101
  | 나머지 | `ScreenLayout`과 같음(`children`·`footer` 제외) | — | `actions`를 주면 `add` 앞에 같은 행으로 놓인다 |
100
102
 
101
103
  ## 배치
@@ -108,6 +110,21 @@ Web의 `<img alt="">`는 장식용 미리보기다. 사진 이름은 `removeLabe
108
110
  | 고정·스크롤 | 헤더·footer 고정, 격자는 본문 스크롤(`scroll` 기본 `"screen"`) | `ScreenLayout` |
109
111
  | 좁은 폭 | 좁은 폭에서도 2열 유지; 항목 버튼 줄은 줄바꿈(`flexWrap: "wrap"`)해 버튼을 자르지 않는다 | `screen-flows.tsx` |
110
112
 
113
+ ## 가상화 목록의 항목 칸
114
+
115
+ 사진이 많아 제품이 `VirtualList`·`FlatList`로 목록을 직접 그릴 때는 같은 칸 `MediaSelectionCell`을 `renderItem`에서 그린다. 미리보기와 행동 줄을 제품이 다시 조립하지 않는다(2026-10-18 Utilverse 사진→PDF·사진 띠 도구가 썸네일·순서·회전·삭제 칸을 직접 만들었다).
116
+
117
+ ```tsx
118
+ // Native
119
+ import { MediaSelectionCell } from "@hjmds/react-native/screen-flows";
120
+
121
+ <FlatList data={photos} keyExtractor={photo => photo.id} renderItem={({ item, index }) =>
122
+ <MediaSelectionCell item={toMediaItem(item)} index={index} count={photos.length} {...itemActions}
123
+ rotate={{ label: t("photo.rotate"), itemLabel: entry => t("photo.rotateNamed", { name: entry.descriptor.name }), onRotate: rotatePhoto }} />} />
124
+ ```
125
+
126
+ `index`·`count`는 전체 목록 기준이다(첫 항목 위로·마지막 항목 아래로 비활성). `itemActions`는 화면과 같은 `labels`·`actionLabels`·`removeLabel`·`moveUpLabel`·`moveDownLabel`·`onRemove`·`onMove`·`onRetry`·`onCancel`이다. 칸 사이 간격·열 수는 host가 준다. 삭제 후 Web 키보드 초점 복구는 화면 격자의 동작이므로 가상화 host는 자기 초점을 소유한다.
127
+
111
128
  ## 꼭 지킬 것
112
129
 
113
130
  - `actionLabels`는 짧은 표시 문구, `removeLabel`·`moveUpLabel`·`moveDownLabel`은 사진 이름을 포함한 접근성 이름이다.
@@ -76,7 +76,7 @@ import { Image } from "react-native";
76
76
  | `value` | `string` | 필수 | 완전 제어형. HJM은 초안을 지우지 않는다 |
77
77
  | `label` | `string` | 필수 | 접근성 이름; placeholder 생략 시 같은 문구 |
78
78
  | `placeholder` | `string` | `label` | 짧은 시각 안내와 전체 접근성 이름을 분리 |
79
- | `description` · `error` · `invalid` | `string` · `string` · `boolean` | 없음 | TextArea의 안내·오류 연결. Web describedby/invalid, Native hint/오류 표면 |
79
+ | `description` · `error` · `invalid` | `string` · `string` · `boolean` | 없음 | TextArea의 안내·오류 연결. Web describedby/invalid, Native hint/오류 표면. `error`는 나타나거나 바뀔 때 즉시(assertive) 알린다(1.30.0 이후 수정). 작성창 전용 알림은 없고 TextArea의 공용 Field 오류 계약([Field](field.md#오류-알림))을 그대로 쓴다 |
80
80
  | `onBlur` | `() => void` | 없음 | host의 입력 중 상태 해제 |
81
81
  | `submitMode` | `"newline"` · `"send"` | `"newline"` | Web Enter 전송은 Shift/IME/229 보호. Native send는 submit, newline은 기존 줄바꿈 |
82
82
  | `sendLabel` | `string` | 필수 | 전송 버튼 문구·접근성 이름 |
@@ -123,6 +123,7 @@ import { Image } from "react-native";
123
123
  - 입력 연결(blur/IME/검증)이 더 필요하면 제품이 내부 TextArea를 따로 조립하지 않고 기존 MessageComposer 엔진의 공개 축을 확장한다.
124
124
  - 로컬 준비 사진은 `attachments`로 표시하며 UploadItem의 서버 업로드 완료로 가장하지 않는다. 준비 중 해당 항목의 `disabled`와 attachmentAction.disabled를 연결해 편집 가능한 본문과 사진 제거를 분리한다. 서버 성공 전 초안을 지우지 않는다.
125
125
  - Web 배치는 `layoutStyle`로 한다. Native는 스타일 통로가 없어 감싸는 레이아웃에서 배치한다.
126
+ - 전송 실패는 `error`로 넘긴다. 알림을 위해 같은 문구를 화면 `notice`의 Notice나 `context`의 `role="alert"` 문구로 한 번 더 그리지 않는다.
126
127
 
127
128
  ## 플랫폼 차이
128
129
 
@@ -140,6 +141,8 @@ Native 내장 삭제·메뉴 기호는 고정 아이콘 틀의 크기를 유지
140
141
 
141
142
  ## 근거
142
143
 
144
+ - `error` 즉시 알림: 2026-10-18 번뚝 DM. 작성창 `error`가 입력 아래에 보이기만 하고 낭독되지 않아(Web 알림 없음, iOS live region 미지원) 전송 실패를 화면 notice의 Notice로 따로 알렸다. 첫 수정은 작성창에만 숨긴 alert를 더해 Web 탐색 모드에서 같은 문구를 두 번 읽었으므로, 같은 날 모든 Field 컨트롤의 공용 오류 계약으로 옮겼다.
145
+
143
146
  - 답장 중 카드 배치: 2026-10-16 카카오톡 실측.
144
147
  - `submitMode` 등 입력 공개 축: 2026-10-07 번뚝 채택에서 기존 blur/IME/검증 연결이 누락됐다.
145
148
  - Native 내장 기호 고정 크기: 2026-10-06 최근 검색 삭제 기호가 큰 글자에서 잘리는 것을 재현했다.
@@ -4,7 +4,7 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.17.1
7
- - 검토일: 2026-10-13
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
 
@@ -12,6 +12,8 @@
12
12
 
13
13
  게시물·댓글·사용자 **신고** 화면에 쓴다. 사유 선택, 사유를 고르기 전까지 막힌 신고 버튼,
14
14
  선택적인 차단 버튼과 그 확인 대화상자를 [ScreenLayout](screen-layout.md) 위에 조합한다.
15
+ 운영자가 신고·이의 신청을 처리하는 화면은 `variant="review"`(1.31.0), 편지·카드·Sheet 안에서 펼치는 신고는
16
+ 화면 틀 없는 `ModerationForm`(1.31.0)이다.
15
17
 
16
18
  ## 쓰지 않을 때
17
19
 
@@ -20,12 +22,16 @@
20
22
  | 차단·삭제 확인만 필요 | [AlertDialog](alert-dialog.md) |
21
23
  | 신고 진입 메뉴(⋯) | [Menu](menu.md) |
22
24
  | 자유 입력 위주의 문의 폼 | [Form](form.md), [EditorScreen](editor-screen.md) |
25
+ | 편지·카드·Sheet 안 신고에 ModerationScreen을 넣고 `contentInset="none"`으로 틀을 끄기 | `ModerationForm` |
26
+ | 운영자 처리 화면을 TextArea·Button·Alert로 직접 조립 | `variant="review"` |
23
27
 
24
28
  ## 공개 이름과 import
25
29
 
26
30
  | 이름 | 역할 | Web | Native |
27
31
  | --- | --- | --- | --- |
28
32
  | `ModerationScreen` | supplemental, 루트 barrel에 없음 | `/screen-flows` | `/screen-flows` |
33
+ | `ModerationForm` | companion(1.31.0). 화면 틀 없는 같은 폼 | `/screen-flows` | `/screen-flows` |
34
+ | `ModerationDecision`·`ModerationDecisionReason`·`ModerationFields`·`ModerationFormProps` (타입) | 운영자 결정·처리 사유·두 변형의 공통 필드·폼 props | `/screen-flows` | `/screen-flows` |
29
35
 
30
36
  `@hjmds/react/screen-flows`, `@hjmds/react-native/screen-flows`로만 import한다. 추가 optional peer는 없다.
31
37
 
@@ -97,13 +103,86 @@ import { ModerationScreen } from "@hjmds/react-native/screen-flows";
97
103
  | `block` | 차단 버튼 행동과 `AlertDialog` 확인 요청(`mode: "confirm"`) |
98
104
  | ScreenLayout props | `title`, `description`, `exit`(push·모달로 여는 신고 화면의 닫기/뒤로, 헤더 시작 끝), `header`, `actions`, `notice`, `state`, `stateAction` 등(`footer` 제외) |
99
105
 
106
+ ### 운영자 검토 `variant="review"` (1.31.0)
107
+
108
+ ```tsx
109
+ // Native(Web은 exit의 onPress 대신 onClick)
110
+ <ModerationScreen
111
+ variant="review"
112
+ title={t("moderation.review")}
113
+ exit={{ kind: "back", label: t("moderation.queue"), onPress: back }}
114
+ decisionReason={{ label: t("moderation.reason"), description: t("moderation.reasonHint"), value: reason, onValueChange: setReason, maxLength: 500 }}
115
+ decisions={[
116
+ { id: "dismiss", label: t("moderation.dismiss"), pending: sending === "dismiss",
117
+ confirmation: { mode: "confirm", title: t("moderation.dismissConfirm"), description: t("moderation.dismissHint"),
118
+ confirmLabel: t("moderation.dismiss"), cancelLabel: t("common.cancel"), fallbackErrorMessage: t("common.error"), onConfirm: () => decide("dismiss") },
119
+ onAction: () => {} },
120
+ { id: "hide", label: t("moderation.hide"), tone: "danger", pending: sending === "hide", disabled: !detail.available,
121
+ confirmation: { mode: "confirm", tone: "danger", title: t("moderation.hideConfirm"), description: t("moderation.hideHint"),
122
+ confirmLabel: t("moderation.hide"), cancelLabel: t("common.cancel"), fallbackErrorMessage: t("common.error"), onConfirm: () => decide("hide") },
123
+ onAction: () => {} },
124
+ ]}
125
+ {...(detail.resolution ? { resolution: <Notice tone="success" title={t("moderation.hidden")} description={detail.resolutionReason} /> } : {})}
126
+ >
127
+ <ReportedContent detail={detail} />
128
+ </ModerationScreen>
129
+ ```
130
+
131
+ | prop | 값 | 기본값 | 설명 |
132
+ | --- | --- | --- | --- |
133
+ | `variant` | `"report"` · `"review"` | `"report"` | `review`면 아래 props를 쓰고 신고 props(`reasons`·`reason`·`submit`·`block` 등)는 받지 않는다 |
134
+ | `children` | `ReactNode` | — | 신고된 내용·신고 사유 등 검토 대상(DescriptionList·Notice 등 HJM 구성으로) |
135
+ | `decisionReason` | `{ label, value, onValueChange, description?, maxLength?, required?, disabled? }` | `required` `true` | 처리 사유 TextArea(제어형). `required`면 공백뿐인 사유로는 어떤 결정도 열리지 않는다 |
136
+ | `decisions` | `ModerationDecision[]`(`ScreenFlowAction` + `{ id, tone?, confirmation? }`) | `tone` `"secondary"` | 결정 버튼, footer에 순서대로 세로로 쌓인다(`spacing.xs` 8). `tone`은 `primary`·`secondary`·`danger`. `confirmation`(AlertDialog `confirm`)이 있으면 누를 때 확인창만 열고 `onConfirm`이 실행하며 `onAction`은 부르지 않는다(`block`과 같은 규칙). 한 결정이 `pending`이면 다른 결정과 사유 입력이 잠긴다. 본문 `state`가 `ready`가 아니어도 잠긴다 |
137
+ | `resolution` | `ReactNode` | — | 이미 처리된 신고. 주면 사유 입력과 결정 버튼 자리를 이것이 대체한다(footer 없음) |
138
+
139
+ ### ModerationForm: 화면 틀 없는 신고 폼
140
+
141
+ ModerationScreen과 같은 필드(`variant` 두 가지 모두)를 [CompositionLayout](composition-layout.md) 하나로 그린다: (제목) → 본문(사유 선택 또는 검토 대상·처리 사유) → 행동(세로 쌓기 `spacing.xs` 8). 화면 머리·바깥 여백·footer 표면·스크롤이 없어 편지·카드(Surface) 안이나 Sheet 본문에 놓는다. 감싸는 표면·위치·스크롤은 host 소유다.
142
+
143
+ ```tsx
144
+ // Web — 편지 아래에 펼친 신고
145
+ import { ModerationForm } from "@hjmds/react/screen-flows";
146
+
147
+ <Surface tone="subtle" radius="lg" padding="lg">
148
+ <ModerationForm
149
+ title={t("report.title")}
150
+ reasons={reasons} reason={reason} onReasonChange={setReason} reasonLabel={t("report.reason")}
151
+ submit={{ label: t("report.confirm"), pending: busy, onAction: report }}
152
+ cancel={{ label: t("common.cancel"), disabled: busy, onAction: collapse }}
153
+ >
154
+ {error ? <Notice tone="danger" title={error} /> : null}
155
+ </ModerationForm>
156
+ </Surface>
157
+ ```
158
+
159
+ ```tsx
160
+ // Native — Sheet 안: Sheet가 제목과 닫기를 가지므로 title·cancel을 생략한다
161
+ import { ModerationForm } from "@hjmds/react-native/screen-flows";
162
+
163
+ <Sheet open={open} onOpenChange={setOpen} title={t("report.title")} closeLabel={t("common.close")}>
164
+ <ModerationForm reasons={reasons} reason={reason} onReasonChange={setReason} reasonLabel={t("report.reason")}
165
+ submit={{ label: t("report.confirm"), pending: busy, onAction: report }} />
166
+ </Sheet>
167
+ ```
168
+
169
+ | prop | 값 | 기본값 | 설명 |
170
+ | --- | --- | --- | --- |
171
+ | 신고·검토 필드 | ModerationScreen과 같음 | — | `variant`·`reasons`…·`block` 또는 `decisionReason`·`decisions`·`resolution` |
172
+ | `title` | 문자열 | — | 구성 제목(`level3`, 문서 수준 `headingLevel` 기본 3). 제목 줄이 있는 Sheet·Dialog 안에서는 생략 |
173
+ | `headingLevel` | 1~6 | 3 | 제목의 문서 수준 |
174
+ | `cancel` | `ScreenFlowAction` | — | 행동 줄 맨 끝 ghost 버튼(펼친 신고 접기) |
175
+ | `layoutStyle` | margin·width·flex·`alignSelf` | — | 바깥 배치 전용 |
176
+
177
+ 화면 상태(`state`)가 없으므로 신고·결정 버튼은 사유·`disabled`·`pending`으로만 잠긴다.
178
+
100
179
  ## 배치
101
180
 
102
181
  | 항목 | 값 | 근거 |
103
182
  | --- | --- | --- |
104
183
  | 크기 | ScreenLayout 폭(최대 720); 사유는 세로 `RadioGroup`, 신고·차단은 `Button` 기본 크기 | `ModerationScreen`, `screen-flows.tsx` `Action` |
105
184
  | 간격 | 화면 padding `spacing.md` 16; 본문 사유 선택–`children` `spacing.lg` 20; footer 신고–차단 `spacing.xs` 8(세로로 쌓은 버튼, `actionGroupRecipe.stackedGap`) | Web·Native `ModerationScreen` `Stack gap="lg"`·`gap="xs"` |
106
- | 순서·정렬 | 헤더(제목) → 본문(사유 → 추가 설명 `children`) → footer(신고 primary → 차단 ghost) | 렌더 순서 |
185
+ | 순서·정렬 | 헤더(제목) → 본문(사유 → 추가 설명 `children`) → footer(신고 primary → 차단 ghost). `review`: 본문(검토 대상 `children` → 처리 사유 TextArea 또는 `resolution`) → footer(결정 버튼, 주어진 순서). `ModerationForm`: (제목) → 본문 → 행동(… → `cancel` ghost) | 렌더 순서 |
107
186
  | 고정·스크롤 | 헤더·footer 고정, 본문 화면 스크롤; 차단 확인은 AlertDialog 오버레이 | `ScreenLayout`, `AlertDialog` |
108
187
  | 좁은 폭·큰 글자 | 사유 문구는 줄바꿈되고 항목이 세로로 늘어난다; footer 버튼은 세로로 쌓여 좁은 폭에서도 나란히 줄지 않는다 | `RadioGroup orientation="vertical"`, `Stack` |
109
188
 
@@ -114,4 +193,11 @@ import { ModerationScreen } from "@hjmds/react-native/screen-flows";
114
193
  - 서버 신고·권한·차단 mutation, 완료 후 이동은 제품 소유다. 사유 목록과 정책 문구도 제품이 정한다.
115
194
  - 신고 화면은 push·모달로 열리므로 `exit`로 나갈 길을 준다. 2026-10-17 화면 검토(S5)에서 exit 없는 예제가 나갈 수 없는 화면으로 렌더링됐다.
116
195
  - 확인 대화상자의 성공 후 닫힘·실패 문구는 [AlertDialog](alert-dialog.md) 계약을 따른다.
196
+ - 운영자 결정의 문구·id·서버 결정 mutation은 제품 소유다. 결정이나 그 `confirmation`이 사라지거나 `resolution`이 오면 열린 확인창도 닫힌다.
197
+ - 편지·카드·Sheet 안 신고는 `ModerationForm`이다. ModerationScreen의 `contentInset`·`scroll`·`header`로 화면 틀을 지우지 않는다.
117
198
  - `block`을 제거하면 열린 확인 세션도 종료한다. 나중에 차단 행동을 다시 공급해도 사용자가 버튼을 새로 눌러야 확인창이 열린다. 2026-10-13 사라진 행동의 확인 상태가 부활하던 경로를 수정했다. 대상이 바뀌는 제품은 대상 id를 화면 key/초안·요청 수명에 함께 연결한다.
199
+
200
+ ## 근거
201
+
202
+ - `variant="review"`(1.31.0): 2026-10-18 앱 감사에서 utilverse 신고·이의 신청 처리 화면이 처리 사유 TextArea, 기각/숨기기 버튼 묶음, OS `Alert` 확인을 화면마다 직접 조립했다. 결정 문구는 큐마다 달라(기각·숨기기·유지·번복) props로 받고, 사유 필수·한 번에 한 결정·확인창 규칙은 HJM이 소유한다.
203
+ - `ModerationForm`(1.31.0): 같은 감사에서 diairy가 편지 안에 펼치는 신고에 ModerationScreen을 넣고 `contentInset="none"`·`scroll="content"`로 화면 틀을 끄면서도 머리 띠가 남았다(Dialog·Sheet는 자기 제목 줄을 가진다). 같은 필드·행동을 화면 틀 없이 그리는 companion으로 분리해 두 표현이 한 구현(`useModeration`)을 공유한다.