@hjmds/design-contracts 1.31.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.
- package/dist/behaviors.d.ts +2 -0
- package/dist/behaviors.d.ts.map +1 -1
- package/dist/bottom-navigation.d.ts +19 -0
- package/dist/bottom-navigation.d.ts.map +1 -1
- package/dist/bottom-navigation.js +21 -0
- package/dist/bottom-navigation.js.map +1 -1
- package/dist/catalog.d.ts +37 -0
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +2 -1
- package/dist/catalog.js.map +1 -1
- package/dist/component-recipes.d.ts +39 -0
- package/dist/component-recipes.d.ts.map +1 -1
- package/dist/component-recipes.js +27 -1
- package/dist/component-recipes.js.map +1 -1
- package/dist/image-choice.d.ts +40 -0
- package/dist/image-choice.d.ts.map +1 -1
- package/dist/image-choice.js +49 -0
- package/dist/image-choice.js.map +1 -1
- package/dist/pagination.d.ts +53 -0
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +48 -0
- package/dist/pagination.js.map +1 -1
- package/dist/screen-patterns.d.ts +52 -2
- package/dist/screen-patterns.d.ts.map +1 -1
- package/dist/screen-patterns.js +34 -1
- package/dist/screen-patterns.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/docs/authoring-tools.md +1 -0
- package/docs/bottom-navigation.md +18 -0
- package/docs/data-workflows.md +1 -0
- package/docs/generated/component-maturity.md +1 -1
- package/docs/generated/renderer-evidence.json +7 -5
- package/docs/generated/renderer-evidence.md +1 -1
- package/docs/generated/showcase-manifest.json +4 -2
- package/docs/pagination.md +17 -0
- package/docs/screen-patterns.md +37 -1
- package/docs/toast.md +10 -0
- package/docs/usage/README.md +1 -1
- package/docs/usage/components/bottom-cta.md +11 -0
- package/docs/usage/components/bottom-navigation.md +36 -2
- package/docs/usage/components/chat-message.md +5 -2
- package/docs/usage/components/comment-thread-row.md +1 -1
- package/docs/usage/components/icon-tile.md +9 -2
- package/docs/usage/components/image-choice-group.md +33 -4
- package/docs/usage/components/media-selection-screen.md +5 -3
- package/docs/usage/components/moderation-screen.md +20 -1
- package/docs/usage/components/pagination.md +28 -4
- package/docs/usage/components/screen-layout.md +6 -3
- package/docs/usage/components/search-screen.md +5 -3
- package/docs/usage/components/toast.md +5 -0
- package/package.json +1 -1
package/docs/usage/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
버튼 위치에서 시작해 구성·컴포넌트 지침으로 내려가고, 값은 토큰 지침에서 고른다.
|
|
7
7
|
[선택 기준](SELECTION.md)으로 그대로 사용·공개 슬롯 확장·공통 API 보완·제품 고유 기능 연결을 고른다. 설치 버전의 지침을 본다: `node_modules/@hjmds/design-contracts/docs/usage/`.
|
|
8
8
|
제공 기준은 확인한 기본 API 버전이며 최초 출시 버전이 아니다. 구성·화면은 각 API 지침의 제공 버전을 확인한다.
|
|
9
|
-
API 안정성은 1.
|
|
9
|
+
API 안정성은 1.31.0의 생성 manifest에서 읽은 정규(canonical) API 기준이다. 동반·선택형 확장은 개별 계약을 확인한다.
|
|
10
10
|
Storybook 배포/실험은 예제 분류이며 npm 게시·기기 검증·제품 반영을 의미하지 않는다.
|
|
11
11
|
`분류`는 Storybook 제목 `<배포|실험>/<단계>/<분류>/<항목>`의 셋째 마디이고, 토큰·구성·화면은 Storybook 메뉴와 같은 순서다.
|
|
12
12
|
|
|
@@ -63,6 +63,7 @@ const insets = useSafeAreaInsets();
|
|
|
63
63
|
| prop | 값 | 기본값 | 설명 |
|
|
64
64
|
| --- | --- | --- | --- |
|
|
65
65
|
| `primaryAction` | Web `{ label, onClick, accessibilityLabel?, disabled?, loading?, loadingLabel?, size?, tone? }` · Native `{ label, onPress, accessibilityLabel?, accessibilityHint?, disabled?, loading?, loadingLabel?, size?, tone? }` | 필수, tone `primary`, size `medium` | Web `onClick: (event: MouseEvent<HTMLButtonElement>) => void`, Native `onPress: (event: GestureResponderEvent) => void` |
|
|
66
|
+
| action `buttonRef` | Web `Ref<HTMLButtonElement>` · Native `Ref<View>` | — | 미게시. 그 행동의 버튼 자체를 가리킨다(컴포넌트 `ref`는 Web에서 바 `div`). 이 행동으로 연 Dialog·Sheet·`ImmersiveOverlay`가 닫힐 때 포커스를 돌려줄 곳으로 Native `returnFocusRef`에 넘긴다. Web Dialog는 연 요소로 스스로 돌아가므로 필요할 때만 쓴다 |
|
|
66
67
|
| `secondaryAction` | 같은 action 객체 또는 제품이 만든 `ReactNode` | tone `secondary` | 객체면 HJM Button으로 그린다. Web은 `label`과 `onClick`이 있어야 객체로 본다 |
|
|
67
68
|
| `description` | `string` | — | 행동 위 caption 한 줄 |
|
|
68
69
|
| `accessibilityLabel` | `string` | — | 바 전체(Web `role="group"`, Native `toolbar`)의 이름 |
|
|
@@ -94,6 +95,15 @@ const insets = useSafeAreaInsets();
|
|
|
94
95
|
큰 글자: [ 주 행동 ] / [ 보조 ] (주 행동 위)
|
|
95
96
|
```
|
|
96
97
|
|
|
98
|
+
### 닫힌 뒤 여는 버튼으로 포커스 돌리기
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
// Native — ScreenLayout footer의 BottomCTA로 연 전체 화면 오버레이
|
|
102
|
+
const startRef = useRef<View>(null);
|
|
103
|
+
<ScreenLayout footer={<BottomCTA primaryAction={{ label: t("display.start"), onPress: open, buttonRef: startRef }} />}>…</ScreenLayout>
|
|
104
|
+
<ImmersiveOverlay open={presenting} onOpenChange={setPresenting} returnFocusRef={startRef} label={t("display.title")} exitLabel={t("common.close")}>…</ImmersiveOverlay>
|
|
105
|
+
```
|
|
106
|
+
|
|
97
107
|
## 꼭 지킬 것
|
|
98
108
|
|
|
99
109
|
- action `label`은 i18n 키로 넣고 비우지 않는다. Web은 빈 label에 `TypeError`를 던진다.
|
|
@@ -129,4 +139,5 @@ const insets = useSafeAreaInsets();
|
|
|
129
139
|
|
|
130
140
|
## 근거
|
|
131
141
|
|
|
142
|
+
- action `buttonRef`: 2026-10-18 유틸버스 전광판 — footer의 BottomCTA 주 행동으로 연 `ImmersiveOverlay`를 닫아도 Native는 명시한 `returnFocusRef`로만 포커스를 돌리는데, BottomCTA가 버튼 ref를 내주지 않아 포커스가 화면 처음으로 갔다.
|
|
132
143
|
- footer 안 하단 inset은 합이 아니라 max: 2026-10-17 사용자 보고 "전체적으로 버튼 아래 여백이 많아". footer 16 위에 `safeAreaBottom`을 더해 홈 인디케이터 기기에서 버튼 아래가 50이 됐다. 마지막 컨트롤 아래 여백을 footer 위 여백 16과 같게 두고, 홈 인디케이터가 있으면 max(16, inset)만 둔다([ScreenLayout 근거](screen-layout.md#근거)).
|
|
@@ -79,6 +79,8 @@ import { BottomNavigation } from "@hjmds/react-native/navigation";
|
|
|
79
79
|
| `configuration.selection` | `emphasis` · `tile` | `emphasis` | `tile`은 선택 목적지 전체를 카드로 채운다. floating·equal·visible labels 전용 |
|
|
80
80
|
| `configuration.surface` | `flat` · `raised` | `flat` | `raised`는 `selection: "tile"` 전용 두께 있는 막대다. surfaceAlt 바탕·borderControl 옆면(4)·선택 카드 옆면(3) |
|
|
81
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` 원은 흐려지지 않는다 |
|
|
82
84
|
| 항목 `prominent` | `boolean` | `false` | 1.31.0 추가. 가운데 목적지를 막대 위로 솟은 브랜드 원(지름 56, 글리프 28)으로 그린다. 선택·재선택·router 소유는 다른 항목과 같다. 홀수 개 항목의 정가운데 하나만, `distribution: "equal"`·`bar`/`floating`·`selection: "emphasis"`에서만 허용(위반 시 오류) |
|
|
83
85
|
| Native `placement` | `inline` · `overlay` | `inline` | 1.31.0 추가. `overlay`는 막대를 host 아래쪽에 겹쳐 고정하고(본문이 떠 있는 막대 뒤로 스크롤) 덮는 높이를 `BottomNavigationInsetProvider`에 알린다. `inline`은 자기 높이를 차지하므로 알리지 않는다 |
|
|
84
86
|
| `primaryAction` | `ReactNode`(Button/IconButton) | — | `configuration` 밖의 별도 prop. 생성 같은 비목적지 행동이며 `center-gap` 또는 `capsule`과 짝짓는다 |
|
|
@@ -125,8 +127,11 @@ floating·capsule: 바깥 좌우 16 · 위 8 띄운 둥근 표면, 최대 폭 38
|
|
|
125
127
|
화면들과 막대를 함께 감싸는 자리(탭 navigator·앱 셸)에 `BottomNavigationInsetProvider`를 둔다. 막대는 자기 높이(안전 영역
|
|
126
128
|
padding과 `prominent` 원이 막대 위로 솟은 부분 포함)를 측정해 알리고, 키보드 때문에 숨으면 0을 알린다.
|
|
127
129
|
`ScreenLayout`은 이 값을 스스로 읽어 마지막 영역(footer, 없으면 본문)의 아래 여백을 `max(기존 값, 화면 여백 16 + 덮인 높이)`로
|
|
128
|
-
둔다. 그래서 탭 화면마다 손으로 둔 하단 빈칸이 필요 없다.
|
|
129
|
-
`
|
|
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 밖에서 직접 만든 레이아웃(막대 위 떠 있는 제품 컨트롤 등)이 읽는다.
|
|
130
135
|
|
|
131
136
|
```tsx
|
|
132
137
|
// Native (Expo Router 탭)
|
|
@@ -171,6 +176,32 @@ Web Provider는 같은 값을 `--hjm-bottom-navigation-inset` CSS 변수로도
|
|
|
171
176
|
`renderIcon`은 원래 어떤 ReactNode든 그릴 수 있다(이미지 포함). `iconSize: "large" | "xlarge"`는 그 자리를 44 · 52로 키워
|
|
172
177
|
런처의 앱 아이콘처럼 큰 그림을 넣게 한다. 그림은 받은 `size`로 그리고 크기를 따로 정하지 않는다.
|
|
173
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
|
+
|
|
174
205
|
## 꼭 지킬 것
|
|
175
206
|
|
|
176
207
|
- `selectedKey`는 router가 확정한 값으로 넘긴다. `onActivate`는 의도일 뿐이며 컴포넌트가 선택을 바꾸지 않는다.
|
|
@@ -197,6 +228,7 @@ Web Provider는 같은 값을 `--hjm-bottom-navigation-inset` CSS 변수로도
|
|
|
197
228
|
|
|
198
229
|
- Web root가 `position: fixed`라 본문 마지막 내용이 막대 아래로 들어간다. `BottomNavigationInsetProvider`로 감싸면 ScreenLayout이 여백을 둔다. 직접 만든 레이아웃은 `--hjm-bottom-navigation-inset`을 쓴다.
|
|
199
230
|
- Native 기본 `inline` 막대는 React Navigation `tabBar`처럼 자기 높이를 차지한다. 이때 탭 화면에 하단 빈칸을 또 두면 두 번 비게 된다. 막대 뒤로 본문을 보이게 하려면 `placement="overlay"`와 Provider를 함께 쓴다.
|
|
231
|
+
- ScreenLayout 안의 목록(`scroll="content"` FlatList 포함)에 `useBottomNavigationInset()`이나 `--hjm-bottom-navigation-inset`을 더하지 않는다. ScreenLayout 마지막 영역이 이미 비웠다.
|
|
200
232
|
- `renderLink` 없이 쓰면 일반 `<a>`로 그려 SPA 전환이 일어나지 않는다.
|
|
201
233
|
- Web `onActivate`는 링크 기본 이동을 막지 않는다. `renderLink`의 라우터 Link가 이동하고 `onActivate`는 계측·맨 위로 스크롤 같은 부수 동작에만 쓴다. 수정키·가운데 클릭에는 불리지 않는다.
|
|
202
234
|
- Native 내비게이션 연구 스토리(`showcase/native/src/reference-navigation-bars.tsx`)는 `configuration`과 [Heading](heading.md)을 사용한다. 과거의 `style`·`listStyle` override는 제거되었으며, 선택 타일과 모서리는 공개 configuration 축으로 정한다.
|
|
@@ -228,7 +260,9 @@ floating 그림자는 그리지 않는다. 옆면이 높이를 표현하므로
|
|
|
228
260
|
## 근거
|
|
229
261
|
|
|
230
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) 기준 없었다.
|
|
231
264
|
- `prominent`: 2026-10-18 다에리 가운데 "메아리"(쓰기) 탭을 그리려고 HJM 막대 대신 Expo Router 기본 탭바와 제품 원 아이콘을 썼다.
|
|
232
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로 했다.
|
|
233
267
|
|
|
234
268
|
- capsule 선택 알약·tile 선택 카드: 2026-10-17 정렬 실측(Web 402·1280, 실험 내비게이션 바 비교). 첫·끝 아이콘의 막대 끝 거리 차이 17~23px은 선택 항목의 `flex: 2` 알약 때문이며 항목 상자 양끝 여백은 5/5로 같았다. 가운데 항목이 14.5px 솟아 보인 값은 tile 선택 카드 상자를 다른 항목의 라벨과 비교한 측정 오차였고 아이콘 244·라벨 268이 모든 항목에서 같았다. 두 renderer에 이미 같은 규칙이 있어 코드는 바꾸지 않고 배치 표에 의도를 적었다.
|
|
@@ -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,11 +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`가 있으면 사진마다 버튼(크게 보기는 제품) |
|
|
88
|
-
| `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`로 다뤄진다. 넘긴 장은 길게 누르기 미리보기에서도 같다. `media`와 함께 주면 `TypeError` |
|
|
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` |
|
|
89
91
|
| `replyAction` | `{ label, onPress(), disabled?, icon? }` | 없음 | `replySwipeDistance` 60px 이상이고 세로 이동의 2배를 넘는 가로 스와이프로 실행(`isReplySwipe`). `reactions`가 있으면 길게 누른 메뉴의 첫 항목으로도 보인다(미게시) |
|
|
90
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 길게 누르기 실측 반영) |
|
|
91
93
|
| `reactions.menuActions[].tone` | `"neutral"` · `"danger"` | `"neutral"` | 미게시(1.30.0 이후). 길게 누른 메뉴 행의 색. 삭제처럼 되돌릴 수 없는 행은 `danger`(빨간 글자). [Menu](menu.md) 항목 `tone`과 같은 어휘(`MenuItemTone`). `reportAction`은 항상 `danger`이고 맨 끝이다. 댓글(CommentThreadRow) 메뉴도 같다 |
|
|
92
|
-
| `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 초점은 말풍선으로
|
|
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 요소)이므로 "메뉴 열기" 같은 행동 문구를 넣지 않는다 |
|
|
93
95
|
| `interactiveContent` | `boolean` | `false` | 사진 앨범·링크처럼 자식이 버튼을 가지면 `true`. 안쪽 버튼·링크의 누름과 Web Enter/Space는 그 컨트롤이 받는다 |
|
|
94
96
|
| `actions` | `ReactNode` | 없음 | 시각 옆 슬롯. 반응 집계 배지·재시도는 제품이 넣는다 |
|
|
95
97
|
| `children` | `ReactNode` | 필수 | 메시지 본문. Native는 말풍선이 이 텍스트로 읽힌다 |
|
|
@@ -154,6 +156,7 @@ import { Text } from "@hjmds/react-native/primitives";
|
|
|
154
156
|
## 근거
|
|
155
157
|
|
|
156
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을 따로 그렸고, 앨범 장 안의 불러오는 중·다시 시도가 사진 칸의 단일 접근성 이미지에 삼켜져 낭독기로 닿지 않았다.
|
|
157
160
|
- `menuActions[].tone`: 2026-10-18 유틸버스 채팅 길게 누르기 메뉴의 삭제 행. 빨간 글자가 `reportAction`에만 있어 삭제가 일반 행으로 보였다.
|
|
158
161
|
|
|
159
162
|
- 아바타 32: 2026-10-17 정렬 실측. recipe·문서는 36이었지만 Web Avatar 단계(32/40/48/64)에 36이 없어 Web은 36 칸에 32 아바타를, Native는 36 아바타를 그렸다. 카카오톡 참고값 약 34에 가장 가까운 Avatar 단계 `small` 32를 recipe가 직접 읽게 해 두 플랫폼이 같은 값을 쓴다. 작성자 첫 줄과의 위쪽 맞춤(카카오톡 방식)은 유지한다.
|
|
@@ -66,7 +66,7 @@ import { CommentThreadRow } from "@hjmds/react-native/screen-flows";
|
|
|
66
66
|
| `bodyText` | 없음 | 주면 `author`와 본문을 한 문장으로, 시각은 아래 행동 줄로 옮긴다 |
|
|
67
67
|
| `timePlacement` | `"auto"` | 미게시. `"auto"`는 `bodyText`가 없으면 시각을 작성자 옆에 둔다. `"actions"`는 글이 아닌 본문(꽃 도장·카드)에서도 시각을 본문 아래 반응 요약·답글과 같은 줄 맨 앞으로 옮긴다 |
|
|
68
68
|
| `panel` | 없음 | 미게시. 댓글별 인라인 패널(수정·신고·차단 양식). 답글 줄 아래, 길게 누르기 대상 밖에 `panelGap` 8 간격으로 놓인다. 열림 상태·초점·제출·닫기는 제품이 소유한다 |
|
|
69
|
-
| `mediaAlbum` | 없음 | 미게시. 한 장씩 넘기는 사진 묶음 `{ count, renderPage(index), pageLabel(index, count), previousLabel, nextLabel, aspectRatio?, onPress?(index), index?·onIndexChange?·defaultIndex? }`. `renderPage`는 화면에 보이는 장에만 호출되어 제품이 그 사진만 조회·디코딩한다. `media`와 함께 주면 `TypeError`. 본문 아래, 길게 누르기 대상 밖에 놓인다 |
|
|
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`. 본문 아래, 길게 누르기 대상 밖에 놓인다 |
|
|
70
70
|
| `avatar` | 없음 | 제품 아바타. 최상위 32·답글 24 폭 칸에 둔다 |
|
|
71
71
|
| `canReply` | `parentId === null` | 답글 행동 표시 여부 |
|
|
72
72
|
| `replyDisabled` | false | 답글 행동 비활성 |
|
|
@@ -45,11 +45,16 @@ import { IconTile } from "@hjmds/react-native/screens";
|
|
|
45
45
|
</Pressable>
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
```tsx
|
|
49
|
+
// Web·Native 같은 형태 — 격자 칸을 채우는 고정 크기 이미지 아트워크
|
|
50
|
+
<IconTile frame="none" size="fill" label={title} icon={({ edge }) => <ToolArtwork id={tool.id} size={edge} />} />
|
|
51
|
+
```
|
|
52
|
+
|
|
48
53
|
## 축과 기본값
|
|
49
54
|
|
|
50
55
|
| prop | 값 | 기본값 | 설명 |
|
|
51
56
|
| --- | --- | --- | --- |
|
|
52
|
-
| icon | ReactNode | 필수 | 아이콘
|
|
57
|
+
| icon | `ReactNode` · `(artwork: IconTileArtwork) => ReactNode` | 필수 | 아이콘 원본. 함수면 그려질 아이콘 한 변 `artwork.edge`를 받는다(`fixed` 64, `fill`은 측정한 칸 크기 48~68). 고정 크기 이미지·생성 아트워크는 이 값으로 크기를 맞춘다(미게시, 1.31.0 이후) |
|
|
53
58
|
| label | string | 필수 | 제목 |
|
|
54
59
|
| overlay | ReactNode | 없음 | 제품 배지 |
|
|
55
60
|
| labelLines | 1 또는 2 | 1 | 제목 줄 수 |
|
|
@@ -58,7 +63,7 @@ import { IconTile } from "@hjmds/react-native/screens";
|
|
|
58
63
|
| shape | `"squircle"` · `"square"` · `"circle"` | `"squircle"` | 미게시. 틀(과 ghost 점선)의 모서리. 크기 대비 비율 `iconTileRecipe.shapes`(squircle 0.22 · square 0 · circle 0.5). 제품 아이콘 테마(아이폰 셸 squircle, Windows·Linux 셸 square)를 따른다 |
|
|
59
64
|
| size | `"fixed"` · `"fill"` | `"fixed"` | 미게시. `fixed`는 64×64, `fill`은 격자 칸 폭을 채우되 `fillMinSize` 48 ~ `fillMaxSize` 68 사이로 제한한 정사각형. 열 수·칸 폭은 host Grid가 정하고 제품이 칸 크기를 계산해 넘기지 않는다 |
|
|
60
65
|
|
|
61
|
-
`icon: ReactNode`, `label: string`, `overlay?: ReactNode`, `labelLines?: 1 | 2`(기본1), `ghost?: boolean`(기본false). 두 줄은 런처 제목 높이44를 예약하고 ghost는 이동 대상의 점선/반투명 표현이다. 아이콘 64, 제목 최대 폭72, 간격6을 공유 recipe가 정한다. overlay는 아이콘 기준 배지를 위한 슬롯이며 넘침을 자르지 않는다.
|
|
66
|
+
`icon: ReactNode | ((artwork: IconTileArtwork) => ReactNode)`, `label: string`, `overlay?: ReactNode`, `labelLines?: 1 | 2`(기본1), `ghost?: boolean`(기본false). 두 줄은 런처 제목 높이44를 예약하고 ghost는 이동 대상의 점선/반투명 표현이다. 아이콘 64, 제목 최대 폭72, 간격6을 공유 recipe가 정한다. overlay는 아이콘 기준 배지를 위한 슬롯이며 넘침을 자르지 않는다.
|
|
62
67
|
|
|
63
68
|
## 배치
|
|
64
69
|
|
|
@@ -76,6 +81,7 @@ import { IconTile } from "@hjmds/react-native/screens";
|
|
|
76
81
|
|
|
77
82
|
아이콘 원본과 제품 배지만 전달한다. 같은 배치를 별도 View/div로 다시 만들지 않는다.
|
|
78
83
|
|
|
84
|
+
- `size="fill"`의 이미지 아트워크는 `icon={({ edge }) => <Artwork size={edge} />}`로 그린다. 제품이 격자 폭에서 48~68 제한을 다시 계산해 넘기지 않는다. 측정 전 쪽 수·높이 추정처럼 타일 밖에서 크기가 필요하면 contracts `resolveIconTileFillEdge(칸 폭)`을 쓴다. 측정 전 첫 렌더의 `edge`는 `fillMinSize` 48이다(Web은 그리기 전에 측정한다).
|
|
79
85
|
- 완성된 아이콘 아트워크 위에 틀이 두 겹으로 그려지면 `frame="none"`, 아이콘 모양이 테마마다 다르면 `shape`, 칸 크기를 따라 커져야 하면 `size="fill"`을 쓴다. 이 이유로 런처 항목을 직접 만들지 않는다.
|
|
80
86
|
|
|
81
87
|
## 상태와 상호작용
|
|
@@ -97,3 +103,4 @@ host에 전체 행동의 현지화 이름을 제공한다. 아이콘 내부 장
|
|
|
97
103
|
## 근거
|
|
98
104
|
|
|
99
105
|
- `frame`·`shape`·`size`: 2026-10-18 HJM 1.30.0 감사. 유틸버스 런처는 도구 아트워크가 완성된 아이콘(셸에 따라 iPhone squircle, Windows·Linux square)이고 크기가 격자를 따라 48~68로 변해, 고정 64 틀을 그리는 IconTile 대신 자체 타일을 유지했다. 범위 48~68은 그 런처의 실제 칸 계산값이다.
|
|
106
|
+
- `icon` 함수와 `resolveIconTileFillEdge`: 2026-10-18 HJM 1.32.0 감사. `size="fill"` 채택 뒤에도 유틸버스 런처는 도구 아트워크가 고정 크기 이미지라 격자 폭에서 48~68 제한을 다시 계산해(`ToolboxScreen` `size`) 아트워크에 넘겼다. 타일이 측정한 한 변을 아트워크에 넘겨 계산을 HJM 한 곳에 둔다.
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
| 이름 | 역할 | Web | Native |
|
|
27
27
|
| --- | --- | --- | --- |
|
|
28
28
|
| `ImageChoiceGroup` | 그림 타일 선택 묶음 | `@hjmds/react/image-choice` | `@hjmds/react-native/image-choice` |
|
|
29
|
-
| `imageChoiceRecipe`, `resolveImageChoiceToggle`, `validateImageChoiceItems` | 크기·선택 규칙 |
|
|
29
|
+
| `imageChoiceRecipe`, `resolveImageChoiceToggle`, `validateImageChoiceItems` | 크기·선택 규칙 |
|
|
30
|
+
| `resolveImageChoiceCaptionLines`, `resolveImageChoiceTileHeight`, `resolveImageChoiceColumns`, `resolveImageChoiceOuterSize`, `validateImageChoiceItemLabelLines` | 미게시. 타일 높이·열 수 계산(렌더러가 쓰며 제품이 직접 부를 일은 드물다) | `@hjmds/design-contracts/components/image-choice` | 같음 |
|
|
30
31
|
|
|
31
32
|
## 최소 사용 예
|
|
32
33
|
|
|
@@ -53,7 +54,10 @@ import { ImageChoiceGroup } from "@hjmds/react-native/image-choice";
|
|
|
53
54
|
| `label` | `string` | 필수 | 묶음의 접근성 이름 |
|
|
54
55
|
| `showLabel` | `boolean` | `false` | 타일 위에 `label`을 보이는 글자로 둔다 |
|
|
55
56
|
| `showItemLabels` | `boolean` | `false` | 미게시. 타일마다 항목 `label`을 그림 아래 한 줄(caption, 넘치면 말줄임)로 보인다. 같은 누름 영역 안에 있고 보조기술은 기존처럼 타일 이름 하나만 읽는다. 사람을 이름으로 알아봐야 하는 받는 사람 선택에 쓴다 |
|
|
56
|
-
| `
|
|
57
|
+
| `itemLabelLines` | `1` · `2` | `1` | 미게시. 보이는 항목 이름이 말줄임 전까지 감길 줄 수. `2`는 단어를 자르지 않고(Web `keep-all`, iOS `hangul-word`) 두 줄에서 말줄임한다. 그 밖의 값은 `RangeError` |
|
|
58
|
+
| `items` | `{ id, label, image, description?, disabled? }[]` | 필수 | id 중복·빈 이름·공백뿐인 `description`이면 `TypeError`. `image`는 칸을 채우는 그림 노드 |
|
|
59
|
+
| item `description` | `string` | — | 미게시. 이름 아래 muted caption 한 줄(넘치면 말줄임). 보내기 상태("보냄") 같은 항목별 상태를 이름에 덧붙이지 않고 따로 둔다. 항상 보이며 보조기술은 타일 이름 다음 설명으로 한 번 읽는다(Web `aria-describedby`, Native `accessibilityHint`). 묶음에 설명이 하나라도 있으면 모든 타일이 그 줄을 비워 둔다 |
|
|
60
|
+
| `virtualized` | `{ height: number; overscan?: number }` | — | 미게시. 긴 목록(수백 명). 묶음이 `height` 높이의 자체 스크롤 영역이 되고 보이는 줄 근처만 그린다(`overscan` 기본 `virtualListRecipe.overscan` 3줄). 열 수는 잰 폭에 맞는다. 스크롤하는 부모(Native `ScrollView`, 스크롤 Sheet 본문) 안에 넣지 않는다 |
|
|
57
61
|
| `selectionMode` | `"single"` · `"multiple"` | 필수 | 단일은 radio, 다중은 checkbox로 읽힌다 |
|
|
58
62
|
| `value`·`onValueChange` | 단일 `string \| null` / 다중 `string[]` | 필수 | 제어형. 다중은 누른 순서대로 추가·제거 |
|
|
59
63
|
| `disabled` | `boolean` | `false` | 묶음 전체 비활성 |
|
|
@@ -63,15 +67,38 @@ import { ImageChoiceGroup } from "@hjmds/react-native/image-choice";
|
|
|
63
67
|
| 항목 | 값 | 근거 |
|
|
64
68
|
| --- | --- | --- |
|
|
65
69
|
| 크기 | 그림 72 정사각, 바깥 고리 2 + 간격 2(터치 영역 80), radius `radius.md` 12, 체크 원 20 | `imageChoiceRecipe` |
|
|
66
|
-
| 간격 | 타일 사이 `spacing.xs` 8, 라벨–타일 8, 그림–항목 라벨 `spacing.xxs` 4 | `imageChoiceRecipe.gap`, `itemLabelGap` |
|
|
70
|
+
| 간격 | 타일 사이 `spacing.xs` 8(가로·세로), 라벨–타일 8, 그림–항목 라벨 `spacing.xxs` 4. 이름과 설명 사이 0 | `imageChoiceRecipe.gap`, `itemLabelGap` |
|
|
71
|
+
| 글자 칸 | 이름 줄(`itemLabelLines`) + 설명 줄(묶음에 설명이 있으면 1)을 caption 행간(16 × 글자 배율)으로 모든 타일이 같은 높이로 비워 둔다. 타일 높이 = 80 + 4 + 줄 수 × 행간(글자 칸이 없으면 80) | `resolveImageChoiceCaptionLines`, `resolveImageChoiceTileHeight` |
|
|
67
72
|
| 순서·정렬 | `items` 순서대로 시작 쪽부터 줄바꿈 배치, 체크는 타일 끝 쪽 위 모서리 | 두 렌더러 |
|
|
68
|
-
| 고정·스크롤 | 자체 스크롤
|
|
73
|
+
| 고정·스크롤 | 기본은 자체 스크롤 없음(바깥 목록·화면이 스크롤). `virtualized`면 묶음이 `height` 높이로 스스로 스크롤한다. Web은 포커스 테두리가 잘리지 않게 안쪽 여백 4(`virtualViewportInset`), 열 수 = ⌊(폭 + 8) / 88⌋(최소 1) | 두 렌더러, `resolveImageChoiceColumns` |
|
|
69
74
|
| 좁은 폭·큰 글자 | 남은 폭에서 줄바꿈, 타일 크기는 유지 | flex wrap |
|
|
70
75
|
|
|
76
|
+
```text
|
|
77
|
+
virtualized (height 320, 폭 368 → 4열)
|
|
78
|
+
┌──────────────────────────────┐ ← 묶음 = 스크롤 영역(radiogroup/group 이름 유지)
|
|
79
|
+
│ [얼굴][얼굴][얼굴][얼굴] │
|
|
80
|
+
│ 서연 민준 지우 하린 │ ← 이름(1~2줄)
|
|
81
|
+
│ 보냄 │ ← 설명 줄(있으면 모든 타일이 비워 둠)
|
|
82
|
+
│ [얼굴][얼굴][얼굴][얼굴] │ ← 보이는 줄 ± 3줄만 그린다
|
|
83
|
+
└──────────────────────────────┘
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### 긴 목록
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
// Native — 시트 안이면 시트 본문 스크롤을 끄고 이 묶음이 스크롤을 맡는다
|
|
90
|
+
<ImageChoiceGroup label={t("share.selectHint")} selectionMode="multiple" showItemLabels virtualized={{ height: 360 }}
|
|
91
|
+
value={selected} onValueChange={setSelected}
|
|
92
|
+
items={people.map(user => ({ id: user.id, label: user.nickname, image: <Avatar user={user} />, ...(sent.has(user.id) ? { description: t("share.sent"), disabled: true } : {}) }))} />
|
|
93
|
+
```
|
|
94
|
+
|
|
71
95
|
## 꼭 지킬 것
|
|
72
96
|
|
|
73
97
|
- 타일마다 그림의 내용을 말하는 지역화 `label`을 준다. 그림 노드 자체는 보조기술에서 숨긴다(`alt=""`, `decorative`).
|
|
74
98
|
- 선택 결과의 저장·업로드·권한은 제품이 맡는다. 이 컴포넌트는 선택 상태만 바꾼다.
|
|
99
|
+
- 항목별 상태는 `description`으로 둔다. 이름에 " · 보냄"처럼 붙이면 긴 이름에서 말줄임으로 가려진다.
|
|
100
|
+
- 두 줄로도 담기지 않는 제목·설명(카드형 시작 선택지 등)은 타일이 아니라 목록 행·카드 선택이 맞다. 타일 폭 80은 고정이다.
|
|
101
|
+
- `virtualized`는 스스로 스크롤한다. Native는 `ScrollView` 안에 FlatList를 넣으면 경고와 함께 가상화가 꺼지므로 시트·화면의 본문 스크롤 밖(또는 스크롤을 끈 본문)에 둔다.
|
|
75
102
|
|
|
76
103
|
## 상태와 상호작용
|
|
77
104
|
|
|
@@ -79,5 +106,7 @@ import { ImageChoiceGroup } from "@hjmds/react-native/image-choice";
|
|
|
79
106
|
|
|
80
107
|
## 근거
|
|
81
108
|
|
|
109
|
+
- `description`·`itemLabelLines`·`virtualized`: 2026-10-18 번뚝 1.31 채택 — 공유 받는 사람의 "보냄"을 이름 줄에 붙여 긴 닉네임에서 가려졌고, 만들기 시작 카드의 여러 단어 제목이 한 줄 캡션에서 잘렸고, 서버 상한 200명 목록을 가상 목록 없이 시트 본문에 그렸다. 가상화는 VirtualList의 창 계산(`resolveVirtualWindow`)을 재사용하되 VirtualList 컴포넌트는 쓰지 않는다 — 줄을 목록 항목으로 읽히고 줄 단위로 포커스를 옮겨 radio/checkbox 묶음 의미를 덮기 때문이다(Native는 같은 이유로 FlatList를 직접 쓴다).
|
|
110
|
+
|
|
82
111
|
- `showItemLabels`: 2026-10-18 번뚝 공유 받는 사람 선택이 이름을 보이려고 링크 버튼에 아바타·체크·이름을 직접 조립했다.
|
|
83
112
|
- 도입 계기: 2026-10-16 구성 예제 점검 — 제품들이 `selected`를 준 ghost 버튼으로 그림 선택을 흉내 내 선택 상태가 일관되지 않았다.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
- 상태: 배포
|
|
5
5
|
- 지원: Web · Native
|
|
6
6
|
- 적용: 1.17.1
|
|
7
|
-
- 검토일: 2026-10-
|
|
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
|
|
|
@@ -98,6 +98,7 @@ Web의 `<img alt="">`는 장식용 미리보기다. 사진 이름은 `removeLabe
|
|
|
98
98
|
| `onMove` | `(id: string, direction: -1 \| 1) => void` | 필수 | 첫 항목의 위로·마지막 항목의 아래로는 비활성 |
|
|
99
99
|
| `onRemove` · `onRetry` · `onCancel` | `(id: string) => void` | 필수 | 삭제, UploadItem 재시도·취소 |
|
|
100
100
|
| `rotate` | `{ label, itemLabel(item), onRotate(id) }` | 없음 | 미게시. 주면 아래로와 삭제 사이에 회전 버튼(`label` 표시 문구, `itemLabel` 사진 이름을 포함한 접근성 이름). 회전 값 저장과 `preview`·결과물 반영은 제품이 한다 |
|
|
101
|
+
| `disabled` | `boolean` | `false` | 1.32.0, 미게시. 제품이 선택한 사진으로 작업(PDF·사진 띠 만들기, 내보내기, 다른 사진 고르기)을 하는 동안 모든 항목의 위로·아래로·회전·삭제를 잠근다. 업로드 재시도·취소는 그 전송 하나의 행동이라 잠그지 않는다. Web은 잠근 버튼을 `aria-disabled`로 두어 누르던 버튼에 키보드 초점이 남고(목록 끝의 위로·아래로는 그대로 `disabled`), Native는 `disabled` |
|
|
101
102
|
| 나머지 | `ScreenLayout`과 같음(`children`·`footer` 제외) | — | `actions`를 주면 `add` 앞에 같은 행으로 놓인다 |
|
|
102
103
|
|
|
103
104
|
## 배치
|
|
@@ -119,11 +120,11 @@ Web의 `<img alt="">`는 장식용 미리보기다. 사진 이름은 `removeLabe
|
|
|
119
120
|
import { MediaSelectionCell } from "@hjmds/react-native/screen-flows";
|
|
120
121
|
|
|
121
122
|
<FlatList data={photos} keyExtractor={photo => photo.id} renderItem={({ item, index }) =>
|
|
122
|
-
<MediaSelectionCell item={toMediaItem(item)} index={index} count={photos.length} {...itemActions}
|
|
123
|
+
<MediaSelectionCell item={toMediaItem(item)} index={index} count={photos.length} {...itemActions} disabled={building}
|
|
123
124
|
rotate={{ label: t("photo.rotate"), itemLabel: entry => t("photo.rotateNamed", { name: entry.descriptor.name }), onRotate: rotatePhoto }} />} />
|
|
124
125
|
```
|
|
125
126
|
|
|
126
|
-
`index`·`count`는 전체 목록 기준이다(첫 항목 위로·마지막 항목 아래로 비활성). `itemActions`는 화면과 같은 `labels`·`actionLabels`·`removeLabel`·`moveUpLabel`·`moveDownLabel`·`onRemove`·`onMove`·`onRetry`·`onCancel
|
|
127
|
+
`index`·`count`는 전체 목록 기준이다(첫 항목 위로·마지막 항목 아래로 비활성). `itemActions`는 화면과 같은 `labels`·`actionLabels`·`removeLabel`·`moveUpLabel`·`moveDownLabel`·`onRemove`·`onMove`·`onRetry`·`onCancel`이고, 작업 중에는 `disabled`(1.32.0)를 함께 넘겨 칸마다 따로 잠그지 않는다. 칸 사이 간격·열 수는 host가 준다. 삭제 후 Web 키보드 초점 복구는 화면 격자의 동작이므로 가상화 host는 자기 초점을 소유한다.
|
|
127
128
|
|
|
128
129
|
## 꼭 지킬 것
|
|
129
130
|
|
|
@@ -131,6 +132,7 @@ import { MediaSelectionCell } from "@hjmds/react-native/screen-flows";
|
|
|
131
132
|
둘 다 현지화한다.
|
|
132
133
|
- preview는 작은 leading 아이콘으로 줄이지 않고 큰 썸네일로 둔다.
|
|
133
134
|
- 업로드 진행·재시도·취소의 실제 동작과 개수·크기 제한, EXIF 처리는 제품이 한다.
|
|
135
|
+
- 선택한 사진으로 결과물을 만드는 동안에는 `disabled`로 순서·회전·삭제를 잠근다. 잠그지 않으면 작업이 읽은 순서와 화면 순서가 달라진다. 이 때문에 제품 칸을 따로 만들지 않는다(2026-10-18 Utilverse 사진→PDF·사진 띠가 잠금 때문에 제품 칸을 유지했다).
|
|
134
136
|
|
|
135
137
|
## 함정
|
|
136
138
|
|
|
@@ -171,18 +171,36 @@ import { ModerationForm } from "@hjmds/react-native/screen-flows";
|
|
|
171
171
|
| 신고·검토 필드 | ModerationScreen과 같음 | — | `variant`·`reasons`…·`block` 또는 `decisionReason`·`decisions`·`resolution` |
|
|
172
172
|
| `title` | 문자열 | — | 구성 제목(`level3`, 문서 수준 `headingLevel` 기본 3). 제목 줄이 있는 Sheet·Dialog 안에서는 생략 |
|
|
173
173
|
| `headingLevel` | 1~6 | 3 | 제목의 문서 수준 |
|
|
174
|
+
| `description` | 문자열 | — | 1.32.0, 미게시. 제목 아래 muted 한 줄(접수·숨김 시각 등 제품이 형식을 정한 문자열) |
|
|
175
|
+
| `actions` | `ReactNode` | — | 1.32.0, 미게시. 제목 줄 끝 행동(새로고침 IconButton `edge="end"` 등). 결정 버튼이 아니라 검토 대상을 다루는 보조 행동만 둔다 |
|
|
174
176
|
| `cancel` | `ScreenFlowAction` | — | 행동 줄 맨 끝 ghost 버튼(펼친 신고 접기) |
|
|
175
177
|
| `layoutStyle` | margin·width·flex·`alignSelf` | — | 바깥 배치 전용 |
|
|
176
178
|
|
|
177
179
|
화면 상태(`state`)가 없으므로 신고·결정 버튼은 사유·`disabled`·`pending`으로만 잠긴다.
|
|
178
180
|
|
|
181
|
+
운영자 검토 큐의 절 하나로 둘 때는 절 제목·접수 시각·새로고침을 폼의 머리에 넣는다(1.32.0). 같은 CompositionLayout 머리이므로 폼을 다른 CompositionLayout으로 감싸 제목을 두 번 쌓지 않는다.
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
// Native(Web은 onPress 대신 onClick)
|
|
185
|
+
<ModerationForm
|
|
186
|
+
variant="review"
|
|
187
|
+
title={t(labels[detail.report.kind])}
|
|
188
|
+
description={formatMessageTime(detail.report.reportedAt)}
|
|
189
|
+
actions={<IconButton label={t("refresh")} tone="ghost" edge="end" disabled={busy || fetching} onPress={refresh}><RefreshGlyph /></IconButton>}
|
|
190
|
+
decisionReason={decisionReason}
|
|
191
|
+
decisions={decisions}
|
|
192
|
+
>
|
|
193
|
+
<ReportedContent detail={detail} />
|
|
194
|
+
</ModerationForm>
|
|
195
|
+
```
|
|
196
|
+
|
|
179
197
|
## 배치
|
|
180
198
|
|
|
181
199
|
| 항목 | 값 | 근거 |
|
|
182
200
|
| --- | --- | --- |
|
|
183
201
|
| 크기 | ScreenLayout 폭(최대 720); 사유는 세로 `RadioGroup`, 신고·차단은 `Button` 기본 크기 | `ModerationScreen`, `screen-flows.tsx` `Action` |
|
|
184
202
|
| 간격 | 화면 padding `spacing.md` 16; 본문 사유 선택–`children` `spacing.lg` 20; footer 신고–차단 `spacing.xs` 8(세로로 쌓은 버튼, `actionGroupRecipe.stackedGap`) | Web·Native `ModerationScreen` `Stack gap="lg"`·`gap="xs"` |
|
|
185
|
-
| 순서·정렬 | 헤더(제목) → 본문(사유 → 추가 설명 `children`) → footer(신고 primary → 차단 ghost). `review`: 본문(검토 대상 `children` → 처리 사유 TextArea 또는 `resolution`) → footer(결정 버튼, 주어진 순서). `ModerationForm`: (제목) → 본문 → 행동(… → `cancel` ghost) | 렌더
|
|
203
|
+
| 순서·정렬 | 헤더(제목) → 본문(사유 → 추가 설명 `children`) → footer(신고 primary → 차단 ghost). `review`: 본문(검토 대상 `children` → 처리 사유 TextArea 또는 `resolution`) → footer(결정 버튼, 주어진 순서). `ModerationForm`: 머리(제목 → `description`(사이 `spacing.xxs` 4), 같은 줄 끝 `actions`(제목 열과 `spacing.sm` 12)) → 본문 → 행동(… → `cancel` ghost) | 렌더 순서, `CompositionLayout` |
|
|
186
204
|
| 고정·스크롤 | 헤더·footer 고정, 본문 화면 스크롤; 차단 확인은 AlertDialog 오버레이 | `ScreenLayout`, `AlertDialog` |
|
|
187
205
|
| 좁은 폭·큰 글자 | 사유 문구는 줄바꿈되고 항목이 세로로 늘어난다; footer 버튼은 세로로 쌓여 좁은 폭에서도 나란히 줄지 않는다 | `RadioGroup orientation="vertical"`, `Stack` |
|
|
188
206
|
|
|
@@ -200,4 +218,5 @@ import { ModerationForm } from "@hjmds/react-native/screen-flows";
|
|
|
200
218
|
## 근거
|
|
201
219
|
|
|
202
220
|
- `variant="review"`(1.31.0): 2026-10-18 앱 감사에서 utilverse 신고·이의 신청 처리 화면이 처리 사유 TextArea, 기각/숨기기 버튼 묶음, OS `Alert` 확인을 화면마다 직접 조립했다. 결정 문구는 큐마다 달라(기각·숨기기·유지·번복) props로 받고, 사유 필수·한 번에 한 결정·확인창 규칙은 HJM이 소유한다.
|
|
221
|
+
- `ModerationForm` `description`·`actions`(1.32.0): 1.31.0 채택에서 utilverse 신고·이의 신청 검토가 절 제목·접수(숨김) 시각·새로고침을 둘 자리가 없어 폼을 CompositionLayout으로 한 번 더 감쌌다. 폼이 이미 그리는 CompositionLayout 머리의 같은 슬롯을 열었다.
|
|
203
222
|
- `ModerationForm`(1.31.0): 같은 감사에서 diairy가 편지 안에 펼치는 신고에 ModerationScreen을 넣고 `contentInset="none"`·`scroll="content"`로 화면 틀을 끄면서도 머리 띠가 남았다(Dialog·Sheet는 자기 제목 줄을 가진다). 같은 필드·행동을 화면 틀 없이 그리는 companion으로 분리해 두 표현이 한 구현(`useModeration`)을 공유한다.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
- 상태: 배포
|
|
5
5
|
- 지원: Web · Native
|
|
6
6
|
- 적용: 1.12.1
|
|
7
|
-
- 검토일: 2026-10-
|
|
7
|
+
- 검토일: 2026-10-18
|
|
8
8
|
- 근거: [Pagination](../../pagination.md), `src/pagination.ts`(`paginationRecipe`)
|
|
9
9
|
- 스토리북: `배포/컴포넌트/탐색/페이지 이동`
|
|
10
10
|
|
|
@@ -13,11 +13,14 @@
|
|
|
13
13
|
총 개수(또는 총 페이지 수)가 정해진 결과 집합에서 사용자가 **임의의 페이지로 바로 이동**해야 할 때
|
|
14
14
|
Web·Native에서 쓴다. 검색 결과, 관리자 테이블, 기록 목록이 여기에 속한다.
|
|
15
15
|
|
|
16
|
+
총량은 모르지만 결과가 **페이지 단위로 오고 다음 페이지가 있는지만 아는** 원천(Kakao Local `meta.is_end`, cursor API의
|
|
17
|
+
`hasMore`)도 Pagination을 쓴다. `descriptor={{ currentPage, hasNext }}`로 이전 · 현재 페이지 표시 · 다음만 그린다(1.32.0).
|
|
18
|
+
|
|
16
19
|
## 쓰지 않을 때
|
|
17
20
|
|
|
18
21
|
| 상황 | 대신 쓸 것 |
|
|
19
22
|
| --- | --- |
|
|
20
|
-
|
|
|
23
|
+
| 계속 이어 붙이는 피드(이전 페이지로 돌아가지 않음) | [LoadMore](load-more.md) |
|
|
21
24
|
| 끝없이 이어지는 Native 피드 | [LoadMore](load-more.md), [VirtualList](virtual-list.md) |
|
|
22
25
|
| 단계형 흐름의 이전/다음 | [Steps](steps.md) |
|
|
23
26
|
| 이미지·카드 넘기기 | [Carousel](carousel.md) |
|
|
@@ -65,6 +68,25 @@ Native는 같은 props를 받는다. 조회 중에는 `disabled`로 이전·번
|
|
|
65
68
|
|
|
66
69
|
2026-10-15 다에리의 유한 편지 목록에서 페이지 번호가 이미 제품 기능이었으나 renderer 부재로 공통 배치를 복제한 원인을 수정했다. Native renderer는 1.26.0부터 게시본에 포함된다.
|
|
67
70
|
|
|
71
|
+
### 전체 수를 모르는 결과 (1.32.0)
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
// Native (Web도 같은 props)
|
|
75
|
+
<Pagination
|
|
76
|
+
label={t("search.pagination")}
|
|
77
|
+
descriptor={{ currentPage: page, hasNext: results.data?.hasMore ?? false }}
|
|
78
|
+
labels={{ previous: t("previousPage"), next: t("nextPage") }}
|
|
79
|
+
composeCurrentPageLabel={({ page }) => t("search.page", { page })}
|
|
80
|
+
onPageChange={(next) => setPage(next)}
|
|
81
|
+
disabled={results.isFetching}
|
|
82
|
+
/>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
번호 목록은 그리지 않는다. 다음 페이지 너머는 주소를 알 수 없기 때문이다. 현재 페이지 표시는 버튼이 아닌 글자이고
|
|
86
|
+
`composeCurrentPageLabel`이 만든 문자열이 보이는 글자이자 접근성 이름이다(Web `aria-current="page"`, Native 선택 상태).
|
|
87
|
+
첫 페이지면 이전, `hasNext: false`면 다음이 비활성이다. 한 페이지뿐이어도 두 단계가 비활성인 채로 그려진다.
|
|
88
|
+
`composeAccessibleName`과 함께 넘기거나 총량 필드와 섞으면 던진다.
|
|
89
|
+
|
|
68
90
|
## 축과 기본값
|
|
69
91
|
|
|
70
92
|
타입은 `@hjmds/design-contracts/components/pagination`에서 가져온다.
|
|
@@ -72,10 +94,11 @@ Native는 같은 props를 받는다. 조회 중에는 `disabled`로 이전·번
|
|
|
72
94
|
| prop | 값 | 기본값 | 설명 |
|
|
73
95
|
| --- | --- | --- | --- |
|
|
74
96
|
| `label` | 문자열 | 필수 | `<nav>` 이름. 비면 던진다 |
|
|
75
|
-
| `descriptor` | `{ currentPage, totalCount, pageSize, siblingCount?, boundaryCount? }` 또는 `{ currentPage, totalPages, siblingCount?, boundaryCount? }` | 필수 | 정확히 하나의 형태. `currentPage`는 1부터 시작하는
|
|
97
|
+
| `descriptor` | `{ currentPage, totalCount, pageSize, siblingCount?, boundaryCount? }` 또는 `{ currentPage, totalPages, siblingCount?, boundaryCount? }` 또는 `{ currentPage, hasNext: boolean }` | 필수 | 정확히 하나의 형태. `currentPage`는 1부터 시작하는 정수. `hasNext` 형태(1.32.0)는 총량을 모를 때 쓴다 |
|
|
76
98
|
| `siblingCount` · `boundaryCount`(descriptor 안) | 정수 | 1 | 생략된 구간은 말줄임으로 표시한다 |
|
|
77
99
|
| `labels` | `{ previous: string; next: string }` | 필수 | 이전·다음 버튼 문구 |
|
|
78
|
-
| `composeAccessibleName` | `(info: { page: number; totalPages: number; current: boolean }) => string` | 필수 | 페이지 버튼마다 부른다. 어순은 제품 i18n이 정한다 |
|
|
100
|
+
| `composeAccessibleName` | `(info: { page: number; totalPages: number; current: boolean }) => string` | 총량 형태에서 필수 | 페이지 버튼마다 부른다. 어순은 제품 i18n이 정한다 |
|
|
101
|
+
| `composeCurrentPageLabel` | `(info: { page: number }) => string` | `hasNext` 형태에서 필수 | 1.32.0 추가. 현재 페이지 표시의 글자이자 접근성 이름(예: "3페이지") |
|
|
79
102
|
| `onPageChange` | `(page: number, reason: "previous" \| "next" \| "page") => void` | 필수 | 제품이 `currentPage`를 바꾼다 |
|
|
80
103
|
| `className` · `layoutStyle` | 문자열 · 배치 전용 style 객체 | — | 루트 배치만 |
|
|
81
104
|
|
|
@@ -87,6 +110,7 @@ Native는 같은 props를 받는다. 조회 중에는 `disabled`로 이전·번
|
|
|
87
110
|
| 간격 | 칸 안쪽 `spacing.xs` 8, 칸 사이 `spacing.xxs` 4 | `paginationRecipe.gap`, `.hjm-pagination__list` |
|
|
88
111
|
| 순서·정렬 | `[‹ 이전] [1] … [4] [5] [6] … [20] [다음 ›]`. 이전·다음은 아이콘 버튼(문구는 접근성 이름), RTL에서 화살표 반전. 정렬은 놓는 레이아웃이 정한다(관리자 테이블은 끝, 검색 결과는 가운데가 흔하다) | `src/pagination.tsx`, `.hjm-pagination__previous`·`__next` |
|
|
89
112
|
| 고정·스크롤 | 목록·테이블 **바로 아래** 한 번. 페이지를 바꾸면 목록 시작으로 스크롤·포커스를 옮기는 것은 제품이 한다 | — |
|
|
113
|
+
| 전체 수를 모를 때 | `[‹ 이전] 3페이지 [다음 ›]`. 현재 페이지 표시는 높이 44(`paginationRecipe.pageLabel.minHeight`), 좌우 여백 8, `content.body` | `paginationRecipe.pageLabel`, `.hjm-pagination__page-label` |
|
|
90
114
|
| 좁은 폭·큰 글자 | 폭이 모자라면 줄바꿈된다(`flex-wrap`). 좁은 폭에서는 `siblingCount`·`boundaryCount`를 0~1로 줄여 한 줄을 유지한다 | `.hjm-pagination__list` |
|
|
91
115
|
|
|
92
116
|
## 꼭 지킬 것
|
|
@@ -84,7 +84,7 @@ import { ScreenLayout } from "@hjmds/react-native/screens";
|
|
|
84
84
|
| `scroll` | `"screen"` · `"content"` | `"screen"` | `content`는 본문 자식(가상화 목록)이 스크롤을 소유한다. 상태 교체 중에는 `screen`으로 돌아간다 |
|
|
85
85
|
| `contentAlignment` | `"start"` · `"center"` | `"start"` | ready 본문을 사용 영역 중앙에 배치. 긴 본문은 위가 잘리지 않게 스크롤한다. 로그인/제품 안내를 가짜 query state로 바꾸지 않는다(1.24.0) |
|
|
86
86
|
| `surface` | `"default"` · `"transparent"` | `"default"` | 투명은 제품 host의 배경·wallpaper를 보존. 배경색 style 우회 대신 사용(1.24.0) |
|
|
87
|
-
| `contentInset` | `"default"` · `"none"` · `"body-edge"` | `"default"` | `none`은 헤더·notice·본문·footer padding을 0으로(Native는 footer 위 테두리도 뺀다). host가 모든 여백을 이미 줄 때 쓴다. `body-edge`는 헤더·notice·footer 여백(16)과 footer 테두리를 유지하고 **본문 좌우 padding만 0**으로 한다. 자기 좌우 여백을 가진 전체 폭 목록 행(ConversationRow·NotificationRow 등 행 padding 16)을 본문에 바로 둘 때 쓴다(미게시). ListRow·NotificationItem은 `default`에 둔다. 값 목록은 계약 `ScreenContentInset`·`screenContentInsets` |
|
|
87
|
+
| `contentInset` | `"default"` · `"none"` · `"body-edge"` | `"default"` | `none`은 헤더·notice·본문·footer padding을 0으로(Native는 footer 위 테두리도 뺀다). host가 모든 여백을 이미 줄 때 쓴다. `body-edge`는 헤더·notice·footer 여백(16)과 footer 테두리를 유지하고 **본문 좌우 padding만 0**으로 한다. 자기 좌우 여백을 가진 전체 폭 목록 행(ConversationRow·NotificationRow 등 행 padding 16)을 본문에 바로 둘 때 쓴다(미게시). ListRow·NotificationItem은 `default`에 둔다. `body-bleed`(미게시, 1.31.0 이후)는 같은 여백을 유지하고 **본문 네 방향 padding을 모두 0**으로 해 지도·캔버스가 헤더(또는 notice)·화면 좌우·footer에 바로 닿는다. footer가 없으면 본문이 마지막 영역이라 여백 16 없이 `safeAreaBottom`(Native)과 겹친 하단 탐색 높이만 비운다. 홈 인디케이터 아래까지 그리려면 `safeAreaBottom`을 넘기지 않는다. `body-edge`·`body-bleed`에서도 로딩·빈 상태·오류 안내 묶음은 자기 여백이 없으므로 좌우 16을 유지한다(1.32.0 수정). 값 목록은 계약 `ScreenContentInset`·`screenContentInsets` |
|
|
88
88
|
| `presentation` | `"dashboard"` · `"editorial"` · `"landscape"` | 가장 가까운 profile.screens.overview / 없으면 기존 골격 | [디자인 프로필](../../design-profile.md)의 제공 범위를 따른다. 명시값이 프로필을 이긴다. 헤더 배치·읽기 폭만 바꾸고 상태·입력·행동 슬롯은 유지한다 |
|
|
89
89
|
| `as` (Web) | `"main"` · `"section"` | `"main"` | 제품 셸에 이미 `<main>`이 있으면 `section` |
|
|
90
90
|
| `initialFocus` | `first-input` · `none` | `none` | 1.28.1. 진입 시 텍스트 입력 하나에 한 번 포커스한다. 아래 [진입 포커스](#진입-포커스) |
|
|
@@ -98,10 +98,10 @@ import { ScreenLayout } from "@hjmds/react-native/screens";
|
|
|
98
98
|
| 항목 | 값 | 근거 |
|
|
99
99
|
| --- | --- | --- |
|
|
100
100
|
| 크기 | 폭 100%, 최대 `screenPatternRecipe.maxWidth`(`layout.readingMaxWidth` 720), 가운데 정렬; 높이는 host가 준 남은 높이(Web `block-size: 100%`, Native `flex: 1`) | Web `.hjm-screen`, Native `ScreenLayout` |
|
|
101
|
-
| 간격 | 바깥 padding `screenPatternRecipe.padding`(`spacing.md` 16)을 헤더 사방·notice 좌우·본문 사방·footer 사방에 준다(`contentInset="none"`이면 0, `"body-edge"`면 본문 좌우만 0); 헤더 안 `exit`·제목·`actions` 간격 `itemGap`(`spacing.sm`) 12(두 플랫폼, `contentInset="none"`이어도 유지); 상태 안내는 위아래 `sectionGap`(`spacing.xl`) 24, 안내–`stateAction` `stateGap`(`spacing.md`) 16 | Web `src/screens.tsx`(`--hjm-screen-item-gap`·`--hjm-screen-state-gap`)·`.hjm-screen__*`, Native `ScreenLayout` |
|
|
101
|
+
| 간격 | 바깥 padding `screenPatternRecipe.padding`(`spacing.md` 16)을 헤더 사방·notice 좌우·본문 사방·footer 사방에 준다(`contentInset="none"`이면 0, `"body-edge"`면 본문 좌우만 0, `"body-bleed"`면 본문 사방 0); 헤더 안 `exit`·제목·`actions` 간격 `itemGap`(`spacing.sm`) 12(두 플랫폼, `contentInset="none"`이어도 유지); 상태 안내는 위아래 `sectionGap`(`spacing.xl`) 24, 안내–`stateAction` `stateGap`(`spacing.md`) 16 | Web `src/screens.tsx`(`--hjm-screen-item-gap`·`--hjm-screen-state-gap`)·`.hjm-screen__*`, Native `ScreenLayout` |
|
|
102
102
|
| 제목 | 기본 가운데 `Heading` `screenHeaderRecipe.centeredTitleLevel`(level5), `headerAlignment="start"`면 `titleLevel`(level3). 추가 기능 버튼(`actions`)은 끝(오른쪽) 위, 나가기(`exit`)는 시작(왼쪽) 위. 헤더 높이는 두 플랫폼 모두 `screenHeaderRecipe`의 고정 띠 | `screenHeaderRecipe` |
|
|
103
103
|
| 순서·정렬 | 헤더(`exit` → 제목·설명 → `actions`) → notice → 본문 → footer; 상태 안내는 본문 세로 가운데 `exit`는 HJM이 `edge="start"`로 그려 아이콘 칸이 헤더 여백 선 16에 온다(Web·Native 31 → 16, 가운데 헤더의 같은 폭 양쪽 열·제목 중앙은 그대로). 끝 `actions`의 마지막 ghost IconButton은 제품이 `edge="end"`를 준다(2026-10-17 실측 감사, [IconButton `edge`](icon-button.md#배치)) | 렌더 순서 |
|
|
104
|
-
| 고정·스크롤 | 헤더·notice·footer 고정, `scroll="screen"`은 본문이 스크롤, `"content"`는 자식이 스크롤; footer 위 테두리 1. 마지막 영역(footer, 없으면 본문)의 아래 여백은 `max(16, 하단 inset)`이다 — Web은 `env(safe-area-inset-bottom)`, Native는 `safeAreaBottom`. 16 + inset으로 더하지 않으며, 키보드가 열려 있으면 키보드가 홈 인디케이터를 덮으므로 16만
|
|
104
|
+
| 고정·스크롤 | 헤더·notice·footer 고정, `scroll="screen"`은 본문이 스크롤, `"content"`는 자식이 스크롤; footer 위 테두리 1. 마지막 영역(footer, 없으면 본문)의 아래 여백은 `max(16, 하단 inset)`이다 — Web은 `env(safe-area-inset-bottom)`, Native는 `safeAreaBottom`. 16 + inset으로 더하지 않으며, 키보드가 열려 있으면 키보드가 홈 인디케이터를 덮으므로 16만 둔다. [BottomNavigationInsetProvider](bottom-navigation.md#막대가-덮는-높이-bottomnavigationinsetprovider-1310) 안이면 같은 마지막 영역이 `max(위 값, 16 + 덮인 높이)`를 한 번 둔다. `scroll="content"` 목록이나 ScreenLayout 위의 화면 구성이 다시 더하지 않는다(1.32.0) | Web `data-scroll`·`.hjm-screen__footer`, Native `ScrollView` |
|
|
105
105
|
| 좁은 폭·큰 글자 | 제목 열 최소 폭 `headerMinWidth` 120 × 글자 배율, 모자라면 `actions`가 다음 줄로 내려간다; 제목은 줄바꿈(`overflow-wrap: anywhere`)되고 자르지 않는다; Native 위·옆 safe area·탭바 inset은 host, 아래 inset은 `safeAreaBottom` | `screenPatternRecipe.headerMinWidth`, Web `.hjm-screen__heading` |
|
|
106
106
|
|
|
107
107
|
## 진입 포커스
|
|
@@ -163,6 +163,7 @@ import { ScreenLayout } from "@hjmds/react-native/screens";
|
|
|
163
163
|
- 이미 `<main>`이 있는 제품 셸 안에서는 `as="section"`을 준다. 중첩 main이 생긴다.
|
|
164
164
|
|
|
165
165
|
- 본문 좌우 여백을 직접 빼려고 목록에 음수 margin이나 제품 CSS를 쓰지 않는다. 행이 자기 여백을 가지면 `contentInset="body-edge"`, host가 모든 여백을 가지면 `"none"`이다. ListRow·NotificationItem은 글자를 부모 콘텐츠 가장자리에 두고 표면만 8 바깥으로 넓히므로(2026-10-17) `default` 본문에서 글자가 여백 선 16에 오고 설정 묶음 제목·알림 그룹 제목과 같은 선에 선다. `body-edge` 본문에 두면 글자가 화면 끝에 붙으므로 두지 않는다.
|
|
166
|
+
- 지도·캔버스를 화면 끝까지 그리려고 `body-edge` 본문에 음수 margin을 주거나 `none` + 직접 만든 헤더 여백을 쓰지 않는다. `contentInset="body-bleed"`다. 본문에 글자 목록을 두는 화면에는 쓰지 않는다(글자가 화면 끝에 붙는다). 지도 위에 띄우는 입력판·버튼의 여백은 제품 오버레이가 정한다.
|
|
166
167
|
|
|
167
168
|
### 디자인 프로필의 화면 표현
|
|
168
169
|
|
|
@@ -179,4 +180,6 @@ dashboard는 가로 헤더/1200 목록 폭, editorial은 세로 헤더/720 읽
|
|
|
179
180
|
- 하단 여백 `max(16, inset)`(Native `safeAreaBottom`): 2026-10-17 사용자 보고 "전체적으로 버튼 아래 여백이 많아". Native Storybook의 사진 선택과 업로드 화면에서 버튼 아래가 약 33pt였다. 원인은 둘이었다. 화면 스토리의 route host가 고정 높이 720이라 iPhone 17 Pro 스토리 영역 737보다 17 짧았고(footer 16 + 빈 칸 17), 컴포넌트는 Native footer가 safe area를 다루지 않아 host의 아래 inset 위에 16을, footer 안 BottomCTA의 `safeAreaBottom`도 16 위에 더했다(16 + 34 = 50). Web footer는 이미 `max(16, env())`였지만 footer 안 BottomCTA의 `safeAreaBottom`은 그 위에 더했다. 표준을 "마지막 컨트롤 아래 = footer 위 여백 16, 홈 인디케이터 기기에서는 max(16, inset)"으로 정하고 footer가 한 번만 소유하게 했다.
|
|
180
181
|
- 화면 여백은 ScreenLayout 소유, 바깥 여백 + `none` 미지원: 2026-10-17 바깥 여백 호스트 예제 사용자 검토. utilverse식(`Container gutter="regular"` 20 + `contentInset="none"`)을 HJM에서 지원하려고 본문 확장·footer 바 소유를 붙인 예제가 어색하다는 판단으로 지원을 접었다(2026-10-15 공통 구조는 HJM 소유 원칙). 측정상 그 구성에서는 Web 402·1280에서 행 표면이 양쪽 8 잘리고 본문이 옆으로 8 스크롤되며, Native 3x에서 선택 테두리 옆 선이 그려지지 않았다. 제품은 바깥 여백을 걷고 기본 inset을 쓴다.
|
|
181
182
|
- `contentInset="body-edge"`: 2026-10-17 사용자 결정. 화면 검토 S5#9에서 대화 목록 아바타가 화면 끝에서 약 32pt 안쪽에서 시작했다. 본문 padding 16과 ConversationRow 자체 padding 16이 겹쳤다(인스타그램 DM은 약 16). 앱 약 45곳이 "host가 모든 여백을 소유한다"는 뜻으로 `none`을 쓰고 있어 `none`의 의미를 바꿀 수 없었으므로 값을 새로 추가했다. ChatScreen의 10 타임라인 여백은 `default`일 때만 공통 여백을 대신한다.
|
|
183
|
+
- `contentInset="body-bleed"`: 2026-10-18 HJM 1.32.0 감사. 선택의창 지도 화면 세 곳(홈·경로 지도·지도에서 고르기)이 `body-edge`로 지도를 좌우 끝까지 그렸지만 본문 위아래 padding 16이 남아 헤더·footer와 지도 사이에 띠가 생겼다. `body-edge`의 뜻(전체 폭 목록 행의 좌우 여백)과 `none`의 뜻(host가 모든 여백)을 바꿀 수 없어 값을 추가했다.
|
|
184
|
+
- 하단 탐색 여백의 단일 소유: 2026-10-18 HJM 1.32.0. BottomNavigation 사용 지침이 `scroll="content"` 화면에 목록 여백으로 다시 더하라고 안내했으나 ScreenLayout은 그 화면에도 이미 적용했다(두 번 쌓임). 지침을 ScreenLayout 단일 소유로 고쳤다.
|
|
182
185
|
- 오버레이 안의 화면 진입 포커스: 2026-10-18 번뚝 검색 오버레이. Web은 화면을 감싼 제품 `aria-modal`을 화면 위의 모달로 보고 진입 포커스를 막았고, HJM Sheet 안에 둔 ScreenLayout은 Sheet의 첫 컨트롤(닫기)에 포커스를 빼앗겼다. Native는 오버레이 표시 전에 칸을 포커스해 무시될 수 있었다. 감싸는 모달은 막지 않고 오버레이 안의 화면은 오버레이와 함께 진입하도록 고쳤다(1.31.0 patch).
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
- 상태: 배포
|
|
5
5
|
- 지원: Web · Native
|
|
6
6
|
- 적용: 1.17.1
|
|
7
|
-
- 검토일: 2026-10-
|
|
8
|
-
- 근거: [반복 화면 조합](../../screen-patterns.md), Web·Native `src/screens.tsx`·`src/screen-flows.tsx`; 기존 개별 지침을 새 규격으로 통합. `onSubmit`·`filtersOverflow`는 2026-10-06 사용자 요청("검색과 필터 있는 앱 서비스 따라해")의 검색 화면 개편에서 추가. 같은 날 사용자 위임 결정(권장안)으로 미리보기에만 있던 두 단계 검색 로직(`committedQuery`·`recentQueries`·`suggestedQueries`·`suggestions`·`resultSummary`·`appliedFilters`·`filterSheet`)과 `queryLabelVisibility`를, utilverse 적용 조사로 `searching`·`searchingLabel`을 추가([계약 결정 표](../../screen-patterns.md#searchscreen-두-단계-검색)). 1.13.0 utilverse 적용에서 드러난 결함으로 `hostGutter`와 확정 시 키보드 닫기(Web 결과 영역 포커스)를 추가(1.13.1 patch). 예제 스토리는 2026-10-06 사용자 승인으로 스토리북 배포([승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정)). 스토리북 배포는 API 게시가 아니다(`적용` 참고)
|
|
7
|
+
- 검토일: 2026-10-18
|
|
8
|
+
- 근거: [반복 화면 조합](../../screen-patterns.md), Web·Native `src/screens.tsx`·`src/screen-flows.tsx`; 기존 개별 지침을 새 규격으로 통합. `onSubmit`·`filtersOverflow`는 2026-10-06 사용자 요청("검색과 필터 있는 앱 서비스 따라해")의 검색 화면 개편에서 추가. 같은 날 사용자 위임 결정(권장안)으로 미리보기에만 있던 두 단계 검색 로직(`committedQuery`·`recentQueries`·`suggestedQueries`·`suggestions`·`resultSummary`·`appliedFilters`·`filterSheet`)과 `queryLabelVisibility`를, utilverse 적용 조사로 `searching`·`searchingLabel`을 추가([계약 결정 표](../../screen-patterns.md#searchscreen-두-단계-검색)). 1.13.0 utilverse 적용에서 드러난 결함으로 `hostGutter`와 확정 시 키보드 닫기(Web 결과 영역 포커스)를 추가(1.13.1 patch). `filtersRole`은 2026-10-18 choose-window 적용 감사에서 추가(검색어 때문인 0건의 복구 수단인 범위 필터가 숨겨져 제품이 `resultSummary`를 쓰지 못했다). 예제 스토리는 2026-10-06 사용자 승인으로 스토리북 배포([승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정)). 스토리북 배포는 API 게시가 아니다(`적용` 참고)
|
|
9
9
|
- 스토리북: `배포/화면/검색/검색 결과와 필터`
|
|
10
10
|
|
|
11
11
|
## 언제 쓰나
|
|
@@ -145,7 +145,8 @@ import { SearchScreen } from "@hjmds/react-native/screen-flows";
|
|
|
145
145
|
| `queryClearLabel` | `string` | `queryField`가 없으면 필수 | 기본 입력의 지우기 버튼 이름. 비어 있으면 `TypeError` |
|
|
146
146
|
| `queryField` | `ReactNode` | 없음 | 기본 `SearchField` 대신 그릴 입력. 지우기 동작을 직접 제공한다 |
|
|
147
147
|
| `debounceMs` | `number` | `searchScreenRecipe.debounceMs` (`300`) | 유한값을 받으며 0 미만은 0. `NaN`·±`Infinity`는 `RangeError`. 두 renderer가 `resolveSearchDebounceMs`를 공유한다 |
|
|
148
|
-
| `filters` | `ReactNode` | 없음 | 검색 입력 아래, 상단에 고정. 두 단계 검색에서는 결과
|
|
148
|
+
| `filters` | `ReactNode` | 없음 | 검색 입력 아래, 상단에 고정. 두 단계 검색에서는 결과 단계에만. 검색어 때문인 0건에는 `filtersRole="narrow"`(기본)면 숨기고 `scope`면 남긴다 |
|
|
149
|
+
| `filtersRole` | `narrow` · `scope` | `narrow` | 1.32.0, 미게시. 필터 줄(`filters`·`filterSheet.trigger`)이 결과에 하는 일. `narrow`는 결과를 좁히기만 해서 적용한 필터 없이 0건이면 검색어 탓이고 어떤 필터도 결과를 되살리지 못하므로 줄을 숨긴다. `scope`는 무엇을 찾을지 고르는 필터(장소명/주소, 지역, 지도 영역)라 적용한 필터가 없어도 바꾸면 결과가 생길 수 있으므로 0건에도 줄을 남긴다. 판정은 두 renderer가 공유하는 `resolveSearchFilterRailVisible`(`@hjmds/design-contracts/screen-patterns`) |
|
|
149
150
|
| `onSubmit` | `(query: string) => void` | 없음 | 확정 신호. 모든 확정에서 키보드를 닫는다(Native `Keyboard.dismiss()`, Web은 고른 항목이면 결과 영역으로 포커스, 1.13.1 변경) — 아래 플랫폼 차이. 기본 입력의 Web Enter(`enterKeyHint="search"`, IME 조합 중 Enter는 무시)·Native 키보드 검색 키(`returnKeyType="search"`, `onSubmitEditing`)와 SearchScreen이 그린 확정 행·제안·최근·추천 검색어가 모두 여기로 온다. 앞뒤 공백을 지운 값이고 공백만이면 부르지 않는다. 고른 값이 입력과 다르면 먼저 `onQueryChange`. 대기 중인 `onSearch` debounce는 그대로 둔다. `queryField`를 주면 그 입력의 키는 연결하지 않는다 |
|
|
150
151
|
| `committedQuery` | `string` | 없음 | 결과를 보여 줄 확정 검색어. 주면 두 단계 검색(`onSubmit` 필수, 타입). 단계: 검색어 공백 = 검색 전, 확정값과 다름 = 입력 중, 같음 = 결과(앞뒤 공백 무시). `children`은 결과 단계에만 그린다 |
|
|
151
152
|
| `queryLabelVisibility` | `visible` · `hidden` | `visible` | `hidden`이면 보이는 label 없이 `queryLabel`을 접근성 이름(Web `aria-label`, Native `accessibilityLabel`)과 placeholder로 쓴다 |
|
|
@@ -188,6 +189,7 @@ import { SearchScreen } from "@hjmds/react-native/screen-flows";
|
|
|
188
189
|
`onSearch`는 보내지 않는다. `onSearch`에서 저장하면 "산", "산책"이 따로 쌓인다(2026-10-06 개편 전 공통 검색 예제의 결함).
|
|
189
190
|
- 두 단계 검색에서 `children`에는 결과 목록만 넣는다. 제안·로딩 행·0건 EmptyState·개수·정렬을 다시 그리면 SearchScreen이 그린 것과 겹친다.
|
|
190
191
|
- 적용 조건은 `appliedFilters`로 넘긴다. 0건 원인(필터면 칩 줄 유지 + `모두 해제`, 검색어면 칩 줄 숨김 + 추천 검색어)을 이 개수로 판정한다.
|
|
192
|
+
- 검색 종류·지역·지도 영역처럼 무엇을 찾을지 고르는 필터는 `filtersRole="scope"`로 표시한다. 검색어 때문인 0건에도 필터 줄이 남아 바꿔 볼 수 있다. 이 때문에 `resultSummary`를 버리고 0건·로딩을 `children`에 다시 그리지 않는다(2026-10-18 choose-window 장소명/주소 전환).
|
|
191
193
|
- 시트 `count`와 결과 개수는 같은 계산에서 나와야 한다. 다르면 "N개 결과 보기"와 적용 뒤 개수가 어긋난다.
|
|
192
194
|
- `contentInset="none"`으로 host가 여백을 주는 화면은 `hostGutter`에 그 여백 이름을 넘긴다. 없으면 `scroll` 줄이 host 여백 안에서 잘린다.
|
|
193
195
|
- 칩이 여러 개인 필터 줄은 `filtersOverflow="scroll"`로 한 줄에 둔다. 제품이 CSS·ScrollView로 가로 스크롤을 따로 만들지 않는다
|
|
@@ -159,6 +159,9 @@ Native의 선택형 `/toast-liquid`도 가장 가까운 프로필의 `radius.xl`
|
|
|
159
159
|
- Provider/Region은 앱에 하나만 두고 화면마다 새로 만들지 않는다. 진행 상태를 바꿀 때는 같은 `id`로 다시 `publish`한다.
|
|
160
160
|
- 문구는 모두 i18n 키로 만든 일반 문자열이다(ReactNode 아님). 아이콘만 있는 닫기 버튼 때문에 `closeLabel`이 필수다.
|
|
161
161
|
- 색·모양은 tone이 정한다. 카드에 브랜드 색을 덮지 않는다. 문구·id·action은 제품 소유, 카드 모양·큐·타이머는 HJM 소유다.
|
|
162
|
+
- 가장자리 상태색 띠는 없다. tone은 배지(또는 `media`)와 문구가 전달한다. 띠를 숨기는 옵션·CSS·팔레트 덮어쓰기를 두지 않는다 —
|
|
163
|
+
1.10.0에 띠를 없앴고, 미게시(1.32.0 예정)부터는 Web의 늘 숨겨져 있던 `.hjm-toast__tone-mark` 요소도 그리지 않는다.
|
|
164
|
+
Native에서 `statusAccents`·`danger`를 바탕색으로 바꾸면 띠가 아니라 tone 아이콘에 넘기는 색(`renderToneIcon`의 `color`)이 지워진다.
|
|
162
165
|
- Native는 `safeAreaInsets`를 제품이 넘긴다(기본 `{}`). 하단 고정 바가 있으면 Web `bottomOffset`, Native `keyboardOffset`으로 띄운다.
|
|
163
166
|
|
|
164
167
|
## 플랫폼 차이
|
|
@@ -174,6 +177,8 @@ Native의 선택형 `/toast-liquid`도 가장 가까운 프로필의 `radius.xl`
|
|
|
174
177
|
|
|
175
178
|
## 함정
|
|
176
179
|
|
|
180
|
+
- 2026-10-18 번뚝은 1.10.0에 이미 없어진 띠를 숨기려고 Web `.hjm-toast__tone-mark { display: none }`과 Native 토스트 영역의 팔레트 덮어쓰기를 유지하고 있었다. 띠 계약은 "없음"이며 `toastRecipe.toneMark`는 호환용으로만 남은 deprecated 값(너비 0)이다.
|
|
181
|
+
|
|
177
182
|
- `/toast-liquid`는 앱에 없을 수 있는 optional native peer `@shopify/react-native-skia`·`react-native-reanimated`·
|
|
178
183
|
`react-native-worklets`를 import한다(peerDependenciesMeta에서 모두 optional). 2026-10에 이런 subpath가
|
|
179
184
|
tsc·테스트는 통과했는데 기기 Metro에서 크래시가 났다. 세 peer가 개발 클라이언트 바이너리에 들어 있을 때만 import한다.
|