@hjmds/design-contracts 1.41.1 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/dist/affix.d.ts +18 -0
  2. package/dist/affix.d.ts.map +1 -1
  3. package/dist/affix.js +13 -1
  4. package/dist/affix.js.map +1 -1
  5. package/dist/alert-dialog.d.ts +32 -0
  6. package/dist/alert-dialog.d.ts.map +1 -1
  7. package/dist/alert-dialog.js +52 -0
  8. package/dist/alert-dialog.js.map +1 -1
  9. package/dist/behaviors.d.ts +6 -5
  10. package/dist/behaviors.d.ts.map +1 -1
  11. package/dist/behaviors.js +1 -1
  12. package/dist/behaviors.js.map +1 -1
  13. package/dist/catalog.d.ts +165 -15
  14. package/dist/catalog.d.ts.map +1 -1
  15. package/dist/catalog.js +4 -4
  16. package/dist/catalog.js.map +1 -1
  17. package/dist/character-count.d.ts +103 -0
  18. package/dist/character-count.d.ts.map +1 -0
  19. package/dist/character-count.js +68 -0
  20. package/dist/character-count.js.map +1 -0
  21. package/dist/chart.d.ts +30 -0
  22. package/dist/chart.d.ts.map +1 -1
  23. package/dist/chart.js +18 -0
  24. package/dist/chart.js.map +1 -1
  25. package/dist/collection-rail.d.ts +13 -1
  26. package/dist/collection-rail.d.ts.map +1 -1
  27. package/dist/collection-rail.js +9 -2
  28. package/dist/collection-rail.js.map +1 -1
  29. package/dist/component-recipes.d.ts +172 -2
  30. package/dist/component-recipes.d.ts.map +1 -1
  31. package/dist/component-recipes.js +122 -8
  32. package/dist/component-recipes.js.map +1 -1
  33. package/dist/design-system-provider.d.ts +13 -2
  34. package/dist/design-system-provider.d.ts.map +1 -1
  35. package/dist/design-system-provider.js +19 -3
  36. package/dist/design-system-provider.js.map +1 -1
  37. package/dist/foundations.d.ts +15 -0
  38. package/dist/foundations.d.ts.map +1 -1
  39. package/dist/foundations.js +7 -0
  40. package/dist/foundations.js.map +1 -1
  41. package/dist/icon-button-recipe.d.ts +33 -0
  42. package/dist/icon-button-recipe.d.ts.map +1 -1
  43. package/dist/icon-button-recipe.js +36 -1
  44. package/dist/icon-button-recipe.js.map +1 -1
  45. package/dist/image.d.ts +13 -0
  46. package/dist/image.d.ts.map +1 -1
  47. package/dist/image.js +17 -0
  48. package/dist/image.js.map +1 -1
  49. package/dist/index.d.ts +2 -2
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +2 -2
  52. package/dist/index.js.map +1 -1
  53. package/dist/recipes.d.ts +1 -1
  54. package/dist/recipes.d.ts.map +1 -1
  55. package/dist/recipes.js +1 -1
  56. package/dist/recipes.js.map +1 -1
  57. package/dist/screen-patterns.d.ts +92 -0
  58. package/dist/screen-patterns.d.ts.map +1 -1
  59. package/dist/screen-patterns.js +79 -2
  60. package/dist/screen-patterns.js.map +1 -1
  61. package/dist/selection-helpers.d.ts +6 -0
  62. package/dist/selection-helpers.d.ts.map +1 -1
  63. package/dist/selection-helpers.js +12 -0
  64. package/dist/selection-helpers.js.map +1 -1
  65. package/dist/sidebar.d.ts +17 -2
  66. package/dist/sidebar.d.ts.map +1 -1
  67. package/dist/sidebar.js +8 -2
  68. package/dist/sidebar.js.map +1 -1
  69. package/dist/steps.d.ts +19 -19
  70. package/dist/steps.d.ts.map +1 -1
  71. package/dist/steps.js +21 -9
  72. package/dist/steps.js.map +1 -1
  73. package/dist/timeline.d.ts +41 -0
  74. package/dist/timeline.d.ts.map +1 -1
  75. package/dist/timeline.js +47 -1
  76. package/dist/timeline.js.map +1 -1
  77. package/dist/top.d.ts +21 -2
  78. package/dist/top.d.ts.map +1 -1
  79. package/dist/top.js +17 -1
  80. package/dist/top.js.map +1 -1
  81. package/dist/version.d.ts +1 -1
  82. package/dist/version.js +1 -1
  83. package/dist/version.js.map +1 -1
  84. package/docs/affix.md +7 -0
  85. package/docs/avatar-fallback.md +19 -0
  86. package/docs/brand-boundary.md +24 -2
  87. package/docs/chart.md +7 -0
  88. package/docs/collection-rail.md +4 -1
  89. package/docs/generated/component-maturity.md +1 -1
  90. package/docs/generated/renderer-evidence.json +10 -6
  91. package/docs/generated/renderer-evidence.md +1 -1
  92. package/docs/generated/showcase-manifest.json +12 -5
  93. package/docs/image.md +5 -0
  94. package/docs/list-row.md +8 -0
  95. package/docs/optional-adapters.md +3 -0
  96. package/docs/overlay-stack.md +27 -0
  97. package/docs/screen-patterns.md +39 -2
  98. package/docs/sidebar.md +15 -0
  99. package/docs/steps.md +23 -19
  100. package/docs/theme-palette.md +3 -3
  101. package/docs/theming.md +1 -0
  102. package/docs/timeline.md +18 -1
  103. package/docs/top.md +7 -0
  104. package/docs/usage/README.md +2 -2
  105. package/docs/usage/components/affix.md +18 -4
  106. package/docs/usage/components/alert-dialog.md +28 -0
  107. package/docs/usage/components/avatar.md +30 -9
  108. package/docs/usage/components/badge.md +20 -5
  109. package/docs/usage/components/chart.md +19 -1
  110. package/docs/usage/components/checkbox-group.md +24 -1
  111. package/docs/usage/components/choice-row.md +22 -5
  112. package/docs/usage/components/composition-layout.md +6 -2
  113. package/docs/usage/components/counter-badge.md +2 -2
  114. package/docs/usage/components/design-system-provider.md +2 -1
  115. package/docs/usage/components/divider.md +12 -1
  116. package/docs/usage/components/field.md +51 -0
  117. package/docs/usage/components/icon-button.md +48 -4
  118. package/docs/usage/components/icon-tile.md +13 -2
  119. package/docs/usage/components/image.md +25 -0
  120. package/docs/usage/components/list-row.md +8 -4
  121. package/docs/usage/components/list.md +1 -1
  122. package/docs/usage/components/notification-item.md +5 -1
  123. package/docs/usage/components/permission-screen.md +25 -4
  124. package/docs/usage/components/screen-layout.md +27 -1
  125. package/docs/usage/components/segmented-control.md +16 -3
  126. package/docs/usage/components/select.md +1 -0
  127. package/docs/usage/components/settings-screen.md +5 -4
  128. package/docs/usage/components/sheet.md +2 -1
  129. package/docs/usage/components/sidebar.md +34 -6
  130. package/docs/usage/components/steps.md +29 -8
  131. package/docs/usage/components/surface.md +2 -1
  132. package/docs/usage/components/text-area.md +2 -1
  133. package/docs/usage/components/timeline.md +25 -4
  134. package/docs/usage/components/top.md +15 -2
  135. package/docs/usage/screens/common-settings.md +3 -2
  136. package/docs/usage/screens/flow-permission.md +3 -3
  137. package/docs/usage/tokens/elevation-opacity.md +14 -2
  138. package/package.json +7 -1
@@ -4,7 +4,7 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.12.1
7
- - 검토일: 2026-10-06
7
+ - 검토일: 2026-10-23
8
8
  - 근거: recipe `dividerRecipe`(`src/component-recipes.ts`)
9
9
  - 스토리북: `배포/컴포넌트/레이아웃/구분선`
10
10
 
@@ -12,6 +12,7 @@
12
12
 
13
13
  서로 다른 내용 묶음 사이에 얇은 구분선이 필요할 때 쓴다. 가로선은 블록 사이, 세로선은
14
14
  한 줄 안의 항목 사이(예: 툴바의 무리 구분)에 쓴다. 색은 `border` semantic 역할이다.
15
+ 한 표면(카드) 안에서 "다른 이야기가 이어진다"는 가벼운 나눔은 `lineStyle="dashed"`다. 실선은 카드 하나를 둘로 읽히게 한다(개발 소스, 미게시).
15
16
 
16
17
  ## 쓰지 않을 때
17
18
 
@@ -54,6 +55,7 @@ import { Divider } from "@hjmds/react-native/data-display";
54
55
  | `orientation` | `horizontal` · `vertical` | `horizontal` | 가로선·세로선 |
55
56
  | `inset` | Web `none` · `start` · `both` / Native 숫자(pt, 토큰 값으로 넘긴다) | Web `none`(0) / Native 0 | Web `start`·`both`는 `spacing.md`. Native는 가로선이면 좌우, 세로선이면 위아래 여백이 된다 |
56
57
  | Web `decorative` | `boolean` | `false` | `true`면 `role="presentation"`·`aria-hidden` |
58
+ | `lineStyle` | `solid` · `dashed` | `solid` | `dashed`는 `dividerRecipe.lineStyles.dashed`의 dash 4 · gap 4를 두께 1로 그리며 시작 쪽 끝에서 dash로 시작한다. 색·두께는 실선과 같다(개발 소스, 미게시) |
57
59
  | `layoutStyle` | 배치 전용 style | — | 루트 배치. Native `style`은 제거된 속성 |
58
60
 
59
61
  콜백 prop은 없다.
@@ -70,6 +72,7 @@ import { Divider } from "@hjmds/react-native/data-display";
70
72
 
71
73
  ## 꼭 지킬 것
72
74
 
75
+ - 점선을 `border-*: dashed` CSS나 SVG `Line`으로 따로 그리지 않는다. `lineStyle="dashed"`를 쓴다.
73
76
  - 색·두께를 `style`/`className`으로 바꾸지 않는다. 구분선 색은 제품 테마의 `border` key로 바뀐다.
74
77
  배치는 `layoutStyle`로 한다. Native `style`은 제거된 속성 — `layoutStyle` 또는 `orientation`·`inset`으로 옮긴다.
75
78
  - Web에서 의미가 없는 장식선은 `decorative`로 표시한다(`role="presentation"`, `aria-hidden`).
@@ -82,8 +85,16 @@ import { Divider } from "@hjmds/react-native/data-display";
82
85
  | `inset` 타입 | `"none" \| "start" \| "both"` | `number`(0 이상) |
83
86
  | 접근성 | `separator`(기본) 또는 `decorative` | 항상 `accessible={false}`, `decorative` prop 없음 |
84
87
  | 요소 | 가로 `hr`, 세로 `div` | `View` |
88
+ | 점선 그리기 | `repeating-linear-gradient`(recipe의 dash·gap을 Provider CSS 변수로) | 측정한 길이에 recipe 크기의 조각 `View`를 나열(iOS는 한쪽 `borderStyle: "dashed"`를 실선으로 그린다). optional SVG peer를 쓰지 않는다 |
85
89
 
86
90
  ## 함정
87
91
 
88
92
  - Native `inset`에 음수·NaN을 넘기면 `RangeError`가 난다.
89
93
  - Native Divider는 테마를 읽으므로 `HjmNativeProvider` 밖에서 렌더하면 예외가 난다.
94
+
95
+ ## 근거
96
+
97
+ - `lineStyle="dashed"`(2026-10-23 소비 감사): 다에리가 같은 의미의 점선을 Web `.cozy-nest-next`의 `border-block-start: 1px dashed`(브라우저가 정한 간격)와
98
+ Native `DashedRule`(SVG `strokeDasharray="4 4"`)로 따로 그렸다. 의미가 같은 선을 두 번 구현해 간격이 갈랐으므로 Divider의 선 모양 축으로 두고,
99
+ 간격·두께는 recipe가 소유한다. 새 컴포넌트가 아니라 축인 이유는 색·두께·inset·접근성이 실선과 같고 패턴만 다르기 때문이다.
100
+ 이관: Web `<Divider lineStyle="dashed" />`, Native `<Divider lineStyle="dashed" />`로 `DashedRule`과 CSS 점선을 대체한다.
@@ -77,6 +77,7 @@ import { Field } from "@hjmds/react-native/forms";
77
77
  | `onValueChange`(`TextField`) | `(value: string) => void` | — | Web·Native 같은 이름·모양. Web은 DOM `onChange`(이벤트)도 함께 불린다. 공용 폼 코드는 `onValueChange`를 쓴다 |
78
78
  | `children`(`Field` render-prop) | `(control: FieldControlProps) => ReactNode` 또는 `ReactNode` | — | Web `FieldControlProps`는 `{ id, required, disabled, "aria-invalid"?: true, "aria-describedby"? }`, Native는 `{ accessibilityLabel, accessibilityHint?, accessibilityState: { disabled } }`(hint는 오류 우선) |
79
79
  | `busy`(Native `TextField`) | `boolean` | `false` | 편집을 막고 접근성 busy를 싣는다 |
80
+ | `characterCount`(`TextField`·`TextArea`) | `CharacterCountOptions` | — | 미게시. 틀 아래 `n/max`와 상태 문장. [글자 수 표시](#글자-수-표시-미게시) |
80
81
 
81
82
  - 상태 테두리는 recipe가 정한다: 기본 `borderControl`, 포커스 `contentBrand`, 오류 `danger`.
82
83
  - 도움말은 오류가 생겨도 사라지지 않고 둘 다 보인다(Web은 `aria-describedby`로 둘 다 연결).
@@ -119,6 +120,56 @@ Native는 제품 프로필의 `tokens.fontFamily.ui`를 실제 텍스트/입력
119
120
  - 커스텀 컨트롤에는 render-prop 값을 그대로 펼쳐 라벨 연결을 끊지 않는다. Web `Field`는 `controlId`가 필수다.
120
121
  - 비활성인 이유는 도움말(`description`)에 적는다. 도움말·오류는 흐려지지 않으므로 제품에서 따로 흐리게 하거나 색을 바꾸지 않는다.
121
122
 
123
+ ### 글자 수 표시 (미게시)
124
+
125
+ `TextField`·`TextArea`의 `characterCount`는 틀 바로 아래, 설명(`description`) 위에 한 줄을 그린다. 앞에는 제품 문장,
126
+ 끝에는 `n/max` 숫자가 온다. 계산과 상태 판정은 `@hjmds/design-contracts/character-count`의 `resolveCharacterCount`가 맡는다.
127
+ 숫자는 `locale`로 HJM이 형식화하고(`1,024/1,000`), 문장은 제품이 `messages`로 준다.
128
+
129
+ ```tsx
130
+ // Web (Native는 onValueChange={setPrompt})
131
+ <TextArea label={t("create.prompt")} description={t("create.countHint")} value={prompt}
132
+ onChange={(event) => setPrompt(event.currentTarget.value)}
133
+ characterCount={{
134
+ min: 5, max: 500, nearLimit: 40, measure: "trimmed", overflow: "allow", locale,
135
+ messages: {
136
+ range: ({ min, max }) => t("create.range", { min, max }),
137
+ remaining: ({ count }) => t("create.remaining", { count }),
138
+ needed: ({ count }) => t("create.needed", { count }),
139
+ over: ({ count }) => t("create.over", { count }),
140
+ },
141
+ }} />
142
+ ```
143
+
144
+ | 옵션 | 값 | 기본값 | 설명 |
145
+ | --- | --- | --- | --- |
146
+ | `max` | 양의 정수 | 필수 | 상한 |
147
+ | `min` | 0 이상 정수 | `0` | 하한. 빈 값은 `short`가 아니다(손대지 않은 칸은 오류가 아니다) |
148
+ | `overflow` | `block` · `allow` | `block` | `block`은 플랫폼 `maxLength`를 걸어 상한에서 입력이 멈춘다. `allow`는 붙여 넣은 긴 초안을 지우지 않고 계속 받으며 초과를 표시한다. 이때 제출은 제품이 `status !== "over"`로 막는다 |
149
+ | `measure` | `length` · `trimmed` | `length` | `length`는 UTF-16 길이(플랫폼 `maxLength`와 같은 단위), `trimmed`는 앞뒤 공백을 뺀 길이 |
150
+ | `nearLimit` | 0 이상 정수 | `ceil(max × 0.1)` | 남은 수가 이 값 이하면 `near`(`characterCountRecipe.nearLimitRatio`) |
151
+ | `locale` | BCP 47 | 필수 | 숫자 형식. 환경 기본값으로 새지 않도록 필수다 |
152
+ | `messages` | `range` · `remaining` · `needed` · `over` 함수 | — | 상태별 문장. 없으면 숫자만 보인다 |
153
+
154
+ | 상태(`status`) | 조건(우선순위 순) | 문장 | 모양 |
155
+ | --- | --- | --- | --- |
156
+ | `over` | 길이 > `max` | `over({ count: 초과 수 })` | 행 `danger`, 숫자 semibold, 칸 오류 테두리 2·`aria-invalid` |
157
+ | `short` | 0 < 길이 < `min` | `needed({ count: 부족 수 })` | 행 `danger`, 숫자 semibold. 칸 테두리는 그대로 |
158
+ | `near` | 남은 수 ≤ `nearLimit` | `remaining({ count: 남은 수 })` | `textMuted` |
159
+ | `within` | 그 밖 | `range({ min, max })` | `textMuted` |
160
+
161
+ - 배치: `characterCountRecipe` — `label` 글자(12/18), 문장과 숫자 사이 `spacing.sm` 8, 숫자는 끝 정렬·고정폭 숫자(`tabular-nums`).
162
+ 비활성일 때도 설명처럼 흐리지 않는다.
163
+ - 접근성: Web은 행에 id를 주고 컨트롤의 `aria-describedby`(자체 → 글자 수 → 설명 → 오류 순)에 넣어 포커스할 때 읽는다.
164
+ Native는 행을 접근성 트리에서 숨기고 문장·숫자를 입력의 `accessibilityHint` 뒤에 붙인다.
165
+ 알림은 `near`·`short`·`over`로 **들어갈 때 한 번**만 정중하게(Web `aria-live="polite"` 영역, Native `announceForAccessibility`) 한다.
166
+ 같은 상태 안에서 글자마다 알리지 않는다. 입력한 글자 낭독을 덮기 때문이다.
167
+ - `maxLength`를 함께 넘기면 `TypeError`다. 상한은 `characterCount.max` 하나로 둔다.
168
+ - `SearchField`·`PasswordField`·`OtpField`에는 없다. 자유 글 상한이 아니기 때문이다.
169
+
170
+ 근거: 2026-10-23 번뚝 웹 `components/ComposerCount.tsx`와 앱 `components/TextLengthHint.tsx`가 같은 규칙(부족·초과·남은 수, `n/max`,
171
+ 초과 시 오류 색)을 따로 그렸고, 웹만 남은 50자에서 숫자를 브랜드색으로 강조해 기준이 어긋나 있었다. 브랜드 강조는 옮기지 않았다.
172
+
122
173
  ### GestureSheetInput (Native optional-extension)
123
174
 
124
175
  `GestureSheet` 안에서 키보드를 따라 움직이는 입력이다. `BottomSheetTextInput`의 props를 그대로 받고
@@ -4,7 +4,7 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.12.1
7
- - 검토일: 2026-10-17
7
+ - 검토일: 2026-10-23
8
8
  - 근거: recipe `iconButtonRecipe`(`src/icon-button-recipe.ts`), NotificationBell 계약: [Compound controls](../../compound-controls.md#notificationbell)
9
9
  - 스토리북: `배포/컴포넌트/동작/아이콘 버튼` · `배포/컴포넌트/상태와 알림/알림 벨`
10
10
 
@@ -28,7 +28,8 @@ Dialog·Sheet footer 행동, 목록 끝 "더 보기", 온보딩 다음·이전,
28
28
  | 상황 | 대신 쓸 것 |
29
29
  | --- | --- |
30
30
  | 글자로 결과를 확인해야 하는 주 행동·확인 행동(저장·완료·보내기) | [Button](button.md) |
31
- | 다른 페이지·URL로 이동 | [Link](link.md) |
31
+ | 글자로 다른 페이지·URL로 이동 | [Link](link.md) |
32
+ | 아이콘만으로 다른 페이지로 이동(셸의 설정·프로필 진입점) | `IconButtonLink`(아래). `<button>`에 router push를 달거나 `<a>`에 제품 CSS로 원형·현재 표시를 그리지 않는다 |
32
33
  | 화면 위에 떠 있는 주 행동 | [FloatingActionButton](floating-action-button.md) |
33
34
  | 여러 아이콘 중 하나를 고름 | [ToggleGroup](toggle-group.md), [SegmentedControl](segmented-control.md) |
34
35
  | 누를 수 없는 그림 | [Icon](icon.md) |
@@ -38,6 +39,7 @@ Dialog·Sheet footer 행동, 목록 끝 "더 보기", 온보딩 다음·이전,
38
39
  | 이름 | 역할 | Web | Native |
39
40
  | --- | --- | --- | --- |
40
41
  | `IconButton` | 기본 | `@hjmds/react`, `/actions` | `@hjmds/react-native`, `/actions`, `/bottom-cta` |
42
+ | `IconButtonLink` | 동반(이동) — IconButton 모양의 링크. 현재 페이지 표시 `current`. 개발 소스·미게시 | `@hjmds/react`, `/actions` | `@hjmds/react-native`, `/actions`, `/bottom-cta` |
41
43
  | `NotificationBell` | 확장 — 미확인 수 배지 + 증가 시 한 번 흔들림 | `/notification-bell` | `/notification-bell` |
42
44
  | `ClipboardIconButton` | 동반(복사) — 아이콘만 있는 복사 도구 행동. `value`, `labels: { idle; copied }`(두 상태의 접근성 이름), `onCopy?`, `onCopyError?`, `feedbackDuration?`(ms), `tone` 기본 `ghost` | `@hjmds/react`, `/clipboard` | — |
43
45
 
@@ -75,6 +77,45 @@ import { NotificationBell } from "@hjmds/react-native/notification-bell";
75
77
  />
76
78
  ```
77
79
 
80
+ ### 아이콘 링크 — `IconButtonLink` (2026-10-23, 개발 소스·미게시)
81
+
82
+ Button에 대한 ButtonLink처럼 IconButton 틀(`tone`·`size`·`shape`·`edge`·`hitSlop`·`layoutStyle`)을 이동 링크에 쓴다.
83
+ 토글·로딩·변경 행동은 IconButton에 남는다. `current`는 지금 보고 있는 화면의 진입점이며 선택된 IconButton과 같은 면
84
+ (`iconButtonRecipe.states.selected`)으로 칠하고 Web `aria-current="page"`, Native `accessibilityState.selected`로 알린다.
85
+
86
+ | prop | Web | Native | 기본값 |
87
+ | --- | --- | --- | --- |
88
+ | 목적지 | `href: string` | `destination: LinkDestination` | 필수 |
89
+ | 이름 | `label: string`(접근성 이름, 빈 값이면 `TypeError`) | 같음 | 필수 |
90
+ | `current` | `boolean` → `aria-current="page"` + 선택 면 | `boolean` → `selected` + 선택 면 | `false` |
91
+ | `disabled` | `href`를 지우고 클릭·가운데 클릭을 막는다(ButtonLink와 같다) | 누름을 막는다 | `false` |
92
+ | router 연결 | `renderAnchor(props)` — `ref`·`href`·이름·`aria-current`·차단 핸들러가 담긴 props를 자기 링크에 펼친다 | `onNavigate(destination)` 또는 `renderLink(props)`(TopBarAction `intent="link"`와 같다). 둘 다 없으면 `TypeError` | — |
93
+
94
+ ```tsx
95
+ // Web (Next.js)
96
+ import NextLink from "next/link";
97
+ import { IconButtonLink } from "@hjmds/react/actions";
98
+
99
+ <IconButtonLink href={`/${locale}/settings`} label={t("action.settings")} shape="circle"
100
+ current={pathname.endsWith("/settings")} renderAnchor={(props) => <NextLink {...props} />}>
101
+ <Icon name="settings" decorative />
102
+ </IconButtonLink>
103
+ ```
104
+
105
+ ```tsx
106
+ // Native (Expo Router)
107
+ import { Link } from "expo-router";
108
+ import { IconButtonLink } from "@hjmds/react-native/actions";
109
+
110
+ <IconButtonLink label={t("action.settings")} destination={{ kind: "internal", href: "/settings" }} current={focused}
111
+ renderLink={({ destination, ...props }) => <Link href={destination.href} asChild><Pressable {...props} /></Link>}>
112
+ <Icon descriptor={{ name: "settings", decorative: true }} renderGlyph={renderGlyph} />
113
+ </IconButtonLink>
114
+ ```
115
+
116
+ `renderAnchor`·`renderLink`는 함수 prop이라 Next 서버 컴포넌트에서 넘길 수 없다. 현재 경로로 `current`를 정하는 셸
117
+ Client Component에서 연결한다.
118
+
78
119
  ## 축과 기본값
79
120
 
80
121
  | prop | 값 | 기본값 | 설명 |
@@ -82,6 +123,7 @@ import { NotificationBell } from "@hjmds/react-native/notification-bell";
82
123
  | `tone` | `ghost` · `primary` · `secondary` · `danger` | `ghost` | — |
83
124
  | `size` | `small` 36 · `medium` 44 · `large` 52 | `medium` | `small`은 터치 여백 4 추가. 아이콘 크기는 각각 `sm`·`md`·`lg` |
84
125
  | `shape` | `rounded` · `circle` | `rounded` | — |
126
+ | `elevation` | `none` · `raised` · `floating` · `overlay` | `none` | 떠 있는 컨트롤(번뚝 Dock 가운데 버튼)의 그림자 토큰 단계(`iconButtonRecipe.elevation`). `primary`·`secondary`·`danger`만 받고 `ghost`에 주면 `TypeError`. Web은 `--hjm-shadow-<단계>`, Native는 iOS 그림자 + Android elevation. `IconButtonLink`에는 없다(미게시, 2026-10-23) |
85
127
  | `selected` | `true` · `false` | — | 주면 토글이 된다(Web `aria-pressed`, Native 접근성 state) |
86
128
  | `edge` | `start` · `end` · `both` | — | 버튼이 놓인 컨테이너 가장자리(논리 방향, RTL에서 뒤집힘). `ghost`면 터치 영역은 그대로 두고 그 쪽으로 (보이는 지름 − 아이콘 칸) / 2만큼 내민다(`medium` 10, `small` 8, `large` 12, `resolveIconButtonEdgeOverhang`). 테두리·배경이 있는 tone은 틀이 정렬 기준이라 움직이지 않는다 |
87
129
  | `loading` | `true` · `false` | — | 누름을 막고 스피너로 바꾼다. Native는 기본으로 포커스를 유지한다(`disableWhileLoading`으로만 옛 동작) |
@@ -104,9 +146,9 @@ import { NotificationBell } from "@hjmds/react-native/notification-bell";
104
146
  | 항목 | 값 | 근거 |
105
147
  | --- | --- | --- |
106
148
  | 크기 | 지름: `small` 36 · `medium` 44 · `large` 52. `small`은 hitSlop 4를 더해 터치 영역을 44(`control.minTouchTarget`)에 맞춘다. 아이콘 크기는 `glyph.sm` 20 · `glyph.md` 24 · `glyph.lg` 28이다 | `icon-button-recipe.ts` `iconButtonRecipe` |
107
- | 간격 | [TopBar](top-bar.md)가 슬롯 최소 폭 44, 슬롯 안 간격 `spacing.xs`(8), 좌우 여백 `spacing.md`(16)를 준다. NotificationBell 배지가 버튼 밖으로 넘칠 수 있으니 옆 요소와 간격을 둔다 | `component-recipes.ts` `topBarRecipe`, `styles.css` `.hjm-top-bar` |
149
+ | 간격 | [TopBar](top-bar.md)가 슬롯 최소 폭 44, 슬롯 안 간격 `spacing.xs`(8), 좌우 여백 `spacing.md`(16)를 준다. NotificationBell 배지는 버튼 위로 6만큼 올라오고 끝 쪽으로는 넘치지 않는다 | `component-recipes.ts` `topBarRecipe`, `styles.css` `.hjm-top-bar` |
108
150
  | 가장자리 정렬 | 행·헤더·카드의 시작/끝 가장자리에 놓인 ghost IconButton은 `edge`를 준다. 아이콘 칸(`medium` 24)이 컨테이너 안쪽 여백 선(보통 16)에 오고 44 터치 영역은 여백 쪽으로 10 넘어간다. 같은 줄에 여러 버튼이 있으면 가장자리에 닿은 마지막(또는 첫) 버튼에만 준다. HJM이 그리는 화면 나가기(`exit`)와 SavedItemsScreen 새 컬렉션, ListDetailScreen 새로고침, MediaSelectionScreen 추가, UploadItem 취소·다시 시도, InlineEdit·RepeatableFieldGroup·TableColumnSettings 행 끝 도구, SearchScreen 정렬·전체 삭제는 이미 적용돼 있다. 제품이 헤더 `actions`·ListRow `trailing`·Native `trailingAction`에 넣는 끝 버튼은 제품이 `edge="end"`를 준다. Web 포커스 링은 잘리지 않게 버튼 안쪽에 그린다 | `icon-button-recipe.ts` `iconButtonRecipe.edge`·`resolveIconButtonEdgeOverhang`, `react/src/theme.ts`(`--hjm-icon-button-edge-*`), `styles.css` `[data-edge]`, `react-native/src/actions.tsx` |
109
- | 순서·정렬 | 화면 맨 위에서는 TopBar의 `leading`(뒤로·닫기)과 `actions`/`trailing`(더보기·공유·알림)에 둔다. 행 끝(목록·카드)에는 `size="small"` 또는 `medium`, `tone="ghost"`를 쓴다. NotificationBell은 IconButton 오른쪽 위 모서리(`end: 0`, `top: 0`)에 [CounterBadge](counter-badge.md) `floating`을 겹친다 | `react-native/src/notification-bell.tsx` |
151
+ | 순서·정렬 | 화면 맨 위에서는 TopBar의 `leading`(뒤로·닫기)과 `actions`/`trailing`(더보기·공유·알림)에 둔다. 행 끝(목록·카드)에는 `size="small"` 또는 `medium`, `tone="ghost"`를 쓴다. NotificationBell은 `iconButtonRecipe.badge`대로 [CounterBadge](counter-badge.md) `small`·`floating`을 버튼 끝 모서리(끝 0)에 두고, 배지 아래 테두리 2(`stroke.strong`)만 아이콘 칸 위쪽에 닿도록 올린다(`resolveIconButtonBadgeOffset`, 작은 버튼은 위 −6). 자릿수가 늘면 시작 쪽으로 넓어지고 아이콘을 덮지 않는다. 2026-10-23 웹 확인에서 `medium` 배지를 `top: 0`에 두어 18px 아이콘의 82%를 가린 것을 고쳤다 | `react-native/src/notification-bell.tsx` |
110
152
  | 고정·스크롤 | — | — |
111
153
  | 좁은 폭·큰 글자 | — | — |
112
154
 
@@ -150,6 +192,8 @@ TopBar
150
192
 
151
193
  ## 근거
152
194
 
195
+ - `IconButtonLink`: 2026-10-23 다에리 웹 소비 감사 — 셸 설정 진입점(`ShellSettingsLink`)이 IconButton은 `<button>`만, ButtonLink는
196
+ 가로 padding이 있는 pill이라 둘 다 쓸 수 없어 `Link` + 제품 `.settings-link` CSS(44 원형, `aria-current` 배경)로 다시 그렸다.
153
197
  - 가장자리 정렬(`edge`): 2026-10-17 화면 정렬 실측 감사(Web Storybook 402·1280, Native iPhone 17 Pro 3x 캡처). 행·헤더 끝의 44 버튼이
154
198
  아이콘을 여백 선보다 10~15 안쪽에 두었다(댓글 더보기 28 vs 아바타 16, 화면 머리 뒤로 31 vs 16, 업로드 취소 26 vs 16, 정렬 화살표 29 vs 16).
155
199
  컴포넌트마다 오프셋을 따로 두지 않고 이 한 규칙과 계약 값으로 맞췄다. Toast 닫기(같은 날 `toastRecipe.close.inlineOverhang`)와 같은 식이다.
@@ -4,7 +4,7 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.26.0
7
- - 검토일: 2026-10-20
7
+ - 검토일: 2026-10-23
8
8
  - 근거: `src/screen-patterns.ts`의 `iconTileRecipe`
9
9
  - 스토리북: `배포/컴포넌트/데이터 표시/아이콘 타일`
10
10
 
@@ -58,7 +58,9 @@ import { IconTile } from "@hjmds/react-native/screens";
58
58
  | onActivate · activationLabel · disabled | callback · string · boolean | 없음 · label · false | 미게시. 단순 항목 열기는 IconTile 자체의 버튼을 사용한다. 44 최소 대상/키보드/초점/비활성/누름 피드백을 HJM이 제공한다 |
59
59
  | Native `onLongPress` · `activationHint` | callback · string | 없음 | 미게시. `onActivate`와 함께 길게 누르기 미리보기·메뉴를 연결한다. 비활성일 때 두 행동을 모두 막고 설명은 버튼의 접근성 hint로 전달한다. Web prop으로 제공하지 않는다 |
60
60
  | layoutStyle | 배치 전용 style | 없음 | 정적 타일 또는 interactive 루트의 폭·외부 배치 |
61
- | overlay | ReactNode | 없음 | 제품 배지 |
61
+ | overlay | ReactNode | 없음 | 틀 안 자유 배치 슬롯(기존). 모서리 표식은 `accessory`를 쓴다 |
62
+ | `accessory` · `accessoryPlacement` | `ReactNode` · `"top-end"` · `"bottom-end"` | 없음 · `"top-end"` | 아이콘 틀 모서리 표식(잠금·대기·개수). `iconTileRecipe.accessory.offset` 4만큼 바깥으로 나가고 `locked`에도 흐려지지 않는다. 누르는 타일 안에서는 보조기기에서 숨기므로 뜻은 `lockedLabel`·`activationLabel`에 담는다(미게시, 2026-10-23) |
63
+ | `locked` · `lockedLabel` | boolean · string | false · 없음 | 눌리지만 흐린 잠금 상태(`iconTileRecipe.locked.opacity` = 비활성 투명도). `onActivate`가 그대로 불려 제품이 이유를 보여 준다. 접근성 이름은 `"<이름>, <lockedLabel>"`. `onActivate`·`lockedLabel` 없이 쓰면 `TypeError`(미게시, 2026-10-23) |
62
64
  | labelLines | 1 또는 2 | 1 | 제목 줄 수 |
63
65
  | ghost | boolean | false | 이동 대상 표시 |
64
66
  | frame | `"surface"` · `"none"` | `"surface"` | 미게시(1.30.0 이후). `surface`는 공통 틀(`surfaceAlt` 채움 + 테두리 1), `none`은 틀을 그리지 않는다. 아이콘 원본이 이미 완성된 런처 아이콘(모양·배경 포함)일 때 `none` |
@@ -89,6 +91,15 @@ import { IconTile } from "@hjmds/react-native/screens";
89
91
  ## 상태와 상호작용
90
92
 
91
93
  `onActivate`가 없으면 표시 전용이며, 있으면 HJM이 버튼·누름 피드백·비활성을 소유한다.
94
+ `disabled`는 누를 수 없는 상태, `locked`는 누르면 이유를 보여 주는 상태다. 잠금 타일을 바깥 View의
95
+ `opacity`로 흐리게 하거나 잠금 배지를 `right: -4, top: -4` 같은 절대 좌표로 놓지 않는다(2026-10-23 유틸버스 우회).
96
+
97
+ ```tsx
98
+ // Native — Web도 같은 prop(onActivate 대신 클릭)
99
+ <IconTile label={name} icon={({ edge }) => <ToolArtwork size={edge} />} size="fill" frame="none"
100
+ onActivate={() => open(tool)} locked={!signedIn} lockedLabel={t("loginRequiredBadge")}
101
+ accessory={!signedIn ? <LockBadge /> : undefined} />
102
+ ```
92
103
  Native의 길게 누르기 미리보기는 `onLongPress`에 제품 콜백을 연결한다. 별도 Pressable로 감싸지 않는다.
93
104
  실제 끌기·재배열 같은 제품 제스처는 정적 타일을 기존 gesture host에 넣어 유지한다.
94
105
 
@@ -32,6 +32,7 @@
32
32
  | `Image` | 기본 | `@hjmds/react`, `/display` | `@hjmds/react-native`, `/data-display` |
33
33
  | `GridReveal` | 확장 — 로드 완료 시 4×4 마스크 연출(optional) | `/grid-reveal` | `/grid-reveal` |
34
34
  | `ImageViewer` | 확장 — 전체 화면 넘겨 보기·확대 | `/image-viewer` (개발 소스) | `/image-viewer` (optional) |
35
+ | `ImageViewerTrigger` | 확장 — 사진을 눌러 `ImageViewer` 열기 | `/image-viewer` | `/image-viewer` (optional) |
35
36
 
36
37
  `GridReveal`은 추가 peer가 없다. `ImageViewer`는 granular subpath로만 import 되며 optional peer
37
38
  `react-native-zoom-toolkit` 5.1.1, `react-native-gesture-handler` 2.32.0과 Reanimated·Worklets 설치가 필요하다.
@@ -65,6 +66,30 @@ import { ImageViewer } from "@hjmds/react-native/image-viewer";
65
66
  errorLabel={t("viewer.loadFailed")} retryLabel={t("common.retry")} />
66
67
  ```
67
68
 
69
+ ### 사진을 눌러 크게 보기 (`ImageViewerTrigger`, 2026-10-23)
70
+
71
+ 사진을 원시 `button`·`Pressable`로 감싸 뷰어를 열지 않는다(포트폴리오 사이트 `App.tsx`, Spint `MarkPhoto`·`ComposeScreen`이던 우회).
72
+ 트리거가 접근성 이름(`label`, Web `aria-haspopup="dialog"`, Native `imagebutton`), 포커스 링(이미지 액자 `radius.md`),
73
+ Web `zoom-in` 커서, 눌림 투명도(`imageViewerTriggerRecipe.pressedOpacity`)를 소유한다. 안의 사진은 `decorative`로 둔다.
74
+ 뷰어의 `open` 상태와 항목은 제품이 갖는다. Web 트리거는 기본으로 부모 폭을 채우므로 썸네일은 `layoutStyle={{ width }}`를 준다.
75
+
76
+ ```tsx
77
+ // Native — Web은 @hjmds/react/image-viewer에서 import하고 onClick을 쓴다
78
+ import { ImageViewer, ImageViewerTrigger } from "@hjmds/react-native/image-viewer";
79
+
80
+ <ImageViewerTrigger label={t("photo.preview")} onPress={() => setOpen(true)}>
81
+ <Image src={uri} width={w} height={h} decorative layoutStyle={{ width: "100%" }} />
82
+ </ImageViewerTrigger>
83
+ ```
84
+
85
+ | prop | 값 | 기본값 | 설명 |
86
+ | --- | --- | --- | --- |
87
+ | `label` | `string` | 필수 | 지역화한 이름. 빈 문자열은 `TypeError` |
88
+ | `children` | `ReactNode` | 필수 | 제품 사진(장식) |
89
+ | Web `onClick` · Native `onPress` | 콜백 | Native 필수 | 뷰어 열기 |
90
+ | `disabled` | `boolean` | `false` | 사진을 아직 못 불렀을 때 |
91
+ | `layoutStyle` | 배치 전용 | — | Web은 기본 폭 100% |
92
+
68
93
  ## 축과 기본값
69
94
 
70
95
  | prop | 값 | 기본값 | 설명 |
@@ -4,7 +4,7 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.12.1
7
- - 검토일: 2026-10-17
7
+ - 검토일: 2026-10-23
8
8
  - 근거: [ListRow](../../list-row.md), recipe `listRowRecipe`(`src/component-recipes.ts`)
9
9
  - 스토리북: `배포/컴포넌트/데이터 표시/목록 행`
10
10
 
@@ -41,10 +41,10 @@
41
41
 
42
42
  ```tsx
43
43
  // Web
44
- import { Icon, ListRow } from "@hjmds/react/display";
44
+ import { ListRow } from "@hjmds/react/display";
45
45
 
46
46
  <ListRow title={t("settings.language")} description={languageName} href="/settings/language"
47
- trailing={<Icon name="chevronEnd" decorative />} />
47
+ trailingAccessory="disclosure" />
48
48
  ```
49
49
 
50
50
  ```tsx
@@ -71,6 +71,7 @@ import { Avatar, ListRow } from "@hjmds/react-native/data-display";
71
71
  | `onClick`(Web) | `(event: MouseEvent<HTMLElement>) => void` | — | 있으면 `<button>` 행 |
72
72
  | `onPress`(Native) | `(event: GestureResponderEvent) => void` | — | 있으면 누를 수 있는 행 |
73
73
  | `loading`·`loadingLabel` | `boolean` · 현지화 문자열 | `false` · — | 같은 슬롯 모양의 자리표시 행. `loadingLabel`이 상태로 읽힌다 |
74
+ | `trailingAccessory` | `none` · `disclosure` | `none` | `disclosure`: 행을 누르면 다른 화면으로 이동한다는 chevron(`chevronEnd`, `glyph.sm` 20, `content.decorative`, Icon `regular` 굵기)을 HJM이 그린다. 장식이라 보조기기 이름에 더하지 않고 RTL에서 뒤집힌다. 제품은 `trailing`에 chevron을 직접 넣지 않는다(개발 소스, 미게시) |
74
75
  | `trailingAction` | ReactNode | — | 행 명령 옆에 따로 그리는 별도 target(IconButton 등, 끝에 둘 때 `edge="end"`). 자기 `onPress`/`onClick`을 가진다 |
75
76
  | `layoutStyle` | 배치 key만 | — | 행 루트 배치. Native는 슬롯 배치용 `leadingStyle`·`contentStyle`·`titleRowStyle`·`trailingStyle`·`trailingActionStyle`(모두 배치 key만)도 받는다 |
76
77
  | `titleStyle`·`descriptionStyle`(Native) | — | — | 제거된 속성 — 배치는 `layoutStyle`, 제목의 위험색은 `tone="danger"`, 글자는 listRowRecipe가 정한다 |
@@ -89,7 +90,7 @@ Native 원형 leading의 `full`은 Provider token에서 읽지만 고정 원형
89
90
  | --- | --- | --- |
90
91
  | 크기 | 행 표면(누름·hover·선택 배경, 포커스 링, 누를 수 있는 영역)은 부모 콘텐츠 폭보다 양쪽으로 `listRowRecipe.surfaceBleed`(= density `paddingHorizontal`, `spacing.xs` 8)씩 넓다(Web `inline-size: calc(100% + 16)` + `margin-inline: -8`, Native `marginHorizontal: -8`). 글자·leading·trailing은 부모 콘텐츠 가장자리에 놓인다. leading 프레임은 40×40(`leadingSize`), `circle`이면 `radius.full`. trailing 아이콘은 `glyph.sm` 20. 최소 높이는 아래 density 표 | `design-contracts/src/component-recipes.ts`(`listRowRecipe`), `design-contracts/src/foundations.ts`(`layout.rowHeight`) |
91
92
  | 간격 | leading·content·trailing 사이 `spacing.sm` 12(`listRowRecipe.gap`). 행 안 좌우 여백 8은 표면이 바깥으로 넓어진 만큼이라 글자는 부모 콘텐츠 가장자리(기본 화면이면 여백 선 16)에서 시작한다. 위아래 여백은 아래 density 표. Native의 투명 선택 테두리 1은 이 여백 안에 포함된다(테두리 + padding = 8). 제품은 행에 margin을 주지 않는다 | `design-contracts/src/component-recipes.ts`(`listRowRecipe`), `react/src/styles.css`(`.hjm-list-row`) |
92
- | 순서·정렬 | leading → content(제목·설명) → trailing. trailing 내용(아이콘·Switch·문구)은 행의 세로 가운데에 둔다(Web `.hjm-list-row__trailing` flex 가운데, Native 행 가운데 정렬 View). `trailingAction`(두 플랫폼)은 행 명령 바깥 끝에 `listRowRecipe.gap` 12 떨어져 놓이고, 명령과 함께 하나의 표면(선택 배경·테두리)을 쓴다. 행동 상자의 끝은 부모 콘텐츠 가장자리에 맞으므로 ghost IconButton에 `edge="end"`를 주면 glyph 칸 끝이 여백 선 16에 온다. 행 사이 구분선·바깥 둥근 배경은 [List](list.md)가 그린다. 행은 leading 여부를 List에 알려(Web 루트 `data-leading`, Native List 항목 context) 자기 아래 `indented` 구분선이 글자 열(leading 있으면 52, 없으면 0)에서 시작하게 한다 | `react-native/src/data-display.tsx`(ListRow), `react/src/styles.css`(`.hjm-list-row__trailing`) |
93
+ | 순서·정렬 | leading → content(제목·설명) → trailing → 이동 표시(`trailingAccessory`) → `trailingAction`. 이동 표시는 행 명령(link/button) 안에 있고, trailing 값이 있으면 그 뒤 `accessories.disclosure.gap` `spacing.xs` 8(행 간격 12보다 좁은 iOS 값·chevron 짝), 없으면 content 뒤 행 간격 12에 놓인다. 로딩 행은 같은 크기의 빈 상자를 둔다. trailing 내용(아이콘·Switch·문구)은 행의 세로 가운데에 둔다(Web `.hjm-list-row__trailing` flex 가운데, Native 행 가운데 정렬 View). `trailingAction`(두 플랫폼)은 행 명령 바깥 끝에 `listRowRecipe.gap` 12 떨어져 놓이고, 명령과 함께 하나의 표면(선택 배경·테두리)을 쓴다. 행동 상자의 끝은 부모 콘텐츠 가장자리에 맞으므로 ghost IconButton에 `edge="end"`를 주면 glyph 칸 끝이 여백 선 16에 온다. 행 사이 구분선·바깥 둥근 배경은 [List](list.md)가 그린다. 행은 leading 여부를 List에 알려(Web 루트 `data-leading`, Native List 항목 context) 자기 아래 `indented` 구분선이 글자 열(leading 있으면 52, 없으면 0)에서 시작하게 한다 | `react-native/src/data-display.tsx`(ListRow), `react/src/styles.css`(`.hjm-list-row__trailing`) |
93
94
  | 고정·스크롤 | — | — |
94
95
  | 좁은 폭·큰 글자 | 긴 제목·설명은 줄바꿈되고(`overflow-wrap: anywhere`) 행 높이가 늘어난다. 큰 글자에서도 자르지 않는다 | `react/src/styles.css`(`.hjm-list-row`) |
95
96
 
@@ -116,6 +117,7 @@ Native 원형 leading의 `full`은 Provider token에서 읽지만 고정 원형
116
117
 
117
118
  - 제목·설명은 i18n 키로 넣는다. leading의 사진·아이콘은 장식으로 두고 의미는 제목이 말한다.
118
119
  - 행 안에 다른 버튼을 넣지 않는다. 별도 target은 `trailingAction`에 둔다(행 명령 옆에 따로 그린다).
120
+ - 이동하는 행의 chevron은 `trailingAccessory="disclosure"`로 켠다. `trailing`에 `ChevronRight`·`UiGlyph`·`Icon`을 직접 넣으면 크기·색·RTL·값과의 간격이 앱마다 갈라진다. 이동하지 않는 행(Switch 설정·정보 행)에는 쓰지 않는다.
119
121
  - 행은 부모가 좌우로 8 이상의 여백을 가진 곳에 둔다(화면 본문 16, Card·Sheet·SidePanel 본문, `grouped` List). 여백이 0인 상자(예: 여백 없는 분할 패널, 제품의 꽉 찬 div)에 바로 두면 표면이 잘리고 Web은 가로 스크롤이 생긴다. 그 상자에 콘텐츠 여백을 준다. HJM의 스크롤 상자는 이 여유를 스스로 만든다: VirtualList는 바깥으로 8 넓히고 안쪽 8을 두며, 스크롤 Sheet 본문은 시트 여백 끝까지 넓고 여백을 안쪽에 둔다(내부 `listRowScrollRoom`). 콘텐츠 위치는 그대로다. 화면을 바깥 Container 여백 + `contentInset="none"`으로 감싸는 구성은 지원하지 않는다([ScreenLayout](screen-layout.md#꼭-지킬-것)). `ScreenLayout contentInset="body-edge"` 본문은 자기 여백 16을 가진 전체 폭 행(ConversationRow·NotificationRow) 전용이다. ListRow·NotificationItem은 `default` 본문에 둔다(그래야 글자가 여백 선 16에 온다).
120
122
  - 배치는 `layoutStyle`로 한다. 높이·여백·배경을 덮지 않는다. 밀도는 `density`로 바꾼다.
121
123
  - 아바타 프레임을 제품이 다시 그리지 않는다. `leadingShape`를 쓴다.
@@ -151,6 +153,8 @@ Web도 Native와 같이 `trailingAction`을 지원한다. 이 슬롯은 행의 l
151
153
 
152
154
  ## 근거
153
155
 
156
+ - 이동 표시(`trailingAccessory`): 2026-10-23 소비 감사. 야잘알 `ChevronRight` 10곳, 유틸버스 `UiGlyph name="next"` 24곳, spint SettingsRow·PlaceRow·NearbyMapScreen 3곳이 trailing에 chevron을 직접 그렸다. iOS disclosure indicator처럼 행 의미(이동)를 HJM 축으로 두고 모양은 recipe가 소유한다. 이름은 기존 `trailing`·`trailingText`·`trailingAction` 계열을 따랐다.
157
+
154
158
  2026-10-17 사용자 결정("굵기 좀더 줄여"): 모든 글자 굵기를 한 단계 낮췄다. 큰 숫자·선택 상태 600(큰 제목 단계는 같은 날 폐지), 제목·강조 500, label 500, 본문 400이며 정본은 [타이포그래피](../tokens/typography.md)다.
155
159
 
156
160
  - 스크롤 상자의 표면 여유: 2026-10-17 1.27.2 채택 점검. Native 스크롤 Sheet는 여백 20이 ScrollView 바깥에 있어 행 표면이 시트 콘텐츠 끝(21pt)에서 잘렸다(3x 측정: 선택 테두리 위 선 21–381, 옆 선 없음 → 수정 후 옆 선 13·끝 13, 제목 위치 그대로). VirtualList와 같은 규칙이라 내부 helper 하나로 묶었다.
@@ -162,7 +162,7 @@ import { CollectionRail } from "@hjmds/react/collection-rail";
162
162
  | `renderItem` | `(item) => ReactNode` | 필수 | 버튼·입력·카드는 기존 공개 API 사용 |
163
163
  | `labels` | previous/next/navigation string | 필수 | 스크롤 행동 이름, empty 금지 |
164
164
  | `showNavigation` | boolean | `true` | `false`면 이동 버튼 행과 그 여백을 제거하고 스와이프·스크롤·초점 노출을 유지한다. 2026-10-20 스핀트 요청: 터치 탐색과 중복되는 버튼 제거 |
165
- | `density` | compact/comfortable | comfortable | 최대 폭240/360, 실제 폭에서 clamp |
165
+ | `density` | compact/comfortable/chip | comfortable | 최대 폭240/360/144(`layout.readingMaxWidth`의 1/3·1/2·1/5), 실제 폭에서 clamp. 간격은 `collectionRailRecipe.gapSteps`(카드 md 16, chip xs 8). `chip`은 미니 스코어보드 같은 같은 폭의 작은 칩 줄이다(미게시, 2026-10-23). 항목마다 다른 내용 폭은 지원하지 않는다 — 이전/다음·앞쪽 anchor·초점 노출이 한 항목 폭 단위로 움직이기 때문이다 |
166
166
  | `defaultStartKey` | item ID | 첫 항목 | 초기 위치만. 선택/상세 값 아님 |
167
167
  | `onStartKeyChange` | `(key: string|null) => void` | 생략 | 앞쪽 항목 변화 관찰; 선택 확정 callback 아님 |
168
168
  | `emptyContent` | ReactNode | 생략 | 제품의 빈 목록 내용 |
@@ -4,7 +4,7 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.17.1
7
- - 검토일: 2026-10-20
7
+ - 검토일: 2026-10-23
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
 
@@ -69,6 +69,7 @@ import { NotificationItem } from "@hjmds/react-native/screens";
69
69
  | `statusLabel` | 지역화 읽음 상태 문구(필수). 굵기만으로 상태를 전하지 않도록 글로도 표시한다 |
70
70
  | `timestamp` | 제품이 포맷한 상대·절대 시각 문자열(필수) |
71
71
  | `title`, `description` | 알림 내용 |
72
+ | `unreadLabel` | 선택. 안 읽음 점의 지역화 접근성 이름(예: "읽지 않음"). 주면 `read={false}`일 때 HJM이 상태·시각 줄 끝에 점(`notificationRowRecipe.unreadSize` 6, content brand — NotificationRow와 같은 표식)을 그린다. 빈 문자열은 `TypeError`. 주지 않으면 기존처럼 굵기와 문구만 바뀐다 |
72
73
  | 나머지 ListRow props | `leading`·`trailing`·`density`·`disabled` 등, 이동은 `href`/`onClick`(Web), `onPress`(Native) |
73
74
 
74
75
  ## 배치
@@ -83,6 +84,9 @@ import { NotificationItem } from "@hjmds/react-native/screens";
83
84
 
84
85
  ## 꼭 지킬 것
85
86
 
87
+ - 안 읽음 점을 `trailing`에 "•" 글자나 제품 View로 그리지 않는다. `unreadLabel`을 준다(2026-10-23 Spint `NotificationsScreen`이 글자로 그리던 자리).
88
+ - Native 누르는 행의 접근성 이름은 제목 → 안 읽음 이름 → 설명 → `statusLabel · timestamp` 순으로 합성된다. 2026-10-23 전에는 상태·시각 줄이 빠져 읽음 상태가 읽히지 않았다.
89
+
86
90
  - 표시·탭·스크롤이 읽음 처리를 하지 않는다. 읽음 mutation은 제품이 `onPress`/`onClick` 또는 화면 정책에서 실행하고 `read`를 갱신한다.
87
91
  - `selected`는 받지 않는다(타입에서 제외). 선택 목록이 필요하면 ListRow를 쓴다.
88
92
  - 시각 포맷·상대시간은 제품 i18n에서 만든다.
@@ -4,7 +4,7 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.17.1
7
- - 검토일: 2026-10-20
7
+ - 검토일: 2026-10-23
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
 
@@ -18,7 +18,7 @@
18
18
  | `prompt` | `request` |
19
19
  | `denied` | `settings` |
20
20
  | `granted` | `continueAction` |
21
- | `unavailable` | 없음(`skip`만 표시 가능) |
21
+ | `unavailable` | 없음(`alternative`·`skip`만 표시 가능) |
22
22
 
23
23
  PermissionScreen은 **OS 권한을 요청하지도, 조회하지도 않는다.** 소스(`screen-flows.tsx`, 두 renderer)는
24
24
  `react`·`react-native`의 `View` 외에 권한·설정 API를 import하지 않고, 버튼은 넘겨받은 `onAction`만 호출한다.
@@ -86,7 +86,9 @@ import { Linking } from "react-native";
86
86
  | `explanation` | 권한이 필요한 이유(필수, 지역화) |
87
87
  | `illustration` | 선택. 제품 일러스트 |
88
88
  | `request`, `settings`, `continueAction` | 세 행동 모두 필수 `{ label, onAction, disabled?, pending? }`. 상태에 맞는 하나만 보인다 |
89
- | `skip` | 선택 보조 행동 |
89
+ | `recheck` | 선택. 설정에서 돌아온 사용자가 권한을 다시 확인하는 행동(제품이 OS 재조회 후 `status` 갱신). `denied`에서만 settings 아래에 보인다(개발 소스, 미게시) |
90
+ | `alternative` | 선택. 권한 없이 계속하는 대체 경로(사진에서 선택·직접 입력). `granted` 전까지 보인다. `pending`으로 진행 중을 표시한다(개발 소스, 미게시) |
91
+ | `skip` | 선택. 기능을 떠나는 행동(나중에·다른 방법으로 등록) |
90
92
  | ScreenLayout props | `title`, `description`, `header`, `leading`, `notice`, `state` 등(`footer` 제외) |
91
93
 
92
94
  ## 배치
@@ -95,7 +97,7 @@ import { Linking } from "react-native";
95
97
  | --- | --- | --- |
96
98
  | 크기 | ScreenLayout 폭(최대 720); 그림 크기는 `illustration`(제품) 소유; 행동은 `Button` 기본 크기 | `PermissionScreen` |
97
99
  | 간격 | 화면 padding `spacing.md` 16; 본문 그림–설명 `spacing.xl` 24(가운데 정렬); footer 주 행동–나중에 `spacing.xs` 8(세로로 쌓은 버튼, `actionGroupRecipe.stackedGap`) | Web·Native `PermissionScreen` `Stack gap="xl" align="center"`·`gap="xs"` |
98
- | 순서·정렬 | 헤더(제목) → 본문(그림 → 설명, 가운데) → footer(상태별 주 행동 primary → 나중에 ghost) | 렌더 순서, `resolvePermissionAction` |
100
+ | 순서·정렬 | 헤더(제목) → 본문(그림 → 설명, 가운데) → footer(상태별 주 행동 `primary` → 다시 확인 `secondary`(denied만) → 대체 경로 `secondary` → 나중에 `ghost`). 모두 같은 폭으로 세로로 쌓는다 | `resolvePermissionFooter`(`@hjmds/design-contracts/screen-patterns`), `resolvePermissionAction` |
99
101
  | 고정·스크롤 | 헤더·footer 고정, 본문 화면 스크롤; OS 권한 창은 제품이 `request.onAction`에서 호출 | `ScreenLayout` |
100
102
  | 좁은 폭·큰 글자 | 설명은 줄바꿈되고 본문이 스크롤된다; footer 버튼은 세로로 쌓인다; `unavailable`이면 주 행동 없이 `skip`만 남는다 | `PermissionScreen` |
101
103
 
@@ -104,4 +106,23 @@ import { Linking } from "react-native";
104
106
  - 설정 앱에서 돌아온 뒤 권한을 다시 조회해 `status`를 갱신한다. HJM은 앱 복귀를 감지하지 않는다.
105
107
  - 렌더링만으로 권한 요청을 띄우지 않는다. 요청은 사용자가 `request` 버튼을 눌렀을 때만 제품이 실행한다.
106
108
  - 알 수 없는 `status` 값은 던진다.
109
+ - 다시 확인·대체 경로 버튼을 `explanation` 안에 넣지 않는다. `recheck`·`alternative`에 넘기면 footer의 순서·폭·위계를 HJM이 정한다.
110
+ 대체 경로의 도움말·실패 `Notice`처럼 행동이 아닌 안내만 `explanation`에 둔다.
107
111
  - 권한 설명 문구·스토어 심사용 사용 목적 문자열은 제품 소유다.
112
+
113
+ ## 근거
114
+
115
+ - `recheck`·`alternative`(2026-10-23 소비 감사): Unairplane 탑승권 스캐너가 footer에 primary·skip 자리만 있어 "다시 확인"(설정 복귀 후
116
+ 권한 재조회)과 "사진에서 선택"(대체 경로)을 `explanation` 본문의 `secondary` Button으로 넣었다. 본문 버튼은 footer와 폭·위치가
117
+ 달랐다. footer 순서(primary → recheck → alternative → skip)와 위계(filled 하나, framed 같은 폭 secondary, 마지막 ghost)는
118
+ `resolvePermissionFooter`가 두 renderer에 같게 정한다. 다시 확인은 설정을 다녀온 뒤에만 의미가 있어 `denied`에서만, 대체 경로는
119
+ 권한 없이 쓰는 길이라 `granted`가 되면 숨긴다.
120
+
121
+ ```tsx
122
+ // Native: Unairplane 이관 예
123
+ <PermissionScreen status={permission.canAskAgain ? "prompt" : "denied"} explanation={<PermissionExplanation />}
124
+ request={request} settings={{ label: t("openSettings"), onAction: () => Linking.openSettings() }} continueAction={next}
125
+ recheck={{ label: t("checkAgain"), onAction: refreshPermission }}
126
+ alternative={{ label: t("photo"), onAction: openPhotoSource, pending: readingPhoto }}
127
+ skip={{ label: t("registerByFlightNumber"), onAction: lookupFlightNumber }} />
128
+ ```
@@ -30,6 +30,8 @@
30
30
  | 이름 | 역할 | Web | Native |
31
31
  | --- | --- | --- | --- |
32
32
  | `ScreenLayout` | supplemental. root에서는 내보내지 않는다 | `/screens` | `/screens` |
33
+ | `ScreenHost` | 동반(Native 전용: 화면 바깥의 창 safe area와 바탕, 개발 소스·미게시) | — | `/screens` |
34
+ | `ScreenRefreshControl` | 동반(Native 전용: `scroll="content"` 목록이 감싸는 ScreenLayout의 당겨서 새로고침, 개발 소스·미게시) | — | `/screens` |
33
35
 
34
36
  ## 최소 사용 예
35
37
 
@@ -90,6 +92,7 @@ import { ScreenLayout } from "@hjmds/react-native/screens";
90
92
  | `as` (Web) | `"main"` · `"section"` | `"main"` | 제품 셸에 이미 `<main>`이 있으면 `section` |
91
93
  | `initialFocus` | `first-input` · `none` | `first-input` | 페이지 진입 시 입력 하나에 한 번 포커스한다. 기본값 변경은 1.33.0부터 제공. 하단 시트 안에서는 자동 포커스하지 않는다. 아래 [진입 포커스](#진입-포커스) |
92
94
  | `pullToDismiss` (Native) | `boolean` | `false` | 미게시. 읽기 route 본문의 손잡이·본문 맨 위에서 새로 시작한 아래 당김을 기존 `exit.onPress`로 연결한다. `exit.kind="close"`, 기본 헤더, `scroll="screen"`이 필요하다. 손잡이가 `exit.label`을 가진 접근 가능한 닫기 버튼을 겸하며, 중복 X 헤더를 표시하지 않는다(2026-10-20 편지 시트 상단 정리 요청). `exit.disabled`면 당김/손잡이/접근성 escape도 비활성이다. Web 읽기 패널은 Sheet의 `swipeDirection="down"`을 사용한다 |
95
+ | `onRefresh` · `refreshing` · `refreshLabel` (Native) | `() => void` · `boolean` · `string` | 없음 · `false` · 없음 | 개발 소스·미게시. 당겨서 새로고침을 화면이 소유한다. `scroll="screen"`은 본문에 바로 붙고, `scroll="content"` 목록은 `refreshControl={<ScreenRefreshControl />}`를 넘긴다. `loadingIndicator`가 있으면 시스템 스피너를 투명하게 두고 그 제품 표시를 본문 위에서 `screenPatternRecipe.refresh.indicatorOffset` 16 아래 가운데에 `refreshing` 동안 보인다(이름 `refreshLabel`, busy). 없으면 시스템 스피너가 `spinnerRecipe.tones.brand` 색과 `surface` 판으로 돈다. `onRefresh`에는 `refreshLabel`이 필요하고 `pullToDismiss`·`scrollProps.refreshControl`과 함께 줄 수 없다(`TypeError`). 사용자의 당김만 `refreshing`으로 연결하고 배경 재조회는 연결하지 않는다 |
93
96
  | `safeAreaBottom` (Native) | 0 이상의 유한수 | `0`(미게시) | 화면이 닿는 창 아래 가장자리의 safe area(`useSafeAreaInsets().bottom`, 탭바 위·이미 아래를 들인 host 안이면 0). 마지막 영역(footer, 없으면 본문)이 아래 여백을 `max(16, inset)`으로 한 번만 준다. 넘길 때는 host의 아래 SafeAreaView edge를 빼고, 둘을 같이 쓰지 않는다. 음수·무한대는 `RangeError` |
94
97
  | `layoutStyle` | `HjmCompositionStyleProp` | 없음 | 화면 루트 배치 전용(예: 분할 화면의 `flex`·`width`). ScreenLayout 위에 만든 화면은 모두 이 prop을 루트까지 넘긴다 |
95
98
  | `className` (Web) | `string` | 없음 | 식별·배치 보조. 색·여백을 덮지 않는다 |
@@ -148,17 +151,19 @@ import { ScreenLayout } from "@hjmds/react-native/screens";
148
151
 
149
152
  - Web의 화면 스크롤 본문은 Tab으로 진입할 수 있다. `scroll="content"`의 정상 본문은 자식이 키보드 스크롤을 소유하지만, 오류·빈 상태 등 대체 안내는 화면 스크롤로 바뀌며 Tab 진입도 함께 복원된다.
150
153
  - Web에서 본문에 초점을 가진 입력·재시도 버튼이 상태 전환으로 제거되면 같은 본문으로 초점을 복구한다. `scroll="content"`의 본문은 이 복구만을 위한 `tabIndex=-1`을 가지며 일반 Tab 순서에는 추가되지 않는다. 사용자가 다른 컨트롤이나 바깥 영역으로 초점을 옮긴 경우에는 이동시키지 않는다. 2026-10-13 재시도·권한 제한 전환에서 초점이 body로 유실된 경로를 수정했다.
154
+ - Web 본문의 초점 표시: Tab으로 본문에 들어오면 HJM 초점 링(`--hjm-focus-width`·`--hjm-color-focus`)을 본문 안쪽에 그린다. 위 복구로 놓인 초점은 사용자가 고른 컨트롤이 아닌 착지점이라 링을 그리지 않는다(검색 결과 착지와 같은 규칙). 다음 Tab·Shift+Tab부터는 일반 규칙을 따른다. 2026-10-23 웹 확인에서 탭(해시) 전환으로 패널이 바뀔 때 브라우저 기본 링이 본문 전체에 그려진 것을 고쳤다.
151
155
  - **화면 여백은 ScreenLayout이 소유한다(기본 `contentInset`).** 화면을 바깥 `Container gutter`로 감싸고 `contentInset="none"`을 주는 구성은 지원하지 않는다. 행의 표면 bleed, footer·BottomCTA 바, 끝 컨트롤의 `edge`는 모두 ScreenLayout이 여백을 가진다는 전제로 맞춰져 있어, 그 구성에서는 행 표면이 본문 끝에서 잘리고 footer가 여백 안에 떠 있는 상자가 된다. `none`은 host가 모든 inset을 소유하는 기존 의미 그대로다(새 동작 없음).
152
156
  - 가상화 목록(FlatList, VirtualList)을 넣으면 `scroll="content"`로 둔다. 스크롤을 이중으로 중첩하지 않는다.
153
157
  - 새로고침 실패·저장 오류는 `state`를 바꾸지 말고 `ready` + `notice`로 알린다. `loading`으로 바꾸면 본문이
154
158
  unmount되어 입력 초안이 사라진다.
155
159
  - `state.title`·문구는 i18n 키로 넣는다. ready 외 상태의 빈 `title`은 `TypeError`다.
156
160
  - **`safeAreaBottom`을 받는 화면 구성(Native, 1.28.1 감사):** ScreenLayout을 그리는 공개 구성은 모두 같은 prop을 받아 자기 ScreenLayout에 넘긴다 — `SettingsScreen`·`NotificationInboxScreen`·`ChatScreen`·`OverviewScreen`·`EditorScreen`·`ProfileScreen`·`ModerationScreen`·`MediaSelectionScreen`·`PermissionScreen`·`CommentThreadScreen`·`SearchScreen`(+`footer`)·`SavedItemsScreen`(ListDetailScreen 경유)·`ListDetailScreen`(목록·상세 pane 둘 다)·`OnboardingScreen`. 로그인은 `AuthScreenLayout` `safeAreaBottom`(같은 max 규칙, 기준은 paddingBlock). 하단 막대를 직접 그리는 `BottomCTA`·`BottomNavigation`은 각자 `safeAreaBottom`, FAB는 `safeAreaBottomInset`을 받는다. `Layout`(primitives)의 `footer`는 배치 슬롯일 뿐 inset을 정하지 않는다.
157
- - Web host는 실제 남은 높이(`height:100%` 등)를, Native host는 위·옆 safe area와 탭바 inset을 먼저 처리한다. 아래 홈 인디케이터는 host가 SafeAreaView로 들이지 말고 `safeAreaBottom`으로 넘긴다(들인 뒤 footer 16을 더하면 버튼 아래가 50이 된다).
161
+ - Web host는 실제 남은 높이(`height:100%` 등)를, Native host는 위·옆 safe area와 탭바 inset을 먼저 처리한다. Native의 위·옆 safe area와 그 뒤 바탕은 제품 `SafeAreaView`+`colors.bg` 대신 [ScreenHost](#화면-바깥-host와-안전-영역--native-screenhost-개발-소스미게시)로 둔다. 아래 홈 인디케이터는 host가 SafeAreaView로 들이지 말고 `safeAreaBottom`으로 넘긴다(들인 뒤 footer 16을 더하면 버튼 아래가 50이 된다).
158
162
  HJM은 기종별 높이를 추측하지 않는다.
159
163
  - 배치는 Web·Native 모두 `layoutStyle`로 한다(Web은 `className`도 있다). 색·여백을 덮지 않는다.
160
164
  - 제목 옆 로고·아트워크 때문에 `header`에 TopBar나 직접 만든 헤더를 넣지 않는다. `titleLeading`을 쓰면 가운데 제목·양쪽 같은 폭 열·`exit`·`actions` 배치가 그대로 유지된다.
161
165
  - 첫 로딩에 브랜드 로더를 보이려고 `ready` 본문 안에 로딩 화면을 직접 두지 않는다. `state={{ kind: "loading", title }}` + `loadingIndicator`로 넘긴다.
166
+ - 당겨서 새로고침의 시스템 스피너를 `tintColor="transparent"`로 숨기고 목록 머리에 제품 로더를 직접 넣지 않는다. Native `onRefresh`·`refreshing`·`refreshLabel`과 `loadingIndicator`로 넘기고, `scroll="content"` 목록은 `<ScreenRefreshControl />`을 쓴다(2026-10-23 spint 홈·알림·셀 세 곳).
162
167
 
163
168
  ## 플랫폼 차이
164
169
 
@@ -197,6 +202,27 @@ dashboard는 가로 헤더/1200 목록 폭, editorial은 세로 헤더/720 읽
197
202
 
198
203
  2026-10-18 사용자 결정: 빈 상태 문구는 화면·부분 영역 모두 `body`(14)로 표시한다. 오류·접근 제한의 제목은 기존 위계를 유지한다. 상단 보조 문구는 Spint 헤더 교체 중 커지고 강조된 문제를 계기로 표시를 제거했다. `state.description`과 본문의 안내는 유지한다.
199
204
 
205
+ ### 화면 바깥 host와 안전 영역 — Native `ScreenHost` (개발 소스·미게시)
206
+
207
+ navigator 전체나 한 화면을 감싸 창의 위·옆 safe area와 그 뒤·읽기 폭 바깥의 바탕을 HJM이 칠한다. inset 값은
208
+ `HjmNativeProvider safeAreaInsets`(보통 `useSafeAreaInsets()`)에서 읽는다. 제품의 `SafeAreaView edges={["top","left","right"]}` +
209
+ `backgroundColor: colors.bg` 감싸기를 이것으로 바꾼다(2026-10-23 선택의창 AppCanvas, Unairplane AppScreenHost).
210
+
211
+ | prop | 값 | 기본값 | 설명 |
212
+ | --- | --- | --- | --- |
213
+ | `edges` | `"top"`·`"right"`·`"bottom"`·`"left"` 배열 | `["top","right","left"]`(`screenHostDefaults.edges`) | 들일 창 가장자리. 아래는 화면의 `safeAreaBottom`이 max(16, inset)으로 한 번 소유하므로 기본에서 뺀다. ScreenLayout·footer가 없는 내용만 감쌀 때 `bottom`을 넣는다. 중복·알 수 없는 값은 `TypeError` |
214
+ | `surface` | `"default"`·`"transparent"` | `"default"` | `default`는 theme `bg`를 칠한다. 제품 배경 그림을 바깥에 둘 때 `transparent` |
215
+ | `testID` | string | 없음 | |
216
+
217
+ ```tsx
218
+ // app/_layout.tsx — 지도처럼 상태바 뒤까지 그리는 화면은 그 화면에서 top을 뺀다.
219
+ <HjmNativeProvider safeAreaInsets={useSafeAreaInsets()}>
220
+ <ScreenHost edges={edgeToEdge ? ["left", "right"] : undefined}><Stack screenOptions={{ headerShown: false }} /></ScreenHost>
221
+ </HjmNativeProvider>
222
+ ```
223
+
224
+ Web에는 없다. HjmProvider `host="surface"`가 바탕을 칠하고 고정 영역이 `env(safe-area-inset-*)`를 직접 쓴다([계약](../../screen-patterns.md#2026-10-23-화면-바깥-host--native-screenhost-개발-소스미게시)).
225
+
200
226
  ### Native 읽기 화면 당겨 닫기 (미게시)
201
227
 
202
228
  다에리 편지 상세가 ScreenLayout 밖에서 손잡이·Animated.View·PanResponder와 본문 상단 당김을
@@ -74,6 +74,7 @@ import { SegmentedControl } from "@hjmds/react-native/inputs";
74
74
  | --- | --- | --- | --- |
75
75
  | `items` | Web `readonly { value: string; label: ReactNode; disabled? }[]` · Native `readonly { value: Value; label: string; disabled?; leading?; renderLeading? }[]` | 필수 | 값이 비거나 중복이면 던진다 |
76
76
  | `presentation` | `connected` · `pills` | `connected` | 연결된 보기 전환 / 독립된 카테고리 필터. `pills`는 1.13.0 변경 |
77
+ | `selectionTone` | `neutral` · `brand` | `brand` | 기본 선택 면은 `primary`와 `onPrimary`. `neutral`은 `surfaceSelected`와 `text`로 차분하게 표시한다. `text` 표현은 채움 없이 기존 표시를 유지한다 |
77
78
  | `size` | `small` · `medium` | `medium` | 항목 최소 높이 36(+hitSlop 4) · 44 |
78
79
  | `value` · `defaultValue` | 항목 값 | 첫 번째 활성 항목 | 선택 해제 상태는 없다 |
79
80
  | `onValueChange` | Web `(value: string) => void` · Native `(value: Value) => void` | — | 다른 항목을 고를 때. `null`은 오지 않는다 |
@@ -99,7 +100,7 @@ import { SegmentedControl } from "@hjmds/react-native/inputs";
99
100
  ┌───────────────────────────────┐ 트랙: radius.lg 16, padding 4 (Native)
100
101
  │ ┌─────────┐ │
101
102
  │ │ 주간 │ 월간 연간 │ 44 (medium), 같은 폭
102
- │ └─────────┘ │ 선택 = focus 색 테두리
103
+ │ └─────────┘ │ 선택 = 브랜드 배경 채움 + 반전 글자
103
104
  └───────────────────────────────┘
104
105
  gap layout.contentGap 16
105
106
  [ 전환되는 내용 ]
@@ -111,7 +112,7 @@ import { SegmentedControl } from "@hjmds/react-native/inputs";
111
112
  - 활성 항목이 하나도 없거나 `value`가 비거나 중복이면 렌더 중 오류를 던진다. Native는 controlled
112
113
  `value`가 비활성 항목이어도 `RangeError`다.
113
114
  - 옛 Native `options` prop은 제거됐다. 넘기면 `TypeError`다([이관표](../../migration-native-legacy-removal.md)).
114
- - 연결형의 선택 표시는 focus 색 테두리(ring), `pills`는 본문색 채움과 반전 글자다. 제품에서 선택 배경을 직접 덮지 않는다. 배치는 `layoutStyle`로 하고, Native `style`은
115
+ - 연결형과 `pills` 모두 기본 선택 배경은 `primary`, 글자·아이콘은 `onPrimary`다. `selectionTone="neutral"`이면 `selection.background`(`surfaceSelected`)와 `selection.content`(`text`)를 쓴다. 연결형의 선택 테두리(ring)는 추가 구분 수단으로 유지한다. 제품에서 선택 배경을 직접 덮지 않는다. 배치는 `layoutStyle`로 하고, Native `style`은
115
116
  제거된 속성이다.
116
117
 
117
118
  ## 플랫폼 차이
@@ -132,7 +133,7 @@ import { SegmentedControl } from "@hjmds/react-native/inputs";
132
133
 
133
134
  - `presentation="pills"`: 카테고리·작성자·기간처럼 같은 목록의 범위를 좁힐 때 쓴다.
134
135
  - 표면 높이·좌우 여백·글자는 같은 size의 Chip과 맞춘다. 터치 영역은 최소 `control.minTouchTarget` 44이며 안쪽 표면은 위아래 `spacing.xxs` 4만큼 들어간다. 2026-10-20 Utilverse 혼합 필터 행에서 작은 Chip36과 pill28의 불일치가 확인돼 공통 크기로 정렬했다(미게시). 누름 영역은 줄이지 않는다.
135
- - 좌우 여백은 Chip과 같이 small12/medium16, 항목 사이 `spacing.xs` 8, 모서리 `radius.full`. 비선택은 `surface.raised`(`canvas`) 바탕과 `border.control` 1pt 테두리, 기본 선택은 `selection.background`(`surfaceSelected`)/`selection.content`(`text`) 조합이다. 2026-10-20 Utilverse의 파스텔 배경에서 비선택 버튼 경계가 묻힌 사용자 지적에 따라 inset 표면을 공통으로 보완했다(미게시). 색은 제품 provider에서 따라온다.
136
+ - 좌우 여백은 Chip과 같이 small12/medium16, 항목 사이 `spacing.xs` 8, 모서리 `radius.full`. 비선택은 `surface.raised`(`canvas`) 바탕과 `border.control` 1pt 테두리, 기본 선택은 `primary`/`onPrimary`, `selectionTone="neutral"` 선택은 `selection.background`(`surfaceSelected`)/`selection.content`(`text`) 조합이다. 2026-10-20 Utilverse의 파스텔 배경에서 비선택 버튼 경계가 묻힌 사용자 지적에 따라 inset 표면을 공통으로 보완했다(미게시). 색은 제품 provider에서 따라온다.
136
137
  - 항목은 내용 폭이며 좁아지면 줄바꿈한다. 큰 글자에서도 세로로 쌓지 않는다(`pills.largeTextLayout` `wrap`): 블록 안에서는 줄바꿈하고, 가로 스크롤 줄 안에서는 한 줄로 남아 스크롤된다. 라벨 높이만큼 항목도 늘어난다. 선택 상태(radio)와 포커스 이동(Web 화살표, Native 접근성 순서)은 배치와 관계없이 같다.
137
138
  - 그룹 이름은 `label`, 하나의 선택은 `value`/`onValueChange`. 복수 조건은 ToggleGroup, 화면 이동은 Tabs/Navigation을 쓴다.
138
139
  - 예제의 전체 선택값도 실제 항목으로 넣는다. 선택된 항목을 다시 눌러도 선택을 해제하지 않는다.
@@ -190,3 +191,15 @@ Spint 스팟 필터가 제품 ScrollView와 고정 추가 행을 재조립하던
190
191
  ### 진한 브랜드 선택 — 개발 소스, 미게시 (2026-10-21)
191
192
 
192
193
  다에리·스핀트의 선택 배경이 너무 옅다는 사용자 피드백에 따라 `selectionTone="brand"`로 선택 면에 `primary`, 글자·아이콘에 `onPrimary`를 쓴다. `pills`·`connected`의 크기와 선택 동작을 유지하며 `text`는 기존 표시를 유지한다.
194
+
195
+ ### 선택 배경 기본값 — 개발 소스, 미게시 (2026-10-23)
196
+
197
+ 사용자가 공개 Storybook의 기록 표현 비교 화면에서 선택 배경이 없는 것처럼 보인다고 지적하고 기본 채움을 요청했다.
198
+ 같은 날 시각 비교 후 사용자가 강조안을 기본으로 선택했다. Web·Native 모두 별도 prop 없이 `selectionTone="brand"`를 사용해 `primary` 배경과 `onPrimary` 글자·아이콘을 그린다.
199
+ 중립색은 `selectionTone="neutral"` 옵션이며 light `#f0f1f3`, dark `#30343b` 및 브랜드 팔레트의 `surfaceSelected` 재정의를 따른다.
200
+ `presentation="text"`는 캡슐 없는 탐색 표현을 선택할 때 명시하며 기존 글자·짧은 표시를 유지한다.
201
+ 선택 값은 배경·굵기·연결형 테두리로, 키보드 포커스는 별도 focus-visible 외곽선으로 구분한다.
202
+ 단일 선택과 짧은 라벨은 [Fluent](https://fluent2.microsoft.design/components/ios/core/segmentedcontrol/usage),
203
+ 선택 면의 구분은 [Material의 단일 선택 예시](https://developer.android.com/develop/ui/compose/components/segmented-button)를 참고한다.
204
+ 강조 기본값은 선택된 항목을 즉시 구분하려는 사용자 결정이며, 외부 지침이 특정 색상값을 요구한다는 뜻은 아니다.
205
+ 공개 Storybook 배포·패키지 게시와 개발 소스 적용은 구분한다.
@@ -21,6 +21,7 @@ Web은 popover listbox, Native는 modal sheet로 목록을 연다.
21
21
  | 2~6개 선택지를 펼쳐서 비교, 항목마다 설명 | [RadioGroup](radio-group.md) |
22
22
  | 2~4개 짧은 보기 즉시 전환 | [SegmentedControl](segmented-control.md) |
23
23
  | 입력하며 후보를 거름 | [Combobox](combobox.md) |
24
+ | 설정 화면의 즉시 저장 한 값 선택(언어·화면 테마) | [ChoiceRow](choice-row.md) |
24
25
  | 행동 목록(편집·삭제) | [Menu](menu.md) |
25
26
  | 여러 개 선택 | [CheckboxGroup](checkbox-group.md), [TransferList](transfer-list.md) |
26
27
  | Web 폼 제출·자동완성·모바일 브라우저 picker가 중요 | `NativeSelect`(아래) |