@hjmds/design-contracts 1.30.0 → 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/behaviors.d.ts +3 -0
- package/dist/behaviors.d.ts.map +1 -1
- package/dist/bottom-navigation.d.ts +43 -0
- package/dist/bottom-navigation.d.ts.map +1 -1
- package/dist/bottom-navigation.js +55 -0
- package/dist/bottom-navigation.js.map +1 -1
- package/dist/carousel.d.ts +30 -0
- package/dist/carousel.d.ts.map +1 -1
- package/dist/carousel.js +32 -0
- package/dist/carousel.js.map +1 -1
- package/dist/catalog.d.ts +178 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +16 -6
- package/dist/catalog.js.map +1 -1
- package/dist/component-recipes.d.ts +126 -0
- package/dist/component-recipes.d.ts.map +1 -1
- package/dist/component-recipes.js +71 -2
- package/dist/component-recipes.js.map +1 -1
- package/dist/icon.d.ts +58 -1
- package/dist/icon.d.ts.map +1 -1
- package/dist/icon.js +69 -0
- package/dist/icon.js.map +1 -1
- package/dist/image-choice.d.ts +47 -0
- package/dist/image-choice.d.ts.map +1 -1
- package/dist/image-choice.js +56 -0
- package/dist/image-choice.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.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/recipes.d.ts +1 -1
- package/dist/recipes.d.ts.map +1 -1
- package/dist/recipes.js.map +1 -1
- package/dist/screen-patterns.d.ts +152 -2
- package/dist/screen-patterns.d.ts.map +1 -1
- package/dist/screen-patterns.js +132 -4
- package/dist/screen-patterns.js.map +1 -1
- package/dist/showcase.d.ts.map +1 -1
- package/dist/showcase.js +3 -0
- package/dist/showcase.js.map +1 -1
- package/dist/steps.d.ts +16 -7
- package/dist/steps.d.ts.map +1 -1
- package/dist/steps.js +6 -1
- package/dist/steps.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 +28 -0
- package/docs/carousel.md +8 -0
- package/docs/data-workflows.md +1 -0
- package/docs/dialog.md +23 -0
- package/docs/generated/component-maturity.md +7 -4
- package/docs/generated/renderer-evidence.json +317 -19
- package/docs/generated/renderer-evidence.md +9 -3
- package/docs/generated/showcase-manifest.json +164 -6
- package/docs/icon.md +9 -0
- package/docs/pagination.md +17 -0
- package/docs/screen-patterns.md +55 -1
- package/docs/steps.md +4 -3
- package/docs/toast.md +10 -0
- package/docs/usage/README.md +5 -2
- package/docs/usage/SELECTION.md +1 -1
- package/docs/usage/components/auth-screen-layout.md +15 -5
- package/docs/usage/components/bottom-cta.md +11 -0
- package/docs/usage/components/bottom-navigation.md +99 -2
- package/docs/usage/components/card.md +36 -5
- package/docs/usage/components/carousel.md +1 -0
- package/docs/usage/components/chat-date-separator.md +86 -0
- package/docs/usage/components/chat-message.md +13 -1
- package/docs/usage/components/chat-screen.md +3 -2
- package/docs/usage/components/comment-thread-row.md +11 -3
- package/docs/usage/components/comment-thread-screen.md +1 -1
- package/docs/usage/components/dialog.md +69 -0
- package/docs/usage/components/editor-screen.md +6 -2
- package/docs/usage/components/field.md +13 -0
- package/docs/usage/components/icon-tile.md +19 -3
- package/docs/usage/components/icon.md +45 -6
- package/docs/usage/components/image-choice-group.md +35 -4
- package/docs/usage/components/media-selection-screen.md +20 -1
- package/docs/usage/components/message-composer.md +4 -1
- package/docs/usage/components/moderation-screen.md +107 -2
- package/docs/usage/components/page-indicator.md +102 -0
- package/docs/usage/components/pagination.md +28 -4
- package/docs/usage/components/profile-screen.md +9 -3
- package/docs/usage/components/profile-summary.md +3 -3
- package/docs/usage/components/screen-layout.md +15 -3
- package/docs/usage/components/search-screen.md +5 -3
- package/docs/usage/components/steps.md +14 -7
- package/docs/usage/components/tabs.md +21 -1
- package/docs/usage/components/text.md +30 -1
- package/docs/usage/components/toast.md +5 -0
- package/docs/usage/components/typing-indicator.md +89 -0
- package/docs/usage/screens/common-login.md +3 -1
- package/docs/usage/screens/common-profile.md +3 -3
- package/docs/usage/screens/flow-editor.md +13 -2
- package/docs/usage/screens/flow-moderation.md +31 -3
- package/package.json +1 -1
|
@@ -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 전에는 틀 전체가 흐려져 잠긴 이유를 적은 도움말까지 대비가 떨어졌다.
|
|
@@ -45,23 +45,31 @@ 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 | 제목 줄 수 |
|
|
56
61
|
| ghost | boolean | false | 이동 대상 표시 |
|
|
62
|
+
| frame | `"surface"` · `"none"` | `"surface"` | 미게시(1.30.0 이후). `surface`는 공통 틀(`surfaceAlt` 채움 + 테두리 1), `none`은 틀을 그리지 않는다. 아이콘 원본이 이미 완성된 런처 아이콘(모양·배경 포함)일 때 `none` |
|
|
63
|
+
| shape | `"squircle"` · `"square"` · `"circle"` | `"squircle"` | 미게시. 틀(과 ghost 점선)의 모서리. 크기 대비 비율 `iconTileRecipe.shapes`(squircle 0.22 · square 0 · circle 0.5). 제품 아이콘 테마(아이폰 셸 squircle, Windows·Linux 셸 square)를 따른다 |
|
|
64
|
+
| size | `"fixed"` · `"fill"` | `"fixed"` | 미게시. `fixed`는 64×64, `fill`은 격자 칸 폭을 채우되 `fillMinSize` 48 ~ `fillMaxSize` 68 사이로 제한한 정사각형. 열 수·칸 폭은 host Grid가 정하고 제품이 칸 크기를 계산해 넘기지 않는다 |
|
|
57
65
|
|
|
58
|
-
`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는 아이콘 기준 배지를 위한 슬롯이며 넘침을 자르지 않는다.
|
|
59
67
|
|
|
60
68
|
## 배치
|
|
61
69
|
|
|
62
70
|
| 항목 | 값 | 근거 |
|
|
63
71
|
| --- | --- | --- |
|
|
64
|
-
| 크기 | 아이콘 64×64(radius 22%), 제목 최대 폭 72 · 두 줄이면 84와 최소 높이 44 | `iconTileRecipe` |
|
|
72
|
+
| 크기 | 아이콘 64×64(radius 22%, `shape`에 따라 0%·50%), `size="fill"`이면 칸 폭 48~68 정사각형; 제목 최대 폭 72 · 두 줄이면 84와 최소 높이 44 | `iconTileRecipe` |
|
|
65
73
|
| 간격 | 아이콘과 제목 사이 6 | `iconTileRecipe.gap` |
|
|
66
74
|
| 순서·정렬 | 아이콘(overlay 포함) 위 → 제목 아래, 가로 가운데, 제목 가운데 정렬 | 양 renderer |
|
|
67
75
|
| 고정·스크롤 | Grid나 가상 목록의 host가 열 수·외부 여백·스크롤을 정한다 | 별도 scroller 없음 |
|
|
@@ -73,6 +81,9 @@ import { IconTile } from "@hjmds/react-native/screens";
|
|
|
73
81
|
|
|
74
82
|
아이콘 원본과 제품 배지만 전달한다. 같은 배치를 별도 View/div로 다시 만들지 않는다.
|
|
75
83
|
|
|
84
|
+
- `size="fill"`의 이미지 아트워크는 `icon={({ edge }) => <Artwork size={edge} />}`로 그린다. 제품이 격자 폭에서 48~68 제한을 다시 계산해 넘기지 않는다. 측정 전 쪽 수·높이 추정처럼 타일 밖에서 크기가 필요하면 contracts `resolveIconTileFillEdge(칸 폭)`을 쓴다. 측정 전 첫 렌더의 `edge`는 `fillMinSize` 48이다(Web은 그리기 전에 측정한다).
|
|
85
|
+
- 완성된 아이콘 아트워크 위에 틀이 두 겹으로 그려지면 `frame="none"`, 아이콘 모양이 테마마다 다르면 `shape`, 칸 크기를 따라 커져야 하면 `size="fill"`을 쓴다. 이 이유로 런처 항목을 직접 만들지 않는다.
|
|
86
|
+
|
|
76
87
|
## 상태와 상호작용
|
|
77
88
|
|
|
78
89
|
이 구성은 표시 전용이다. 링크/버튼/드래그 host가 disabled·포커스·키보드·길게 누르기와 라우팅을 소유하므로 기존 앱의 drag engine을 교체하지 않는다.
|
|
@@ -88,3 +99,8 @@ host에 전체 행동의 현지화 이름을 제공한다. 아이콘 내부 장
|
|
|
88
99
|
## 관련 지침
|
|
89
100
|
|
|
90
101
|
[선택 기준](../SELECTION.md), [Grid](grid.md), [Card](card.md).
|
|
102
|
+
|
|
103
|
+
## 근거
|
|
104
|
+
|
|
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 한 곳에 둔다.
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
## 언제 쓰나
|
|
12
12
|
|
|
13
|
-
HJM semantic 이름(`search`, `back`, `chevronEnd`, `notifications` 등
|
|
14
|
-
크기·색·선 굵기·RTL 반전은 HJM이 정하고,
|
|
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.
|
|
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 —
|
|
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
|
|
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`를 낸다.
|
|
@@ -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
|
|
|
@@ -52,7 +53,11 @@ import { ImageChoiceGroup } from "@hjmds/react-native/image-choice";
|
|
|
52
53
|
| --- | --- | --- | --- |
|
|
53
54
|
| `label` | `string` | 필수 | 묶음의 접근성 이름 |
|
|
54
55
|
| `showLabel` | `boolean` | `false` | 타일 위에 `label`을 보이는 글자로 둔다 |
|
|
55
|
-
| `
|
|
56
|
+
| `showItemLabels` | `boolean` | `false` | 미게시. 타일마다 항목 `label`을 그림 아래 한 줄(caption, 넘치면 말줄임)로 보인다. 같은 누름 영역 안에 있고 보조기술은 기존처럼 타일 이름 하나만 읽는다. 사람을 이름으로 알아봐야 하는 받는 사람 선택에 쓴다 |
|
|
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 본문) 안에 넣지 않는다 |
|
|
56
61
|
| `selectionMode` | `"single"` · `"multiple"` | 필수 | 단일은 radio, 다중은 checkbox로 읽힌다 |
|
|
57
62
|
| `value`·`onValueChange` | 단일 `string \| null` / 다중 `string[]` | 필수 | 제어형. 다중은 누른 순서대로 추가·제거 |
|
|
58
63
|
| `disabled` | `boolean` | `false` | 묶음 전체 비활성 |
|
|
@@ -62,15 +67,38 @@ import { ImageChoiceGroup } from "@hjmds/react-native/image-choice";
|
|
|
62
67
|
| 항목 | 값 | 근거 |
|
|
63
68
|
| --- | --- | --- |
|
|
64
69
|
| 크기 | 그림 72 정사각, 바깥 고리 2 + 간격 2(터치 영역 80), radius `radius.md` 12, 체크 원 20 | `imageChoiceRecipe` |
|
|
65
|
-
| 간격 | 타일 사이 `spacing.xs` 8, 라벨–타일 8 | `imageChoiceRecipe.gap` |
|
|
70
|
+
| 간격 | 타일 사이 `spacing.xs` 8(가로·세로), 라벨–타일 8, 그림–항목 라벨 `spacing.xxs` 4. 이름과 설명 사이 0 | `imageChoiceRecipe.gap`, `itemLabelGap` |
|
|
71
|
+
| 글자 칸 | 이름 줄(`itemLabelLines`) + 설명 줄(묶음에 설명이 있으면 1)을 caption 행간(16 × 글자 배율)으로 모든 타일이 같은 높이로 비워 둔다. 타일 높이 = 80 + 4 + 줄 수 × 행간(글자 칸이 없으면 80) | `resolveImageChoiceCaptionLines`, `resolveImageChoiceTileHeight` |
|
|
66
72
|
| 순서·정렬 | `items` 순서대로 시작 쪽부터 줄바꿈 배치, 체크는 타일 끝 쪽 위 모서리 | 두 렌더러 |
|
|
67
|
-
| 고정·스크롤 | 자체 스크롤
|
|
73
|
+
| 고정·스크롤 | 기본은 자체 스크롤 없음(바깥 목록·화면이 스크롤). `virtualized`면 묶음이 `height` 높이로 스스로 스크롤한다. Web은 포커스 테두리가 잘리지 않게 안쪽 여백 4(`virtualViewportInset`), 열 수 = ⌊(폭 + 8) / 88⌋(최소 1) | 두 렌더러, `resolveImageChoiceColumns` |
|
|
68
74
|
| 좁은 폭·큰 글자 | 남은 폭에서 줄바꿈, 타일 크기는 유지 | flex wrap |
|
|
69
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
|
+
|
|
70
95
|
## 꼭 지킬 것
|
|
71
96
|
|
|
72
97
|
- 타일마다 그림의 내용을 말하는 지역화 `label`을 준다. 그림 노드 자체는 보조기술에서 숨긴다(`alt=""`, `decorative`).
|
|
73
98
|
- 선택 결과의 저장·업로드·권한은 제품이 맡는다. 이 컴포넌트는 선택 상태만 바꾼다.
|
|
99
|
+
- 항목별 상태는 `description`으로 둔다. 이름에 " · 보냄"처럼 붙이면 긴 이름에서 말줄임으로 가려진다.
|
|
100
|
+
- 두 줄로도 담기지 않는 제목·설명(카드형 시작 선택지 등)은 타일이 아니라 목록 행·카드 선택이 맞다. 타일 폭 80은 고정이다.
|
|
101
|
+
- `virtualized`는 스스로 스크롤한다. Native는 `ScrollView` 안에 FlatList를 넣으면 경고와 함께 가상화가 꺼지므로 시트·화면의 본문 스크롤 밖(또는 스크롤을 끈 본문)에 둔다.
|
|
74
102
|
|
|
75
103
|
## 상태와 상호작용
|
|
76
104
|
|
|
@@ -78,4 +106,7 @@ import { ImageChoiceGroup } from "@hjmds/react-native/image-choice";
|
|
|
78
106
|
|
|
79
107
|
## 근거
|
|
80
108
|
|
|
109
|
+
- `description`·`itemLabelLines`·`virtualized`: 2026-10-18 번뚝 1.31 채택 — 공유 받는 사람의 "보냄"을 이름 줄에 붙여 긴 닉네임에서 가려졌고, 만들기 시작 카드의 여러 단어 제목이 한 줄 캡션에서 잘렸고, 서버 상한 200명 목록을 가상 목록 없이 시트 본문에 그렸다. 가상화는 VirtualList의 창 계산(`resolveVirtualWindow`)을 재사용하되 VirtualList 컴포넌트는 쓰지 않는다 — 줄을 목록 항목으로 읽히고 줄 단위로 포커스를 옮겨 radio/checkbox 묶음 의미를 덮기 때문이다(Native는 같은 이유로 FlatList를 직접 쓴다).
|
|
110
|
+
|
|
111
|
+
- `showItemLabels`: 2026-10-18 번뚝 공유 받는 사람 선택이 이름을 보이려고 링크 버튼에 아바타·체크·이름을 직접 조립했다.
|
|
81
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
|
|
|
@@ -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,8 @@ 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`·결과물 반영은 제품이 한다 |
|
|
101
|
+
| `disabled` | `boolean` | `false` | 1.32.0, 미게시. 제품이 선택한 사진으로 작업(PDF·사진 띠 만들기, 내보내기, 다른 사진 고르기)을 하는 동안 모든 항목의 위로·아래로·회전·삭제를 잠근다. 업로드 재시도·취소는 그 전송 하나의 행동이라 잠그지 않는다. Web은 잠근 버튼을 `aria-disabled`로 두어 누르던 버튼에 키보드 초점이 남고(목록 끝의 위로·아래로는 그대로 `disabled`), Native는 `disabled` |
|
|
99
102
|
| 나머지 | `ScreenLayout`과 같음(`children`·`footer` 제외) | — | `actions`를 주면 `add` 앞에 같은 행으로 놓인다 |
|
|
100
103
|
|
|
101
104
|
## 배치
|
|
@@ -108,12 +111,28 @@ Web의 `<img alt="">`는 장식용 미리보기다. 사진 이름은 `removeLabe
|
|
|
108
111
|
| 고정·스크롤 | 헤더·footer 고정, 격자는 본문 스크롤(`scroll` 기본 `"screen"`) | `ScreenLayout` |
|
|
109
112
|
| 좁은 폭 | 좁은 폭에서도 2열 유지; 항목 버튼 줄은 줄바꿈(`flexWrap: "wrap"`)해 버튼을 자르지 않는다 | `screen-flows.tsx` |
|
|
110
113
|
|
|
114
|
+
## 가상화 목록의 항목 칸
|
|
115
|
+
|
|
116
|
+
사진이 많아 제품이 `VirtualList`·`FlatList`로 목록을 직접 그릴 때는 같은 칸 `MediaSelectionCell`을 `renderItem`에서 그린다. 미리보기와 행동 줄을 제품이 다시 조립하지 않는다(2026-10-18 Utilverse 사진→PDF·사진 띠 도구가 썸네일·순서·회전·삭제 칸을 직접 만들었다).
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
// Native
|
|
120
|
+
import { MediaSelectionCell } from "@hjmds/react-native/screen-flows";
|
|
121
|
+
|
|
122
|
+
<FlatList data={photos} keyExtractor={photo => photo.id} renderItem={({ item, index }) =>
|
|
123
|
+
<MediaSelectionCell item={toMediaItem(item)} index={index} count={photos.length} {...itemActions} disabled={building}
|
|
124
|
+
rotate={{ label: t("photo.rotate"), itemLabel: entry => t("photo.rotateNamed", { name: entry.descriptor.name }), onRotate: rotatePhoto }} />} />
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`index`·`count`는 전체 목록 기준이다(첫 항목 위로·마지막 항목 아래로 비활성). `itemActions`는 화면과 같은 `labels`·`actionLabels`·`removeLabel`·`moveUpLabel`·`moveDownLabel`·`onRemove`·`onMove`·`onRetry`·`onCancel`이고, 작업 중에는 `disabled`(1.32.0)를 함께 넘겨 칸마다 따로 잠그지 않는다. 칸 사이 간격·열 수는 host가 준다. 삭제 후 Web 키보드 초점 복구는 화면 격자의 동작이므로 가상화 host는 자기 초점을 소유한다.
|
|
128
|
+
|
|
111
129
|
## 꼭 지킬 것
|
|
112
130
|
|
|
113
131
|
- `actionLabels`는 짧은 표시 문구, `removeLabel`·`moveUpLabel`·`moveDownLabel`은 사진 이름을 포함한 접근성 이름이다.
|
|
114
132
|
둘 다 현지화한다.
|
|
115
133
|
- preview는 작은 leading 아이콘으로 줄이지 않고 큰 썸네일로 둔다.
|
|
116
134
|
- 업로드 진행·재시도·취소의 실제 동작과 개수·크기 제한, EXIF 처리는 제품이 한다.
|
|
135
|
+
- 선택한 사진으로 결과물을 만드는 동안에는 `disabled`로 순서·회전·삭제를 잠근다. 잠그지 않으면 작업이 읽은 순서와 화면 순서가 달라진다. 이 때문에 제품 칸을 따로 만들지 않는다(2026-10-18 Utilverse 사진→PDF·사진 띠가 잠금 때문에 제품 칸을 유지했다).
|
|
117
136
|
|
|
118
137
|
## 함정
|
|
119
138
|
|
|
@@ -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-
|
|
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,104 @@ 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
|
+
| `description` | 문자열 | — | 1.32.0, 미게시. 제목 아래 muted 한 줄(접수·숨김 시각 등 제품이 형식을 정한 문자열) |
|
|
175
|
+
| `actions` | `ReactNode` | — | 1.32.0, 미게시. 제목 줄 끝 행동(새로고침 IconButton `edge="end"` 등). 결정 버튼이 아니라 검토 대상을 다루는 보조 행동만 둔다 |
|
|
176
|
+
| `cancel` | `ScreenFlowAction` | — | 행동 줄 맨 끝 ghost 버튼(펼친 신고 접기) |
|
|
177
|
+
| `layoutStyle` | margin·width·flex·`alignSelf` | — | 바깥 배치 전용 |
|
|
178
|
+
|
|
179
|
+
화면 상태(`state`)가 없으므로 신고·결정 버튼은 사유·`disabled`·`pending`으로만 잠긴다.
|
|
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
|
+
|
|
100
197
|
## 배치
|
|
101
198
|
|
|
102
199
|
| 항목 | 값 | 근거 |
|
|
103
200
|
| --- | --- | --- |
|
|
104
201
|
| 크기 | ScreenLayout 폭(최대 720); 사유는 세로 `RadioGroup`, 신고·차단은 `Button` 기본 크기 | `ModerationScreen`, `screen-flows.tsx` `Action` |
|
|
105
202
|
| 간격 | 화면 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) | 렌더
|
|
203
|
+
| 순서·정렬 | 헤더(제목) → 본문(사유 → 추가 설명 `children`) → footer(신고 primary → 차단 ghost). `review`: 본문(검토 대상 `children` → 처리 사유 TextArea 또는 `resolution`) → footer(결정 버튼, 주어진 순서). `ModerationForm`: 머리(제목 → `description`(사이 `spacing.xxs` 4), 같은 줄 끝 `actions`(제목 열과 `spacing.sm` 12)) → 본문 → 행동(… → `cancel` ghost) | 렌더 순서, `CompositionLayout` |
|
|
107
204
|
| 고정·스크롤 | 헤더·footer 고정, 본문 화면 스크롤; 차단 확인은 AlertDialog 오버레이 | `ScreenLayout`, `AlertDialog` |
|
|
108
205
|
| 좁은 폭·큰 글자 | 사유 문구는 줄바꿈되고 항목이 세로로 늘어난다; footer 버튼은 세로로 쌓여 좁은 폭에서도 나란히 줄지 않는다 | `RadioGroup orientation="vertical"`, `Stack` |
|
|
109
206
|
|
|
@@ -114,4 +211,12 @@ import { ModerationScreen } from "@hjmds/react-native/screen-flows";
|
|
|
114
211
|
- 서버 신고·권한·차단 mutation, 완료 후 이동은 제품 소유다. 사유 목록과 정책 문구도 제품이 정한다.
|
|
115
212
|
- 신고 화면은 push·모달로 열리므로 `exit`로 나갈 길을 준다. 2026-10-17 화면 검토(S5)에서 exit 없는 예제가 나갈 수 없는 화면으로 렌더링됐다.
|
|
116
213
|
- 확인 대화상자의 성공 후 닫힘·실패 문구는 [AlertDialog](alert-dialog.md) 계약을 따른다.
|
|
214
|
+
- 운영자 결정의 문구·id·서버 결정 mutation은 제품 소유다. 결정이나 그 `confirmation`이 사라지거나 `resolution`이 오면 열린 확인창도 닫힌다.
|
|
215
|
+
- 편지·카드·Sheet 안 신고는 `ModerationForm`이다. ModerationScreen의 `contentInset`·`scroll`·`header`로 화면 틀을 지우지 않는다.
|
|
117
216
|
- `block`을 제거하면 열린 확인 세션도 종료한다. 나중에 차단 행동을 다시 공급해도 사용자가 버튼을 새로 눌러야 확인창이 열린다. 2026-10-13 사라진 행동의 확인 상태가 부활하던 경로를 수정했다. 대상이 바뀌는 제품은 대상 id를 화면 key/초안·요청 수명에 함께 연결한다.
|
|
217
|
+
|
|
218
|
+
## 근거
|
|
219
|
+
|
|
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 머리의 같은 슬롯을 열었다.
|
|
222
|
+
- `ModerationForm`(1.31.0): 같은 감사에서 diairy가 편지 안에 펼치는 신고에 ModerationScreen을 넣고 `contentInset="none"`·`scroll="content"`로 화면 틀을 끄면서도 머리 띠가 남았다(Dialog·Sheet는 자기 제목 줄을 가진다). 같은 필드·행동을 화면 틀 없이 그리는 companion으로 분리해 두 표현이 한 구현(`useModeration`)을 공유한다.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# PageIndicator
|
|
2
|
+
|
|
3
|
+
- 단계: 컴포넌트
|
|
4
|
+
- 상태: 실험
|
|
5
|
+
- 지원: Web · Native
|
|
6
|
+
- 적용: 미게시(1.30.0 이후)
|
|
7
|
+
- 검토일: 2026-10-18
|
|
8
|
+
- 근거: [Carousel](../../carousel.md), contract `src/carousel.ts`(`resolvePageIndicator`·`getPageIndicatorTarget`), recipe `carouselRecipe.dot`
|
|
9
|
+
- 스토리북: `실험/컴포넌트/탐색/페이지 위치 표시`
|
|
10
|
+
|
|
11
|
+
## 언제 쓰나
|
|
12
|
+
|
|
13
|
+
제품이 직접 만든 가로 페이저(Native 페이징 `FlatList`, Web scroll-snap 줄) 아래에 지금 몇 번째 페이지인지 점으로 보여 주고,
|
|
14
|
+
점을 눌러 그 페이지로 옮길 때 쓴다. Carousel의 위치 점 줄과 같은 부품·같은 recipe다. 홈 화면 런처처럼 페이지 안의 내용과
|
|
15
|
+
스크롤을 제품이 소유해 Carousel에 넣을 수 없는 자리다.
|
|
16
|
+
|
|
17
|
+
## 쓰지 않을 때
|
|
18
|
+
|
|
19
|
+
| 상황 | 대신 쓸 것 |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| 카드 묶음을 HJM이 그리고 넘겨도 됨 | [Carousel](carousel.md)(점·이전/다음이 이미 들어 있다) |
|
|
22
|
+
| 번호가 있는 결과 페이지 이동 | [Pagination](pagination.md) |
|
|
23
|
+
| 같은 화면의 패널 전환 | [Tabs](tabs.md)(페이저와 함께 쓰면 `swipePosition`) |
|
|
24
|
+
| 진행 단계 | [Steps](steps.md) |
|
|
25
|
+
|
|
26
|
+
## 공개 이름과 import
|
|
27
|
+
|
|
28
|
+
| 이름 | 역할 | Web | Native |
|
|
29
|
+
| --- | --- | --- | --- |
|
|
30
|
+
| `PageIndicator` | 기본 | `@hjmds/react`, `/carousel` | `@hjmds/react-native`, `/carousel` |
|
|
31
|
+
| `resolvePageIndicator`·`getPageIndicatorTarget` | 보조(계약 함수) | `@hjmds/design-contracts/components/carousel` | 같음 |
|
|
32
|
+
|
|
33
|
+
## 최소 사용 예
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
// Web
|
|
37
|
+
import { PageIndicator } from "@hjmds/react/carousel";
|
|
38
|
+
|
|
39
|
+
<PageIndicator
|
|
40
|
+
label={t("home.pages")}
|
|
41
|
+
count={pages.length}
|
|
42
|
+
currentIndex={page}
|
|
43
|
+
onCurrentIndexChange={(index) => scrollToPage(index)}
|
|
44
|
+
composeAccessibleName={({ position, total }) => t("home.pageName", { position, total })}
|
|
45
|
+
/>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
// Native
|
|
50
|
+
import { PageIndicator } from "@hjmds/react-native/carousel";
|
|
51
|
+
|
|
52
|
+
<PageIndicator
|
|
53
|
+
label={t("homePages")}
|
|
54
|
+
count={pages.length}
|
|
55
|
+
currentIndex={selected}
|
|
56
|
+
onCurrentIndexChange={(index) => pager.current?.scrollToIndex({ index, animated: !reducedMotion })}
|
|
57
|
+
composeAccessibleName={({ position, total }) => t("homePageName", { position, total })}
|
|
58
|
+
labels={{ previous: t("previousPage"), next: t("nextPage") }}
|
|
59
|
+
/>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 축과 기본값
|
|
63
|
+
|
|
64
|
+
| prop | 값 | 기본값 | 설명 |
|
|
65
|
+
| --- | --- | --- | --- |
|
|
66
|
+
| `label` | `string` | 필수 | 페이지 묶음의 이름. Web은 group 이름, Native는 `label: 현재 페이지 이름`으로 읽힌다. 비면 `TypeError` |
|
|
67
|
+
| `count` | 1 이상 정수 | 필수 | 페이지 수. 밖이면 `RangeError` |
|
|
68
|
+
| `currentIndex` | 0부터 `count - 1` | 필수 | 페이저가 확정한 현재 페이지. 컴포넌트는 따로 기억하지 않는다 |
|
|
69
|
+
| `onCurrentIndexChange` | `(index: number) => void` | 필수 | 점 누름·Web 화살표 키·Native 조절 동작이 요청한 페이지. 처음·끝에서는 같은 값이 다시 올 수 있다(넘어가지 않고 멈춘다) |
|
|
70
|
+
| `composeAccessibleName` | `({ position, total }) => string` | 필수 | 점 하나의 이름. 숫자 문법은 제품 문구다. 빈 문자열은 `TypeError` |
|
|
71
|
+
| Native `labels` | `{ previous: string; next: string }` | 필수 | 조절(adjustable) 동작 이름 |
|
|
72
|
+
| `layoutStyle` | `HjmCompositionStyleProp` | — | 배치 전용 |
|
|
73
|
+
|
|
74
|
+
## 배치
|
|
75
|
+
|
|
76
|
+
| 항목 | 값 | 근거 |
|
|
77
|
+
| --- | --- | --- |
|
|
78
|
+
| 크기 | 점 지름 8, 점마다 누름 영역 44×44(`control.minTouchTarget`). 현재 점 `content.brand`, 나머지 `border.strong` | `carouselRecipe.dot` |
|
|
79
|
+
| 간격 | Web 점 사이 `spacing.xs` 8(`.hjm-page-indicator`). Native는 44 누름 영역끼리 붙여 둔다(Carousel과 같다) | `styles.css`, `react-native/src/carousel.tsx` |
|
|
80
|
+
| 순서·정렬 | 점은 페이지 순서대로 가운데 정렬한다. RTL에서는 줄이 뒤집히고 Web 화살표 키도 읽는 방향을 따른다 | `PageIndicator` |
|
|
81
|
+
| 고정·스크롤 | 스크롤하지 않는다. 보통 페이저 바로 아래에 둔다 | — |
|
|
82
|
+
| 좁은 폭·큰 글자 | 점이 많으면 다음 줄로 넘어간다(`flex-wrap`). 글자가 없어 글자 배율의 영향이 없다 | `styles.css`, `carousel.tsx` |
|
|
83
|
+
|
|
84
|
+
## 꼭 지킬 것
|
|
85
|
+
|
|
86
|
+
- `currentIndex`는 페이저의 실제 위치로 넘긴다. 스크롤이 멈춘 페이지(예: `onScroll`의 반올림 index)를 그대로 쓴다.
|
|
87
|
+
- `onCurrentIndexChange`에서 페이저를 그 페이지로 옮긴다. 이 컴포넌트는 스크롤하지 않는다.
|
|
88
|
+
- 숨은 페이지의 내용은 제품 페이저가 접근성 트리에서 뺀다(`accessibilityElementsHidden`·`importantForAccessibility`).
|
|
89
|
+
- 점의 색·크기를 덮지 않는다. Carousel과 같은 recipe를 쓴다.
|
|
90
|
+
|
|
91
|
+
## 플랫폼 차이
|
|
92
|
+
|
|
93
|
+
| 항목 | Web | Native |
|
|
94
|
+
| --- | --- | --- |
|
|
95
|
+
| 접근성 | `role="group"` 안의 점 버튼(현재 점 `aria-disabled`·`data-current`) | 줄 하나가 adjustable 요소(값 `position / total`, increment·decrement 동작) |
|
|
96
|
+
| 키보드 | 점에서 ←/→가 한 페이지씩 요청 | — |
|
|
97
|
+
| `labels` | 없음 | 필수(조절 동작 이름) |
|
|
98
|
+
| ref | `forwardRef`(`div`) | 없음 |
|
|
99
|
+
|
|
100
|
+
## 근거
|
|
101
|
+
|
|
102
|
+
- PageIndicator 추가: 2026-10-18 Utilverse 홈 런처가 Native 페이저 아래 점을 직접 그렸다(HJM 1.30.0 감사 "HJM gap"). Carousel 점 줄을 공개 부품으로 빼서 같은 recipe·같은 이동 규칙을 쓰게 했다.
|