@hjmds/design-contracts 0.8.2

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 (353) hide show
  1. package/README.md +208 -0
  2. package/dist/alert-dialog.d.ts +102 -0
  3. package/dist/alert-dialog.d.ts.map +1 -0
  4. package/dist/alert-dialog.js +136 -0
  5. package/dist/alert-dialog.js.map +1 -0
  6. package/dist/base-recipes.d.ts +184 -0
  7. package/dist/base-recipes.d.ts.map +1 -0
  8. package/dist/base-recipes.js +129 -0
  9. package/dist/base-recipes.js.map +1 -0
  10. package/dist/behaviors.d.ts +1254 -0
  11. package/dist/behaviors.d.ts.map +1 -0
  12. package/dist/behaviors.js +972 -0
  13. package/dist/behaviors.js.map +1 -0
  14. package/dist/bottom-navigation-defaults.d.ts +13 -0
  15. package/dist/bottom-navigation-defaults.d.ts.map +1 -0
  16. package/dist/bottom-navigation-defaults.js +10 -0
  17. package/dist/bottom-navigation-defaults.js.map +1 -0
  18. package/dist/bottom-navigation.d.ts +114 -0
  19. package/dist/bottom-navigation.d.ts.map +1 -0
  20. package/dist/bottom-navigation.js +223 -0
  21. package/dist/bottom-navigation.js.map +1 -0
  22. package/dist/breadcrumb.d.ts +97 -0
  23. package/dist/breadcrumb.d.ts.map +1 -0
  24. package/dist/breadcrumb.js +99 -0
  25. package/dist/breadcrumb.js.map +1 -0
  26. package/dist/calendar.d.ts +285 -0
  27. package/dist/calendar.d.ts.map +1 -0
  28. package/dist/calendar.js +297 -0
  29. package/dist/calendar.js.map +1 -0
  30. package/dist/card.d.ts +39 -0
  31. package/dist/card.d.ts.map +1 -0
  32. package/dist/card.js +50 -0
  33. package/dist/card.js.map +1 -0
  34. package/dist/carousel.d.ts +180 -0
  35. package/dist/carousel.d.ts.map +1 -0
  36. package/dist/carousel.js +172 -0
  37. package/dist/carousel.js.map +1 -0
  38. package/dist/catalog.d.ts +7158 -0
  39. package/dist/catalog.d.ts.map +1 -0
  40. package/dist/catalog.js +220 -0
  41. package/dist/catalog.js.map +1 -0
  42. package/dist/collection.d.ts +123 -0
  43. package/dist/collection.d.ts.map +1 -0
  44. package/dist/collection.js +211 -0
  45. package/dist/collection.js.map +1 -0
  46. package/dist/color-references.d.ts +46 -0
  47. package/dist/color-references.d.ts.map +1 -0
  48. package/dist/color-references.js +39 -0
  49. package/dist/color-references.js.map +1 -0
  50. package/dist/colors.d.ts +56 -0
  51. package/dist/colors.d.ts.map +1 -0
  52. package/dist/colors.js +90 -0
  53. package/dist/colors.js.map +1 -0
  54. package/dist/command-palette.d.ts +240 -0
  55. package/dist/command-palette.d.ts.map +1 -0
  56. package/dist/command-palette.js +97 -0
  57. package/dist/command-palette.js.map +1 -0
  58. package/dist/component-contracts.d.ts +137 -0
  59. package/dist/component-contracts.d.ts.map +1 -0
  60. package/dist/component-contracts.js +53 -0
  61. package/dist/component-contracts.js.map +1 -0
  62. package/dist/component-definitions.d.ts +135 -0
  63. package/dist/component-definitions.d.ts.map +1 -0
  64. package/dist/component-definitions.js +142 -0
  65. package/dist/component-definitions.js.map +1 -0
  66. package/dist/component-recipes.d.ts +3583 -0
  67. package/dist/component-recipes.d.ts.map +1 -0
  68. package/dist/component-recipes.js +1503 -0
  69. package/dist/component-recipes.js.map +1 -0
  70. package/dist/component-references.d.ts +416 -0
  71. package/dist/component-references.d.ts.map +1 -0
  72. package/dist/component-references.js +140 -0
  73. package/dist/component-references.js.map +1 -0
  74. package/dist/content-state.d.ts +104 -0
  75. package/dist/content-state.d.ts.map +1 -0
  76. package/dist/content-state.js +116 -0
  77. package/dist/content-state.js.map +1 -0
  78. package/dist/counter-badge-recipe.d.ts +80 -0
  79. package/dist/counter-badge-recipe.d.ts.map +1 -0
  80. package/dist/counter-badge-recipe.js +44 -0
  81. package/dist/counter-badge-recipe.js.map +1 -0
  82. package/dist/counter-badge.d.ts +12 -0
  83. package/dist/counter-badge.d.ts.map +1 -0
  84. package/dist/counter-badge.js +21 -0
  85. package/dist/counter-badge.js.map +1 -0
  86. package/dist/data-table.d.ts +203 -0
  87. package/dist/data-table.d.ts.map +1 -0
  88. package/dist/data-table.js +182 -0
  89. package/dist/data-table.js.map +1 -0
  90. package/dist/date-picker.d.ts +268 -0
  91. package/dist/date-picker.d.ts.map +1 -0
  92. package/dist/date-picker.js +168 -0
  93. package/dist/date-picker.js.map +1 -0
  94. package/dist/description-list.d.ts +68 -0
  95. package/dist/description-list.d.ts.map +1 -0
  96. package/dist/description-list.js +85 -0
  97. package/dist/description-list.js.map +1 -0
  98. package/dist/design-system-provider.d.ts +80 -0
  99. package/dist/design-system-provider.d.ts.map +1 -0
  100. package/dist/design-system-provider.js +147 -0
  101. package/dist/design-system-provider.js.map +1 -0
  102. package/dist/evidence.d.ts +49 -0
  103. package/dist/evidence.d.ts.map +1 -0
  104. package/dist/evidence.js +133 -0
  105. package/dist/evidence.js.map +1 -0
  106. package/dist/file-picker.d.ts +183 -0
  107. package/dist/file-picker.d.ts.map +1 -0
  108. package/dist/file-picker.js +224 -0
  109. package/dist/file-picker.js.map +1 -0
  110. package/dist/floating-action-button.d.ts +143 -0
  111. package/dist/floating-action-button.d.ts.map +1 -0
  112. package/dist/floating-action-button.js +149 -0
  113. package/dist/floating-action-button.js.map +1 -0
  114. package/dist/form.d.ts +143 -0
  115. package/dist/form.d.ts.map +1 -0
  116. package/dist/form.js +206 -0
  117. package/dist/form.js.map +1 -0
  118. package/dist/foundations.d.ts +300 -0
  119. package/dist/foundations.d.ts.map +1 -0
  120. package/dist/foundations.js +238 -0
  121. package/dist/foundations.js.map +1 -0
  122. package/dist/grid.d.ts +75 -0
  123. package/dist/grid.d.ts.map +1 -0
  124. package/dist/grid.js +133 -0
  125. package/dist/grid.js.map +1 -0
  126. package/dist/icon-button-recipe.d.ts +99 -0
  127. package/dist/icon-button-recipe.d.ts.map +1 -0
  128. package/dist/icon-button-recipe.js +53 -0
  129. package/dist/icon-button-recipe.js.map +1 -0
  130. package/dist/icon.d.ts +41 -0
  131. package/dist/icon.d.ts.map +1 -0
  132. package/dist/icon.js +147 -0
  133. package/dist/icon.js.map +1 -0
  134. package/dist/image.d.ts +82 -0
  135. package/dist/image.d.ts.map +1 -0
  136. package/dist/image.js +100 -0
  137. package/dist/image.js.map +1 -0
  138. package/dist/index.d.ts +58 -0
  139. package/dist/index.d.ts.map +1 -0
  140. package/dist/index.js +66 -0
  141. package/dist/index.js.map +1 -0
  142. package/dist/layout.d.ts +118 -0
  143. package/dist/layout.d.ts.map +1 -0
  144. package/dist/layout.js +118 -0
  145. package/dist/layout.js.map +1 -0
  146. package/dist/link.d.ts +57 -0
  147. package/dist/link.d.ts.map +1 -0
  148. package/dist/link.js +142 -0
  149. package/dist/link.js.map +1 -0
  150. package/dist/load-more.d.ts +64 -0
  151. package/dist/load-more.d.ts.map +1 -0
  152. package/dist/load-more.js +124 -0
  153. package/dist/load-more.js.map +1 -0
  154. package/dist/mentions.d.ts +67 -0
  155. package/dist/mentions.d.ts.map +1 -0
  156. package/dist/mentions.js +107 -0
  157. package/dist/mentions.js.map +1 -0
  158. package/dist/number-field.d.ts +208 -0
  159. package/dist/number-field.d.ts.map +1 -0
  160. package/dist/number-field.js +247 -0
  161. package/dist/number-field.js.map +1 -0
  162. package/dist/otp-field.d.ts +152 -0
  163. package/dist/otp-field.d.ts.map +1 -0
  164. package/dist/otp-field.js +117 -0
  165. package/dist/otp-field.js.map +1 -0
  166. package/dist/pagination.d.ts +181 -0
  167. package/dist/pagination.d.ts.map +1 -0
  168. package/dist/pagination.js +226 -0
  169. package/dist/pagination.js.map +1 -0
  170. package/dist/password-field.d.ts +187 -0
  171. package/dist/password-field.d.ts.map +1 -0
  172. package/dist/password-field.js +120 -0
  173. package/dist/password-field.js.map +1 -0
  174. package/dist/popover.d.ts +152 -0
  175. package/dist/popover.d.ts.map +1 -0
  176. package/dist/popover.js +137 -0
  177. package/dist/popover.js.map +1 -0
  178. package/dist/progress-recipe.d.ts +43 -0
  179. package/dist/progress-recipe.d.ts.map +1 -0
  180. package/dist/progress-recipe.js +16 -0
  181. package/dist/progress-recipe.js.map +1 -0
  182. package/dist/recipes.d.ts +31 -0
  183. package/dist/recipes.d.ts.map +1 -0
  184. package/dist/recipes.js +46 -0
  185. package/dist/recipes.js.map +1 -0
  186. package/dist/responsive.d.ts +27 -0
  187. package/dist/responsive.d.ts.map +1 -0
  188. package/dist/responsive.js +66 -0
  189. package/dist/responsive.js.map +1 -0
  190. package/dist/result.d.ts +111 -0
  191. package/dist/result.d.ts.map +1 -0
  192. package/dist/result.js +97 -0
  193. package/dist/result.js.map +1 -0
  194. package/dist/selection-helpers.d.ts +16 -0
  195. package/dist/selection-helpers.d.ts.map +1 -0
  196. package/dist/selection-helpers.js +52 -0
  197. package/dist/selection-helpers.js.map +1 -0
  198. package/dist/semantic-colors.d.ts +275 -0
  199. package/dist/semantic-colors.d.ts.map +1 -0
  200. package/dist/semantic-colors.js +84 -0
  201. package/dist/semantic-colors.js.map +1 -0
  202. package/dist/sheet.d.ts +51 -0
  203. package/dist/sheet.d.ts.map +1 -0
  204. package/dist/sheet.js +69 -0
  205. package/dist/sheet.js.map +1 -0
  206. package/dist/showcase.d.ts +153 -0
  207. package/dist/showcase.d.ts.map +1 -0
  208. package/dist/showcase.js +210 -0
  209. package/dist/showcase.js.map +1 -0
  210. package/dist/side-panel.d.ts +199 -0
  211. package/dist/side-panel.d.ts.map +1 -0
  212. package/dist/side-panel.js +111 -0
  213. package/dist/side-panel.js.map +1 -0
  214. package/dist/slider.d.ts +138 -0
  215. package/dist/slider.d.ts.map +1 -0
  216. package/dist/slider.js +150 -0
  217. package/dist/slider.js.map +1 -0
  218. package/dist/splitter.d.ts +113 -0
  219. package/dist/splitter.d.ts.map +1 -0
  220. package/dist/splitter.js +99 -0
  221. package/dist/splitter.js.map +1 -0
  222. package/dist/statistic.d.ts +41 -0
  223. package/dist/statistic.d.ts.map +1 -0
  224. package/dist/statistic.js +76 -0
  225. package/dist/statistic.js.map +1 -0
  226. package/dist/steps.d.ts +196 -0
  227. package/dist/steps.d.ts.map +1 -0
  228. package/dist/steps.js +160 -0
  229. package/dist/steps.js.map +1 -0
  230. package/dist/tag.d.ts +128 -0
  231. package/dist/tag.d.ts.map +1 -0
  232. package/dist/tag.js +93 -0
  233. package/dist/tag.js.map +1 -0
  234. package/dist/timeline.d.ts +147 -0
  235. package/dist/timeline.d.ts.map +1 -0
  236. package/dist/timeline.js +127 -0
  237. package/dist/timeline.js.map +1 -0
  238. package/dist/toast.d.ts +164 -0
  239. package/dist/toast.d.ts.map +1 -0
  240. package/dist/toast.js +529 -0
  241. package/dist/toast.js.map +1 -0
  242. package/dist/tokens.d.ts +9 -0
  243. package/dist/tokens.d.ts.map +1 -0
  244. package/dist/tokens.js +9 -0
  245. package/dist/tokens.js.map +1 -0
  246. package/dist/tooltip.d.ts +44 -0
  247. package/dist/tooltip.d.ts.map +1 -0
  248. package/dist/tooltip.js +88 -0
  249. package/dist/tooltip.js.map +1 -0
  250. package/dist/tour.d.ts +218 -0
  251. package/dist/tour.d.ts.map +1 -0
  252. package/dist/tour.js +211 -0
  253. package/dist/tour.js.map +1 -0
  254. package/dist/transfer-list.d.ts +207 -0
  255. package/dist/transfer-list.d.ts.map +1 -0
  256. package/dist/transfer-list.js +193 -0
  257. package/dist/transfer-list.js.map +1 -0
  258. package/dist/tree-select.d.ts +78 -0
  259. package/dist/tree-select.d.ts.map +1 -0
  260. package/dist/tree-select.js +132 -0
  261. package/dist/tree-select.js.map +1 -0
  262. package/dist/tree.d.ts +206 -0
  263. package/dist/tree.d.ts.map +1 -0
  264. package/dist/tree.js +223 -0
  265. package/dist/tree.js.map +1 -0
  266. package/dist/upload-item.d.ts +173 -0
  267. package/dist/upload-item.d.ts.map +1 -0
  268. package/dist/upload-item.js +156 -0
  269. package/dist/upload-item.js.map +1 -0
  270. package/dist/version.d.ts +3 -0
  271. package/dist/version.d.ts.map +1 -0
  272. package/dist/version.js +3 -0
  273. package/dist/version.js.map +1 -0
  274. package/docs/affix.md +63 -0
  275. package/docs/anchor.md +59 -0
  276. package/docs/ant-design-coverage.md +119 -0
  277. package/docs/app-provider.md +50 -0
  278. package/docs/app-rn-adoption.md +227 -0
  279. package/docs/architecture.md +320 -0
  280. package/docs/authoring-brief.md +87 -0
  281. package/docs/border-beam.md +66 -0
  282. package/docs/bottom-navigation.md +127 -0
  283. package/docs/breadcrumb.md +82 -0
  284. package/docs/calendar.md +154 -0
  285. package/docs/carousel.md +130 -0
  286. package/docs/cascader.md +93 -0
  287. package/docs/catalog-decision-status.md +306 -0
  288. package/docs/color-picker.md +72 -0
  289. package/docs/command-palette.md +116 -0
  290. package/docs/confirm-popover.md +93 -0
  291. package/docs/consistency-audit.md +383 -0
  292. package/docs/consumer-release-gate.md +90 -0
  293. package/docs/content-state.md +156 -0
  294. package/docs/context-panel.md +88 -0
  295. package/docs/cross-platform-core-normalization.md +118 -0
  296. package/docs/data-table.md +79 -0
  297. package/docs/date-picker.md +84 -0
  298. package/docs/description-list.md +75 -0
  299. package/docs/design-system-provider.md +139 -0
  300. package/docs/dropdown.md +78 -0
  301. package/docs/expansion-roadmap.md +285 -0
  302. package/docs/file-picker.md +59 -0
  303. package/docs/floating-action-button.md +111 -0
  304. package/docs/form.md +134 -0
  305. package/docs/generated/component-maturity.md +103 -0
  306. package/docs/generated/renderer-evidence.json +5597 -0
  307. package/docs/generated/renderer-evidence.md +132 -0
  308. package/docs/generated/showcase-manifest.json +3732 -0
  309. package/docs/icon.md +22 -0
  310. package/docs/identity.md +121 -0
  311. package/docs/image.md +84 -0
  312. package/docs/implementation-0.5.md +85 -0
  313. package/docs/layout-primitives.md +107 -0
  314. package/docs/layout.md +83 -0
  315. package/docs/library-reference-decisions.md +123 -0
  316. package/docs/link.md +67 -0
  317. package/docs/load-more.md +31 -0
  318. package/docs/mentions.md +82 -0
  319. package/docs/migration-0.2.md +108 -0
  320. package/docs/migration-0.3.md +72 -0
  321. package/docs/migration-0.5.md +82 -0
  322. package/docs/migration-0.6.md +197 -0
  323. package/docs/notification.md +54 -0
  324. package/docs/number-field.md +82 -0
  325. package/docs/otp-field.md +103 -0
  326. package/docs/pagination.md +139 -0
  327. package/docs/password-field.md +98 -0
  328. package/docs/popover.md +124 -0
  329. package/docs/promotion-candidates.md +169 -0
  330. package/docs/qr-code.md +69 -0
  331. package/docs/rating.md +58 -0
  332. package/docs/responsive-grid.md +90 -0
  333. package/docs/result.md +96 -0
  334. package/docs/showcase.md +97 -0
  335. package/docs/side-panel.md +71 -0
  336. package/docs/slider.md +79 -0
  337. package/docs/splitter.md +55 -0
  338. package/docs/statistic.md +16 -0
  339. package/docs/steps.md +115 -0
  340. package/docs/tag.md +57 -0
  341. package/docs/time-picker.md +88 -0
  342. package/docs/timeline.md +146 -0
  343. package/docs/toast.md +127 -0
  344. package/docs/tooltip.md +58 -0
  345. package/docs/tour.md +136 -0
  346. package/docs/transfer-list.md +94 -0
  347. package/docs/tree-select.md +101 -0
  348. package/docs/tree.md +123 -0
  349. package/docs/upload-item.md +68 -0
  350. package/docs/utility.md +53 -0
  351. package/docs/virtual-list.md +70 -0
  352. package/docs/watermark.md +58 -0
  353. package/package.json +402 -0
@@ -0,0 +1,54 @@
1
+ # Notification — 별도 컴포넌트를 만들지 않는다
2
+
3
+ **문제로 제기된 것.** Ant Design은 `message`(Toast)와 `notification`을 지속 시간과
4
+ 정보량으로 가릅니다 — `message`는 한 줄, 짧게, 화면 중앙; `notification`은 제목+본문
5
+ 두 층, 모서리에 여러 개 쌓임, 기본적으로 더 오래 남거나 닫을 때까지 유지됩니다.
6
+
7
+ **판정: 이 구분은 HJM에서 성립하지 않는다.** `src/toast.ts`와 `docs/toast.md`를 정독한
8
+ 결과, antd가 두 컴포넌트로 나눈 모든 축이 이미 `Toast` 하나의 **설정 값**으로 존재합니다.
9
+ 새 상태 축도, 새 접근성 개념도 필요하지 않습니다.
10
+
11
+ | antd가 가르는 축 | antd `message` | antd `notification` | HJM `Toast`의 같은 축 |
12
+ | --- | --- | --- | --- |
13
+ | 정보 층 | 한 줄 | 제목 + 본문 | `ToastDescriptor.title?` + `description`(이미 두 층) |
14
+ | 지속 시간 | 짧음, 자동 닫힘 | 길거나 수동 닫힘 | `durationMs: number \| null` — `null`이 이미 수동 닫힘 |
15
+ | 동시 개수 | 보통 1개 | 여러 개 모서리에 쌓임 | `ToastStoreOptions.maxVisible` — 1보다 크게 설정하면 여러 개 |
16
+ | 위치 | 화면 상/하단 중앙 | 모서리(우상단 등) | `docs/toast.md`의 `top-start\|top-end\|bottom-start\|bottom-end` placement |
17
+ | 긴급도 | 낮음 | 상대적으로 높음 | `priority: normal \| high` |
18
+
19
+ 즉 "notification처럼 보이는 Toast"는 `createToastStore({ maxVisible: 3, ... })` +
20
+ `durationMs: null` + 모서리 placement + `priority: "high"` 조합이며, 이는 **제품이
21
+ Toast를 구성하는 방식**이지 새로운 platform-neutral 계약이 아닙니다. 이 저장소는
22
+ `Radio`(`selectionControlRecipe`), `TextArea`(`fieldRecipe`)처럼 antd 목록의 한 항목이
23
+ 이미 있는 recipe/behavior를 재사용하고 새 recipe를 얻지 못하는 사례를 이미 갖고
24
+ 있습니다 — Notification도 같은 자리입니다.
25
+
26
+ **성립하지 않는다고 판단한 근거.**
27
+
28
+ 1. 새 상태 축이 없습니다. `idle→visible→closing→closed`, pause reason, exact-once
29
+ dismiss 중 어느 것도 "notification이라서 다르게" 필요하지 않습니다.
30
+ 2. 새 접근성 개념이 없습니다. `normal`/`high` announcement priority가 이미 긴급도
31
+ 차이를 담습니다.
32
+ 3. 측정된 제품 요구가 없습니다. 로드맵 어디에도 "Toast로는 안 되고 별도 알림이
33
+ 필요했다"는 vertical slice 기록이 없습니다. 이 저장소는 측정되지 않은 표면을
34
+ 미리 만들지 않습니다(로드맵 「무엇을 흡수하는가」 원칙).
35
+
36
+ **만들지 않은 것.** `src/notification.ts`, `test/notification.test.ts`는 없습니다. 새
37
+ 계약이 없는데 파일만 만들면 다음 사람이 "Toast와 뭐가 다른가"를 또 물어야 하는 빈
38
+ 추상화가 됩니다.
39
+
40
+ ## catalog 배선 명세 (리드 적용)
41
+
42
+ crosswalk(`component-references.ts:117`, `Notification → direct`)는 antd 범위 추적용
43
+ 그대로 둡니다. catalog 항목은 새 recipe를 만들지 않고 기존 Toast 계약을 가리키는 쪽을
44
+ 권합니다.
45
+
46
+ ```ts
47
+ { name: "Notification", category: "feedback", platform: "adaptive", status: "planned", recipe: "toastRecipe", behavior: "toast" }
48
+ ```
49
+
50
+ `planned → beta` 승격은 이 recipe/behavior 재사용을 그대로 유지한 채, 실제 제품이
51
+ `maxVisible > 1` + 모서리 placement + persistent duration 조합을 notification 용도로
52
+ 쓰는 vertical slice가 나온 뒤 결정합니다. 만약 그 슬라이스에서 Toast로 표현할 수 없는
53
+ 요구(예: 화면 밖 push 발표, 별도 알림함 이력)가 나오면 그때는 이 문서를 갱신하고 새
54
+ 계약을 여는 것이 맞습니다 — 지금은 그 증거가 없습니다.
@@ -0,0 +1,82 @@
1
+ # NumberField contract
2
+
3
+ **문제.** 사용자가 정확한 수 하나를 정한다 — 타율 계산기의 타수, 예약 인원, 코멘트
4
+ 글자 수 제한처럼 "이 숫자 그대로"가 중요한 입력이다. 대략적인 감으로 고르는 Slider와는
5
+ 반대 극단의 문제다.
6
+
7
+ **일반화한 계약.** min/max/step/value의 판정은 Slider와 `src/number-field.ts`의
8
+ `validateNumericRangeConfig`/`clampToRange`/`snapToStep`을 공유한다. 두 컴포넌트가 각자
9
+ 다른 반올림·경계 규칙을 갖게 되면 같은 "범위 있는 수"라는 개념이 두 이름으로 갈라지기
10
+ 때문이다. NumberField 쪽 전용 판단은 값이 `number | null`이라는 점뿐이다 — `null`은
11
+ 아직 아무 값도 입력하지 않은 상태이며 `min`을 포함한 어떤 실수 값과도 다르다.
12
+
13
+ - `value: number | null`, `min`, `max`, `step?`(기본 1).
14
+ - validator는 min≥max, step≤0, step이 min–max 폭보다 큰 조합, 범위 밖 value를 던진다.
15
+ 경계값(`value === min` 또는 `max`)과 `value === null`은 허용해야 하는 입력이라 먼저
16
+ 통과 시험한다.
17
+ - stepper 방향 disabled는 `resolveNumberFieldStepperState`가 계산한다: 현재 값이 `min`이면
18
+ decrement, `max`이면 increment가 disabled. `value === null`일 때는 아직 경계에
19
+ 닿지 않았으므로 두 방향 모두 disabled가 아니다.
20
+ - `stepNumberFieldValue`는 empty에서 누르면 increment는 `min`, decrement는 `max`로
21
+ 이동한다 — 어느 방향으로 처음 눌러도 그 방향이 향하는 경계에 착지한다.
22
+ - step grid의 소수 정밀도는 `step`만이 아니라 기준점 `min`까지 함께 사용한다. 예를 들어
23
+ `min=0.05, step=0.1`의 유효한 순서는 `0.05, 0.15, 0.25…`이며 `0.15`를 `0.2`로
24
+ 반올림하지 않는다.
25
+ - 표시 문자열(단위, 소수 자릿수, 통화 기호 등)은 만들지 않는다. 제품이 포맷한 문자열을
26
+ 받는 Statistic과 같은 원칙이다.
27
+ - Web/RN renderer의 편집 중 문자열은 `parseNumberFieldInput`으로 같은 판정을 받는다.
28
+ 빈 문자열은 `null`, `-`/`1e` 같은 미완성 draft는 `undefined`이고, blur/submit에서만
29
+ `commitNumberFieldInput`이 clamp와 step snap을 적용한다. 따라서 controlled 숫자 모델에
30
+ 미완성 문자열을 밀어 넣지 않으면서도 사용자의 단일 행 편집을 중간에 방해하지 않는다.
31
+ - 증감 action은 `stepNumberFieldInput`으로 draft를 직접 판정한다. off-grid draft에서 먼저
32
+ nearest snap을 한 뒤 다시 한 step을 더하지 않고, 요청 방향의 바로 다음 유효 경계로
33
+ 이동한다(`4.26`, step `0.5`의 increment는 `4.5`, decrement는 `4.0`).
34
+ - `resolveNumberFieldInputStepperState`도 같은 draft를 사용한다. committed 값이 max여도 사용자가
35
+ 더 작은 draft를 편집 중이면 increment 버튼을 활성화하여 visible value, keyboard, 버튼과
36
+ Native accessibility action의 capability가 서로 모순되지 않게 한다.
37
+
38
+ **HJM 기본값.** Field의 `fieldFrameContract`/`formSupportContract`를 그대로 재사용한다
39
+ (`numberFieldRecipe.frame`, `.support`). 새 프레임을 만들면 Field와 높이·radius·invalid
40
+ border가 갈린다. stepper 버튼은 44-unit target(`control.minTouchTarget`)을 유지하고
41
+ disabled는 opacity만으로 표현하지 않는다 — 버튼 자체가 눌리지 않는 상태(no press
42
+ feedback)와 짝지어 판단할 수 있는 대상이라 색만으로 말하지 않는다.
43
+
44
+ held-repeat(꾹 눌러 연속 증가)는 이번 계약에 넣지 않는다. 실제 제품에서 측정된 수요가
45
+ 없고, 지금 넣으면 반복 속도·가속 곡선을 검증 없이 추측해야 한다. 한 번 누름 = 한 번
46
+ step만 공개한다.
47
+
48
+ **플랫폼 번역.**
49
+
50
+ - Web: role `spinbutton`, keyboard `Tab`(진입/이탈), `ArrowUp`/`ArrowDown`(step). 타이핑
51
+ 자체는 native text input 동작이라 별도 키를 등록하지 않는다.
52
+ locale-neutral draft와 브라우저 단일 행 편집을 지키기 위해 `type="text"`를 사용하므로
53
+ 무효인 HTML `min`/`max`/`step` attribute에는 기대지 않는다. 범위는 `aria-valuemin`/
54
+ `aria-valuemax`와 공통 resolver가 집행한다. stepper는 `tabIndex=-1`인 보조 action이다.
55
+ 입력 하나만 Tab 순서에 두고 Arrow 키로 같은 기능을 제공하되, pointer와 voice control은
56
+ 제품이 주입한 이름의 증감 버튼을 직접 활성화할 수 있다.
57
+ - React Native: `text` role(입력)과 `button` role(stepper) 조합, `disabled` state,
58
+ `focus`/`setText`/`increment`/`decrement` action. RN에는 `spinbutton` 동등물이 없어
59
+ 두 역할의 조합으로 번역한다.
60
+ - `validation`(valid/invalid) 축은 NumberField에만 공개한다 — 범위 자체의 유효성과는
61
+ 독립적인, 제품이 매기는 별도 판정(예: "홀수만 허용")이기 때문이다. Slider는 이 축을
62
+ 갖지 않는다.
63
+
64
+ **검증 화면.** 아직 실제 제품 vertical slice가 없다 — catalog는 `planned`으로 남고,
65
+ `beta` 승격은 로드맵의 gate(실제 화면 검증)를 통과한 뒤 리드가 진행한다.
66
+
67
+ **명시적 범위 밖.** 기본 parser는 ASCII 부호·10진수·exponent만 받는 locale-neutral
68
+ grammar다. 통화 기호, 단위 suffix, grouping separator, 아라비아/한 숫자 체계, locale별
69
+ decimal separator의 자동 parse/format은 지원한다고 주장하지 않는다. 그런 요구는 향후
70
+ 양방향 parse/format adapter 계약과 IME·locale fixture를 함께 추가한 뒤 확장한다.
71
+ Composition 중 문자열은 draft에만 남지만, 비-ASCII 숫자 IME의 parsing·caret 보존을
72
+ 검증했다는 claim도 하지 않는다.
73
+
74
+ **참고한 공개 기준.** WAI-ARIA APG의
75
+ [Spinbutton pattern](https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/)에서 입력을
76
+ 단일 Tab stop으로 두고 ArrowUp/ArrowDown을 기본 조정 키로 삼았다. Adobe React Aria의
77
+ [useNumberField](https://react-spectrum.adobe.com/react-aria/useNumberField.html)에서
78
+ 부분 문자열을 numeric model과 분리하고 blur에서 commit하는 판단을 참고했지만, 그
79
+ 라이브러리의 locale/currency parse나 held-repeat까지 구현했다고 주장하지 않는다.
80
+ Native의 증감 action은 React Native 공식
81
+ [Accessibility actions](https://reactnative.dev/docs/accessibility#accessibility-actions)
82
+ API로 번역한다.
@@ -0,0 +1,103 @@
1
+ # OtpField contract
2
+
3
+ ## 문제
4
+
5
+ 문자로 온 인증번호를 한 자씩 여러 칸에 입력한다. Ant Design `Input.OTP`와 `direct`
6
+ crosswalk를 따른다(`Input`의 decomposed target 중 하나).
7
+
8
+ ## 접근성이 본체다: 여러 칸이지만 하나의 값이다
9
+
10
+ **판정.** OtpField는 **하나의 포커스 가능한, 하나의 접근 가능한 이름·값을 가진 텍스트
11
+ 컨트롤**이다 — 칸마다 별도로 포커스를 받는 N개의 실제 input이 아니다. 화면에 보이는
12
+ N개의 칸은 그 하나의 컨트롤이 가진 문자들을 보여주는 **장식**일 뿐이다
13
+ (`getOtpFieldSlotValues`).
14
+
15
+ **근거.** 칸마다 독립된 실제 input을 만들면 스크린 리더는 각 칸에 포커스가 갈 때마다
16
+ "6개 중 3번째, 빈 칸" 같은 것을 읽는다 — 사용자는 자신이 지금 무엇을 입력하고 있는지
17
+ (인증번호 전체 중 어디까지 왔는지, 지금까지 뭘 입력했는지) 알 수 없다. 이건 이 종류의
18
+ 컴포넌트가 접근성에서 가장 흔히 실패하는 지점이다. 반면 하나의 실제 텍스트 필드에
19
+ 글자 간격(letter-spacing) 스타일과 그 위에 겹쳐 그리는 장식 칸 테두리를 쓰면, 화면
20
+ 읽기는 평범한 한 텍스트 필드("인증번호, 123 입력됨" 같은 하나의 이름과 값)이고, 보기에는
21
+ 여전히 칸이 나뉜 것처럼 보인다. 시각적 유연성(칸별 강조색 등)도 잃지 않는다 —
22
+ `getOtpFieldSlotValues`가 칸별 문자를 그대로 주므로 장식 칸 하나하나를 다르게 칠하는
23
+ 것은 여전히 가능하다.
24
+
25
+ ## 붙여넣기·입력·지우기가 거의 공짜인 이유
26
+
27
+ 실제 input이 하나이므로 타이핑·붙여넣기(중간 선택 영역에 붙여넣기 포함)·Backspace는
28
+ **모두 플랫폼의 기본 텍스트 편집**이 처리한다 — 문자 삽입 위치, 선택 영역 교체, 커서
29
+ 이동을 이 패키지가 다시 구현하지 않는다. 브리프가 요구한 "지우기 동작"(빈 칸에서
30
+ Backspace → 앞 칸으로)도 이 모델에서는 별도 로직이 필요 없다 — 마지막 문자 바로 뒤
31
+ 커서에서 Backspace를 누르면 원래 텍스트 필드가 마지막 문자를 지우고 커서를 한 칸
32
+ 앞으로 옮기는 것과 정확히 같은 동작이기 때문이다.
33
+
34
+ 그래서 HJM이 실제로 소유하는 판단은 딱 하나 — **편집 결과로 나온 원시 문자열을 어떻게
35
+ 정리하는가**(`resolveOtpFieldValue(length, rawText)`): 숫자만 남기고, `length`를 넘지
36
+ 않게 자른다. 타이핑이든, 붙여넣기든, 중간 위치에 붙여넣은 결과든 항상 이 하나의
37
+ 함수로 들어온다 — 그래서 이 계약이 함수를 여러 개(칸별 입력, 칸별 붙여넣기, 칸별
38
+ 지우기) 만들지 않고 하나만 갖는다.
39
+
40
+ **경계 입력(테스트로 잠갔다).**
41
+
42
+ - 길이 초과: `"1234567890"` → `length`만큼 자른다(던지지 않는다).
43
+ - 숫자가 아닌 문자: `"12-34 56"`, `"abc123def456"` → 숫자만 남긴다(거부하지 않는다,
44
+ 붙여넣은 문자열에 공백·하이픈이 섞여 오는 실제 SMS 문구를 그대로 허용하기 위해서다).
45
+ - 중간 위치에 붙여넣기: 실제 input에서는 "칸 3에 붙여넣기"가 아니라 "이미 있던
46
+ 텍스트의 중간에 붙여넣어 병합된 새 문자열"로 이 함수에 도착한다 — 그 이미
47
+ 병합된 문자열을 정리하는 것으로 충분하다(테스트: `"12" + "99"(붙여넣음) + "34"` →
48
+ `"129934"`, `length=4`면 `"1299"`로 자름).
49
+
50
+ `value.length > length`인 **커밋된 descriptor**는 여전히 던진다
51
+ (`validateOtpFieldDescriptor`) — 이건 편집 경로가 아니라 "이미 잘못된 상태를 그대로
52
+ 렌더링하려는" 렌더러 버그 신호다. 편집 중(자름)과 커밋된 상태 검증(던짐)은 다른
53
+ 문제다.
54
+
55
+ ## 값은 항상 빈틈없다
56
+
57
+ `OtpFieldDescriptor.value`는 항상 인덱스 0부터 채워진 문자열이다 — 칸 2는 채워지고 칸
58
+ 0~1은 비어 있는 상태를 표현하지 않는다(`getOtpFieldSlotValues`가 인덱스 `i`는
59
+ `i < value.length`일 때만 채워졌다고 보는 것과 정확히 같다). 이 하나의 불변식 때문에
60
+ 타이핑·붙여넣기·지우기가 모두 "평범한 문자열 편집"으로 환원된다 — 빈틈을 허용했다면
61
+ 칸별 위치 계산을 이 패키지가 다시 만들어야 했을 것이다.
62
+
63
+ ## 숫자만 받는다
64
+
65
+ 인증번호는 이 브리프가 설명하는 그대로 숫자다. 영숫자 혼합 OTP는 지금 계약에 없다 —
66
+ 문자 집합을 설정 가능하게 만들면 검증되지 않은 패턴을 미리 얹는 것이 된다. 실제
67
+ 필요가 나오면 그때 `characterPattern` 같은 축을 연다.
68
+
69
+ ## HJM 기본값
70
+
71
+ - 칸(`slot`) 테두리는 새 팔레트를 만들지 않고 `fieldFrameContract`의 `border`/
72
+ `focusBorder`/`invalidBorder`/`radius`/`borderWidth`를 그대로 쓴다 — 칸 하나하나가
73
+ 작은 Field 프레임이라는 뜻이다. 채워진 칸의 테두리만 `content.brand`로 강조한다.
74
+ - `support`(hint/error)는 `formSupportContract`를 그대로 쓴다.
75
+
76
+ ## 플랫폼 번역
77
+
78
+ - Web: 실제 `<input>` 하나(`type="text"`, `inputMode="numeric"`, `maxLength={length}`,
79
+ `autoComplete="one-time-code"`). 화면에는 letter-spacing과 겹쳐진 칸 테두리로
80
+ 나뉜 것처럼 보이지만 접근 가능한 이름과 값은 하나다. 클릭으로 특정 칸 근처를
81
+ 누르면 그 위치 근처로 네이티브 캐럿이 이동한다 — 별도 포커스 이동 로직이 필요
82
+ 없다.
83
+ - Native: `TextInput` 하나, `keyboardType="number-pad"`, `maxLength={length}`. 칸은
84
+ 같은 방식으로 그 위에 겹쳐 그리는 장식 뷰다.
85
+ - 낭독 팁(코드로 강제하지 않음, 렌더러 안내): 여러 자리 숫자를 화면 낭독기가 통째로
86
+ 큰 수("이십삼만사천오백육십칠")로 읽을 수 있다. 자릿수 낭독이 필요하면 렌더러가
87
+ 접근성 이름/값에 숫자 사이 공백을 넣어 한 자씩 읽히게 할 수 있다 — 이건 제품
88
+ 카피(어떻게 띄어 읽을지)의 문제라 composer 콜백을 새로 만들지 않고 문서로만
89
+ 안내한다.
90
+
91
+ ## 공개한 축 / 배제한 축
92
+
93
+ | 축 | 상태 |
94
+ | --- | --- |
95
+ | `value`(controlled, 하나의 문자열) | 공개 |
96
+ | `availability`(enabled/disabled/readOnly/busy) | 공개 — 서버 인증 중 `busy` |
97
+ | `validation`(valid/invalid) | 공개 — 오류 카피는 Field의 `error` 슬롯 |
98
+ | 칸별 포커스·칸별 접근성 발화 | **배제**(의도적) — 위 판정 참고 |
99
+ | 영숫자 문자 집합 | **배제** — 측정된 요구 없음 |
100
+
101
+ ## 검증 화면
102
+
103
+ 아직 없음. `planned → beta` 승격은 실제 제품 vertical slice 이후 리드가 진행한다.
@@ -0,0 +1,139 @@
1
+ # Pagination contract
2
+
3
+ ## 문제
4
+
5
+ 경계가 있는 결과 집합(검색 결과, 관리자 테이블)에서 사용자가 임의의 페이지로
6
+ 바로 이동할 수 있어야 한다. 이는 계속 스크롤해서 다음 항목을 불러오는 열린
7
+ 목록과는 다른 문제다.
8
+
9
+ ## Pagination과 LoadMore의 경계 (로드맵이 이미 그은 선)
10
+
11
+ `docs/expansion-roadmap.md`는 이미 명시한다: "Native 긴 목록에는 페이지 번호
12
+ 보다 LoadMore/infinite loading을 사용한다." 그래서 Pagination은 `platform:
13
+ web` 전용이고 LoadMore(`shared`)는 두 플랫폼 다 있다. 이유는 표면적
14
+ "페이지 번호 vs 무한 스크롤" 취향이 아니라 총량과 입력 장치의 차이다.
15
+
16
+ - Pagination은 **안정된 총 개수/총 페이지**를 전제한다 — 사용자가 "42페이지로
17
+ 가겠다"는 의도를 표현할 수 있는 자리다.
18
+ - LoadMore는 **총량을 몰라도** 되고, 사용자는 "다음 조금 더"만 의도한다 —
19
+ `LoadMoreState`에 `totalPages` 개념 자체가 없다.
20
+ - 마우스/키보드가 있는 Web에서는 임의 페이지 클릭이 싸다. 터치로 스크롤하는
21
+ Native 긴 목록에서는 페이지 번호 탭이 스크롤 관성과 경쟁하고, 오차 없이
22
+ 작은 숫자를 누르기도 어렵다.
23
+
24
+ 두 계약은 서로를 참조하지 않는다(`src/load-more.ts`는 Pagination을 모르고
25
+ 반대도 마찬가지). 제품이 고르는 기준은 이 문서에 있다: **총량이 고정되고
26
+ 사용자가 임의 페이지로 점프해야 하면 Pagination, 그렇지 않고 계속 이어지는
27
+ 피드면 LoadMore.** 같은 화면에 두 계약을 동시에 쓸 이유는 없다 — 하나의
28
+ 목록은 둘 중 하나의 탐색 모델만 가진다.
29
+
30
+ ## 일반화한 계약
31
+
32
+ ### 필수 입력
33
+
34
+ `PaginationDescriptor`는 `currentPage`와, 총량을 표현하는 두 형태 중
35
+ 정확히 하나를 받는 discriminated union이다.
36
+
37
+ - `{ currentPage, totalCount, pageSize }` — 제품이 원본 개수와 페이지 크기를
38
+ 아는 가장 흔한 경우. `totalPages = max(1, ceil(totalCount / pageSize))`로
39
+ 유도한다.
40
+ - `{ currentPage, totalPages }` — 서버가 이미 페이지 수 단위로 응답하거나
41
+ 페이지 크기 개념이 없는 경우.
42
+
43
+ `totalPages`와 `totalCount`/`pageSize`를 동시에 주거나 아무것도 안 주면
44
+ validator가 `TypeError`로 거부한다. `currentPage`가 유도된 `totalPages`
45
+ 범위를 벗어나면 `RangeError`로 거부한다 — 조용히 clamp하지 않는다(브리프:
46
+ "validator는 던진다").
47
+
48
+ ### 배제한 것 — size changer와 jump-to-page
49
+
50
+ **페이지 크기 변경(size changer)과 "몇 페이지로 이동" 입력은 넣지 않는다.**
51
+ 둘 다 antd `Pagination`에는 있지만:
52
+
53
+ - 측정된 제품 수요가 없다 — 로드맵의 어떤 vertical slice도 이 두 기능을
54
+ 요구한 적이 없다.
55
+ - 둘 다 이미 있는 `currentPage`/`totalPages` 상태에 도달하는 **또 다른
56
+ 경로**일 뿐 새 상태 축이 아니다. size changer는 제품이 `pageSize`를 바꿔서
57
+ 같은 `PaginationDescriptor`를 다시 넘기면 되고, jump-to-page는 별도
58
+ `NumberField` + 제품 코드가 같은 `onPageChange(page, "page")`를 호출하면
59
+ 된다. 이 모듈이 그 UI를 대신 만들 필요가 없다.
60
+
61
+ 필요해지면 그때 이 문서를 갱신하고 여는 것이 맞다 — 지금은 그 증거가 없다
62
+ (Dropdown/Notification 문서와 같은 원칙).
63
+
64
+ ### 페이지 번호 목록은 순수 함수다
65
+
66
+ `computePaginationItems(totalPages, currentPage, { siblingCount,
67
+ boundaryCount })`는 접근성 이름 조립과 완전히 분리된 순수 함수다. 항상
68
+ 보여야 하는 페이지(양쪽 경계 `boundaryCount`개 + `currentPage` 주변
69
+ `siblingCount`개)의 합집합을 구하고, 그 사이 숨는 페이지 수에 따라:
70
+
71
+ - 숨는 페이지가 **정확히 1개**면 그 번호를 그대로 보여준다 — `...`이 숫자
72
+ 하나보다 자리를 아끼지 않으므로 생략할 이유가 없다.
73
+ - 숨는 페이지가 **2개 이상**이면 생략 표시 하나로 접는다.
74
+
75
+ 경계 입력을 테스트로 잠갔다: 총 1페이지(생략 없음, 번호 하나), 총 2페이지
76
+ (생략 없음, 둘 다 표시), 현재가 첫 페이지(꼬리만 접힘), 현재가 마지막 페이지
77
+ (머리만 접힘), 숨는 페이지가 정확히 1개인 경우(생략 대신 번호), 1~14페이지
78
+ 전 조합에 대한 "연속 생략 없음 / 항상 1과 totalPages로 시작·끝남" 불변식.
79
+
80
+ ### 접근성 이름은 제품이 조립한다
81
+
82
+ `ComposePaginationAccessibleName`은 `steps.ts`의 `composeAccessibleName`
83
+ 선례를 그대로 따른다 — `{ page, totalPages, current }`를 받아 "5 페이지 중 3
84
+ 페이지" 같은 문장을 제품이 조립해 각 페이지 버튼의 accessible name으로 쓴다.
85
+ Steps와 같은 이유다: 순서를 나타내는 문장의 어순과 조사는 언어마다 다르고
86
+ (한국어 "5 페이지 중 3 페이지"는 영어 "page 3 of 5"와 구조가 다르다), 생략
87
+ 표시로 페이지가 듬성듬성 보이는 상황에서는 화면에 찍힌 숫자 하나만으로
88
+ 전체 맥락(총 몇 페이지 중인지)이 전달되지 않는다. resolver는 composer가 빈
89
+ 문자열을 반환하면 던진다.
90
+
91
+ 생략 표시(`...`)는 **장식**이다. `ResolvedPaginationItem`의 ellipsis 분기는
92
+ `accessibleName` 필드 자체가 없다 — 렌더러가 `aria-hidden`으로 완전히 숨겨야
93
+ 하고, 페이지 버튼과 달리 focus를 받지 않는다.
94
+
95
+ ## HJM 기본값
96
+
97
+ - `siblingCount: 1`, `boundaryCount: 1` — 현재 페이지 양옆 1개, 양쪽 경계
98
+ 1개씩만 항상 보인다. antd 기본값을 복사한 것이 아니라 가장 흔한 폭(모바일
99
+ 전체 폭 웹뷰~데스크톱 사이드바)에서 항목 수가 과도하게 늘어나지 않는
100
+ 선에서 고른 값이다. 더 넓은 화면이 필요하면 제품이 두 값을 올린다.
101
+ - 현재 페이지 표시는 `identity.md`의 "primary fill은 주요 행동에, 더 밝은
102
+ contentBrand는 focus·선택 indicator·현재 위치에" 원칙과 `stepsRecipe`의
103
+ 현재 단계 처리(배경 채우지 않고 border+글자색만 brand)를 그대로 따른다.
104
+ `paginationRecipe.item`은 현재 페이지에 `action.brand` 채움을 쓰지 않고
105
+ `border.focus`/`content.brand` 외곽선만 쓴다 — 페이지 번호는 위치 표시이지
106
+ 버튼 커맨드가 아니다.
107
+ - 이전/다음 아이콘은 새 glyph를 만들지 않고 기존 논리 방향 아이콘
108
+ `chevronStart`/`chevronEnd`(RTL에서 자동 mirror)를 재사용한다. 생략 표시의
109
+ 장식 마크도 기존 `more` 아이콘을 재사용한다.
110
+
111
+ ## 플랫폼 번역
112
+
113
+ - Web: root는 `<nav>` 랜드마크다. 각 페이지 버튼은 `aria-current="page"`를
114
+ 현재 페이지에만 달고, 시각 숫자와 함께 제품이 조립한 `accessibleName`을
115
+ accessible name으로 쓴다. 생략 표시는 `aria-hidden`이며 tabbable하지 않다.
116
+ 이전/다음 버튼은 `PaginationLabels`의 고정 현지화 문구를 쓰고, 경계에서는
117
+ `disabled`(색만이 아니라 `aria-disabled`와 `opacity.disabled`)로 표시한다.
118
+ - Native: `platform: web`이므로 이 계약은 Native 렌더러를 갖지 않는다 —
119
+ 긴 목록의 Native 대응은 `LoadMore`다(위 경계 참고).
120
+ - Reduce Motion: 페이지 전환은 이동 애니메이션 없이 콘텐츠만 교체한다 —
121
+ Pagination 자체는 전환 모션을 소유하지 않는다.
122
+
123
+ ## 공개한 축 / 배제한 축
124
+
125
+ | 축 | 상태 |
126
+ | --- | --- |
127
+ | `currentPage`, `totalPages`(또는 `totalCount`+`pageSize`) | 공개(필수) |
128
+ | 페이지 window 크기(`siblingCount`/`boundaryCount`) | 공개(선택, 기본값 있음) |
129
+ | 생략 표시 | 공개하되 장식으로 한정 — 낭독 제외 |
130
+ | page size changer | **배제** — 측정된 수요 없음, 필요 시 제품이 같은 상태를 다시 넘기는 방식으로 조합 가능 |
131
+ | "몇 페이지로 이동" 입력 | **배제** — 위와 같은 이유, 별도 `NumberField` + `onPageChange`로 조합 가능 |
132
+ | `disabled`(컨트롤 전체) | **배제** — 브리프가 요구한 필수 계약을 넘는 축이라 지금은 열지 않는다. 필요해지면 availability 축에서 `enabled`/`disabled`만 추가한다 |
133
+ | Native 대응 | **배제** — `LoadMore`가 이미 같은 문제의 Native 해法이다 |
134
+
135
+ ## 검증 화면
136
+
137
+ 아직 없음. `planned → beta` 승격은 실제 제품 vertical slice 이후 리드가
138
+ 진행한다(로드맵 maturity gate). 유력 후보는 검색 결과나 관리자 테이블처럼
139
+ 안정된 총 개수가 있는 Web 목록이다.
@@ -0,0 +1,98 @@
1
+ # PasswordField contract
2
+
3
+ ## 문제
4
+
5
+ 비밀번호를 입력하면서 필요할 때만 값을 눈으로 확인한다. Ant Design `Input.Password`와
6
+ `direct` crosswalk를 따르되, 이 저장소에서는 `Input`이 `Field`/`TextArea`/`SearchField`/
7
+ `PasswordField`/`OtpField`로 이미 분해돼 있다(`decomposed`).
8
+
9
+ ## Field 프레임을 재사용한다
10
+
11
+ `Field`는 이미 stable이고, `NumberField`가 방금 같은 판단을 내렸다(`src/number-field.ts`) —
12
+ 새 필드 프레임을 만들지 않고 `fieldFrameContract`/`formSupportContract`(둘 다
13
+ `src/component-contracts.ts`)를 그대로 가져다 쓴다. PasswordField는 `value`/
14
+ `onValueChange`도 새로 정의하지 않는다 — 비밀번호의 값은 그냥 문자열이고, Field의 값과
15
+ 다른 어떤 것도 아니다(NumberField가 파싱된 숫자를 다뤄야 해서 자기 값 타입을 가진 것과
16
+ 다른 지점). 그래서 이 모듈이 계약하는 새 문제는 정확히 하나, **가림 토글**뿐이다.
17
+
18
+ ## 토글의 접근 가능한 이름: 상태가 아니라 행동
19
+
20
+ **판정.** 토글의 이름은 "지금 비밀번호가 보이는가/숨겨져 있는가"(현재 상태)가 아니라
21
+ **누르면 무슨 일이 일어나는가**(다음 행동)를 말한다 — 접힘일 때 "비밀번호 보이기",
22
+ 펼침일 때 "비밀번호 숨기기".
23
+
24
+ **근거.**
25
+
26
+ 1. 이 컨트롤은 `aria-pressed`가 있는 진짜 토글 버튼이 아니라 평범한 버튼이다(눌렀다 뗀
27
+ 자국이 남는 게 아니라 그때그때 동작만 일어난다). `aria-pressed` 없이 상태만 말하는
28
+ 이름("비밀번호 숨겨짐")을 쓰면 사용자가 "그래서 눌러서 뭐가 되는가"를 추론해야 한다.
29
+ 2. 실제 참조 구현들(브라우저 내장 비밀번호 보기, 비밀번호 관리자, 접근성 가이드가 이
30
+ 패턴을 설명할 때)이 일관되게 행동형 문구("Show password"/"Hide password")를 쓴다 —
31
+ 상태형 문구를 쓰는 참조 구현을 찾지 못했다.
32
+ 3. 이 저장소에 이미 있는 비슷한 자리도 행동/다음 상태 쪽이다 — `LoadMore`의 트리거
33
+ 라벨은 "지금 안 불러와짐"이 아니라 "더 보기"(누르면 할 일)를 말하고,
34
+ `play`/`pause`처럼 서로 다른 아이콘 이름이 지금 상태가 아니라 누르면 일어날 동작을
35
+ 가리킨다(`semanticIconNames`).
36
+
37
+ **타입으로 강제한 지점.** `PasswordToggleAccessibleNameInfo`는 `revealed`가 아니라
38
+ `willReveal`(= `!revealed`)을 담아 제품에 넘긴다. 필드 이름을 "현재 상태"로 지어
39
+ 어차피 뒤집어야 하는 계산을 제품 composer 안에 숨기지 않고, 호출부(`resolvePasswordFieldDescriptor`)에서
40
+ 미리 뒤집어 건넨다 — Steps/Timeline/Carousel/Tree가 이미 하는 "제품이 어순·조사를
41
+ 조립하되 계산은 HJM이 미리 한다"는 원칙과 같다. 아이콘도 같은 논리로
42
+ `concealed → visibility`(누르면 보임), `revealed → visibilityOff`(누르면 숨김)로 매칭했다
43
+ — 이름과 아이콘이 서로 다른 근거로 다른 결론에 도달하지 않는다.
44
+
45
+ ## 값은 절대 바뀌지 않는다
46
+
47
+ `resolvePasswordFieldDescriptor`는 값을 파라미터로도 받지 않는다 — 받을 수 없으니
48
+ 바꿀 수도 없다. `passwordFieldBehavior.controlled`는 `value`/`onValueChange`와 `revealed`/
49
+ `onRevealedChange`를 **서로 다른 두 triplet**으로 나열한다(같은 자리 하나로 합치지
50
+ 않는다) — Carousel의 `currentKey`(정체성)와 `position`(발화)을 분리한 것과 같은 이유로,
51
+ "토글이 표시 방식만 바꾼다"는 것을 상태 축 자체로 증명한다.
52
+
53
+ ## 자동완성 힌트: 계약은 만들지만 값은 제품이 정한다
54
+
55
+ `autofillHint: "current" | "new"`는 필수 필드다. HJM이 로그인 화면인지 가입/비밀번호
56
+ 변경 화면인지 판단할 방법이 없고, 잘못 추측하면(로그인 화면에 "새 비밀번호" 힌트를
57
+ 주는 식) 브라우저/OS의 자동완성 제안을 실제로 망가뜨린다 — 그래서 제품이 명시적으로
58
+ 결정해서 넘긴다. 반면 각 플랫폼의 **정확한 속성 이름**(Web `autocomplete` 토큰, iOS
59
+ `textContentType`, Android autofill 힌트)은 여기서 문자열로 고정하지 않는다 — 이 값들은
60
+ RN 버전에 따라 이름이 바뀐 적이 있고, 이 패키지는 런타임 의존성이 없어 그 사실을
61
+ 검증할 방법이 없다(`docs/architecture.md`의 "런타임 의존성 금지" 원칙). 렌더러가 자기
62
+ 플랫폼의 현재 API로 `current`/`new` 두 값 중 하나를 골라 번역한다 — Icon의 semantic
63
+ name → 실제 글리프 번역과 같은 경계다.
64
+
65
+ ## 강도 표시(strength meter)는 넣지 않는다
66
+
67
+ 비밀번호 강도 판정은 보안 정책이고, 정책은 제품(또는 서버)마다 다르며 일반화된 "이
68
+ 정도면 강하다" 계산을 이 패키지가 갖고 있을 이유가 없다. 강도를 보여주고 싶은 제품은
69
+ Field가 이미 가진 `description`/`hint` 슬롯에 자기 문구를 채우면 된다 — 새 상태 축이
70
+ 필요 없다.
71
+
72
+ ## HJM 기본값
73
+
74
+ - `frame`/`support`는 `fieldFrameContract`/`formSupportContract` 그대로.
75
+ - toggle 자리는 SearchField의 clear 버튼과 같은 자리·같은 hit target
76
+ (`control.minTouchTarget`)을 쓴다 — 필드 안에 있는 보조 버튼이라는 점에서 같은 문제다.
77
+
78
+ ## 플랫폼 번역
79
+
80
+ - Web: `<input type={webInputType}>`(`text`/`password`) — `resolvePasswordFieldDescriptor`가
81
+ `revealed`로부터 유도한다. 토글은 SearchField의 clear 버튼처럼 필드 뒤에 오는 별도
82
+ tab stop이다(architecture.md의 "선택 행 안에 또 다른 button을 넣지 않는다"는 규칙은
83
+ 선택 목록 행에 대한 것이고, Field의 trailing action에는 이미 SearchField가 선례로
84
+ 적용된다).
85
+ - Native: `secureTextEntry={nativeSecureTextEntry}`(= `!revealed`).
86
+
87
+ ## 공개한 축 / 배제한 축
88
+
89
+ | 축 | 상태 |
90
+ | --- | --- |
91
+ | `revealed`(controlled) | 공개 |
92
+ | `autofillHint`(필수 입력) | 공개 — 값은 제품, 플랫폼 번역표는 문서로만(코드로 단정하지 않음) |
93
+ | `value`/`onValueChange` | Field 재사용, 새로 정의 안 함 |
94
+ | 강도 표시 | **배제** — 보안 정책은 제품 몫 |
95
+
96
+ ## 검증 화면
97
+
98
+ 아직 없음. `planned → beta` 승격은 실제 제품 vertical slice 이후 리드가 진행한다.
@@ -0,0 +1,124 @@
1
+ # Popover contract
2
+
3
+ ## 문제
4
+
5
+ 트리거에 붙어 뜨는 표면 중, plain text 보충 설명(Tooltip)도 아니고 항목 목록
6
+ (Menu)도 아닌 **임의의 interactive 콘텐츠**(작은 폼, 링크가 섞인 본문, 여러
7
+ control이 섞인 레이아웃)를 보여줘야 하는 자리가 있다. `docs/dropdown.md`가
8
+ 이미 이 문제를 `Popover`의 이름표로 예약해 두었다 — Dropdown을 "임의 콘텐츠"용
9
+ 으로 다시 정의하는 대신, 그 문제를 여기서 계약한다.
10
+
11
+ ## Tooltip · Menu · Popover 삼각 경계 (판정)
12
+
13
+ 세 컴포넌트는 모두 "트리거에 붙는 floating surface"라는 같은 anatomy를 공유하지만
14
+ 콘텐츠 종류와 focus 계약이 다르다.
15
+
16
+ | | Tooltip | Menu | Popover |
17
+ | --- | --- | --- | --- |
18
+ | 콘텐츠 | 현지화된 plain text 한 문장 | stable id를 가진 action/선택 항목 목록 | 임의의 interactive 콘텐츠(폼, 링크, 혼합 레이아웃) |
19
+ | focus가 surface 안으로 들어가는가 | **아니오** — `docs/tooltip.md`: "tabbable descendant가 없다" | 예 — 항목에 roving/activate focus | **예** — 결정적 차이 |
20
+ | dismiss 계기 | hover/focus 해제, Escape, sibling 전환 | 선택, Escape/back, outside | close action, outside pointer, **outside-focus**(Tab이 surface 밖으로 나감), Escape |
21
+ | trigger 상호작용 | hover(지연) + focus(즉시) | click/Enter/Space로 열림, 방향키로 탐색 | **click/Enter/Space만** — content가 interactive라 hover로 열고 hover로 유지하는 Tooltip 모델을 재사용하면 키보드·터치 사용자가 content에 도달하기 전에 닫히는 경쟁이 생긴다 |
22
+ | positioning | 제품 renderer의 비공개 `AnchoredOverlay` | 같은 anatomy(`menuRecipe`)가 이미 소유 | Tooltip과 동일하게 비공개 `AnchoredOverlay`에 위임 — 새 portal/flip/shift API를 만들지 않는다 |
23
+
24
+ `docs/tooltip.md`가 그은 경계("interactive Popover로 확장하지 않는다")를
25
+ Popover가 정확히 이어받는다 — Tooltip이 멈춘 자리에서 focus가 surface 안으로
26
+ 들어가는 순간 계약이 달라지고, 그 한 가지 사실에서 나머지 전부(비-hover
27
+ 트리거, 명시적 dismiss 어휘, focus 복귀)가 따라 나온다.
28
+
29
+ Menu와 구분되는 지점은 콘텐츠 형태다. Menu는 Collection 기본 계약(stable id,
30
+ label/textValue, selection mode)을 따르는 항목 **목록**이고 그 role/keyboard
31
+ 표는 `behaviorRegistry.menu`가 이미 완결했다. Popover는 그 계약을 만족하지
32
+ 않는, 목록이 아닌 콘텐츠를 위한 자리다. 제품이 실제로 액션 목록을 띄우려는
33
+ 것이라면 그것은 Popover가 아니라 Menu다.
34
+
35
+ ## ConfirmPopover는 별개로 만들지 않는다 (판정, 한 줄)
36
+
37
+ catalog에 별도 `planned` 항목으로 있는 `ConfirmPopover`(antd `Popconfirm`,
38
+ `relationship: "adapted"`)는 이 Popover 위의 **조합**이다 — Popover의 anchored
39
+ 비-모달 surface에 AlertDialog의 `idle → busy → error/closing → closed` confirm
40
+ session(`docs/architecture.md`의 "위험 확인의 생명주기")을 얹은 것이지, 셋째
41
+ 독립 primitive가 아니다. 지금은 만들지 않는다: 이 판정만 남긴다.
42
+
43
+ ## 일반화한 계약
44
+
45
+ ### Open state
46
+
47
+ `PopoverOpenState`는 Tooltip과 같은 `open`/`defaultOpen`/`onOpenChange`
48
+ discriminated union이다. `onOpenChange`는 `reason`을 `trigger` 또는
49
+ 구체적인 dismiss reason 중 하나로 보고한다 — Sheet가 dismiss reason을 추측하지
50
+ 않고 값으로 보고하는 것과 같은 이유(`SheetOpenChangeDetails`).
51
+
52
+ ### Dismiss 어휘와 정책
53
+
54
+ `PopoverDismissReason`은 `close-action | outside-pointer | outside-focus |
55
+ escape | programmatic`이다. `SheetDismissReason`과 다른 점 둘:
56
+
57
+ - `back`/`swipe`가 없다 — Popover는 web 전용이고 native 뒤로가기·스와이프
58
+ 표면이 아니다.
59
+ - `outside-pointer`와 `outside-focus`를 분리한다 — Popover는 Dialog/Sheet처럼
60
+ 모달이 아니므로 Tab이 surface 밖으로 정당하게 나갈 수 있고, 그 키보드
61
+ 이탈은 포인터로 바깥을 클릭하는 것과 다른 입력 양식이라 정책으로 따로
62
+ 켜고 끌 수 있어야 한다.
63
+
64
+ `PopoverDismissPolicy`는 `dismissible`, `outsideDismiss`, `escapeDismiss`,
65
+ `focusOutDismiss` 네 값이다. `busy` 축은 없다 — Sheet/AlertDialog와 달리
66
+ Popover는 모달이 아니라 "모든 dismiss를 막는 전역 상태"가 성립하지 않는다.
67
+ 콘텐츠 안의 폼이 제출 중 Escape를 무시하고 싶다면 그것은 콘텐츠 레벨의
68
+ 판단이지 Popover 계약의 축이 아니다. `canDismissPopover`는 `canDismissSheet`와
69
+ 같은 모양으로 controlled owner의 `programmatic` 닫힘은 항상 허용한다.
70
+
71
+ ### Positioning은 소유하지 않는다
72
+
73
+ `docs/tooltip.md`의 "Positioning boundary"를 그대로 재사용한다. HJM은
74
+ preferred placement(`top|bottom|start|end`), align(`start|center|end`),
75
+ arrow 크기, spacing, collision padding, motion만 소유한다. DOM 측정, portal,
76
+ flip/shift, RTL 논리 방향 변환은 제품 Web renderer의 비공개 `AnchoredOverlay`가
77
+ 소유하며 이 모듈은 그 도구를 공개 API로 노출하지 않는다 — Tooltip이 세운
78
+ "이 내부 도구를 catalog의 public Popover로 노출하지 않는다"는 경계를 Popover
79
+ 자신이 어기지 않는다.
80
+
81
+ ## HJM 기본값
82
+
83
+ - `placement` 기본값 `bottom`, `align` 기본값 `start` — Tooltip(`top`/`center`)
84
+ 과 의도적으로 다르다. Popover 콘텐츠는 Menu처럼 아래로 펼쳐지는 목록형
85
+ 레이아웃을 담는 경우가 많아 Menu의 시각적 관성(아래로 열림)에 더 가깝다.
86
+ - `accessibilityLabel`은 선택 사항이다. 대부분의 Popover 콘텐츠는 자체 heading을
87
+ 가지므로 그것이 surface의 접근 가능한 이름이 된다. heading이 없는 콘텐츠만
88
+ 명시적으로 공급한다 — 기본값을 발명하지 않는다(Tooltip이 `content`를 필수로
89
+ 요구하는 것과 반대로, Popover는 콘텐츠 자체를 타입으로 갖지 않으므로 대신
90
+ 이 escape hatch만 둔다).
91
+ - trigger는 click/Enter/Space로만 연다. hover는 열지 않는다 — 위 삼각 경계
92
+ 표의 이유와 동일하다.
93
+
94
+ ## 플랫폼 번역
95
+
96
+ - Web: surface는 `role="dialog"`(비모달, `aria-modal` 없음), trigger는
97
+ `aria-haspopup="dialog"`와 `aria-expanded`를 합성한다. 열릴 때 초기 focus는
98
+ 콘텐츠의 첫 focusable 요소로 이동하고(없으면 콘텐츠 root, `tabIndex={-1}`),
99
+ 모든 dismiss 경로 이후 focus는 trigger로 복귀한다. Escape·outside pointer·
100
+ focus가 surface 밖으로 나감 세 경로 모두 이 복귀 규칙을 따른다.
101
+ - Native: 이 컴포넌트는 `platform: web`이다 — `docs/expansion-roadmap.md`
102
+ Batch 3에 `web`으로만 분류되어 있고, Native adaptive 대응(예: bottom sheet로
103
+ 펼치는 대안)이 필요해지면 그때 별도 적응 계약을 연다.
104
+ - Reduce Motion: Tooltip과 같은 enter/exit preset(`motionPreset.enter/exit`)을
105
+ 재사용하며 이동 없는 opacity로 대체한다.
106
+
107
+ ## 공개한 축 / 배제한 축
108
+
109
+ | 축 | 상태 |
110
+ | --- | --- |
111
+ | `open` (controlled/uncontrolled) | 공개 |
112
+ | dismiss reason(`close-action`/`outside-pointer`/`outside-focus`/`escape`/`programmatic`) | 공개 |
113
+ | `placement`/`align` | 공개(Tooltip과 같은 값 집합) |
114
+ | `busy`(모든 dismiss 차단) | **배제** — 비모달이라 전역 차단 상태가 성립하지 않는다 |
115
+ | hover trigger | **배제** — focus가 콘텐츠 안으로 들어가는 계약과 hover 열기/유지가 경쟁한다 |
116
+ | portal/flip/shift 공개 API | **배제** — Tooltip의 `AnchoredOverlay` 경계를 그대로 상속 |
117
+ | content 데이터 모델 | **배제** — 런타임 의존성 금지 원칙상 React 콘텐츠 타입을 이 패키지가 가질 수 없다. 콘텐츠 자체는 항상 제품/렌더러 소유다 |
118
+
119
+ ## 검증 화면
120
+
121
+ 아직 없음. `planned → beta` 승격은 실제 제품 vertical slice 이후 리드가
122
+ 진행한다. 유력 후보는 필터·정렬 옵션처럼 여러 control이 섞인 작은 패널이며,
123
+ 단순 action 목록이면 그것은 Popover가 아니라 이미 beta인 Menu를 먼저
124
+ 검토해야 한다.