@eifi1/ui-kit 0.34.0 → 0.36.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 (446) hide show
  1. package/README.md +115 -41
  2. package/dist/admin/role-select.d.ts +3 -2
  3. package/dist/admin/role-select.js +2 -0
  4. package/dist/admin/role-select.js.map +1 -1
  5. package/dist/billing/billing-banner.d.ts +5 -2
  6. package/dist/billing/billing-banner.js.map +1 -1
  7. package/dist/billing/plan-limit-notice.d.ts +5 -2
  8. package/dist/billing/plan-limit-notice.js.map +1 -1
  9. package/dist/chart.js.map +1 -1
  10. package/dist/components/amount-input.d.ts +2 -5
  11. package/dist/components/amount-input.js +2 -2
  12. package/dist/components/amount-input.js.map +1 -1
  13. package/dist/components/autocomplete.d.ts +2 -2
  14. package/dist/components/autocomplete.js +2 -1
  15. package/dist/components/autocomplete.js.map +1 -1
  16. package/dist/components/button-look.d.ts +1 -1
  17. package/dist/components/button-look.js +9 -4
  18. package/dist/components/button-look.js.map +1 -1
  19. package/dist/components/calculator.d.ts +1 -4
  20. package/dist/components/calculator.js +1 -3
  21. package/dist/components/calculator.js.map +1 -1
  22. package/dist/components/chart-zoom.d.ts +0 -37
  23. package/dist/components/chart-zoom.js +1 -28
  24. package/dist/components/chart-zoom.js.map +1 -1
  25. package/dist/components/checkbox-group.d.ts +5 -2
  26. package/dist/components/checkbox-group.js +15 -4
  27. package/dist/components/checkbox-group.js.map +1 -1
  28. package/dist/components/chip-input.d.ts +0 -4
  29. package/dist/components/chip-input.js +1 -5
  30. package/dist/components/chip-input.js.map +1 -1
  31. package/dist/components/column-mapper.d.ts +5 -3
  32. package/dist/components/column-mapper.js +2 -0
  33. package/dist/components/column-mapper.js.map +1 -1
  34. package/dist/components/combobox.d.ts +3 -3
  35. package/dist/components/combobox.js +2 -1
  36. package/dist/components/combobox.js.map +1 -1
  37. package/dist/components/copy-button.d.ts +5 -1
  38. package/dist/components/copy-button.js.map +1 -1
  39. package/dist/components/country-select.d.ts +3 -3
  40. package/dist/components/country-select.js +3 -0
  41. package/dist/components/country-select.js.map +1 -1
  42. package/dist/components/currency-option-list.d.ts +3 -8
  43. package/dist/components/currency-option-list.js +1 -29
  44. package/dist/components/currency-option-list.js.map +1 -1
  45. package/dist/components/currency-select.d.ts +3 -2
  46. package/dist/components/currency-select.js +10 -1
  47. package/dist/components/currency-select.js.map +1 -1
  48. package/dist/components/danger-confirm.d.ts +3 -3
  49. package/dist/components/danger-confirm.js.map +1 -1
  50. package/dist/components/data-table-filter-popover.d.ts +1 -1
  51. package/dist/components/data-table-filter-popover.js +1 -1
  52. package/dist/components/data-table-filter-popover.js.map +1 -1
  53. package/dist/components/data-table-labels.d.ts +1 -42
  54. package/dist/components/data-table-labels.js +2 -31
  55. package/dist/components/data-table-labels.js.map +1 -1
  56. package/dist/components/data-table-shared.d.ts +17 -1
  57. package/dist/components/data-table-shared.js +22 -0
  58. package/dist/components/data-table-shared.js.map +1 -1
  59. package/dist/components/data-table.d.ts +12 -2
  60. package/dist/components/data-table.js +18 -14
  61. package/dist/components/data-table.js.map +1 -1
  62. package/dist/components/date-field.d.ts +2 -1
  63. package/dist/components/date-field.js.map +1 -1
  64. package/dist/components/date-picker.js +3 -0
  65. package/dist/components/date-picker.js.map +1 -1
  66. package/dist/components/date-range-picker.js +3 -0
  67. package/dist/components/date-range-picker.js.map +1 -1
  68. package/dist/components/dialog-frame.d.ts +3 -1
  69. package/dist/components/dialog-frame.js.map +1 -1
  70. package/dist/components/dropdown.d.ts +18 -15
  71. package/dist/components/dropdown.js +11 -3
  72. package/dist/components/dropdown.js.map +1 -1
  73. package/dist/components/entity-combobox.d.ts +3 -3
  74. package/dist/components/entity-combobox.js +2 -1
  75. package/dist/components/entity-combobox.js.map +1 -1
  76. package/dist/components/field-base.d.ts +0 -5
  77. package/dist/components/field-base.js +0 -5
  78. package/dist/components/field-base.js.map +1 -1
  79. package/dist/components/field-parts.d.ts +69 -22
  80. package/dist/components/field-parts.js +33 -19
  81. package/dist/components/field-parts.js.map +1 -1
  82. package/dist/components/field-strip.d.ts +9 -23
  83. package/dist/components/field-strip.js +26 -23
  84. package/dist/components/field-strip.js.map +1 -1
  85. package/dist/components/field-sync.d.ts +0 -4
  86. package/dist/components/field-sync.js +0 -4
  87. package/dist/components/field-sync.js.map +1 -1
  88. package/dist/components/field.d.ts +7 -6
  89. package/dist/components/field.js +24 -15
  90. package/dist/components/field.js.map +1 -1
  91. package/dist/components/full-bleed-dialog.js +2 -2
  92. package/dist/components/full-bleed-dialog.js.map +1 -1
  93. package/dist/components/help-popover.d.ts +9 -7
  94. package/dist/components/help-popover.js.map +1 -1
  95. package/dist/components/hover-menu.d.ts +6 -13
  96. package/dist/components/hover-menu.js +3 -3
  97. package/dist/components/hover-menu.js.map +1 -1
  98. package/dist/components/icon-picker.d.ts +3 -2
  99. package/dist/components/icon-picker.js +3 -0
  100. package/dist/components/icon-picker.js.map +1 -1
  101. package/dist/components/inline-entity-combobox.d.ts +3 -3
  102. package/dist/components/inline-entity-combobox.js +2 -1
  103. package/dist/components/inline-entity-combobox.js.map +1 -1
  104. package/dist/components/input.d.ts +2 -2
  105. package/dist/components/input.js +2 -1
  106. package/dist/components/input.js.map +1 -1
  107. package/dist/components/modal.d.ts +9 -1
  108. package/dist/components/modal.js +7 -4
  109. package/dist/components/modal.js.map +1 -1
  110. package/dist/components/month-picker.d.ts +2 -1
  111. package/dist/components/month-picker.js +8 -1
  112. package/dist/components/month-picker.js.map +1 -1
  113. package/dist/components/multi-entity-combobox.d.ts +2 -2
  114. package/dist/components/multi-entity-combobox.js +2 -1
  115. package/dist/components/multi-entity-combobox.js.map +1 -1
  116. package/dist/components/multi-select.d.ts +7 -18
  117. package/dist/components/multi-select.js +8 -10
  118. package/dist/components/multi-select.js.map +1 -1
  119. package/dist/components/number-input.d.ts +2 -5
  120. package/dist/components/number-input.js +2 -4
  121. package/dist/components/number-input.js.map +1 -1
  122. package/dist/components/numeric-field.d.ts +5 -2
  123. package/dist/components/numeric-field.js +4 -1
  124. package/dist/components/numeric-field.js.map +1 -1
  125. package/dist/components/one-time-code-input.d.ts +2 -1
  126. package/dist/components/one-time-code-input.js +2 -1
  127. package/dist/components/one-time-code-input.js.map +1 -1
  128. package/dist/components/phone-input.d.ts +1 -1
  129. package/dist/components/phone-input.js +4 -0
  130. package/dist/components/phone-input.js.map +1 -1
  131. package/dist/components/popover.js +3 -3
  132. package/dist/components/popover.js.map +1 -1
  133. package/dist/components/select.d.ts +4 -3
  134. package/dist/components/select.js +2 -1
  135. package/dist/components/select.js.map +1 -1
  136. package/dist/components/series-chart.js.map +1 -1
  137. package/dist/components/server-wake.d.ts +0 -6
  138. package/dist/components/server-wake.js.map +1 -1
  139. package/dist/components/settings-fields.d.ts +4 -2
  140. package/dist/components/settings-fields.js.map +1 -1
  141. package/dist/components/slider.d.ts +3 -2
  142. package/dist/components/slider.js +7 -1
  143. package/dist/components/slider.js.map +1 -1
  144. package/dist/components/stat-tile.d.ts +8 -2
  145. package/dist/components/stat-tile.js +2 -11
  146. package/dist/components/stat-tile.js.map +1 -1
  147. package/dist/components/swatch-picker.d.ts +3 -2
  148. package/dist/components/swatch-picker.js +3 -0
  149. package/dist/components/swatch-picker.js.map +1 -1
  150. package/dist/components/swipeable-row.d.ts +2 -17
  151. package/dist/components/swipeable-row.js +11 -16
  152. package/dist/components/swipeable-row.js.map +1 -1
  153. package/dist/components/text-link.js +3 -1
  154. package/dist/components/text-link.js.map +1 -1
  155. package/dist/components/textarea.d.ts +2 -1
  156. package/dist/components/textarea.js +9 -3
  157. package/dist/components/textarea.js.map +1 -1
  158. package/dist/components/toggle-group.d.ts +2 -2
  159. package/dist/components/toggle-group.js +7 -3
  160. package/dist/components/toggle-group.js.map +1 -1
  161. package/dist/components/tooltip.d.ts +9 -1
  162. package/dist/components/tooltip.js +20 -5
  163. package/dist/components/tooltip.js.map +1 -1
  164. package/dist/components/translation-review.d.ts +10 -2
  165. package/dist/components/translation-review.js +11 -3
  166. package/dist/components/translation-review.js.map +1 -1
  167. package/dist/components/use-table-state.d.ts +31 -3
  168. package/dist/components/use-table-state.js +53 -8
  169. package/dist/components/use-table-state.js.map +1 -1
  170. package/dist/components/user-avatar.d.ts +2 -2
  171. package/dist/components/user-avatar.js.map +1 -1
  172. package/dist/components/wizard-stepper.d.ts +1 -4
  173. package/dist/components/wizard-stepper.js +2 -3
  174. package/dist/components/wizard-stepper.js.map +1 -1
  175. package/dist/currencies.d.ts +23 -0
  176. package/dist/currencies.js +38 -0
  177. package/dist/currencies.js.map +1 -0
  178. package/dist/demo/demo-banner.d.ts +7 -2
  179. package/dist/demo/demo-banner.js.map +1 -1
  180. package/dist/eslint/index.d.ts +83 -0
  181. package/dist/eslint/index.js +69 -0
  182. package/dist/eslint/index.js.map +1 -0
  183. package/dist/eslint/role-utilities.d.ts +48 -0
  184. package/dist/eslint/role-utilities.generated.d.ts +2 -0
  185. package/dist/eslint/role-utilities.generated.js +143 -0
  186. package/dist/eslint/role-utilities.generated.js.map +1 -0
  187. package/dist/eslint/role-utilities.js +43 -0
  188. package/dist/eslint/role-utilities.js.map +1 -0
  189. package/dist/feedback/feedback-menu.d.ts +2 -2
  190. package/dist/feedback/feedback-menu.js.map +1 -1
  191. package/dist/feedback/feedback-thread.d.ts +3 -8
  192. package/dist/feedback/feedback-thread.js +0 -2
  193. package/dist/feedback/feedback-thread.js.map +1 -1
  194. package/dist/hooks/use-dismiss.d.ts +16 -3
  195. package/dist/hooks/use-dismiss.js +14 -2
  196. package/dist/hooks/use-dismiss.js.map +1 -1
  197. package/dist/i18n/defaults.js +5 -1
  198. package/dist/i18n/defaults.js.map +1 -1
  199. package/dist/i18n/german.js +97 -1
  200. package/dist/i18n/german.js.map +1 -1
  201. package/dist/i18n/kit-labels.d.ts +29 -4
  202. package/dist/i18n/kit-labels.js +21 -1
  203. package/dist/i18n/kit-labels.js.map +1 -1
  204. package/dist/i18n/locales/en.js +104 -3
  205. package/dist/i18n/locales/en.js.map +3 -3
  206. package/dist/i18n/locales/es.js +95 -1
  207. package/dist/i18n/locales/es.js.map +1 -1
  208. package/dist/i18n/locales/fr.js +99 -3
  209. package/dist/i18n/locales/fr.js.map +1 -1
  210. package/dist/i18n/locales/hu.js +95 -1
  211. package/dist/i18n/locales/hu.js.map +1 -1
  212. package/dist/i18n/locales/it.js +99 -3
  213. package/dist/i18n/locales/it.js.map +1 -1
  214. package/dist/i18n/locales/zh.js +95 -1
  215. package/dist/i18n/locales/zh.js.map +1 -1
  216. package/dist/i18n/review.js +13 -0
  217. package/dist/i18n/review.js.map +1 -1
  218. package/dist/i18n/shared-labels.js +3 -1
  219. package/dist/i18n/shared-labels.js.map +1 -1
  220. package/dist/index.d.ts +23 -1
  221. package/dist/index.js +40 -2
  222. package/dist/index.js.map +1 -1
  223. package/dist/lib/device-settings.d.ts +72 -0
  224. package/dist/lib/device-settings.js +64 -0
  225. package/dist/lib/device-settings.js.map +1 -0
  226. package/dist/lib/plain-text.d.ts +11 -0
  227. package/dist/lib/plain-text.js +16 -0
  228. package/dist/lib/plain-text.js.map +1 -0
  229. package/dist/lib/refs.d.ts +13 -0
  230. package/dist/lib/refs.js +15 -0
  231. package/dist/lib/refs.js.map +1 -0
  232. package/dist/lib/safe-storage.d.ts +6 -0
  233. package/dist/lib/safe-storage.js +11 -0
  234. package/dist/lib/safe-storage.js.map +1 -1
  235. package/dist/notifications/notification-parts.d.ts +39 -0
  236. package/dist/notifications/notification-parts.js +47 -0
  237. package/dist/notifications/notification-parts.js.map +1 -0
  238. package/dist/notifications/notification-types-setting.d.ts +62 -0
  239. package/dist/notifications/notification-types-setting.js +192 -0
  240. package/dist/notifications/notification-types-setting.js.map +1 -0
  241. package/dist/notifications/notification-types.d.ts +75 -0
  242. package/dist/notifications/notification-types.js +2 -0
  243. package/dist/notifications/notification-types.js.map +1 -0
  244. package/dist/notifications/notifications-labels.d.ts +131 -0
  245. package/dist/notifications/notifications-labels.js +83 -0
  246. package/dist/notifications/notifications-labels.js.map +1 -0
  247. package/dist/notifications/push-device-setting.d.ts +37 -0
  248. package/dist/notifications/push-device-setting.js +164 -0
  249. package/dist/notifications/push-device-setting.js.map +1 -0
  250. package/dist/notifications/push-devices-setting.d.ts +44 -0
  251. package/dist/notifications/push-devices-setting.js +203 -0
  252. package/dist/notifications/push-devices-setting.js.map +1 -0
  253. package/dist/notifications/quiet-hours-setting.d.ts +47 -0
  254. package/dist/notifications/quiet-hours-setting.js +131 -0
  255. package/dist/notifications/quiet-hours-setting.js.map +1 -0
  256. package/dist/notifications/time-zone-select.d.ts +50 -0
  257. package/dist/notifications/time-zone-select.js +76 -0
  258. package/dist/notifications/time-zone-select.js.map +1 -0
  259. package/dist/notifications/unsubscribe-panel.d.ts +53 -0
  260. package/dist/notifications/unsubscribe-panel.js +139 -0
  261. package/dist/notifications/unsubscribe-panel.js.map +1 -0
  262. package/dist/notifications/use-push-device.d.ts +122 -0
  263. package/dist/notifications/use-push-device.js +230 -0
  264. package/dist/notifications/use-push-device.js.map +1 -0
  265. package/dist/rhf/fields.d.ts +36 -29
  266. package/dist/rhf/fields.js +51 -22
  267. package/dist/rhf/fields.js.map +1 -1
  268. package/dist/rhf/form-core.d.ts +5 -4
  269. package/dist/rhf/form-core.js.map +1 -1
  270. package/dist/rhf/form.d.ts +7 -36
  271. package/dist/rhf/form.js +1 -16
  272. package/dist/rhf/form.js.map +1 -1
  273. package/dist/rhf/line-items.d.ts +1 -1
  274. package/dist/rhf/line-items.js.map +1 -1
  275. package/dist/rhf/use-rhf-wizard-step.js.map +1 -1
  276. package/dist/rhf.js.map +1 -1
  277. package/dist/settings/device-reset-setting-labels.d.ts +3 -0
  278. package/dist/settings/device-reset-setting-labels.js +15 -0
  279. package/dist/settings/device-reset-setting-labels.js.map +1 -0
  280. package/dist/settings/device-reset-setting.d.ts +58 -0
  281. package/dist/settings/device-reset-setting.js +51 -0
  282. package/dist/settings/device-reset-setting.js.map +1 -0
  283. package/dist/shell/app-shell.js +5 -0
  284. package/dist/shell/app-shell.js.map +1 -1
  285. package/dist/shell/option-switcher-menu.d.ts +5 -17
  286. package/dist/shell/option-switcher-menu.js +1 -3
  287. package/dist/shell/option-switcher-menu.js.map +1 -1
  288. package/dist/shell/role-switcher.d.ts +1 -4
  289. package/dist/shell/role-switcher.js +2 -3
  290. package/dist/shell/role-switcher.js.map +1 -1
  291. package/dist/shell/topbar-action-menu.d.ts +7 -19
  292. package/dist/shell/topbar-action-menu.js +1 -3
  293. package/dist/shell/topbar-action-menu.js.map +1 -1
  294. package/dist/shell/topbar-controls.d.ts +3 -12
  295. package/dist/shell/topbar-controls.js +6 -9
  296. package/dist/shell/topbar-controls.js.map +1 -1
  297. package/dist/testing/index.d.ts +1 -1
  298. package/dist/testing/index.js +2 -0
  299. package/dist/testing/index.js.map +1 -1
  300. package/dist/testing/viewport.d.ts +25 -2
  301. package/dist/testing/viewport.js +61 -10
  302. package/dist/testing/viewport.js.map +1 -1
  303. package/dist/theme/contrast-tokens.d.ts +42 -4
  304. package/dist/theme/contrast-tokens.js +56 -9
  305. package/dist/theme/contrast-tokens.js.map +1 -1
  306. package/dist/theme/contrast.d.ts +4 -2
  307. package/dist/theme/contrast.js +13 -0
  308. package/dist/theme/contrast.js.map +1 -1
  309. package/dist/theme/palette-derive.d.ts +15 -1
  310. package/dist/theme/palette-derive.js +37 -8
  311. package/dist/theme/palette-derive.js.map +1 -1
  312. package/dist/theme/palette-presets.d.ts +2 -8
  313. package/dist/theme/palette-presets.js +5 -284
  314. package/dist/theme/palette-presets.js.map +1 -1
  315. package/dist/theme/palette-store.js +6 -0
  316. package/dist/theme/palette-store.js.map +1 -1
  317. package/dist/theme/text-size.js +6 -0
  318. package/dist/theme/text-size.js.map +1 -1
  319. package/dist/theme/theme-store.js +6 -0
  320. package/dist/theme/theme-store.js.map +1 -1
  321. package/dist/wizard/types.d.ts +0 -5
  322. package/dist/wizard/types.js +1 -5
  323. package/dist/wizard/types.js.map +1 -1
  324. package/package.json +9 -1
  325. package/src/admin/role-select.tsx +4 -1
  326. package/src/billing/billing-banner.tsx +5 -2
  327. package/src/billing/plan-limit-notice.tsx +5 -2
  328. package/src/chart.ts +5 -3
  329. package/src/components/amount-input.tsx +4 -7
  330. package/src/components/autocomplete.tsx +5 -2
  331. package/src/components/button-look.ts +15 -5
  332. package/src/components/calculator.tsx +2 -7
  333. package/src/components/chart-zoom.tsx +4 -47
  334. package/src/components/checkbox-group.tsx +22 -4
  335. package/src/components/chip-input.tsx +0 -7
  336. package/src/components/column-mapper.tsx +6 -2
  337. package/src/components/combobox.tsx +6 -3
  338. package/src/components/copy-button.tsx +5 -1
  339. package/src/components/country-select.tsx +6 -1
  340. package/src/components/currency-option-list.tsx +8 -42
  341. package/src/components/currency-select.tsx +12 -3
  342. package/src/components/danger-confirm.tsx +3 -3
  343. package/src/components/data-table-filter-popover.tsx +2 -1
  344. package/src/components/data-table-labels.ts +2 -84
  345. package/src/components/data-table-shared.tsx +55 -2
  346. package/src/components/data-table.tsx +29 -17
  347. package/src/components/date-field.tsx +2 -2
  348. package/src/components/date-picker.tsx +3 -0
  349. package/src/components/date-range-picker.tsx +3 -0
  350. package/src/components/dialog-frame.tsx +3 -1
  351. package/src/components/dropdown.tsx +42 -18
  352. package/src/components/entity-combobox.tsx +6 -3
  353. package/src/components/field-base.tsx +0 -11
  354. package/src/components/field-parts.tsx +112 -38
  355. package/src/components/field-strip.tsx +12 -27
  356. package/src/components/field-sync.tsx +0 -7
  357. package/src/components/field.tsx +32 -19
  358. package/src/components/full-bleed-dialog.tsx +4 -3
  359. package/src/components/help-popover.tsx +9 -7
  360. package/src/components/hover-menu.tsx +12 -16
  361. package/src/components/icon-picker.tsx +8 -3
  362. package/src/components/inline-entity-combobox.tsx +6 -3
  363. package/src/components/input.tsx +4 -2
  364. package/src/components/modal.tsx +20 -4
  365. package/src/components/month-picker.tsx +10 -3
  366. package/src/components/multi-entity-combobox.tsx +5 -2
  367. package/src/components/multi-select.tsx +19 -31
  368. package/src/components/number-input.tsx +5 -10
  369. package/src/components/numeric-field.ts +10 -2
  370. package/src/components/one-time-code-input.tsx +5 -3
  371. package/src/components/phone-input.tsx +4 -0
  372. package/src/components/popover.tsx +8 -4
  373. package/src/components/select.tsx +6 -3
  374. package/src/components/series-chart.tsx +1 -1
  375. package/src/components/server-wake.tsx +0 -6
  376. package/src/components/settings-fields.tsx +4 -2
  377. package/src/components/slider.tsx +10 -3
  378. package/src/components/stat-tile.tsx +11 -20
  379. package/src/components/swatch-picker.tsx +9 -4
  380. package/src/components/swipeable-row.tsx +15 -37
  381. package/src/components/text-link.tsx +3 -1
  382. package/src/components/textarea.tsx +11 -4
  383. package/src/components/toggle-group.tsx +21 -5
  384. package/src/components/tooltip.tsx +37 -6
  385. package/src/components/translation-review.tsx +25 -7
  386. package/src/components/use-table-state.ts +91 -10
  387. package/src/components/user-avatar.tsx +2 -2
  388. package/src/components/wizard-stepper.tsx +2 -6
  389. package/src/currencies.ts +70 -0
  390. package/src/demo/demo-banner.tsx +7 -2
  391. package/src/eslint/index.ts +161 -0
  392. package/src/eslint/role-utilities.generated.ts +146 -0
  393. package/src/eslint/role-utilities.ts +99 -0
  394. package/src/feedback/feedback-menu.tsx +2 -2
  395. package/src/feedback/feedback-thread.tsx +3 -8
  396. package/src/hooks/use-dismiss.ts +39 -4
  397. package/src/i18n/defaults.ts +4 -0
  398. package/src/i18n/german.ts +113 -0
  399. package/src/i18n/kit-labels.tsx +66 -6
  400. package/src/i18n/locales/en.ts +6 -0
  401. package/src/i18n/locales/es.ts +106 -0
  402. package/src/i18n/locales/fr.ts +109 -2
  403. package/src/i18n/locales/hu.ts +104 -0
  404. package/src/i18n/locales/it.ts +108 -2
  405. package/src/i18n/locales/zh.ts +96 -0
  406. package/src/i18n/review.ts +13 -0
  407. package/src/i18n/shared-labels.ts +2 -0
  408. package/src/index.ts +53 -1
  409. package/src/lib/device-settings.ts +146 -0
  410. package/src/lib/plain-text.ts +22 -0
  411. package/src/lib/refs.ts +22 -0
  412. package/src/lib/safe-storage.ts +16 -0
  413. package/src/notifications/notification-parts.tsx +83 -0
  414. package/src/notifications/notification-types-setting.tsx +309 -0
  415. package/src/notifications/notification-types.ts +80 -0
  416. package/src/notifications/notifications-labels.ts +238 -0
  417. package/src/notifications/push-device-setting.tsx +224 -0
  418. package/src/notifications/push-devices-setting.tsx +306 -0
  419. package/src/notifications/quiet-hours-setting.tsx +185 -0
  420. package/src/notifications/time-zone-select.tsx +126 -0
  421. package/src/notifications/unsubscribe-panel.tsx +203 -0
  422. package/src/notifications/use-push-device.ts +392 -0
  423. package/src/rhf/fields.tsx +87 -39
  424. package/src/rhf/form-core.tsx +5 -4
  425. package/src/rhf/form.tsx +7 -43
  426. package/src/rhf/line-items.tsx +1 -1
  427. package/src/rhf/use-rhf-wizard-step.ts +4 -4
  428. package/src/rhf.ts +3 -2
  429. package/src/settings/device-reset-setting-labels.ts +20 -0
  430. package/src/settings/device-reset-setting.tsx +119 -0
  431. package/src/shell/app-shell.tsx +8 -0
  432. package/src/shell/option-switcher-menu.tsx +5 -22
  433. package/src/shell/role-switcher.tsx +2 -6
  434. package/src/shell/topbar-action-menu.tsx +7 -24
  435. package/src/shell/topbar-controls.tsx +6 -18
  436. package/src/testing/index.ts +3 -1
  437. package/src/testing/viewport.ts +125 -19
  438. package/src/theme/contrast-tokens.ts +106 -12
  439. package/src/theme/contrast.ts +30 -4
  440. package/src/theme/palette-derive.ts +68 -7
  441. package/src/theme/palette-presets.ts +16 -299
  442. package/src/theme/palette-store.ts +12 -0
  443. package/src/theme/text-size.ts +8 -0
  444. package/src/theme/theme-store.ts +10 -0
  445. package/src/wizard/types.ts +0 -8
  446. package/tokens.css +38 -4
@@ -27,10 +27,6 @@ export interface FieldSyncLabels {
27
27
  retry: string;
28
28
  }
29
29
  export { DEFAULT_FIELD_SYNC_LABELS };
30
- /** @deprecated 0.34 — `useKitLabels("fieldSync", DEFAULT_FIELD_SYNC_LABELS, labels)`, which
31
- * the provider reaches too, or the field's own `labels`; removed in 0.35 (refactor audit
32
- * 2026-10, API-6a). A merge from before the provider, which no kit component calls. */
33
- export declare function resolveFieldSyncLabels(labels?: Partial<FieldSyncLabels>): FieldSyncLabels;
34
30
  export interface UseFieldSyncOptions<T> {
35
31
  /** What the database currently holds. Changing it re-seeds a CLEAN field. */
36
32
  value: T;
@@ -8,9 +8,6 @@ import { FOCUS_RING } from "./focus-ring.js";
8
8
  import { useKitLabels } from "../i18n/kit-labels.js";
9
9
  import { useLargeText } from "../hooks/use-large-text.js";
10
10
  import { DEFAULT_FIELD_SYNC_LABELS } from "./field-sync-labels.js";
11
- function resolveFieldSyncLabels(labels) {
12
- return labels ? { ...DEFAULT_FIELD_SYNC_LABELS, ...labels } : DEFAULT_FIELD_SYNC_LABELS;
13
- }
14
11
  function toError(cause) {
15
12
  if (cause instanceof Error) return cause;
16
13
  return new Error(typeof cause === "string" ? cause : "", { cause });
@@ -269,7 +266,6 @@ export {
269
266
  FIELD_SYNC_SAVED_MS,
270
267
  FieldSyncIndicator,
271
268
  FieldSyncRow,
272
- resolveFieldSyncLabels,
273
269
  useFieldSync
274
270
  };
275
271
  //# sourceMappingURL=field-sync.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/field-sync.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { AlertCircle, Check, Loader2, Pencil, PencilLine } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Tooltip } from \"./tooltip\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { useLargeText } from \"../hooks/use-large-text\";\nimport { DEFAULT_FIELD_SYNC_LABELS } from \"./field-sync-labels\";\n\n/**\n * Sync state for a field backed by a database row.\n *\n * The four states a value can be in between the keyboard and the server:\n *\n * synced the draft equals what was last persisted\n * edited the draft differs and nothing has been sent yet\n * pending a save is in flight\n * error the last save was rejected; `error` carries why\n *\n * `useFieldSync` is the engine and holds no opinion about markup;\n * `FieldSyncIndicator` is the affordance and holds no state. They are separate\n * because the state is useful without the dot — a form can disable its submit\n * button while any field is `pending`, and a router can block navigation while\n * any field is `edited`.\n */\nexport type FieldSyncState = \"synced\" | \"edited\" | \"pending\" | \"error\";\n\nexport interface FieldSyncLabels {\n synced: string;\n edited: string;\n pending: string;\n /** Shown when the failure carries no message of its own. */\n error: string;\n retry: string;\n}\n\n// Defined in ./field-sync-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_FIELD_SYNC_LABELS };\n\n/** @deprecated 0.34 — `useKitLabels(\"fieldSync\", DEFAULT_FIELD_SYNC_LABELS, labels)`, which\n * the provider reaches too, or the field's own `labels`; removed in 0.35 (refactor audit\n * 2026-10, API-6a). A merge from before the provider, which no kit component calls. */\nexport function resolveFieldSyncLabels(labels?: Partial<FieldSyncLabels>): FieldSyncLabels {\n return labels ? { ...DEFAULT_FIELD_SYNC_LABELS, ...labels } : DEFAULT_FIELD_SYNC_LABELS;\n}\n\nexport interface UseFieldSyncOptions<T> {\n /** What the database currently holds. Changing it re-seeds a CLEAN field. */\n value: T;\n /** Persist `next`. Reject — or throw — to put the field in `error`. */\n onSave: (next: T) => Promise<void> | void;\n /**\n * Quiet period before an edit saves itself, in ms. Default `0`: no auto-save at\n * all, so `save()` is the only way to persist — called on blur by `FieldSyncRow`,\n * and on Enter by the caller. Saving after a pause in typing wrote half-finished\n * values to the database whenever somebody stopped to think; pass a positive\n * number only for a field where that is genuinely wanted.\n */\n debounceMs?: number;\n /** How to tell two values apart. Defaults to `Object.is`. */\n equals?: (a: T, b: T) => boolean;\n /** Called once per rejected save, with whatever `onSave` threw. */\n onError?: (error: Error) => void;\n}\n\nexport interface UseFieldSyncReturn<T> {\n /** The draft — bind this to the input, not the `value` option. */\n value: T;\n setValue: (next: T) => void;\n state: FieldSyncState;\n /** Non-null exactly when `state === \"error\"`. */\n error: Error | null;\n /** True whenever the draft differs from the last persisted value. */\n dirty: boolean;\n /** Persist now, cancelling any pending debounce. A no-op on a clean field. */\n save: () => void;\n /** Send the current draft again after a failure. */\n retry: () => void;\n /** Throw the draft away and go back to the last persisted value. */\n reset: () => void;\n}\n\nfunction toError(cause: unknown): Error {\n if (cause instanceof Error) return cause;\n // An EMPTY message for a rejection that carried none, not an English one: the\n // indicator shows `error.message` when there is one and the translated\n // `fieldSync.error` otherwise, so a fallback message here was an English sentence\n // that outranked the app's own translation. The original value stays on `cause`\n // for whoever logs it from `onError`.\n return new Error(typeof cause === \"string\" ? cause : \"\", { cause });\n}\n\n/**\n * Track one database-backed field: a local draft, a debounced save, and the state\n * of the round trip.\n *\n * Four things this handles that a `useState` + `useEffect` in a page does not:\n *\n * 1. **An edit during a save is never lost.** A save in flight is not cancelled\n * and not raced — when it settles, the draft is compared again and sent once\n * more if it moved. Cancelling instead would drop a keystroke that arrived\n * mid-flight; firing immediately would let two writes land out of order.\n * 2. **The server value is only adopted when the field is clean.** An external\n * refresh that overwrote what somebody was typing would be a data-loss bug, so\n * a dirty draft wins and keeps winning until it is saved or reset.\n * 3. **Nothing is set after unmount.** The save resolves into a component that\n * may be gone — a row scrolled out of a virtualised table, a closed dialog.\n * 4. **`onSave` is read through a ref**, so an inline arrow — which every call\n * site will pass — does not restart the debounce on every render.\n */\nexport function useFieldSync<T>({\n value,\n onSave,\n debounceMs = 0,\n equals = Object.is,\n onError,\n}: UseFieldSyncOptions<T>): UseFieldSyncReturn<T> {\n const [draft, setDraft] = useState<T>(value);\n const [state, setState] = useState<FieldSyncState>(\"synced\");\n const [error, setError] = useState<Error | null>(null);\n\n // The last value known to be on the server. Not state: it changes together with\n // `state` inside the save, and a second render for it would be a wasted one.\n const savedRef = useRef<T>(value);\n const draftRef = useRef<T>(value);\n const inFlightRef = useRef(false);\n const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const mountedRef = useRef(true);\n\n // Latest-ref for the callbacks and comparators, so a caller passing inline\n // functions (all of them) does not invalidate the timer on every render.\n const onSaveRef = useRef(onSave);\n const onErrorRef = useRef(onError);\n const equalsRef = useRef(equals);\n useEffect(() => {\n onSaveRef.current = onSave;\n onErrorRef.current = onError;\n equalsRef.current = equals;\n });\n\n useEffect(() => {\n mountedRef.current = true;\n return () => {\n mountedRef.current = false;\n if (timerRef.current) clearTimeout(timerRef.current);\n };\n }, []);\n\n // A named inner function so the settle path can start the next round by calling it\n // again; a `useCallback` cannot refer to its own binding while it is being built.\n const flush = useCallback(() => {\n const run = async (): Promise<void> => {\n if (inFlightRef.current) return; // the settle path below picks the new draft up\n const next = draftRef.current;\n if (equalsRef.current(next, savedRef.current)) return;\n\n inFlightRef.current = true;\n setState(\"pending\");\n setError(null);\n try {\n await onSaveRef.current(next);\n savedRef.current = next;\n inFlightRef.current = false;\n if (!mountedRef.current) return;\n // The draft may have moved while this was in the air. Compare against what is\n // on screen NOW, not against what was sent.\n if (equalsRef.current(draftRef.current, next)) {\n setState(\"synced\");\n } else {\n setState(\"edited\");\n void run();\n }\n } catch (cause) {\n inFlightRef.current = false;\n const err = toError(cause);\n onErrorRef.current?.(err);\n if (!mountedRef.current) return;\n setState(\"error\");\n setError(err);\n }\n };\n return run();\n }, []);\n\n const setValue = useCallback(\n (next: T) => {\n draftRef.current = next;\n setDraft(next);\n if (timerRef.current) clearTimeout(timerRef.current);\n\n if (equalsRef.current(next, savedRef.current)) {\n // Typed back to what the server already has. Nothing to send, and an error\n // from the previous attempt no longer describes anything.\n setState(inFlightRef.current ? \"pending\" : \"synced\");\n setError(null);\n return;\n }\n\n setState(\"edited\");\n setError(null);\n if (debounceMs > 0) timerRef.current = setTimeout(() => void flush(), debounceMs);\n },\n [debounceMs, flush],\n );\n\n const save = useCallback(() => {\n if (timerRef.current) clearTimeout(timerRef.current);\n void flush();\n }, [flush]);\n\n const retry = save;\n\n const reset = useCallback(() => {\n if (timerRef.current) clearTimeout(timerRef.current);\n draftRef.current = savedRef.current;\n setDraft(savedRef.current);\n setState(inFlightRef.current ? \"pending\" : \"synced\");\n setError(null);\n }, []);\n\n // Anything that is not `synced` has something not yet on the server: `edited` and\n // `error` obviously, and `pending` too — the write is in the air, not landed.\n // Derived from state rather than by comparing against `savedRef` during render,\n // because a ref read here would not re-render when the comparison's answer changes.\n const dirty = state !== \"synced\";\n\n // Adopt a new server value ONLY when there is nothing local to lose.\n useEffect(() => {\n if (inFlightRef.current) return;\n if (!equalsRef.current(draftRef.current, savedRef.current)) return; // dirty: keep it\n if (equalsRef.current(value, savedRef.current)) return; // unchanged\n savedRef.current = value;\n draftRef.current = value;\n setDraft(value);\n setState(\"synced\");\n setError(null);\n }, [value]);\n\n return { value: draft, setValue, state, error, dirty, save, retry, reset };\n}\n\nconst STATE_STYLE: Record<FieldSyncState, { color: string; Icon: typeof Check }> = {\n synced: { color: \"var(--status-synced)\", Icon: Check },\n edited: { color: \"var(--status-edited)\", Icon: Pencil },\n pending: { color: \"var(--status-pending)\", Icon: Loader2 },\n error: { color: \"var(--status-error)\", Icon: AlertCircle },\n};\n\nexport interface FieldSyncIndicatorProps {\n state: FieldSyncState;\n /** The failure. Its message becomes the tooltip when `state === \"error\"`. */\n error?: Error | null;\n /** Override any of the five English defaults. */\n labels?: LabelOverride<FieldSyncLabels>;\n /** Render the label beside the icon instead of only in the tooltip. Always on at\n * Large and Extra large (0.32, docs/text-size-harmonization.md §4 \"No fact only in a\n * tooltip\"): the reader who asked for bigger type gets the words, not a 14 px glyph\n * and a bubble. */\n showLabel?: boolean;\n /** When given, the error state renders a retry button after the icon. */\n onRetry?: () => void;\n className?: string;\n}\n\n/**\n * The affordance for {@link useFieldSync}: a coloured icon, and the reason on hover.\n *\n * **Every state has its own icon, and that is not decoration.** The four colours\n * include the green/red pair, which is the one combination this package's palette\n * otherwise refuses (see the note in `tokens.css`) because red-green colour blindness\n * affects roughly one man in twelve. The icon is what carries the meaning for them,\n * and what carries it into a greyscale print or a screenshot. WCAG 1.4.1 is the same\n * requirement stated formally.\n *\n * The state is also announced: the wrapper is a polite live region, so a screen\n * reader hears \"Saving…\" then \"Saved\" without the user going looking. `error` is\n * assertive instead — a failed write is worth interrupting for.\n */\nexport function FieldSyncIndicator({\n state,\n error,\n labels,\n showLabel = false,\n onRetry,\n className,\n}: FieldSyncIndicatorProps) {\n const l = useKitLabels(\"fieldSync\", DEFAULT_FIELD_SYNC_LABELS, labels);\n const large = useLargeText();\n showLabel = showLabel || large;\n const { color, Icon } = STATE_STYLE[state];\n const text =\n state === \"error\" ? (error?.message?.trim() ? error.message : l.error) : l[state];\n\n return (\n <span\n // The ICON carries the status colour; the words are `--text-secondary`, because\n // the amber of `edited` is a graphic colour (3:1), not a text colour (4.5:1).\n className={cn(\"inline-flex items-center gap-1.5 text-xs text-secondary\", className)}\n // Polite for the three ordinary states so save chatter does not interrupt\n // typing; assertive for a failure, which the user has to know about now.\n role={state === \"error\" ? \"alert\" : \"status\"}\n aria-live={state === \"error\" ? \"assertive\" : \"polite\"}\n >\n <Tooltip label={showLabel ? undefined : text} redact={state === \"error\"}>\n <span className=\"inline-flex items-center gap-1.5\">\n <Icon\n aria-hidden\n style={{ color }}\n className={cn(\"size-3.5 shrink-0\", state === \"pending\" && \"animate-spin\")}\n />\n {/* Always in the DOM, so the live region has something to announce even\n when the label is visually a tooltip. */}\n <span className={showLabel ? undefined : \"sr-only\"}>{text}</span>\n </span>\n </Tooltip>\n {state === \"error\" && onRetry && (\n <button\n type=\"button\"\n onClick={onRetry}\n // The kit's focus frame (0.32, §5), stood off the word.\n className={cn(\n \"rounded underline underline-offset-2 hover:no-underline focus-visible:ring-offset-2 focus-visible:ring-offset-surface\",\n FOCUS_RING,\n )}\n style={{ color }}\n >\n {l.retry}\n </button>\n )}\n </span>\n );\n}\n\n/**\n * The field's own frame, coloured by state — applied from the WRAPPER to whatever\n * native control sits inside it, so {@link FieldSyncRow} works with any of the kit's\n * inputs (and a caller's own) without each one growing a `syncState` prop.\n *\n * Border AND a 1px ring, for the reason {@link FIELD_INVALID} gives: a lone 1px\n * border loses a vertical edge to sub-pixel spread at 125% display scaling. The\n * `:focus` twins are what keep the state visible while the reader is typing —\n * the base field class's `focus:` brand colour is `(0,2,0)` and would otherwise win\n * over a descendant selector exactly when the field is in use.\n *\n * `synced` is green, but {@link FieldSyncRow} paints it only for a moment after a\n * save lands — a form of eight permanently green boxes says nothing the absence of\n * colour does not.\n *\n * Every class is a literal so Tailwind's scanner can see it.\n */\nexport const FIELD_SYNC_FRAME: Record<FieldSyncState, string> = {\n synced:\n \"[&_:is(input,select,textarea)]:border-[var(--status-synced)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-synced)] [&_:is(input,select,textarea):focus]:border-[var(--status-synced)] [&_:is(input,select,textarea):focus]:ring-[var(--status-synced)]\",\n edited:\n \"[&_:is(input,select,textarea)]:border-[var(--status-edited)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-edited)] [&_:is(input,select,textarea):focus]:border-[var(--status-edited)] [&_:is(input,select,textarea):focus]:ring-[var(--status-edited)]\",\n pending:\n \"[&_:is(input,select,textarea)]:border-[var(--status-pending)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-pending)] [&_:is(input,select,textarea):focus]:border-[var(--status-pending)] [&_:is(input,select,textarea):focus]:ring-[var(--status-pending)]\",\n error:\n \"[&_:is(input,select,textarea)]:border-[var(--status-error)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-error)] [&_:is(input,select,textarea):focus]:border-[var(--status-error)] [&_:is(input,select,textarea):focus]:ring-[var(--status-error)]\",\n};\n\n/** How long the green \"saved\" frame and check stay after a save lands, in ms. */\nexport const FIELD_SYNC_SAVED_MS = 1500;\n\nexport interface FieldSyncRowProps<T> extends Omit<FieldSyncIndicatorProps, \"state\" | \"error\" | \"showLabel\"> {\n sync: UseFieldSyncReturn<T>;\n children: ReactNode;\n /** Call `sync.save()` when focus leaves the field. Default true — together with the\n * hook's default `debounceMs: 0` this is \"save on blur\": nothing is written while\n * the reader is still typing, however long they pause. */\n saveOnBlur?: boolean;\n /** How long the saved confirmation shows. Default {@link FIELD_SYNC_SAVED_MS}. */\n savedMs?: number;\n}\n\n/**\n * A field wearing its sync state — as its FRAME COLOUR and an ICON AT THE END OF THE\n * FIELD, so the field never changes size (a helper line appearing under it moved\n * everything below it on every save):\n *\n * edited amber frame, pencil — changed, not yet saved\n * pending blue frame, spinning circle — for as long as the save is in flight\n * synced green frame, check — for {@link FIELD_SYNC_SAVED_MS} after a\n * save lands, then an ordinary field again\n * error red frame, alert mark — hover or focus the mark for the reason;\n * click it to retry\n *\n * It saves when focus LEAVES the field (`saveOnBlur`), not after a pause in typing: a\n * database write in the middle of a word is a write nobody asked for. Enter in a\n * single-line field is the caller's to wire to `sync.save`.\n *\n * Colour is never the only signal: every state has its own icon, and the state text is\n * always in the DOM as a live region — polite for the ordinary states, assertive for\n * a failure. The input gets end padding for the icon whether or not one is showing,\n * so the text never reflows when the state changes.\n */\nexport function FieldSyncRow<T>({\n sync,\n children,\n className,\n labels,\n onRetry,\n saveOnBlur = true,\n savedMs = FIELD_SYNC_SAVED_MS,\n}: FieldSyncRowProps<T>) {\n const l = useKitLabels(\"fieldSync\", DEFAULT_FIELD_SYNC_LABELS, labels);\n const { state, error } = sync;\n const isError = state === \"error\";\n const text = isError ? (error?.message?.trim() ? error.message : l.error) : l[state];\n const retry = onRetry ?? sync.retry;\n\n // The saved confirmation: on only for the moment after a save lands. Keyed on the\n // TRANSITION pending → synced, so a field that starts synced shows nothing. The\n // transition is seen while rendering, against the previous state; the effect only\n // owns the timer that turns it off again.\n const [previous, setPrevious] = useState(state);\n const [justSaved, setJustSaved] = useState(false);\n if (state !== previous) {\n setPrevious(state);\n setJustSaved(state === \"synced\" && previous === \"pending\");\n }\n useEffect(() => {\n if (!justSaved) return;\n const t = setTimeout(() => setJustSaved(false), savedMs);\n return () => clearTimeout(t);\n }, [justSaved, savedMs]);\n const shown: FieldSyncState | null = state === \"synced\" ? (justSaved ? \"synced\" : null) : state;\n\n return (\n /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- not an interaction:\n `onBlur` listens for focus leaving the row's own controls (which are the\n interactive elements) to save; the wrapper itself is never focused. */\n <div\n data-field-sync={state}\n className={cn(\n // `relative` for the icon and for the sr-only live region's containing block.\n \"relative [&_:is(input,select,textarea)]:pe-9\",\n shown && FIELD_SYNC_FRAME[shown],\n className,\n )}\n onBlur={\n saveOnBlur\n ? (e) => {\n // Focus moving between two controls INSIDE the row is not leaving it.\n if (!e.currentTarget.contains(e.relatedTarget as Node | null)) sync.save();\n }\n : undefined\n }\n >\n {children}\n <span className=\"pointer-events-none absolute inset-y-0 end-0 flex items-center pe-3\">\n {state === \"edited\" && (\n <PencilLine aria-hidden className=\"size-4 text-[var(--status-edited)]\" />\n )}\n {state === \"pending\" && (\n <Loader2 aria-hidden className=\"size-4 animate-spin text-[var(--status-pending)] motion-reduce:animate-none\" />\n )}\n {state === \"synced\" && (\n <Check\n aria-hidden\n className={cn(\n \"size-4 text-[var(--status-synced)] transition-opacity duration-500 motion-reduce:transition-none\",\n justSaved ? \"opacity-100\" : \"opacity-0\",\n )}\n />\n )}\n {isError && (\n <Tooltip label={text} portal redact>\n <button\n type=\"button\"\n onClick={retry}\n aria-label={l.retry}\n className={cn(\n \"pointer-events-auto flex rounded-full text-[var(--status-error)] focus-visible:ring-offset-2 focus-visible:ring-offset-surface\",\n FOCUS_RING,\n )}\n >\n <AlertCircle aria-hidden className=\"size-4\" />\n </button>\n </Tooltip>\n )}\n </span>\n <span\n role={isError ? \"alert\" : \"status\"}\n aria-live={isError ? \"assertive\" : \"polite\"}\n className=\"sr-only\"\n >\n {shown === null ? \"\" : text}\n </span>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/field-sync.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { AlertCircle, Check, Loader2, Pencil, PencilLine } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Tooltip } from \"./tooltip\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { useLargeText } from \"../hooks/use-large-text\";\nimport { DEFAULT_FIELD_SYNC_LABELS } from \"./field-sync-labels\";\n\n/**\n * Sync state for a field backed by a database row.\n *\n * The four states a value can be in between the keyboard and the server:\n *\n * synced the draft equals what was last persisted\n * edited the draft differs and nothing has been sent yet\n * pending a save is in flight\n * error the last save was rejected; `error` carries why\n *\n * `useFieldSync` is the engine and holds no opinion about markup;\n * `FieldSyncIndicator` is the affordance and holds no state. They are separate\n * because the state is useful without the dot — a form can disable its submit\n * button while any field is `pending`, and a router can block navigation while\n * any field is `edited`.\n */\nexport type FieldSyncState = \"synced\" | \"edited\" | \"pending\" | \"error\";\n\nexport interface FieldSyncLabels {\n synced: string;\n edited: string;\n pending: string;\n /** Shown when the failure carries no message of its own. */\n error: string;\n retry: string;\n}\n\n// Defined in ./field-sync-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_FIELD_SYNC_LABELS };\n\nexport interface UseFieldSyncOptions<T> {\n /** What the database currently holds. Changing it re-seeds a CLEAN field. */\n value: T;\n /** Persist `next`. Reject — or throw — to put the field in `error`. */\n onSave: (next: T) => Promise<void> | void;\n /**\n * Quiet period before an edit saves itself, in ms. Default `0`: no auto-save at\n * all, so `save()` is the only way to persist — called on blur by `FieldSyncRow`,\n * and on Enter by the caller. Saving after a pause in typing wrote half-finished\n * values to the database whenever somebody stopped to think; pass a positive\n * number only for a field where that is genuinely wanted.\n */\n debounceMs?: number;\n /** How to tell two values apart. Defaults to `Object.is`. */\n equals?: (a: T, b: T) => boolean;\n /** Called once per rejected save, with whatever `onSave` threw. */\n onError?: (error: Error) => void;\n}\n\nexport interface UseFieldSyncReturn<T> {\n /** The draft — bind this to the input, not the `value` option. */\n value: T;\n setValue: (next: T) => void;\n state: FieldSyncState;\n /** Non-null exactly when `state === \"error\"`. */\n error: Error | null;\n /** True whenever the draft differs from the last persisted value. */\n dirty: boolean;\n /** Persist now, cancelling any pending debounce. A no-op on a clean field. */\n save: () => void;\n /** Send the current draft again after a failure. */\n retry: () => void;\n /** Throw the draft away and go back to the last persisted value. */\n reset: () => void;\n}\n\nfunction toError(cause: unknown): Error {\n if (cause instanceof Error) return cause;\n // An EMPTY message for a rejection that carried none, not an English one: the\n // indicator shows `error.message` when there is one and the translated\n // `fieldSync.error` otherwise, so a fallback message here was an English sentence\n // that outranked the app's own translation. The original value stays on `cause`\n // for whoever logs it from `onError`.\n return new Error(typeof cause === \"string\" ? cause : \"\", { cause });\n}\n\n/**\n * Track one database-backed field: a local draft, a debounced save, and the state\n * of the round trip.\n *\n * Four things this handles that a `useState` + `useEffect` in a page does not:\n *\n * 1. **An edit during a save is never lost.** A save in flight is not cancelled\n * and not raced — when it settles, the draft is compared again and sent once\n * more if it moved. Cancelling instead would drop a keystroke that arrived\n * mid-flight; firing immediately would let two writes land out of order.\n * 2. **The server value is only adopted when the field is clean.** An external\n * refresh that overwrote what somebody was typing would be a data-loss bug, so\n * a dirty draft wins and keeps winning until it is saved or reset.\n * 3. **Nothing is set after unmount.** The save resolves into a component that\n * may be gone — a row scrolled out of a virtualised table, a closed dialog.\n * 4. **`onSave` is read through a ref**, so an inline arrow — which every call\n * site will pass — does not restart the debounce on every render.\n */\nexport function useFieldSync<T>({\n value,\n onSave,\n debounceMs = 0,\n equals = Object.is,\n onError,\n}: UseFieldSyncOptions<T>): UseFieldSyncReturn<T> {\n const [draft, setDraft] = useState<T>(value);\n const [state, setState] = useState<FieldSyncState>(\"synced\");\n const [error, setError] = useState<Error | null>(null);\n\n // The last value known to be on the server. Not state: it changes together with\n // `state` inside the save, and a second render for it would be a wasted one.\n const savedRef = useRef<T>(value);\n const draftRef = useRef<T>(value);\n const inFlightRef = useRef(false);\n const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const mountedRef = useRef(true);\n\n // Latest-ref for the callbacks and comparators, so a caller passing inline\n // functions (all of them) does not invalidate the timer on every render.\n const onSaveRef = useRef(onSave);\n const onErrorRef = useRef(onError);\n const equalsRef = useRef(equals);\n useEffect(() => {\n onSaveRef.current = onSave;\n onErrorRef.current = onError;\n equalsRef.current = equals;\n });\n\n useEffect(() => {\n mountedRef.current = true;\n return () => {\n mountedRef.current = false;\n if (timerRef.current) clearTimeout(timerRef.current);\n };\n }, []);\n\n // A named inner function so the settle path can start the next round by calling it\n // again; a `useCallback` cannot refer to its own binding while it is being built.\n const flush = useCallback(() => {\n const run = async (): Promise<void> => {\n if (inFlightRef.current) return; // the settle path below picks the new draft up\n const next = draftRef.current;\n if (equalsRef.current(next, savedRef.current)) return;\n\n inFlightRef.current = true;\n setState(\"pending\");\n setError(null);\n try {\n await onSaveRef.current(next);\n savedRef.current = next;\n inFlightRef.current = false;\n if (!mountedRef.current) return;\n // The draft may have moved while this was in the air. Compare against what is\n // on screen NOW, not against what was sent.\n if (equalsRef.current(draftRef.current, next)) {\n setState(\"synced\");\n } else {\n setState(\"edited\");\n void run();\n }\n } catch (cause) {\n inFlightRef.current = false;\n const err = toError(cause);\n onErrorRef.current?.(err);\n if (!mountedRef.current) return;\n setState(\"error\");\n setError(err);\n }\n };\n return run();\n }, []);\n\n const setValue = useCallback(\n (next: T) => {\n draftRef.current = next;\n setDraft(next);\n if (timerRef.current) clearTimeout(timerRef.current);\n\n if (equalsRef.current(next, savedRef.current)) {\n // Typed back to what the server already has. Nothing to send, and an error\n // from the previous attempt no longer describes anything.\n setState(inFlightRef.current ? \"pending\" : \"synced\");\n setError(null);\n return;\n }\n\n setState(\"edited\");\n setError(null);\n if (debounceMs > 0) timerRef.current = setTimeout(() => void flush(), debounceMs);\n },\n [debounceMs, flush],\n );\n\n const save = useCallback(() => {\n if (timerRef.current) clearTimeout(timerRef.current);\n void flush();\n }, [flush]);\n\n const retry = save;\n\n const reset = useCallback(() => {\n if (timerRef.current) clearTimeout(timerRef.current);\n draftRef.current = savedRef.current;\n setDraft(savedRef.current);\n setState(inFlightRef.current ? \"pending\" : \"synced\");\n setError(null);\n }, []);\n\n // Anything that is not `synced` has something not yet on the server: `edited` and\n // `error` obviously, and `pending` too — the write is in the air, not landed.\n // Derived from state rather than by comparing against `savedRef` during render,\n // because a ref read here would not re-render when the comparison's answer changes.\n const dirty = state !== \"synced\";\n\n // Adopt a new server value ONLY when there is nothing local to lose.\n useEffect(() => {\n if (inFlightRef.current) return;\n if (!equalsRef.current(draftRef.current, savedRef.current)) return; // dirty: keep it\n if (equalsRef.current(value, savedRef.current)) return; // unchanged\n savedRef.current = value;\n draftRef.current = value;\n setDraft(value);\n setState(\"synced\");\n setError(null);\n }, [value]);\n\n return { value: draft, setValue, state, error, dirty, save, retry, reset };\n}\n\nconst STATE_STYLE: Record<FieldSyncState, { color: string; Icon: typeof Check }> = {\n synced: { color: \"var(--status-synced)\", Icon: Check },\n edited: { color: \"var(--status-edited)\", Icon: Pencil },\n pending: { color: \"var(--status-pending)\", Icon: Loader2 },\n error: { color: \"var(--status-error)\", Icon: AlertCircle },\n};\n\nexport interface FieldSyncIndicatorProps {\n state: FieldSyncState;\n /** The failure. Its message becomes the tooltip when `state === \"error\"`. */\n error?: Error | null;\n /** Override any of the five English defaults. */\n labels?: LabelOverride<FieldSyncLabels>;\n /** Render the label beside the icon instead of only in the tooltip. Always on at\n * Large and Extra large (0.32, docs/text-size-harmonization.md §4 \"No fact only in a\n * tooltip\"): the reader who asked for bigger type gets the words, not a 14 px glyph\n * and a bubble. */\n showLabel?: boolean;\n /** When given, the error state renders a retry button after the icon. */\n onRetry?: () => void;\n className?: string;\n}\n\n/**\n * The affordance for {@link useFieldSync}: a coloured icon, and the reason on hover.\n *\n * **Every state has its own icon, and that is not decoration.** The four colours\n * include the green/red pair, which is the one combination this package's palette\n * otherwise refuses (see the note in `tokens.css`) because red-green colour blindness\n * affects roughly one man in twelve. The icon is what carries the meaning for them,\n * and what carries it into a greyscale print or a screenshot. WCAG 1.4.1 is the same\n * requirement stated formally.\n *\n * The state is also announced: the wrapper is a polite live region, so a screen\n * reader hears \"Saving…\" then \"Saved\" without the user going looking. `error` is\n * assertive instead — a failed write is worth interrupting for.\n */\nexport function FieldSyncIndicator({\n state,\n error,\n labels,\n showLabel = false,\n onRetry,\n className,\n}: FieldSyncIndicatorProps) {\n const l = useKitLabels(\"fieldSync\", DEFAULT_FIELD_SYNC_LABELS, labels);\n const large = useLargeText();\n showLabel = showLabel || large;\n const { color, Icon } = STATE_STYLE[state];\n const text =\n state === \"error\" ? (error?.message?.trim() ? error.message : l.error) : l[state];\n\n return (\n <span\n // The ICON carries the status colour; the words are `--text-secondary`, because\n // the amber of `edited` is a graphic colour (3:1), not a text colour (4.5:1).\n className={cn(\"inline-flex items-center gap-1.5 text-xs text-secondary\", className)}\n // Polite for the three ordinary states so save chatter does not interrupt\n // typing; assertive for a failure, which the user has to know about now.\n role={state === \"error\" ? \"alert\" : \"status\"}\n aria-live={state === \"error\" ? \"assertive\" : \"polite\"}\n >\n <Tooltip label={showLabel ? undefined : text} redact={state === \"error\"}>\n <span className=\"inline-flex items-center gap-1.5\">\n <Icon\n aria-hidden\n style={{ color }}\n className={cn(\"size-3.5 shrink-0\", state === \"pending\" && \"animate-spin\")}\n />\n {/* Always in the DOM, so the live region has something to announce even\n when the label is visually a tooltip. */}\n <span className={showLabel ? undefined : \"sr-only\"}>{text}</span>\n </span>\n </Tooltip>\n {state === \"error\" && onRetry && (\n <button\n type=\"button\"\n onClick={onRetry}\n // The kit's focus frame (0.32, §5), stood off the word.\n className={cn(\n \"rounded underline underline-offset-2 hover:no-underline focus-visible:ring-offset-2 focus-visible:ring-offset-surface\",\n FOCUS_RING,\n )}\n style={{ color }}\n >\n {l.retry}\n </button>\n )}\n </span>\n );\n}\n\n/**\n * The field's own frame, coloured by state — applied from the WRAPPER to whatever\n * native control sits inside it, so {@link FieldSyncRow} works with any of the kit's\n * inputs (and a caller's own) without each one growing a `syncState` prop.\n *\n * Border AND a 1px ring, for the reason {@link FIELD_INVALID} gives: a lone 1px\n * border loses a vertical edge to sub-pixel spread at 125% display scaling. The\n * `:focus` twins are what keep the state visible while the reader is typing —\n * the base field class's `focus:` brand colour is `(0,2,0)` and would otherwise win\n * over a descendant selector exactly when the field is in use.\n *\n * `synced` is green, but {@link FieldSyncRow} paints it only for a moment after a\n * save lands — a form of eight permanently green boxes says nothing the absence of\n * colour does not.\n *\n * Every class is a literal so Tailwind's scanner can see it.\n */\nexport const FIELD_SYNC_FRAME: Record<FieldSyncState, string> = {\n synced:\n \"[&_:is(input,select,textarea)]:border-[var(--status-synced)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-synced)] [&_:is(input,select,textarea):focus]:border-[var(--status-synced)] [&_:is(input,select,textarea):focus]:ring-[var(--status-synced)]\",\n edited:\n \"[&_:is(input,select,textarea)]:border-[var(--status-edited)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-edited)] [&_:is(input,select,textarea):focus]:border-[var(--status-edited)] [&_:is(input,select,textarea):focus]:ring-[var(--status-edited)]\",\n pending:\n \"[&_:is(input,select,textarea)]:border-[var(--status-pending)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-pending)] [&_:is(input,select,textarea):focus]:border-[var(--status-pending)] [&_:is(input,select,textarea):focus]:ring-[var(--status-pending)]\",\n error:\n \"[&_:is(input,select,textarea)]:border-[var(--status-error)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-error)] [&_:is(input,select,textarea):focus]:border-[var(--status-error)] [&_:is(input,select,textarea):focus]:ring-[var(--status-error)]\",\n};\n\n/** How long the green \"saved\" frame and check stay after a save lands, in ms. */\nexport const FIELD_SYNC_SAVED_MS = 1500;\n\nexport interface FieldSyncRowProps<T> extends Omit<FieldSyncIndicatorProps, \"state\" | \"error\" | \"showLabel\"> {\n sync: UseFieldSyncReturn<T>;\n children: ReactNode;\n /** Call `sync.save()` when focus leaves the field. Default true — together with the\n * hook's default `debounceMs: 0` this is \"save on blur\": nothing is written while\n * the reader is still typing, however long they pause. */\n saveOnBlur?: boolean;\n /** How long the saved confirmation shows. Default {@link FIELD_SYNC_SAVED_MS}. */\n savedMs?: number;\n}\n\n/**\n * A field wearing its sync state — as its FRAME COLOUR and an ICON AT THE END OF THE\n * FIELD, so the field never changes size (a helper line appearing under it moved\n * everything below it on every save):\n *\n * edited amber frame, pencil — changed, not yet saved\n * pending blue frame, spinning circle — for as long as the save is in flight\n * synced green frame, check — for {@link FIELD_SYNC_SAVED_MS} after a\n * save lands, then an ordinary field again\n * error red frame, alert mark — hover or focus the mark for the reason;\n * click it to retry\n *\n * It saves when focus LEAVES the field (`saveOnBlur`), not after a pause in typing: a\n * database write in the middle of a word is a write nobody asked for. Enter in a\n * single-line field is the caller's to wire to `sync.save`.\n *\n * Colour is never the only signal: every state has its own icon, and the state text is\n * always in the DOM as a live region — polite for the ordinary states, assertive for\n * a failure. The input gets end padding for the icon whether or not one is showing,\n * so the text never reflows when the state changes.\n */\nexport function FieldSyncRow<T>({\n sync,\n children,\n className,\n labels,\n onRetry,\n saveOnBlur = true,\n savedMs = FIELD_SYNC_SAVED_MS,\n}: FieldSyncRowProps<T>) {\n const l = useKitLabels(\"fieldSync\", DEFAULT_FIELD_SYNC_LABELS, labels);\n const { state, error } = sync;\n const isError = state === \"error\";\n const text = isError ? (error?.message?.trim() ? error.message : l.error) : l[state];\n const retry = onRetry ?? sync.retry;\n\n // The saved confirmation: on only for the moment after a save lands. Keyed on the\n // TRANSITION pending → synced, so a field that starts synced shows nothing. The\n // transition is seen while rendering, against the previous state; the effect only\n // owns the timer that turns it off again.\n const [previous, setPrevious] = useState(state);\n const [justSaved, setJustSaved] = useState(false);\n if (state !== previous) {\n setPrevious(state);\n setJustSaved(state === \"synced\" && previous === \"pending\");\n }\n useEffect(() => {\n if (!justSaved) return;\n const t = setTimeout(() => setJustSaved(false), savedMs);\n return () => clearTimeout(t);\n }, [justSaved, savedMs]);\n const shown: FieldSyncState | null = state === \"synced\" ? (justSaved ? \"synced\" : null) : state;\n\n return (\n /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- not an interaction:\n `onBlur` listens for focus leaving the row's own controls (which are the\n interactive elements) to save; the wrapper itself is never focused. */\n <div\n data-field-sync={state}\n className={cn(\n // `relative` for the icon and for the sr-only live region's containing block.\n \"relative [&_:is(input,select,textarea)]:pe-9\",\n shown && FIELD_SYNC_FRAME[shown],\n className,\n )}\n onBlur={\n saveOnBlur\n ? (e) => {\n // Focus moving between two controls INSIDE the row is not leaving it.\n if (!e.currentTarget.contains(e.relatedTarget as Node | null)) sync.save();\n }\n : undefined\n }\n >\n {children}\n <span className=\"pointer-events-none absolute inset-y-0 end-0 flex items-center pe-3\">\n {state === \"edited\" && (\n <PencilLine aria-hidden className=\"size-4 text-[var(--status-edited)]\" />\n )}\n {state === \"pending\" && (\n <Loader2 aria-hidden className=\"size-4 animate-spin text-[var(--status-pending)] motion-reduce:animate-none\" />\n )}\n {state === \"synced\" && (\n <Check\n aria-hidden\n className={cn(\n \"size-4 text-[var(--status-synced)] transition-opacity duration-500 motion-reduce:transition-none\",\n justSaved ? \"opacity-100\" : \"opacity-0\",\n )}\n />\n )}\n {isError && (\n <Tooltip label={text} portal redact>\n <button\n type=\"button\"\n onClick={retry}\n aria-label={l.retry}\n className={cn(\n \"pointer-events-auto flex rounded-full text-[var(--status-error)] focus-visible:ring-offset-2 focus-visible:ring-offset-surface\",\n FOCUS_RING,\n )}\n >\n <AlertCircle aria-hidden className=\"size-4\" />\n </button>\n </Tooltip>\n )}\n </span>\n <span\n role={isError ? \"alert\" : \"status\"}\n aria-live={isError ? \"assertive\" : \"polite\"}\n className=\"sr-only\"\n >\n {shown === null ? \"\" : text}\n </span>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -29,15 +29,16 @@
29
29
  *
30
30
  * Deliberately knows neither the wizard nor react-hook-form: `error` is a plain
31
31
  * value. Pass `wizard.fieldErrors.iban`, a step-local error, or
32
- * `fieldState.error?.message` from a `FormField` render — the markup is the same
33
- * whichever of them gates the step, so a step can move between them without its
34
- * fields changing. (Inside a react-hook-form form, `FormItem` + `FormLabel` from
35
- * `@eifi1/ui-kit/rhf` do the same job with the ids taken from the form.)
32
+ * `fieldState.error?.message` from a react-hook-form `Controller` — the markup is the
33
+ * same whichever of them gates the step, so a step can move between them without its
34
+ * fields changing. (Inside a react-hook-form form, `RhfField` from `@eifi1/ui-kit/rhf`
35
+ * does the same job with the ids taken from the form.)
36
36
  *
37
37
  * The error is NOT `role="alert"`, for the reason the kit's `error` prop gives (see
38
38
  * `useFieldError` in components/field-parts.tsx): it is read when focus reaches the control.
39
39
  */
40
40
  import { type ReactNode } from "react";
41
+ import { type FieldHelpProps } from "./field-parts.js";
41
42
  /** What a `Field` hands its render-prop children, to spread on the control. */
42
43
  export interface FieldControlProps {
43
44
  /** The id the label's `htmlFor` points at. */
@@ -74,7 +75,7 @@ export interface FieldRenderMeta {
74
75
  * on the control at every width; only the ink changes.
75
76
  */
76
77
  export type FieldLabelVisibility = "visible" | "sr-only" | "below-sm" | "below-md" | "below-lg";
77
- export interface FieldProps {
78
+ export interface FieldProps extends FieldHelpProps {
78
79
  /** The label above the control. Omit it for a group whose control is labelled
79
80
  * some other way; the hint and error still render. */
80
81
  label?: ReactNode;
@@ -101,4 +102,4 @@ export interface FieldProps {
101
102
  * {@link FieldRenderMeta} (the label's id, for a group). */
102
103
  children: ReactNode | ((control: FieldControlProps, meta: FieldRenderMeta) => ReactNode);
103
104
  }
104
- export declare function Field({ label, required, hint: hintProp, error, htmlFor, disabled, labelSize, labelVisibility, className, children, }: FieldProps): import("react").JSX.Element;
105
+ export declare function Field({ label, required, hint: hintProp, help, error, htmlFor, disabled, labelSize, labelVisibility, className, children, }: FieldProps): import("react").JSX.Element;
@@ -3,7 +3,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useId } from "react";
4
4
  import { cn } from "../lib/cn.js";
5
5
  import { hasContent } from "../lib/presence.js";
6
- import { useInlineHint } from "./field-parts.js";
6
+ import { fieldHelpMark, useInlineHint } from "./field-parts.js";
7
7
  import { Label } from "./field-base.js";
8
8
  const LABEL_VISIBILITY = {
9
9
  visible: void 0,
@@ -16,6 +16,7 @@ function Field({
16
16
  label,
17
17
  required,
18
18
  hint: hintProp,
19
+ help,
19
20
  error,
20
21
  htmlFor,
21
22
  disabled,
@@ -29,7 +30,8 @@ function Field({
29
30
  const hintId = `${generated}-hint`;
30
31
  const errorId = `${generated}-error`;
31
32
  const labelId = `${generated}-label`;
32
- const hint = useInlineHint(hintProp);
33
+ const helpMark = fieldHelpMark(help, label);
34
+ const hint = useInlineHint(hintProp, helpMark !== void 0);
33
35
  const hasHint = hasContent(hint);
34
36
  const hasError = hasContent(error);
35
37
  const describedBy = [hasHint && hintId, hasError && errorId].filter(Boolean).join(" ");
@@ -39,6 +41,19 @@ function Field({
39
41
  "aria-invalid": hasError || void 0,
40
42
  "aria-required": required || void 0
41
43
  };
44
+ const labelEl = label !== void 0 && /* @__PURE__ */ jsx(
45
+ Label,
46
+ {
47
+ id: labelId,
48
+ htmlFor: id,
49
+ required,
50
+ disabled,
51
+ size: labelSize,
52
+ "data-error": hasError || void 0,
53
+ className: cn("data-[error=true]:text-danger", LABEL_VISIBILITY[labelVisibility]),
54
+ children: label
55
+ }
56
+ );
42
57
  return /* @__PURE__ */ jsxs(
43
58
  "div",
44
59
  {
@@ -54,19 +69,13 @@ function Field({
54
69
  className
55
70
  ),
56
71
  children: [
57
- label !== void 0 && /* @__PURE__ */ jsx(
58
- Label,
59
- {
60
- id: labelId,
61
- htmlFor: id,
62
- required,
63
- disabled,
64
- size: labelSize,
65
- "data-error": hasError || void 0,
66
- className: cn("data-[error=true]:text-danger", LABEL_VISIBILITY[labelVisibility]),
67
- children: label
68
- }
69
- ),
72
+ helpMark === void 0 && labelEl,
73
+ helpMark !== void 0 && // The label and its "?" on one line; the "?" stays visible where the label is
74
+ // hidden (`labelVisibility`) — a hidden button would still take the focus.
75
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
76
+ labelEl,
77
+ /* @__PURE__ */ jsx("span", { className: "flex shrink-0 items-center", children: helpMark })
78
+ ] }),
70
79
  typeof children === "function" ? children(control, { labelId: label !== void 0 ? labelId : void 0 }) : children,
71
80
  hasHint && /* @__PURE__ */ jsx("p", { id: hintId, className: "text-caption leading-tight text-muted", children: hint }),
72
81
  hasError && /* @__PURE__ */ jsx("p", { id: errorId, className: "text-caption leading-tight text-danger", children: error })
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/field.tsx"],"sourcesContent":["/**\n * A field group with its label ABOVE the control: label, control, hint, error.\n *\n * ```tsx\n * <Field label=\"IBAN\" required hint=\"22 characters\" error={errors.iban}>\n * {(ids) => <Input {...ids} value={iban} onChange={…} />}\n * </Field>\n * ```\n *\n * The kit's `Input` and `Select` float their label inside the field, and the pickers\n * draw theirs the same way. That is the wrong shape for a control the kit did not\n * render, a radio group, a range slider, or a form that sets its labels above the\n * fields throughout — which is what kastlan's wizards are, and why it kept its own\n * `WizardField` after the kit dropped the one it had never rendered. This is that\n * group, with the wiring the old one left to the caller: the label names the control\n * through `htmlFor`, and the control is described by the hint and the error that are\n * actually on screen.\n *\n * ## Wiring\n *\n * Pass a function as `children` and it receives {@link FieldControlProps} — `id`,\n * `aria-describedby`, `aria-invalid` and `aria-required` — to spread on the control.\n * Plain children render as they are, for a control that wires itself (a checkbox that\n * carries its own label, a group with `aria-labelledby`); `htmlFor` then points the\n * label at the control's own id, and the hint and error are on screen but describe\n * nothing — the same as the `WizardField` this replaces.\n *\n * ## Decoupled\n *\n * Deliberately knows neither the wizard nor react-hook-form: `error` is a plain\n * value. Pass `wizard.fieldErrors.iban`, a step-local error, or\n * `fieldState.error?.message` from a `FormField` render — the markup is the same\n * whichever of them gates the step, so a step can move between them without its\n * fields changing. (Inside a react-hook-form form, `FormItem` + `FormLabel` from\n * `@eifi1/ui-kit/rhf` do the same job with the ids taken from the form.)\n *\n * The error is NOT `role=\"alert\"`, for the reason the kit's `error` prop gives (see\n * `useFieldError` in components/field-parts.tsx): it is read when focus reaches the control.\n */\nimport { useId, type ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { hasContent } from \"../lib/presence\";\nimport { useInlineHint } from \"./field-parts\";\nimport { Label } from \"./field-base\";\n\n/** What a `Field` hands its render-prop children, to spread on the control. */\nexport interface FieldControlProps {\n /** The id the label's `htmlFor` points at. */\n id: string;\n /** The ids of the hint and the error that are rendered, or `undefined` when\n * neither is. */\n \"aria-describedby\": string | undefined;\n /** `true` while the field has an error, `undefined` otherwise. */\n \"aria-invalid\": true | undefined;\n /** `true` when the field is `required`. The label's star is `aria-hidden`; this\n * is what a screen reader hears instead. */\n \"aria-required\": true | undefined;\n}\n\n/**\n * The second argument of a `Field`'s render-prop children: what a GROUP needs that\n * a single control does not. `htmlFor` names an `<input>`, but a `<label>` pointing\n * at a `role=\"radiogroup\"` div names nothing — so a group (a {@link ToggleGroup})\n * takes `aria-labelledby={labelId}` instead. Kept out of {@link FieldControlProps}\n * so the spread every existing control takes is unchanged.\n */\nexport interface FieldRenderMeta {\n /** The label element's id, or `undefined` when the field has no label. */\n labelId: string | undefined;\n}\n\n/**\n * Where the label is visible. `\"visible\"` (default); `\"sr-only\"`: never drawn, still\n * the control's name; `\"below-sm\"` / `\"below-md\"` / `\"below-lg\"`: drawn only under\n * that breakpoint and screen-reader-only from it up.\n *\n * For table-like editors — kastlan's journal entry lines\n * (accounting/components/wizard/journal-entry-lines-step.tsx) — where a desktop\n * row sits under column headers that already say \"Account\" and \"Amount\", but the\n * phone layout stacks each line as a card and needs the labels back. The name stays\n * on the control at every width; only the ink changes.\n */\nexport type FieldLabelVisibility = \"visible\" | \"sr-only\" | \"below-sm\" | \"below-md\" | \"below-lg\";\n\n// Literal class strings so Tailwind's scanner sees each one.\nconst LABEL_VISIBILITY: Record<FieldLabelVisibility, string | undefined> = {\n visible: undefined,\n \"sr-only\": \"sr-only\",\n \"below-sm\": \"sm:sr-only\",\n \"below-md\": \"md:sr-only\",\n \"below-lg\": \"lg:sr-only\",\n};\n\nexport interface FieldProps {\n /** The label above the control. Omit it for a group whose control is labelled\n * some other way; the hint and error still render. */\n label?: ReactNode;\n /** Draws the kit's required mark after the label, and sets `aria-required` on the\n * render-prop control. */\n required?: boolean;\n /** Muted helper text below the control. */\n hint?: ReactNode;\n /** The error below the control. `null`, `false` and `\"\"` are no error — what a\n * `touched && errors.x` evaluates to on the happy path. */\n error?: ReactNode;\n /** The control's id. Defaults to a generated one; set it when the control carries\n * its own id (plain children). */\n htmlFor?: string;\n /** Dims the label with its control. */\n disabled?: boolean;\n /** The label's size: `sm` for a dense row, `md` otherwise. */\n labelSize?: \"sm\" | \"md\";\n /** See {@link FieldLabelVisibility}. */\n labelVisibility?: FieldLabelVisibility;\n /** Wrapper className. */\n className?: string;\n /** The control. A function receives {@link FieldControlProps} to spread on it, and\n * {@link FieldRenderMeta} (the label's id, for a group). */\n children: ReactNode | ((control: FieldControlProps, meta: FieldRenderMeta) => ReactNode);\n}\n\nexport function Field({\n label,\n required,\n hint: hintProp,\n error,\n htmlFor,\n disabled,\n labelSize,\n labelVisibility = \"visible\",\n className,\n children,\n}: FieldProps) {\n const generated = useId();\n const id = htmlFor ?? `${generated}-control`;\n const hintId = `${generated}-hint`;\n const errorId = `${generated}-error`;\n const labelId = `${generated}-label`;\n // §4: a FieldHint under the control is its words at Large and on a touch screen, not\n // a lone \"?\" (DUP-2, refactor audit 2026-10). A HelpPopover stays a \"?\".\n const hint = useInlineHint(hintProp);\n const hasHint = hasContent(hint);\n const hasError = hasContent(error);\n const describedBy = [hasHint && hintId, hasError && errorId].filter(Boolean).join(\" \");\n const control: FieldControlProps = {\n id,\n \"aria-describedby\": describedBy || undefined,\n \"aria-invalid\": hasError || undefined,\n \"aria-required\": required || undefined,\n };\n return (\n <div\n data-slot=\"field\"\n className={cn(\n // `content-start`: in a grid row next to a taller item (one with a hint), a\n // stretched Field would hand the extra height to its rows — the label grew,\n // and the control sat lower than its neighbour's (showcase audit, #/forms).\n \"grid content-start gap-1.5\",\n // A hidden label is `position: absolute`; `relative` keeps its containing\n // block here rather than the page's (see sr-only-containment.test).\n labelVisibility !== \"visible\" && \"relative\",\n className,\n )}\n >\n {label !== undefined && (\n <Label\n id={labelId}\n htmlFor={id}\n required={required}\n disabled={disabled}\n size={labelSize}\n data-error={hasError || undefined}\n className={cn(\"data-[error=true]:text-danger\", LABEL_VISIBILITY[labelVisibility])}\n >\n {label}\n </Label>\n )}\n {typeof children === \"function\"\n ? children(control, { labelId: label !== undefined ? labelId : undefined })\n : children}\n {hasHint && (\n <p id={hintId} className=\"text-caption leading-tight text-muted\">\n {hint}\n </p>\n )}\n {hasError && (\n <p id={errorId} className=\"text-caption leading-tight text-danger\">\n {error}\n </p>\n )}\n </div>\n );\n}\n"],"mappings":";AAuJI,SAcI,KAdJ;AAhHJ,SAAS,aAA6B;AACtC,SAAS,UAAU;AACnB,SAAS,kBAAkB;AAC3B,SAAS,qBAAqB;AAC9B,SAAS,aAAa;AA0CtB,MAAM,mBAAqE;AAAA,EACzE,SAAS;AAAA,EACT,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,YAAY;AACd;AA8BO,SAAS,MAAM;AAAA,EACpB;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB;AAAA,EACA;AACF,GAAe;AACb,QAAM,YAAY,MAAM;AACxB,QAAM,KAAK,WAAW,GAAG,SAAS;AAClC,QAAM,SAAS,GAAG,SAAS;AAC3B,QAAM,UAAU,GAAG,SAAS;AAC5B,QAAM,UAAU,GAAG,SAAS;AAG5B,QAAM,OAAO,cAAc,QAAQ;AACnC,QAAM,UAAU,WAAW,IAAI;AAC/B,QAAM,WAAW,WAAW,KAAK;AACjC,QAAM,cAAc,CAAC,WAAW,QAAQ,YAAY,OAAO,EAAE,OAAO,OAAO,EAAE,KAAK,GAAG;AACrF,QAAM,UAA6B;AAAA,IACjC;AAAA,IACA,oBAAoB,eAAe;AAAA,IACnC,gBAAgB,YAAY;AAAA,IAC5B,iBAAiB,YAAY;AAAA,EAC/B;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,WAAW;AAAA;AAAA;AAAA;AAAA,QAIT;AAAA;AAAA;AAAA,QAGA,oBAAoB,aAAa;AAAA,QACjC;AAAA,MACF;AAAA,MAEC;AAAA,kBAAU,UACT;AAAA,UAAC;AAAA;AAAA,YACC,IAAI;AAAA,YACJ,SAAS;AAAA,YACT;AAAA,YACA;AAAA,YACA,MAAM;AAAA,YACN,cAAY,YAAY;AAAA,YACxB,WAAW,GAAG,iCAAiC,iBAAiB,eAAe,CAAC;AAAA,YAE/E;AAAA;AAAA,QACH;AAAA,QAED,OAAO,aAAa,aACjB,SAAS,SAAS,EAAE,SAAS,UAAU,SAAY,UAAU,OAAU,CAAC,IACxE;AAAA,QACH,WACC,oBAAC,OAAE,IAAI,QAAQ,WAAU,yCACtB,gBACH;AAAA,QAED,YACC,oBAAC,OAAE,IAAI,SAAS,WAAU,0CACvB,iBACH;AAAA;AAAA;AAAA,EAEJ;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/field.tsx"],"sourcesContent":["/**\n * A field group with its label ABOVE the control: label, control, hint, error.\n *\n * ```tsx\n * <Field label=\"IBAN\" required hint=\"22 characters\" error={errors.iban}>\n * {(ids) => <Input {...ids} value={iban} onChange={…} />}\n * </Field>\n * ```\n *\n * The kit's `Input` and `Select` float their label inside the field, and the pickers\n * draw theirs the same way. That is the wrong shape for a control the kit did not\n * render, a radio group, a range slider, or a form that sets its labels above the\n * fields throughout — which is what kastlan's wizards are, and why it kept its own\n * `WizardField` after the kit dropped the one it had never rendered. This is that\n * group, with the wiring the old one left to the caller: the label names the control\n * through `htmlFor`, and the control is described by the hint and the error that are\n * actually on screen.\n *\n * ## Wiring\n *\n * Pass a function as `children` and it receives {@link FieldControlProps} — `id`,\n * `aria-describedby`, `aria-invalid` and `aria-required` — to spread on the control.\n * Plain children render as they are, for a control that wires itself (a checkbox that\n * carries its own label, a group with `aria-labelledby`); `htmlFor` then points the\n * label at the control's own id, and the hint and error are on screen but describe\n * nothing — the same as the `WizardField` this replaces.\n *\n * ## Decoupled\n *\n * Deliberately knows neither the wizard nor react-hook-form: `error` is a plain\n * value. Pass `wizard.fieldErrors.iban`, a step-local error, or\n * `fieldState.error?.message` from a react-hook-form `Controller` — the markup is the\n * same whichever of them gates the step, so a step can move between them without its\n * fields changing. (Inside a react-hook-form form, `RhfField` from `@eifi1/ui-kit/rhf`\n * does the same job with the ids taken from the form.)\n *\n * The error is NOT `role=\"alert\"`, for the reason the kit's `error` prop gives (see\n * `useFieldError` in components/field-parts.tsx): it is read when focus reaches the control.\n */\nimport { useId, type ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { hasContent } from \"../lib/presence\";\nimport { fieldHelpMark, useInlineHint, type FieldHelpProps } from \"./field-parts\";\nimport { Label } from \"./field-base\";\n\n/** What a `Field` hands its render-prop children, to spread on the control. */\nexport interface FieldControlProps {\n /** The id the label's `htmlFor` points at. */\n id: string;\n /** The ids of the hint and the error that are rendered, or `undefined` when\n * neither is. */\n \"aria-describedby\": string | undefined;\n /** `true` while the field has an error, `undefined` otherwise. */\n \"aria-invalid\": true | undefined;\n /** `true` when the field is `required`. The label's star is `aria-hidden`; this\n * is what a screen reader hears instead. */\n \"aria-required\": true | undefined;\n}\n\n/**\n * The second argument of a `Field`'s render-prop children: what a GROUP needs that\n * a single control does not. `htmlFor` names an `<input>`, but a `<label>` pointing\n * at a `role=\"radiogroup\"` div names nothing — so a group (a {@link ToggleGroup})\n * takes `aria-labelledby={labelId}` instead. Kept out of {@link FieldControlProps}\n * so the spread every existing control takes is unchanged.\n */\nexport interface FieldRenderMeta {\n /** The label element's id, or `undefined` when the field has no label. */\n labelId: string | undefined;\n}\n\n/**\n * Where the label is visible. `\"visible\"` (default); `\"sr-only\"`: never drawn, still\n * the control's name; `\"below-sm\"` / `\"below-md\"` / `\"below-lg\"`: drawn only under\n * that breakpoint and screen-reader-only from it up.\n *\n * For table-like editors — kastlan's journal entry lines\n * (accounting/components/wizard/journal-entry-lines-step.tsx) — where a desktop\n * row sits under column headers that already say \"Account\" and \"Amount\", but the\n * phone layout stacks each line as a card and needs the labels back. The name stays\n * on the control at every width; only the ink changes.\n */\nexport type FieldLabelVisibility = \"visible\" | \"sr-only\" | \"below-sm\" | \"below-md\" | \"below-lg\";\n\n// Literal class strings so Tailwind's scanner sees each one.\nconst LABEL_VISIBILITY: Record<FieldLabelVisibility, string | undefined> = {\n visible: undefined,\n \"sr-only\": \"sr-only\",\n \"below-sm\": \"sm:sr-only\",\n \"below-md\": \"md:sr-only\",\n \"below-lg\": \"lg:sr-only\",\n};\n\nexport interface FieldProps extends FieldHelpProps {\n /** The label above the control. Omit it for a group whose control is labelled\n * some other way; the hint and error still render. */\n label?: ReactNode;\n /** Draws the kit's required mark after the label, and sets `aria-required` on the\n * render-prop control. */\n required?: boolean;\n /** Muted helper text below the control. */\n hint?: ReactNode;\n /** The error below the control. `null`, `false` and `\"\"` are no error — what a\n * `touched && errors.x` evaluates to on the happy path. */\n error?: ReactNode;\n /** The control's id. Defaults to a generated one; set it when the control carries\n * its own id (plain children). */\n htmlFor?: string;\n /** Dims the label with its control. */\n disabled?: boolean;\n /** The label's size: `sm` for a dense row, `md` otherwise. */\n labelSize?: \"sm\" | \"md\";\n /** See {@link FieldLabelVisibility}. */\n labelVisibility?: FieldLabelVisibility;\n /** Wrapper className. */\n className?: string;\n /** The control. A function receives {@link FieldControlProps} to spread on it, and\n * {@link FieldRenderMeta} (the label's id, for a group). */\n children: ReactNode | ((control: FieldControlProps, meta: FieldRenderMeta) => ReactNode);\n}\n\nexport function Field({\n label,\n required,\n hint: hintProp,\n help,\n error,\n htmlFor,\n disabled,\n labelSize,\n labelVisibility = \"visible\",\n className,\n children,\n}: FieldProps) {\n const generated = useId();\n const id = htmlFor ?? `${generated}-control`;\n const hintId = `${generated}-hint`;\n const errorId = `${generated}-error`;\n const labelId = `${generated}-label`;\n // §4: a FieldHint under the control is its words at Large and on a touch screen, not\n // a lone \"?\" (DUP-2, refactor audit 2026-10). A HelpPopover stays a \"?\".\n // With a `help` (0.36) its \"?\" follows the label, and a FieldHint is its words at\n // every size: the help's is the field's only \"?\".\n const helpMark = fieldHelpMark(help, label);\n const hint = useInlineHint(hintProp, helpMark !== undefined);\n const hasHint = hasContent(hint);\n const hasError = hasContent(error);\n const describedBy = [hasHint && hintId, hasError && errorId].filter(Boolean).join(\" \");\n const control: FieldControlProps = {\n id,\n \"aria-describedby\": describedBy || undefined,\n \"aria-invalid\": hasError || undefined,\n \"aria-required\": required || undefined,\n };\n const labelEl = label !== undefined && (\n <Label\n id={labelId}\n htmlFor={id}\n required={required}\n disabled={disabled}\n size={labelSize}\n data-error={hasError || undefined}\n className={cn(\"data-[error=true]:text-danger\", LABEL_VISIBILITY[labelVisibility])}\n >\n {label}\n </Label>\n );\n return (\n <div\n data-slot=\"field\"\n className={cn(\n // `content-start`: in a grid row next to a taller item (one with a hint), a\n // stretched Field would hand the extra height to its rows — the label grew,\n // and the control sat lower than its neighbour's (showcase audit, #/forms).\n \"grid content-start gap-1.5\",\n // A hidden label is `position: absolute`; `relative` keeps its containing\n // block here rather than the page's (see sr-only-containment.test).\n labelVisibility !== \"visible\" && \"relative\",\n className,\n )}\n >\n {helpMark === undefined && labelEl}\n {helpMark !== undefined && (\n // The label and its \"?\" on one line; the \"?\" stays visible where the label is\n // hidden (`labelVisibility`) — a hidden button would still take the focus.\n <div className=\"flex items-center gap-1\">\n {labelEl}\n <span className=\"flex shrink-0 items-center\">{helpMark}</span>\n </div>\n )}\n {typeof children === \"function\"\n ? children(control, { labelId: label !== undefined ? labelId : undefined })\n : children}\n {hasHint && (\n <p id={hintId} className=\"text-caption leading-tight text-muted\">\n {hint}\n </p>\n )}\n {hasError && (\n <p id={errorId} className=\"text-caption leading-tight text-danger\">\n {error}\n </p>\n )}\n </div>\n );\n}\n"],"mappings":";AA2JI,cA8BI,YA9BJ;AApHJ,SAAS,aAA6B;AACtC,SAAS,UAAU;AACnB,SAAS,kBAAkB;AAC3B,SAAS,eAAe,qBAA0C;AAClE,SAAS,aAAa;AA0CtB,MAAM,mBAAqE;AAAA,EACzE,SAAS;AAAA,EACT,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,YAAY;AACd;AA8BO,SAAS,MAAM;AAAA,EACpB;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB;AAAA,EACA;AACF,GAAe;AACb,QAAM,YAAY,MAAM;AACxB,QAAM,KAAK,WAAW,GAAG,SAAS;AAClC,QAAM,SAAS,GAAG,SAAS;AAC3B,QAAM,UAAU,GAAG,SAAS;AAC5B,QAAM,UAAU,GAAG,SAAS;AAK5B,QAAM,WAAW,cAAc,MAAM,KAAK;AAC1C,QAAM,OAAO,cAAc,UAAU,aAAa,MAAS;AAC3D,QAAM,UAAU,WAAW,IAAI;AAC/B,QAAM,WAAW,WAAW,KAAK;AACjC,QAAM,cAAc,CAAC,WAAW,QAAQ,YAAY,OAAO,EAAE,OAAO,OAAO,EAAE,KAAK,GAAG;AACrF,QAAM,UAA6B;AAAA,IACjC;AAAA,IACA,oBAAoB,eAAe;AAAA,IACnC,gBAAgB,YAAY;AAAA,IAC5B,iBAAiB,YAAY;AAAA,EAC/B;AACA,QAAM,UAAU,UAAU,UACxB;AAAA,IAAC;AAAA;AAAA,MACC,IAAI;AAAA,MACJ,SAAS;AAAA,MACT;AAAA,MACA;AAAA,MACA,MAAM;AAAA,MACN,cAAY,YAAY;AAAA,MACxB,WAAW,GAAG,iCAAiC,iBAAiB,eAAe,CAAC;AAAA,MAE/E;AAAA;AAAA,EACH;AAEF,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,WAAW;AAAA;AAAA;AAAA;AAAA,QAIT;AAAA;AAAA;AAAA,QAGA,oBAAoB,aAAa;AAAA,QACjC;AAAA,MACF;AAAA,MAEC;AAAA,qBAAa,UAAa;AAAA,QAC1B,aAAa;AAAA;AAAA,QAGZ,qBAAC,SAAI,WAAU,2BACZ;AAAA;AAAA,UACD,oBAAC,UAAK,WAAU,8BAA8B,oBAAS;AAAA,WACzD;AAAA,QAED,OAAO,aAAa,aACjB,SAAS,SAAS,EAAE,SAAS,UAAU,SAAY,UAAU,OAAU,CAAC,IACxE;AAAA,QACH,WACC,oBAAC,OAAE,IAAI,QAAQ,WAAU,yCACtB,gBACH;AAAA,QAED,YACC,oBAAC,OAAE,IAAI,SAAS,WAAU,0CACvB,iBACH;AAAA;AAAA;AAAA,EAEJ;AAEJ;","names":[]}
@@ -39,9 +39,9 @@ function FullBleedDialog({
39
39
  const hasTitle = title !== void 0 && title !== null && title !== false;
40
40
  const hasHeader = header !== void 0 && header !== null && header !== false;
41
41
  useFocusTrap(dialogRef, { active: open });
42
- useDismissLayer(open, requestClose);
42
+ const layer = useDismissLayer(open, requestClose);
43
43
  const handleKeyDown = (e) => {
44
- runOwnKeyDown(e, onKeyDown);
44
+ runOwnKeyDown(e, onKeyDown, layer);
45
45
  takeEscape(e);
46
46
  };
47
47
  if (!open || typeof document === "undefined") return null;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/full-bleed-dialog.tsx"],"sourcesContent":["import { useId, useMemo, useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { mergeRefs } from \"./field-parts\";\nimport { useBackdropClose } from \"./modal\";\nimport { useBodyScrollLock } from \"../hooks/use-body-scroll-lock\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { useCloseTransition } from \"../hooks/use-close-transition\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\nimport { runOwnKeyDown, takeEscape, useDismissLayer } from \"../hooks/use-dismiss\";\n\n/**\n * The padding around the dialog's scrolling body, as a CSS length (0.32,\n * docs/text-size-harmonization.md §10.11). Exported because a caller's sticky footer has\n * to reach across it: Chrome resolves a sticky offset against the scrollport's CONTENT\n * box, so a pinned row sits this far short of the panel's edge unless it cancels it\n * (keksdose's editor footer). In rem, so it grows with the text size — keksdose's px\n * copy (`SCROLLER_GUTTER_PX = 12`) was 3–6 px off at 125/150 %. Use it in `calc()`:\n * `bottom: calc(${inset}px - ${DIALOG_GUTTER})`.\n */\nexport const DIALOG_GUTTER = \"0.75rem\";\n\n/**\n * Named and exported, because `ComponentProps<typeof FullBleedDialog>` was the only way\n * to say \"the props of this\" and five call sites across keksdose and kastlan had already\n * written one (audit §api-design).\n *\n * It extends the `<div>` attributes so a caller can reach the OUTER element — the one\n * carrying `role=\"dialog\"`, and therefore the one an `aria-label`, an\n * `aria-describedby` or a `data-tour` anchor belongs on. {@link className} keeps going\n * to the panel inside it, which is what it has always meant here.\n */\nexport interface FullBleedDialogProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"title\"> {\n open: boolean;\n /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —\n * the platform Back gesture all call this. */\n onClose: () => void;\n /**\n * The dialog's HEADING: rendered as a real `h2` (see {@link headingAs}) at the start\n * of the header strip, and wired to the dialog's `aria-labelledby`, so the dialog is\n * announced by name rather than as just \"dialog\".\n *\n * Before this the strip was a styled `div`, and a caller that wanted its full-screen\n * phone editor NAMED had to leave this component for `DialogFrame fullBleed` —\n * keksdose's budget-table phone editor says so in a comment at the call site. A\n * caller's own `aria-label` / `aria-labelledby` still wins.\n */\n title?: ReactNode;\n /** The heading level of {@link title}. `h2` by default, as `DialogFrame`'s. */\n headingAs?: \"h1\" | \"h2\" | \"h3\" | \"h4\";\n /**\n * Free content for the header strip, beside the close button — after the\n * {@link title} when there is one, or on its own (the row editor shows the row's\n * own first cell here). It does not name the dialog; `title` does.\n */\n header?: ReactNode;\n closeLabel: string;\n children: ReactNode;\n /**\n * Push a history entry so Back dismisses this dialog instead of navigating\n * (Keksdose feedback #172). On by default, because a full-screen form that ignores\n * the phone's universal \"go back\" gesture does not merely fail to close — the\n * navigation lands somewhere else with the form still notionally open.\n *\n * **Off for a caller whose open state is already in the URL.** The transactions\n * create card is `?action=new`, so Back already pops it through the router; a\n * sentinel on top of that would cost two presses to close one dialog. One owner of\n * the Back press, always.\n */\n backCloses?: boolean;\n /** Extra classes for the PANEL (not the backdrop). */\n className?: string;\n /**\n * A row pinned under the scrolling body — an Apply, a Save. Keksdose's `RangeSheet`\n * (reports/report-range-field.tsx) is a hand-rolled copy of this dialog for want of\n * exactly this slot: a date range picked on a phone is committed by a sticky Apply,\n * and a button at the end of the body scrolls away with the calendar.\n *\n * Outside the scroller, so it stays put without `position: sticky` (which would\n * need the body to be the scroll container of its own child and paint over the\n * last field); padded for the home indicator, since it is the panel's bottom edge.\n */\n footer?: ReactNode;\n /** The `role=\"dialog\"` element, where the rest of the props go (0.34, API-2, refactor\n * audit 2026-10) — merged with its own ref, which the focus trap holds. */\n ref?: Ref<HTMLDivElement>;\n}\n\n/**\n * The phone's full-screen dialog: a panel that covers the viewport edge to edge.\n *\n * Not {@link Modal}, and that is a decision rather than an omission. Keksdose feedback\n * #32: the inset panel #204 shipped left a strip of page showing beside it on a phone,\n * and tapping that strip dismissed a form mid-edit. With no backdrop exposed there is\n * nothing to mis-tap, so the X is the way out — `backdropClose` stays wired for any\n * layout that does leave a backdrop visible, and costs nothing where none is.\n *\n * ## Why it is a component\n *\n * It was markup inside `data-table.tsx`, where it is the phone's row EDITOR. Keksdose\n * live #307's follow-up needed the same shell for the transactions CREATE card —\n * *\"Is the creation window of the tx still different than the edit window? Since the tx\n * list still appears below when scrolling down?\"* — and the two answers to that are\n * either one component or two copies that drift. Every previous convergence in that app\n * drifted the moment a second surface was written beside the first, which is what\n * `field-layer-parity` exists to catch one layer down.\n *\n * So the panel, the header strip, the scrolling body, the body-scroll lock and the Back\n * handling are all here, once.\n *\n * ## What the caller still owns\n *\n * The HEADER's content, because the two callers name themselves differently: the row\n * editor shows the row's own first column, the create card a plain title. A plain\n * title belongs in `title`, which is a real heading and the dialog's name; `header`\n * stays the free slot beside it. And `open`,\n * because who decides is the caller's business — see {@link backCloses}.\n */\nexport function FullBleedDialog({\n open,\n onClose,\n title,\n headingAs: Heading = \"h2\",\n header,\n closeLabel,\n children,\n backCloses = true,\n className,\n footer,\n onMouseDown,\n onMouseUp,\n onKeyDown,\n ref,\n ...rest\n}: FullBleedDialogProps) {\n // Every way OUT goes through `requestClose`, so the panel lowers itself before the\n // caller unmounts it (live #320 rework). `onClose` is still what finally runs — this\n // only delays it by the length of the animation.\n const { closing, requestClose } = useCloseTransition(onClose);\n const backdropClose = useBackdropClose(requestClose);\n // The page behind must not scroll or jump under the overlay (feedback #204).\n useBodyScrollLock(open);\n useOverlayHistory(open && backCloses, requestClose);\n\n const dialogRef = useRef<HTMLDivElement>(null);\n const setDialog = useMemo(() => mergeRefs(dialogRef, ref), [ref]);\n const titleId = useId();\n const hasTitle = title !== undefined && title !== null && title !== false;\n const hasHeader = header !== undefined && header !== null && header !== false;\n // The half of \"modal\" this dialog was only claiming. It has said\n // `aria-modal=\"true\"` since it was extracted from `data-table.tsx`, which tells\n // assistive technology to hide everything outside it — while the user's focus stayed\n // on the row they tapped, in the part that is now hidden. Tab then walked them\n // through a table the screen reader would not describe, and on a phone this dialog\n // covers the whole screen, so there was nothing on screen to say where they were.\n //\n // `initialFocus` is left at the default — the dialog itself, not the first field.\n // This is the phone's row EDITOR and its first child is usually an input; focusing it\n // would pull the software keyboard up over half the form before the user has said\n // which field they came for. `Modal` made the same call for the same reason.\n //\n // That default only decides when nothing inside asked for focus. A field the app\n // autofocuses on purpose keeps it (0.33.1): keksdose's create card autofocuses its\n // amount, and the trap used to take focus straight back to the dialog — and on a\n // phone the numpad sheet, which shows only while the field is focused, went with it\n // (live #402). Closing still hands focus back to whatever opened the dialog.\n useFocusTrap(dialogRef, { active: open });\n\n // A layer on the Escape stack while open (Wave 2, DUP-1). This dialog is what a\n // `PickerSheet`, a list or the numpad opens on top of, and the stack hands the key\n // to whichever of them is topmost — one press dismissing the sheet AND the edit\n // behind it was Keksdose live #309 (*\"Mouse Back does not only close the select but\n // also the whole edit or create dialog\"*) on a different key.\n useDismissLayer(open, requestClose);\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n // The caller's handler first (keksdose's transaction-search keeps its list keys on\n // the dialog); a `preventDefault()` IN THAT HANDLER claims Escape. Only there: a\n // descendant's does not count. A SearchField in the header prevents Escape to stop\n // Chromium clearing a type=search input, and in 0.8.0 that silently stopped Escape\n // from closing the dialog (keksdose). The one rule `Modal` now shares.\n runOwnKeyDown(e, onKeyDown);\n takeEscape(e);\n };\n\n if (!open || typeof document === \"undefined\") return null;\n\n return createPortal(\n /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the dialog\n container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for whatever\n inside it holds focus, and the mouse handlers are the backdrop press (Escape and the\n close button are its keyboard path). */\n <div\n // `animate-overlay` on the backdrop and `animate-sheet` on the panel (live #320):\n // the backdrop fades, the panel rises from the bottom edge it is anchored to.\n // Both are no-ops under prefers-reduced-motion — see tokens.css.\n //\n // …and the same two backwards while `closing`, which is the #320 rework. The\n // backdrop keeps taking pointer events on the way out: a tap during those 220ms\n // is a tap on a dialog that is leaving, and letting it through to the row\n // underneath would open a second one.\n //\n // `...rest` first, the dialog's own contract after it. A caller adding a\n // `data-tour` anchor must not be able to take away the role, the modality or the\n // `tabIndex` below by passing one of them too — this is the element the focus\n // trap holds, and each of those failures is invisible on screen.\n {...rest}\n className={cn(\n \"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40 outline-none\",\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n )}\n role=\"dialog\"\n aria-modal=\"true\"\n // The heading names the dialog unless the caller named it some other way — an\n // `aria-labelledby` of its own, or an `aria-label` (which a `labelledby` would\n // silently outrank).\n aria-labelledby={rest[\"aria-labelledby\"] ?? (hasTitle && !rest[\"aria-label\"] ? titleId : undefined)}\n // `tabIndex={-1}` is what makes the line above more than a claim: without it the\n // container cannot take focus and `useFocusTrap` silently does nothing.\n tabIndex={-1}\n ref={setDialog}\n onKeyDown={handleKeyDown}\n // COMPOSED with the backdrop's, not overridden by them. These two used to ride\n // `...rest` and then lose to `{...backdropClose}` spread after it, silently —\n // and the one caller about to pass one is keksdose's RangeSheet, whose\n // `onMouseDown={(e) => e.stopPropagation()}` is its dev#477 fix: a press in a\n // portalled sheet must not reach a document-level \"outside click\" listener,\n // which would unmount the day cell before its `click`. The caller's runs first;\n // the backdrop's still runs after it, since it is the same element.\n onMouseDown={(e) => {\n onMouseDown?.(e);\n backdropClose.onMouseDown(e);\n }}\n onMouseUp={(e) => {\n onMouseUp?.(e);\n backdropClose.onMouseUp(e);\n }}\n >\n <div\n className={cn(\n \"flex h-full w-full flex-col overflow-hidden bg-surface shadow-xl\",\n closing ? \"animate-sheet-out\" : \"animate-sheet\",\n className,\n )}\n >\n <div className=\"flex items-center justify-between gap-2 border-b border-subtle px-3 py-3\">\n {hasTitle ? (\n <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n <Heading id={titleId} className=\"min-w-0 break-words text-base font-semibold leading-snug text-primary\">\n {title}\n </Heading>\n {hasHeader && <div className=\"min-w-0 flex-1 font-medium\">{header}</div>}\n </div>\n ) : (\n <div className=\"min-w-0 flex-1 font-medium\">{header}</div>\n )}\n <button\n type=\"button\"\n onClick={requestClose}\n aria-label={closeLabel}\n className=\"-me-1 shrink-0 rounded p-1.5 text-muted hover:bg-hover hover:text-secondary\"\n >\n <X className=\"size-5\" />\n </button>\n </div>\n {/* DIALOG_GUTTER (0.75rem, the px-3 py-3 it was), not px-4: every pixel of\n chrome here is width the form fields lose on a phone (feedback #32). The\n body is the only thing that scrolls, so the header stays put and the page\n underneath cannot move at all. */}\n {/* `min-h-0` so it is the BODY that gives way to a footer, not the footer that\n is pushed off the bottom edge. */}\n <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\" style={{ padding: DIALOG_GUTTER }}>\n {children}\n </div>\n {footer !== undefined && footer !== null && footer !== false && (\n <div\n data-full-bleed-footer=\"\"\n className=\"flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-subtle px-3 pt-3\"\n style={{ paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\" }}\n >\n {footer}\n </div>\n )}\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/full-bleed-dialog.tsx"],"sourcesContent":["import { useId, useMemo, useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { mergeRefs } from \"./field-parts\";\nimport { useBackdropClose } from \"./modal\";\nimport { useBodyScrollLock } from \"../hooks/use-body-scroll-lock\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { useCloseTransition } from \"../hooks/use-close-transition\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\nimport { runOwnKeyDown, takeEscape, useDismissLayer } from \"../hooks/use-dismiss\";\n\n/**\n * The padding around the dialog's scrolling body, as a CSS length (0.32,\n * docs/text-size-harmonization.md §10.11). Exported because a caller's sticky footer has\n * to reach across it: Chrome resolves a sticky offset against the scrollport's CONTENT\n * box, so a pinned row sits this far short of the panel's edge unless it cancels it\n * (keksdose's editor footer). In rem, so it grows with the text size — keksdose's px\n * copy (`SCROLLER_GUTTER_PX = 12`) was 3–6 px off at 125/150 %. Use it in `calc()`:\n * `bottom: calc(${inset}px - ${DIALOG_GUTTER})`.\n */\nexport const DIALOG_GUTTER = \"0.75rem\";\n\n/**\n * Named and exported, because `ComponentProps<typeof FullBleedDialog>` was the only way\n * to say \"the props of this\" and five call sites across keksdose and kastlan had already\n * written one (audit §api-design).\n *\n * It extends the `<div>` attributes so a caller can reach the OUTER element — the one\n * carrying `role=\"dialog\"`, and therefore the one an `aria-label`, an\n * `aria-describedby` or a `data-tour` anchor belongs on. {@link className} keeps going\n * to the panel inside it, which is what it has always meant here.\n */\nexport interface FullBleedDialogProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"title\"> {\n open: boolean;\n /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —\n * the platform Back gesture all call this. */\n onClose: () => void;\n /**\n * The dialog's HEADING: rendered as a real `h2` (see {@link headingAs}) at the start\n * of the header strip, and wired to the dialog's `aria-labelledby`, so the dialog is\n * announced by name rather than as just \"dialog\".\n *\n * Before this the strip was a styled `div`, and a caller that wanted its full-screen\n * phone editor NAMED had to leave this component for `DialogFrame fullBleed` —\n * keksdose's budget-table phone editor says so in a comment at the call site. A\n * caller's own `aria-label` / `aria-labelledby` still wins.\n */\n title?: ReactNode;\n /** The heading level of {@link title}. `h2` by default, as `DialogFrame`'s. */\n headingAs?: \"h1\" | \"h2\" | \"h3\" | \"h4\";\n /**\n * Free content for the header strip, beside the close button — after the\n * {@link title} when there is one, or on its own (the row editor shows the row's\n * own first cell here). It does not name the dialog; `title` does.\n */\n header?: ReactNode;\n closeLabel: string;\n children: ReactNode;\n /**\n * Push a history entry so Back dismisses this dialog instead of navigating\n * (Keksdose feedback #172). On by default, because a full-screen form that ignores\n * the phone's universal \"go back\" gesture does not merely fail to close — the\n * navigation lands somewhere else with the form still notionally open.\n *\n * **Off for a caller whose open state is already in the URL.** The transactions\n * create card is `?action=new`, so Back already pops it through the router; a\n * sentinel on top of that would cost two presses to close one dialog. One owner of\n * the Back press, always.\n */\n backCloses?: boolean;\n /** Extra classes for the PANEL (not the backdrop). */\n className?: string;\n /**\n * A row pinned under the scrolling body — an Apply, a Save. Keksdose's `RangeSheet`\n * (reports/report-range-field.tsx) is a hand-rolled copy of this dialog for want of\n * exactly this slot: a date range picked on a phone is committed by a sticky Apply,\n * and a button at the end of the body scrolls away with the calendar.\n *\n * Outside the scroller, so it stays put without `position: sticky` (which would\n * need the body to be the scroll container of its own child and paint over the\n * last field); padded for the home indicator, since it is the panel's bottom edge.\n */\n footer?: ReactNode;\n /** The `role=\"dialog\"` element, where the rest of the props go (0.34, API-2, refactor\n * audit 2026-10) — merged with its own ref, which the focus trap holds. */\n ref?: Ref<HTMLDivElement>;\n}\n\n/**\n * The phone's full-screen dialog: a panel that covers the viewport edge to edge.\n *\n * Not {@link Modal}, and that is a decision rather than an omission. Keksdose feedback\n * #32: the inset panel #204 shipped left a strip of page showing beside it on a phone,\n * and tapping that strip dismissed a form mid-edit. With no backdrop exposed there is\n * nothing to mis-tap, so the X is the way out — `backdropClose` stays wired for any\n * layout that does leave a backdrop visible, and costs nothing where none is.\n *\n * ## Why it is a component\n *\n * It was markup inside `data-table.tsx`, where it is the phone's row EDITOR. Keksdose\n * live #307's follow-up needed the same shell for the transactions CREATE card —\n * *\"Is the creation window of the tx still different than the edit window? Since the tx\n * list still appears below when scrolling down?\"* — and the two answers to that are\n * either one component or two copies that drift. Every previous convergence in that app\n * drifted the moment a second surface was written beside the first, which is what\n * `field-layer-parity` exists to catch one layer down.\n *\n * So the panel, the header strip, the scrolling body, the body-scroll lock and the Back\n * handling are all here, once.\n *\n * ## What the caller still owns\n *\n * The HEADER's content, because the two callers name themselves differently: the row\n * editor shows the row's own first column, the create card a plain title. A plain\n * title belongs in `title`, which is a real heading and the dialog's name; `header`\n * stays the free slot beside it. And `open`,\n * because who decides is the caller's business — see {@link backCloses}.\n */\nexport function FullBleedDialog({\n open,\n onClose,\n title,\n headingAs: Heading = \"h2\",\n header,\n closeLabel,\n children,\n backCloses = true,\n className,\n footer,\n onMouseDown,\n onMouseUp,\n onKeyDown,\n ref,\n ...rest\n}: FullBleedDialogProps) {\n // Every way OUT goes through `requestClose`, so the panel lowers itself before the\n // caller unmounts it (live #320 rework). `onClose` is still what finally runs — this\n // only delays it by the length of the animation.\n const { closing, requestClose } = useCloseTransition(onClose);\n const backdropClose = useBackdropClose(requestClose);\n // The page behind must not scroll or jump under the overlay (feedback #204).\n useBodyScrollLock(open);\n useOverlayHistory(open && backCloses, requestClose);\n\n const dialogRef = useRef<HTMLDivElement>(null);\n const setDialog = useMemo(() => mergeRefs(dialogRef, ref), [ref]);\n const titleId = useId();\n const hasTitle = title !== undefined && title !== null && title !== false;\n const hasHeader = header !== undefined && header !== null && header !== false;\n // The half of \"modal\" this dialog was only claiming. It has said\n // `aria-modal=\"true\"` since it was extracted from `data-table.tsx`, which tells\n // assistive technology to hide everything outside it — while the user's focus stayed\n // on the row they tapped, in the part that is now hidden. Tab then walked them\n // through a table the screen reader would not describe, and on a phone this dialog\n // covers the whole screen, so there was nothing on screen to say where they were.\n //\n // `initialFocus` is left at the default — the dialog itself, not the first field.\n // This is the phone's row EDITOR and its first child is usually an input; focusing it\n // would pull the software keyboard up over half the form before the user has said\n // which field they came for. `Modal` made the same call for the same reason.\n //\n // That default only decides when nothing inside asked for focus. A field the app\n // autofocuses on purpose keeps it (0.33.1): keksdose's create card autofocuses its\n // amount, and the trap used to take focus straight back to the dialog — and on a\n // phone the numpad sheet, which shows only while the field is focused, went with it\n // (live #402). Closing still hands focus back to whatever opened the dialog.\n useFocusTrap(dialogRef, { active: open });\n\n // A layer on the Escape stack while open (Wave 2, DUP-1). This dialog is what a\n // `PickerSheet`, a list or the numpad opens on top of, and the stack hands the key\n // to whichever of them is topmost — one press dismissing the sheet AND the edit\n // behind it was Keksdose live #309 (*\"Mouse Back does not only close the select but\n // also the whole edit or create dialog\"*) on a different key.\n const layer = useDismissLayer(open, requestClose);\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n // The caller's handler first (keksdose's transaction-search keeps its list keys on\n // the dialog); a `preventDefault()` IN THAT HANDLER claims Escape. Only there: a\n // descendant's does not count. A SearchField in the header prevents Escape to stop\n // Chromium clearing a type=search input, and in 0.8.0 that silently stopped Escape\n // from closing the dialog (keksdose). The one rule `Modal` now shares — as is this\n // one: the handler sees Escape only while this dialog is the layer on top (0.35).\n runOwnKeyDown(e, onKeyDown, layer);\n takeEscape(e);\n };\n\n if (!open || typeof document === \"undefined\") return null;\n\n return createPortal(\n /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the dialog\n container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for whatever\n inside it holds focus, and the mouse handlers are the backdrop press (Escape and the\n close button are its keyboard path). */\n <div\n // `animate-overlay` on the backdrop and `animate-sheet` on the panel (live #320):\n // the backdrop fades, the panel rises from the bottom edge it is anchored to.\n // Both are no-ops under prefers-reduced-motion — see tokens.css.\n //\n // …and the same two backwards while `closing`, which is the #320 rework. The\n // backdrop keeps taking pointer events on the way out: a tap during those 220ms\n // is a tap on a dialog that is leaving, and letting it through to the row\n // underneath would open a second one.\n //\n // `...rest` first, the dialog's own contract after it. A caller adding a\n // `data-tour` anchor must not be able to take away the role, the modality or the\n // `tabIndex` below by passing one of them too — this is the element the focus\n // trap holds, and each of those failures is invisible on screen.\n {...rest}\n className={cn(\n \"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40 outline-none\",\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n )}\n role=\"dialog\"\n aria-modal=\"true\"\n // The heading names the dialog unless the caller named it some other way — an\n // `aria-labelledby` of its own, or an `aria-label` (which a `labelledby` would\n // silently outrank).\n aria-labelledby={rest[\"aria-labelledby\"] ?? (hasTitle && !rest[\"aria-label\"] ? titleId : undefined)}\n // `tabIndex={-1}` is what makes the line above more than a claim: without it the\n // container cannot take focus and `useFocusTrap` silently does nothing.\n tabIndex={-1}\n ref={setDialog}\n onKeyDown={handleKeyDown}\n // COMPOSED with the backdrop's, not overridden by them. These two used to ride\n // `...rest` and then lose to `{...backdropClose}` spread after it, silently —\n // and the one caller about to pass one is keksdose's RangeSheet, whose\n // `onMouseDown={(e) => e.stopPropagation()}` is its dev#477 fix: a press in a\n // portalled sheet must not reach a document-level \"outside click\" listener,\n // which would unmount the day cell before its `click`. The caller's runs first;\n // the backdrop's still runs after it, since it is the same element.\n onMouseDown={(e) => {\n onMouseDown?.(e);\n backdropClose.onMouseDown(e);\n }}\n onMouseUp={(e) => {\n onMouseUp?.(e);\n backdropClose.onMouseUp(e);\n }}\n >\n <div\n className={cn(\n \"flex h-full w-full flex-col overflow-hidden bg-surface shadow-xl\",\n closing ? \"animate-sheet-out\" : \"animate-sheet\",\n className,\n )}\n >\n <div className=\"flex items-center justify-between gap-2 border-b border-subtle px-3 py-3\">\n {hasTitle ? (\n <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n <Heading id={titleId} className=\"min-w-0 break-words text-base font-semibold leading-snug text-primary\">\n {title}\n </Heading>\n {hasHeader && <div className=\"min-w-0 flex-1 font-medium\">{header}</div>}\n </div>\n ) : (\n <div className=\"min-w-0 flex-1 font-medium\">{header}</div>\n )}\n <button\n type=\"button\"\n onClick={requestClose}\n aria-label={closeLabel}\n className=\"-me-1 shrink-0 rounded p-1.5 text-muted hover:bg-hover hover:text-secondary\"\n >\n <X className=\"size-5\" />\n </button>\n </div>\n {/* DIALOG_GUTTER (0.75rem, the px-3 py-3 it was), not px-4: every pixel of\n chrome here is width the form fields lose on a phone (feedback #32). The\n body is the only thing that scrolls, so the header stays put and the page\n underneath cannot move at all. */}\n {/* `min-h-0` so it is the BODY that gives way to a footer, not the footer that\n is pushed off the bottom edge. */}\n <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\" style={{ padding: DIALOG_GUTTER }}>\n {children}\n </div>\n {footer !== undefined && footer !== null && footer !== false && (\n <div\n data-full-bleed-footer=\"\"\n className=\"flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-subtle px-3 pt-3\"\n style={{ paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\" }}\n >\n {footer}\n </div>\n )}\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":"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","names":[]}
@@ -32,12 +32,14 @@ export interface HelpPopoverProps {
32
32
  * new row on a phone). `useInlineHint` turns only a FieldHint into a caption, so this
33
33
  * one stays on the label line at every size.
34
34
  *
35
- * **On a field.** Pass it as `hint`, as a FieldHint is passed: every kit field that
36
- * puts a FieldHint on its label line puts this there too, as it does any `hint` that is
37
- * not text (Input, Select, Textarea, NumberInput, AmountInput, the comboboxes and
38
- * pickers, ToggleGroup, {@link FieldStrip}) — and an unlabelled one at its end edge,
39
- * outside the box. The `@eifi1/ui-kit/rhf` bindings draw every `hint` under the field,
40
- * this one included. It also stands on its own, beside a heading or a column title.
35
+ * **On a field: its `help`** (0.36, round 0.36 decision 1). Every kit field that takes a
36
+ * `hint` takes a `help` beside it, and the `@eifi1/ui-kit/rhf` bindings too: pass the
37
+ * help's words and the field draws this "?" on its label line (an unlabelled one at its
38
+ * end edge, outside the box), named "About <label>" — or pass a HelpPopover to name it
39
+ * yourself. `hint` stays the FACT, printed under the field; while `help` is set a
40
+ * FieldHint there draws no "?" of its own, so the field has one. Passed as `hint`, as in
41
+ * 0.34, it still rides the label line. It also stands on its own, beside a heading or a
42
+ * column title.
41
43
  *
42
44
  * **A popover, not a tooltip**: a tooltip bubble is for a line, and opens on hover a
43
45
  * finger never makes. This is the kit {@link Popover}: it opens and closes on a tap or a
@@ -50,7 +52,7 @@ export interface HelpPopoverProps {
50
52
  * accepted and cleared?", keksdose live #247); "!" is a warning.
51
53
  *
52
54
  * ```tsx
53
- * <Select label="Status" hint={
55
+ * <Select label="Status" hint="Cleared and reconciled rows are locked." help={
54
56
  * <HelpPopover label="What do the statuses mean?">
55
57
  * Pending: … Accepted: … Cleared: … Reconciled: …
56
58
  * </HelpPopover>
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/help-popover.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { HelpCircle } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport type { RemLength } from \"../hooks/use-breakpoint\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { Popover } from \"./popover\";\n\n/** {@link HelpPopover}'s props. */\nexport interface HelpPopoverProps {\n /**\n * Names the \"?\" and the panel — a short question (\"What do the statuses mean?\") or\n * \"About <field>\" (\"About the settlement date\"), in the reader's language. Required:\n * a \"?\" named \"?\" tells a screen reader nothing about what it opens. Not the help\n * itself, which is `children`.\n */\n label: string;\n /** The help: a paragraph, or a few. Rendered in the panel at body size. */\n children: ReactNode;\n /** The panel's width — {@link Popover}'s `width`. Default `\"20rem\"`, a paragraph's\n * measure; it follows the text size and never exceeds the viewport. */\n width?: number | RemLength;\n /** Classes for the \"?\" button. */\n className?: string;\n}\n\n/** A paragraph's measure: 320 px at Normal, in rem so it grows with the text size. */\nconst HELP_WIDTH: RemLength = \"20rem\";\n\n/**\n * A \"?\" whose HELP opens on a tap, a click or Enter — for a paragraph too long to print\n * under a field (0.34, keksdose K3, live #400: *\"Remove the long text and move it behind\n * a clickable notice like a circle\"*).\n *\n * **Help, not a fact** (docs/text-size-harmonization.md §10.23). {@link FieldHint} is\n * the \"?\" for a FACT — a format, a limit, a consequence — and since 0.32 it turns into\n * its own words under the field at Large and on a touch screen (`useInlineHint`, §4 \"No\n * fact only in a tooltip\"), because a fact a reader cannot reach is a fact they do not\n * have. That rule stays. This part is for HELP: why the field exists, how to choose,\n * what the options mean — text the field is usable without. Printed inline it was the\n * longest thing in keksdose's transaction form (four statuses, ~500 characters, on every\n * new row on a phone). `useInlineHint` turns only a FieldHint into a caption, so this\n * one stays on the label line at every size.\n *\n * **On a field.** Pass it as `hint`, as a FieldHint is passed: every kit field that\n * puts a FieldHint on its label line puts this there too, as it does any `hint` that is\n * not text (Input, Select, Textarea, NumberInput, AmountInput, the comboboxes and\n * pickers, ToggleGroup, {@link FieldStrip}) — and an unlabelled one at its end edge,\n * outside the box. The `@eifi1/ui-kit/rhf` bindings draw every `hint` under the field,\n * this one included. It also stands on its own, beside a heading or a column title.\n *\n * **A popover, not a tooltip**: a tooltip bubble is for a line, and opens on hover a\n * finger never makes. This is the kit {@link Popover}: it opens and closes on a tap or a\n * click (and on Enter or Space, as any button does), closes on Escape and on a press\n * outside, and takes the focus into its panel and gives it back to the \"?\" on close. The\n * panel is a `dialog` named by `label` and described by the help, so a screen reader\n * reads both on opening. The \"?\" says `aria-expanded` and `aria-haspopup=\"dialog\"`.\n *\n * **\"?\" rather than \"!\"**: it answers a question (\"what is the difference between\n * accepted and cleared?\", keksdose live #247); \"!\" is a warning.\n *\n * ```tsx\n * <Select label=\"Status\" hint={\n * <HelpPopover label=\"What do the statuses mean?\">\n * Pending: … Accepted: … Cleared: … Reconciled: …\n * </HelpPopover>\n * } … />\n * ```\n */\nexport function HelpPopover({ label, children, width = HELP_WIDTH, className }: HelpPopoverProps) {\n const panelId = useId();\n const bodyId = useId();\n return (\n <Popover\n width={width}\n panelId={panelId}\n // The panel is named by the question and described by the answer, so focus moving\n // into it reads both.\n aria-label={label}\n aria-describedby={bodyId}\n trigger={({ open, toggle, ref }) => (\n <button\n ref={ref}\n // Never `submit`: the \"?\" sits on a field's label line, inside a form.\n type=\"button\"\n aria-label={label}\n aria-expanded={open}\n aria-haspopup=\"dialog\"\n aria-controls={open ? panelId : undefined}\n onClick={(e) => {\n // The \"?\" sits in a label strip: the press is the \"?\"'s, not the field's.\n e.preventDefault();\n toggle();\n }}\n // FieldHint's look, so the two marks read as one family on a label line; the\n // open one holds its hover colour while its panel shows.\n className={cn(\n \"flex rounded-full text-placeholder transition-colors hover:text-secondary\",\n open && \"text-secondary\",\n FOCUS_RING,\n className,\n )}\n >\n <HelpCircle aria-hidden className=\"size-3.5\" />\n </button>\n )}\n >\n {() => (\n <div id={bodyId} className=\"space-y-2 text-sm leading-snug text-primary\">\n {children}\n </div>\n )}\n </Popover>\n );\n}\n"],"mappings":";AAuGU;AAvGV,SAAS,aAAa;AAEtB,SAAS,kBAAkB;AAC3B,SAAS,UAAU;AAEnB,SAAS,kBAAkB;AAC3B,SAAS,eAAe;AAqBxB,MAAM,aAAwB;AA0CvB,SAAS,YAAY,EAAE,OAAO,UAAU,QAAQ,YAAY,UAAU,GAAqB;AAChG,QAAM,UAAU,MAAM;AACtB,QAAM,SAAS,MAAM;AACrB,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA;AAAA,MAGA,cAAY;AAAA,MACZ,oBAAkB;AAAA,MAClB,SAAS,CAAC,EAAE,MAAM,QAAQ,IAAI,MAC5B;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UAEA,MAAK;AAAA,UACL,cAAY;AAAA,UACZ,iBAAe;AAAA,UACf,iBAAc;AAAA,UACd,iBAAe,OAAO,UAAU;AAAA,UAChC,SAAS,CAAC,MAAM;AAEd,cAAE,eAAe;AACjB,mBAAO;AAAA,UACT;AAAA,UAGA,WAAW;AAAA,YACT;AAAA,YACA,QAAQ;AAAA,YACR;AAAA,YACA;AAAA,UACF;AAAA,UAEA,8BAAC,cAAW,eAAW,MAAC,WAAU,YAAW;AAAA;AAAA,MAC/C;AAAA,MAGD,gBACC,oBAAC,SAAI,IAAI,QAAQ,WAAU,+CACxB,UACH;AAAA;AAAA,EAEJ;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/help-popover.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { HelpCircle } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport type { RemLength } from \"../hooks/use-breakpoint\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { Popover } from \"./popover\";\n\n/** {@link HelpPopover}'s props. */\nexport interface HelpPopoverProps {\n /**\n * Names the \"?\" and the panel — a short question (\"What do the statuses mean?\") or\n * \"About <field>\" (\"About the settlement date\"), in the reader's language. Required:\n * a \"?\" named \"?\" tells a screen reader nothing about what it opens. Not the help\n * itself, which is `children`.\n */\n label: string;\n /** The help: a paragraph, or a few. Rendered in the panel at body size. */\n children: ReactNode;\n /** The panel's width — {@link Popover}'s `width`. Default `\"20rem\"`, a paragraph's\n * measure; it follows the text size and never exceeds the viewport. */\n width?: number | RemLength;\n /** Classes for the \"?\" button. */\n className?: string;\n}\n\n/** A paragraph's measure: 320 px at Normal, in rem so it grows with the text size. */\nconst HELP_WIDTH: RemLength = \"20rem\";\n\n/**\n * A \"?\" whose HELP opens on a tap, a click or Enter — for a paragraph too long to print\n * under a field (0.34, keksdose K3, live #400: *\"Remove the long text and move it behind\n * a clickable notice like a circle\"*).\n *\n * **Help, not a fact** (docs/text-size-harmonization.md §10.23). {@link FieldHint} is\n * the \"?\" for a FACT — a format, a limit, a consequence — and since 0.32 it turns into\n * its own words under the field at Large and on a touch screen (`useInlineHint`, §4 \"No\n * fact only in a tooltip\"), because a fact a reader cannot reach is a fact they do not\n * have. That rule stays. This part is for HELP: why the field exists, how to choose,\n * what the options mean — text the field is usable without. Printed inline it was the\n * longest thing in keksdose's transaction form (four statuses, ~500 characters, on every\n * new row on a phone). `useInlineHint` turns only a FieldHint into a caption, so this\n * one stays on the label line at every size.\n *\n * **On a field: its `help`** (0.36, round 0.36 decision 1). Every kit field that takes a\n * `hint` takes a `help` beside it, and the `@eifi1/ui-kit/rhf` bindings too: pass the\n * help's words and the field draws this \"?\" on its label line (an unlabelled one at its\n * end edge, outside the box), named \"About <label>\" — or pass a HelpPopover to name it\n * yourself. `hint` stays the FACT, printed under the field; while `help` is set a\n * FieldHint there draws no \"?\" of its own, so the field has one. Passed as `hint`, as in\n * 0.34, it still rides the label line. It also stands on its own, beside a heading or a\n * column title.\n *\n * **A popover, not a tooltip**: a tooltip bubble is for a line, and opens on hover a\n * finger never makes. This is the kit {@link Popover}: it opens and closes on a tap or a\n * click (and on Enter or Space, as any button does), closes on Escape and on a press\n * outside, and takes the focus into its panel and gives it back to the \"?\" on close. The\n * panel is a `dialog` named by `label` and described by the help, so a screen reader\n * reads both on opening. The \"?\" says `aria-expanded` and `aria-haspopup=\"dialog\"`.\n *\n * **\"?\" rather than \"!\"**: it answers a question (\"what is the difference between\n * accepted and cleared?\", keksdose live #247); \"!\" is a warning.\n *\n * ```tsx\n * <Select label=\"Status\" hint=\"Cleared and reconciled rows are locked.\" help={\n * <HelpPopover label=\"What do the statuses mean?\">\n * Pending: … Accepted: … Cleared: … Reconciled: …\n * </HelpPopover>\n * } … />\n * ```\n */\nexport function HelpPopover({ label, children, width = HELP_WIDTH, className }: HelpPopoverProps) {\n const panelId = useId();\n const bodyId = useId();\n return (\n <Popover\n width={width}\n panelId={panelId}\n // The panel is named by the question and described by the answer, so focus moving\n // into it reads both.\n aria-label={label}\n aria-describedby={bodyId}\n trigger={({ open, toggle, ref }) => (\n <button\n ref={ref}\n // Never `submit`: the \"?\" sits on a field's label line, inside a form.\n type=\"button\"\n aria-label={label}\n aria-expanded={open}\n aria-haspopup=\"dialog\"\n aria-controls={open ? panelId : undefined}\n onClick={(e) => {\n // The \"?\" sits in a label strip: the press is the \"?\"'s, not the field's.\n e.preventDefault();\n toggle();\n }}\n // FieldHint's look, so the two marks read as one family on a label line; the\n // open one holds its hover colour while its panel shows.\n className={cn(\n \"flex rounded-full text-placeholder transition-colors hover:text-secondary\",\n open && \"text-secondary\",\n FOCUS_RING,\n className,\n )}\n >\n <HelpCircle aria-hidden className=\"size-3.5\" />\n </button>\n )}\n >\n {() => (\n <div id={bodyId} className=\"space-y-2 text-sm leading-snug text-primary\">\n {children}\n </div>\n )}\n </Popover>\n );\n}\n"],"mappings":";AAyGU;AAzGV,SAAS,aAAa;AAEtB,SAAS,kBAAkB;AAC3B,SAAS,UAAU;AAEnB,SAAS,kBAAkB;AAC3B,SAAS,eAAe;AAqBxB,MAAM,aAAwB;AA4CvB,SAAS,YAAY,EAAE,OAAO,UAAU,QAAQ,YAAY,UAAU,GAAqB;AAChG,QAAM,UAAU,MAAM;AACtB,QAAM,SAAS,MAAM;AACrB,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA;AAAA,MAGA,cAAY;AAAA,MACZ,oBAAkB;AAAA,MAClB,SAAS,CAAC,EAAE,MAAM,QAAQ,IAAI,MAC5B;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UAEA,MAAK;AAAA,UACL,cAAY;AAAA,UACZ,iBAAe;AAAA,UACf,iBAAc;AAAA,UACd,iBAAe,OAAO,UAAU;AAAA,UAChC,SAAS,CAAC,MAAM;AAEd,cAAE,eAAe;AACjB,mBAAO;AAAA,UACT;AAAA,UAGA,WAAW;AAAA,YACT;AAAA,YACA,QAAQ;AAAA,YACR;AAAA,YACA;AAAA,UACF;AAAA,UAEA,8BAAC,cAAW,eAAW,MAAC,WAAU,YAAW;AAAA;AAAA,MAC/C;AAAA,MAGD,gBACC,oBAAC,SAAI,IAAI,QAAQ,WAAU,+CACxB,UACH;AAAA;AAAA,EAEJ;AAEJ;","names":[]}
@@ -17,12 +17,13 @@ export interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<"div">, "c
17
17
  }) => ReactNode;
18
18
  children: (close: () => void) => ReactNode;
19
19
  /**
20
- * Which edge of the trigger the panel lines up with. `start`/`end` follow the
21
- * reading direction (`end` is the right edge in LTR, the left in RTL); `left`/`right`
22
- * are physical and deprecated ({@link PhysicalAlign}). Default `end` — the same
23
- * right-aligned panel as before in LTR, mirrored in RTL.
20
+ * Which edge of the trigger the panel lines up with, by the reading direction: `end`
21
+ * is the right edge in LTR and the left in RTL. Default `end` — the same right-aligned
22
+ * panel as before in LTR, mirrored in RTL. The physical `left` / `right` went in 0.35
23
+ * (round 0.35 R1, refactor audit 2026-10, API-6e): `right` is `end` in a left-to-right
24
+ * page, `left` is `start`.
24
25
  */
25
- align?: "start" | "end" | PhysicalAlign;
26
+ align?: "start" | "end";
26
27
  panelClassName?: string;
27
28
  /** Extra classes for the WRAPPER (the panel has {@link panelClassName}). */
28
29
  className?: string;
@@ -30,12 +31,4 @@ export interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<"div">, "c
30
31
  * merged with its own ref, which the outside click and the hover are measured on. */
31
32
  ref?: Ref<HTMLDivElement>;
32
33
  }
33
- /**
34
- * @deprecated 0.34 — `"start"` / `"end"`, which follow the reading direction: `"right"`
35
- * is `"end"` in a left-to-right page, the default; removed in 0.35 (refactor audit
36
- * 2026-10, API-6e). The physical edges HoverMenu's `align` — and TopBarActionMenu's and
37
- * FeedbackMenu's, which hand it on — still take.
38
- */
39
- type PhysicalAlign = "left" | "right";
40
34
  export declare function HoverMenu({ trigger, children, align, panelClassName, className, "aria-label": ariaLabel, ref, ...rest }: HoverMenuProps): import("react").JSX.Element;
41
- export {};
@@ -8,7 +8,7 @@ import { anchoredInline } from "../hooks/use-anchored-panel.js";
8
8
  import { dirOf } from "../lib/direction.js";
9
9
  const OPEN_DELAY_MS = 120;
10
10
  const CLOSE_DELAY_MS = 120;
11
- const ALIGN_CLASS = { start: "start-0", end: "end-0", left: "left-0", right: "right-0" };
11
+ const ALIGN_CLASS = { start: "start-0", end: "end-0" };
12
12
  const ITEM_ROLES = '[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]';
13
13
  const PLAIN_ITEMS = "button:not([role]),a[href]:not([role])";
14
14
  const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])';
@@ -90,7 +90,7 @@ function HoverMenu({
90
90
  (el) => !menuRef.current?.contains(el)
91
91
  ) ?? null;
92
92
  const items = () => menuRef.current ? applyMenuSemantics(menuRef.current) : [];
93
- useDismissLayer(open, () => {
93
+ const layer = useDismissLayer(open, () => {
94
94
  const hadFocus = wrapperRef.current?.contains(document.activeElement) ?? false;
95
95
  close();
96
96
  if (hadFocus) triggerEl()?.focus();
@@ -176,7 +176,7 @@ function HoverMenu({
176
176
  onMouseEnter: handleMouseEnter,
177
177
  onMouseLeave: handleMouseLeave,
178
178
  onKeyDown: (e) => {
179
- runOwnKeyDown(e, rest.onKeyDown);
179
+ runOwnKeyDown(e, rest.onKeyDown, layer);
180
180
  if (open) takeEscape(e);
181
181
  onKeyDown(e);
182
182
  },