@eifi1/ui-kit 0.31.1 → 0.32.1

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 (565) hide show
  1. package/README.md +51 -28
  2. package/dist/account/delete-account-setting.d.ts +8 -3
  3. package/dist/admin/admin-action-confirm.d.ts +8 -3
  4. package/dist/admin/admin-action-log.d.ts +1 -1
  5. package/dist/admin/admin-action-log.js +4 -2
  6. package/dist/admin/admin-action-log.js.map +1 -1
  7. package/dist/admin/invitations-panel.d.ts +8 -3
  8. package/dist/admin/invitations-panel.js +29 -26
  9. package/dist/admin/invitations-panel.js.map +1 -1
  10. package/dist/admin/reviewer-scope-editor.d.ts +8 -3
  11. package/dist/admin/role-select.d.ts +10 -5
  12. package/dist/admin/transfer-ownership-dialog.d.ts +8 -3
  13. package/dist/admin/user-roster.d.ts +10 -5
  14. package/dist/admin/user-roster.js +8 -47
  15. package/dist/admin/user-roster.js.map +1 -1
  16. package/dist/auth/auth-errors.d.ts +43 -8
  17. package/dist/auth/auth-errors.js +24 -1
  18. package/dist/auth/auth-errors.js.map +1 -1
  19. package/dist/auth/register-form.d.ts +8 -3
  20. package/dist/billing/billing-banner.d.ts +184 -0
  21. package/dist/billing/billing-banner.js +98 -0
  22. package/dist/billing/billing-banner.js.map +1 -0
  23. package/dist/billing/billing-labels.d.ts +100 -0
  24. package/dist/billing/billing-labels.js +76 -0
  25. package/dist/billing/billing-labels.js.map +1 -0
  26. package/dist/billing/billing-lock.d.ts +158 -0
  27. package/dist/billing/billing-lock.js +17 -0
  28. package/dist/billing/billing-lock.js.map +1 -0
  29. package/dist/billing/billing-standing.d.ts +149 -0
  30. package/dist/billing/billing-standing.js +28 -0
  31. package/dist/billing/billing-standing.js.map +1 -0
  32. package/dist/billing/plan-card.d.ts +248 -0
  33. package/dist/billing/plan-card.js +248 -0
  34. package/dist/billing/plan-card.js.map +1 -0
  35. package/dist/billing/plan-limit-notice.d.ts +156 -0
  36. package/dist/billing/plan-limit-notice.js +76 -0
  37. package/dist/billing/plan-limit-notice.js.map +1 -0
  38. package/dist/billing/plan-limit.d.ts +34 -0
  39. package/dist/billing/plan-limit.js +20 -0
  40. package/dist/billing/plan-limit.js.map +1 -0
  41. package/dist/billing/plan-price.d.ts +61 -0
  42. package/dist/billing/plan-price.js +45 -0
  43. package/dist/billing/plan-price.js.map +1 -0
  44. package/dist/billing/subscription-actions.d.ts +133 -0
  45. package/dist/billing/subscription-actions.js +28 -0
  46. package/dist/billing/subscription-actions.js.map +1 -0
  47. package/dist/billing/subscription-status-chip.d.ts +149 -0
  48. package/dist/billing/subscription-status-chip.js +48 -0
  49. package/dist/billing/subscription-status-chip.js.map +1 -0
  50. package/dist/chart.d.ts +9 -3
  51. package/dist/chart.js.map +1 -1
  52. package/dist/components/account-chips.d.ts +16 -4
  53. package/dist/components/account-chips.js +21 -3
  54. package/dist/components/account-chips.js.map +1 -1
  55. package/dist/components/alert-banner.d.ts +6 -1
  56. package/dist/components/alert-banner.js +5 -4
  57. package/dist/components/alert-banner.js.map +1 -1
  58. package/dist/components/amount-input.d.ts +8 -3
  59. package/dist/components/amount-input.js +5 -5
  60. package/dist/components/amount-input.js.map +1 -1
  61. package/dist/components/appearance-labels.d.ts +38 -0
  62. package/dist/components/appearance-labels.js +19 -0
  63. package/dist/components/appearance-labels.js.map +1 -0
  64. package/dist/components/autocomplete.js +2 -2
  65. package/dist/components/autocomplete.js.map +1 -1
  66. package/dist/components/breadcrumbs.js +3 -2
  67. package/dist/components/breadcrumbs.js.map +1 -1
  68. package/dist/components/bulk-action-bar.d.ts +2 -2
  69. package/dist/components/bulk-action-bar.js +7 -12
  70. package/dist/components/bulk-action-bar.js.map +1 -1
  71. package/dist/components/button-group.d.ts +8 -3
  72. package/dist/components/button-group.js +7 -1
  73. package/dist/components/button-group.js.map +1 -1
  74. package/dist/components/calculator.d.ts +8 -3
  75. package/dist/components/calculator.js +4 -3
  76. package/dist/components/calculator.js.map +1 -1
  77. package/dist/components/calendar-heatmap.js +5 -5
  78. package/dist/components/calendar-heatmap.js.map +1 -1
  79. package/dist/components/chart-height.d.ts +23 -0
  80. package/dist/components/chart-height.js +15 -0
  81. package/dist/components/chart-height.js.map +1 -0
  82. package/dist/components/chart-zoom.js +6 -1
  83. package/dist/components/chart-zoom.js.map +1 -1
  84. package/dist/components/checkbox-group.js +2 -2
  85. package/dist/components/checkbox-group.js.map +1 -1
  86. package/dist/components/checkbox.d.ts +10 -0
  87. package/dist/components/checkbox.js +25 -10
  88. package/dist/components/checkbox.js.map +1 -1
  89. package/dist/components/chip.d.ts +19 -3
  90. package/dist/components/chip.js +54 -23
  91. package/dist/components/chip.js.map +1 -1
  92. package/dist/components/choice-card.d.ts +1 -1
  93. package/dist/components/choice-card.js +9 -8
  94. package/dist/components/choice-card.js.map +1 -1
  95. package/dist/components/column-mapper.js +1 -1
  96. package/dist/components/column-mapper.js.map +1 -1
  97. package/dist/components/combobox-core.js +8 -7
  98. package/dist/components/combobox-core.js.map +1 -1
  99. package/dist/components/combobox.js +8 -8
  100. package/dist/components/combobox.js.map +1 -1
  101. package/dist/components/confirm-dialog.d.ts +8 -3
  102. package/dist/components/copy-button.d.ts +10 -5
  103. package/dist/components/country-select.js +1 -1
  104. package/dist/components/country-select.js.map +1 -1
  105. package/dist/components/currency-select.js +3 -3
  106. package/dist/components/currency-select.js.map +1 -1
  107. package/dist/components/danger-confirm.d.ts +8 -3
  108. package/dist/components/data-table-cells.d.ts +1 -1
  109. package/dist/components/data-table-filter-popover.d.ts +1 -1
  110. package/dist/components/data-table-filter-popover.js +4 -1
  111. package/dist/components/data-table-filter-popover.js.map +1 -1
  112. package/dist/components/data-table-filters.d.ts +1 -1
  113. package/dist/components/data-table-labels.d.ts +1 -1
  114. package/dist/components/data-table-pagination.d.ts +3 -2
  115. package/dist/components/data-table-pagination.js +15 -6
  116. package/dist/components/data-table-pagination.js.map +1 -1
  117. package/dist/components/data-table.d.ts +1 -1
  118. package/dist/components/data-table.js +123 -52
  119. package/dist/components/data-table.js.map +1 -1
  120. package/dist/components/date-picker.d.ts +1 -1
  121. package/dist/components/date-picker.js +18 -6
  122. package/dist/components/date-picker.js.map +1 -1
  123. package/dist/components/description-list.js +2 -2
  124. package/dist/components/description-list.js.map +1 -1
  125. package/dist/components/dialog-frame.js +2 -1
  126. package/dist/components/dialog-frame.js.map +1 -1
  127. package/dist/components/disclosure.js +3 -2
  128. package/dist/components/disclosure.js.map +1 -1
  129. package/dist/components/dropdown.d.ts +1 -1
  130. package/dist/components/dropdown.js +7 -1
  131. package/dist/components/dropdown.js.map +1 -1
  132. package/dist/components/facing-pair.d.ts +19 -6
  133. package/dist/components/facing-pair.js +3 -2
  134. package/dist/components/facing-pair.js.map +1 -1
  135. package/dist/components/field-parts.d.ts +102 -6
  136. package/dist/components/field-parts.js +81 -6
  137. package/dist/components/field-parts.js.map +1 -1
  138. package/dist/components/field-sync.d.ts +4 -1
  139. package/dist/components/field-sync.js +12 -2
  140. package/dist/components/field-sync.js.map +1 -1
  141. package/dist/components/field.js +2 -2
  142. package/dist/components/field.js.map +1 -1
  143. package/dist/components/file-button.d.ts +8 -3
  144. package/dist/components/file-dropzone.d.ts +8 -3
  145. package/dist/components/floating-panel.js +7 -4
  146. package/dist/components/floating-panel.js.map +1 -1
  147. package/dist/components/focus-ring.d.ts +12 -0
  148. package/dist/components/focus-ring.js +8 -0
  149. package/dist/components/focus-ring.js.map +1 -0
  150. package/dist/components/form-actions.d.ts +8 -3
  151. package/dist/components/form-actions.js +33 -19
  152. package/dist/components/form-actions.js.map +1 -1
  153. package/dist/components/full-bleed-dialog.d.ts +11 -1
  154. package/dist/components/full-bleed-dialog.js +3 -1
  155. package/dist/components/full-bleed-dialog.js.map +1 -1
  156. package/dist/components/iban-input.d.ts +8 -3
  157. package/dist/components/image-grid.js +1 -1
  158. package/dist/components/image-grid.js.map +1 -1
  159. package/dist/components/inline-edit-field.js +10 -4
  160. package/dist/components/inline-edit-field.js.map +1 -1
  161. package/dist/components/language-select.d.ts +8 -3
  162. package/dist/components/legal-page.d.ts +26 -5
  163. package/dist/components/legal-page.js +3 -1
  164. package/dist/components/legal-page.js.map +1 -1
  165. package/dist/components/legal.d.ts +8 -3
  166. package/dist/components/legal.js +2 -1
  167. package/dist/components/legal.js.map +1 -1
  168. package/dist/components/lightbox.js +24 -2
  169. package/dist/components/lightbox.js.map +1 -1
  170. package/dist/components/line-items.js +2 -1
  171. package/dist/components/line-items.js.map +1 -1
  172. package/dist/components/list.d.ts +5 -0
  173. package/dist/components/list.js +13 -9
  174. package/dist/components/list.js.map +1 -1
  175. package/dist/components/measured-grid.d.ts +3 -1
  176. package/dist/components/measured-grid.js +38 -20
  177. package/dist/components/measured-grid.js.map +1 -1
  178. package/dist/components/menu-item.js +2 -1
  179. package/dist/components/menu-item.js.map +1 -1
  180. package/dist/components/mini-calendar.js +8 -4
  181. package/dist/components/mini-calendar.js.map +1 -1
  182. package/dist/components/money-field.d.ts +8 -3
  183. package/dist/components/month-picker.js +50 -39
  184. package/dist/components/month-picker.js.map +1 -1
  185. package/dist/components/nav-pills.js +1 -1
  186. package/dist/components/nav-pills.js.map +1 -1
  187. package/dist/components/number-field.d.ts +8 -3
  188. package/dist/components/number-input.d.ts +11 -4
  189. package/dist/components/number-input.js +8 -6
  190. package/dist/components/number-input.js.map +1 -1
  191. package/dist/components/numpad-sheet.d.ts +8 -3
  192. package/dist/components/numpad-sheet.js +2 -1
  193. package/dist/components/numpad-sheet.js.map +1 -1
  194. package/dist/components/one-time-code-input.js +2 -2
  195. package/dist/components/one-time-code-input.js.map +1 -1
  196. package/dist/components/page-contents.js +2 -2
  197. package/dist/components/page-contents.js.map +1 -1
  198. package/dist/components/phone-input.d.ts +8 -3
  199. package/dist/components/phone-input.js +4 -4
  200. package/dist/components/phone-input.js.map +1 -1
  201. package/dist/components/pie-chart.d.ts +8 -2
  202. package/dist/components/pie-chart.js +11 -7
  203. package/dist/components/pie-chart.js.map +1 -1
  204. package/dist/components/popover.d.ts +9 -1
  205. package/dist/components/popover.js +8 -4
  206. package/dist/components/popover.js.map +1 -1
  207. package/dist/components/row-actions.d.ts +100 -0
  208. package/dist/components/row-actions.js +231 -0
  209. package/dist/components/row-actions.js.map +1 -0
  210. package/dist/components/scroll-area.js +1 -1
  211. package/dist/components/scroll-area.js.map +1 -1
  212. package/dist/components/search-field.js +3 -2
  213. package/dist/components/search-field.js.map +1 -1
  214. package/dist/components/series-chart-budget.d.ts +30 -7
  215. package/dist/components/series-chart-budget.js +12 -4
  216. package/dist/components/series-chart-budget.js.map +1 -1
  217. package/dist/components/series-chart-ticks.d.ts +7 -1
  218. package/dist/components/series-chart-ticks.js +6 -0
  219. package/dist/components/series-chart-ticks.js.map +1 -1
  220. package/dist/components/series-chart.d.ts +58 -28
  221. package/dist/components/series-chart.js +42 -32
  222. package/dist/components/series-chart.js.map +1 -1
  223. package/dist/components/settings-fields.d.ts +57 -8
  224. package/dist/components/settings-fields.js +39 -1
  225. package/dist/components/settings-fields.js.map +1 -1
  226. package/dist/components/share-card.d.ts +11 -6
  227. package/dist/components/share-card.js +8 -2
  228. package/dist/components/share-card.js.map +1 -1
  229. package/dist/components/sign-chip.d.ts +4 -0
  230. package/dist/components/sign-chip.js +17 -4
  231. package/dist/components/sign-chip.js.map +1 -1
  232. package/dist/components/signature-pad.d.ts +3 -1
  233. package/dist/components/signature-pad.js +8 -5
  234. package/dist/components/signature-pad.js.map +1 -1
  235. package/dist/components/slider.js +7 -6
  236. package/dist/components/slider.js.map +1 -1
  237. package/dist/components/sparkline.d.ts +5 -1
  238. package/dist/components/sparkline.js +4 -2
  239. package/dist/components/sparkline.js.map +1 -1
  240. package/dist/components/stat-tile.d.ts +17 -1
  241. package/dist/components/stat-tile.js +48 -20
  242. package/dist/components/stat-tile.js.map +1 -1
  243. package/dist/components/status-dot.d.ts +4 -0
  244. package/dist/components/status-dot.js +9 -2
  245. package/dist/components/status-dot.js.map +1 -1
  246. package/dist/components/swipeable-row.js +10 -2
  247. package/dist/components/swipeable-row.js.map +1 -1
  248. package/dist/components/switch.d.ts +21 -7
  249. package/dist/components/switch.js +24 -11
  250. package/dist/components/switch.js.map +1 -1
  251. package/dist/components/table.js +2 -2
  252. package/dist/components/table.js.map +1 -1
  253. package/dist/components/text-link.d.ts +8 -3
  254. package/dist/components/text-link.js +1 -1
  255. package/dist/components/text-link.js.map +1 -1
  256. package/dist/components/text.d.ts +7 -2
  257. package/dist/components/text.js +5 -5
  258. package/dist/components/text.js.map +1 -1
  259. package/dist/components/tile-radio.js +2 -1
  260. package/dist/components/tile-radio.js.map +1 -1
  261. package/dist/components/time-input.d.ts +8 -3
  262. package/dist/components/toast.d.ts +18 -2
  263. package/dist/components/toast.js +8 -5
  264. package/dist/components/toast.js.map +1 -1
  265. package/dist/components/toggle-group.d.ts +25 -3
  266. package/dist/components/toggle-group.js +40 -11
  267. package/dist/components/toggle-group.js.map +1 -1
  268. package/dist/components/toggle-legend.d.ts +10 -1
  269. package/dist/components/toggle-legend.js +21 -7
  270. package/dist/components/toggle-legend.js.map +1 -1
  271. package/dist/components/translation-review-editor.js +1 -1
  272. package/dist/components/translation-review-editor.js.map +1 -1
  273. package/dist/components/translation-review-parts.d.ts +4 -0
  274. package/dist/components/translation-review.d.ts +4 -0
  275. package/dist/components/translation-review.js +3 -3
  276. package/dist/components/translation-review.js.map +1 -1
  277. package/dist/components/tree-view.js +3 -2
  278. package/dist/components/tree-view.js.map +1 -1
  279. package/dist/components/treemap.d.ts +5 -2
  280. package/dist/components/treemap.js +33 -16
  281. package/dist/components/treemap.js.map +1 -1
  282. package/dist/components/ui.d.ts +8 -3
  283. package/dist/components/ui.js +207 -119
  284. package/dist/components/ui.js.map +1 -1
  285. package/dist/components/use-table-state.d.ts +1 -1
  286. package/dist/components/user-avatar.d.ts +5 -1
  287. package/dist/components/user-avatar.js +1 -1
  288. package/dist/components/user-avatar.js.map +1 -1
  289. package/dist/components/wizard-stepper.js +20 -14
  290. package/dist/components/wizard-stepper.js.map +1 -1
  291. package/dist/{data-table-labels-DUELeXpa.d.ts → data-table-labels-kpAAVi6g.d.ts} +26 -2
  292. package/dist/data-table.d.ts +1 -1
  293. package/dist/{delete-account-setting-CpIhzxxZ.d.ts → delete-account-setting-FLyJf-kZ.d.ts} +642 -60
  294. package/dist/demo/demo-banner.js +1 -0
  295. package/dist/demo/demo-banner.js.map +1 -1
  296. package/dist/feedback/feedback-attachment.d.ts +8 -3
  297. package/dist/feedback/feedback-context.d.ts +8 -3
  298. package/dist/feedback/feedback-crash.d.ts +8 -3
  299. package/dist/feedback/feedback-dialog.d.ts +8 -3
  300. package/dist/feedback/feedback-dialog.js +3 -3
  301. package/dist/feedback/feedback-dialog.js.map +1 -1
  302. package/dist/feedback/feedback-inbox.d.ts +8 -3
  303. package/dist/feedback/feedback-inbox.js +2 -2
  304. package/dist/feedback/feedback-inbox.js.map +1 -1
  305. package/dist/feedback/feedback-labels.d.ts +8 -3
  306. package/dist/feedback/feedback-menu.d.ts +9 -4
  307. package/dist/feedback/feedback-record.d.ts +8 -3
  308. package/dist/feedback/feedback-row-detail.d.ts +8 -3
  309. package/dist/feedback/feedback-status-undo.d.ts +8 -3
  310. package/dist/feedback/feedback-submit.d.ts +8 -3
  311. package/dist/feedback/feedback-swipe.d.ts +8 -3
  312. package/dist/feedback/feedback-table.d.ts +8 -3
  313. package/dist/feedback/feedback-thread.d.ts +8 -3
  314. package/dist/feedback/feedback-thread.js +3 -3
  315. package/dist/feedback/feedback-thread.js.map +1 -1
  316. package/dist/feedback.d.ts +8 -3
  317. package/dist/hooks/use-anchored-panel.d.ts +32 -3
  318. package/dist/hooks/use-anchored-panel.js +21 -3
  319. package/dist/hooks/use-anchored-panel.js.map +1 -1
  320. package/dist/hooks/use-breakpoint.d.ts +88 -0
  321. package/dist/hooks/use-breakpoint.js +50 -0
  322. package/dist/hooks/use-breakpoint.js.map +1 -0
  323. package/dist/hooks/use-file-drop.d.ts +8 -3
  324. package/dist/hooks/use-large-text.d.ts +29 -0
  325. package/dist/hooks/use-large-text.js +20 -0
  326. package/dist/hooks/use-large-text.js.map +1 -0
  327. package/dist/hooks/use-windowed-rows.d.ts +52 -8
  328. package/dist/hooks/use-windowed-rows.js +94 -5
  329. package/dist/hooks/use-windowed-rows.js.map +1 -1
  330. package/dist/i18n/defaults.d.ts +8 -3
  331. package/dist/i18n/defaults.js +8 -0
  332. package/dist/i18n/defaults.js.map +1 -1
  333. package/dist/i18n/german.d.ts +8 -3
  334. package/dist/i18n/german.js +94 -3
  335. package/dist/i18n/german.js.map +1 -1
  336. package/dist/i18n/kit-labels.d.ts +8 -3
  337. package/dist/i18n/kit-labels.js.map +1 -1
  338. package/dist/i18n/languages.d.ts +10 -5
  339. package/dist/i18n/locales/de-CH.d.ts +8 -3
  340. package/dist/i18n/locales/en.d.ts +8 -3
  341. package/dist/i18n/locales/es.d.ts +8 -3
  342. package/dist/i18n/locales/es.js +89 -3
  343. package/dist/i18n/locales/es.js.map +1 -1
  344. package/dist/i18n/locales/fr.d.ts +8 -3
  345. package/dist/i18n/locales/fr.js +91 -3
  346. package/dist/i18n/locales/fr.js.map +1 -1
  347. package/dist/i18n/locales/hu.d.ts +8 -3
  348. package/dist/i18n/locales/hu.js +91 -3
  349. package/dist/i18n/locales/hu.js.map +1 -1
  350. package/dist/i18n/locales/it.d.ts +8 -3
  351. package/dist/i18n/locales/it.js +92 -3
  352. package/dist/i18n/locales/it.js.map +1 -1
  353. package/dist/i18n/locales/zh.d.ts +8 -3
  354. package/dist/i18n/locales/zh.js +89 -3
  355. package/dist/i18n/locales/zh.js.map +1 -1
  356. package/dist/i18n/review.d.ts +8 -3
  357. package/dist/i18n/review.js +15 -1
  358. package/dist/i18n/review.js.map +1 -1
  359. package/dist/index.d.ts +27 -11
  360. package/dist/index.js +77 -4
  361. package/dist/index.js.map +1 -1
  362. package/dist/landing/landing-actions.d.ts +8 -3
  363. package/dist/landing/landing-sections.d.ts +8 -3
  364. package/dist/landing/landing-sections.js +7 -3
  365. package/dist/landing/landing-sections.js.map +1 -1
  366. package/dist/landing/public-header.d.ts +9 -4
  367. package/dist/landing/public-header.js +3 -3
  368. package/dist/landing/public-header.js.map +1 -1
  369. package/dist/landing/routing.d.ts +8 -3
  370. package/dist/lib/cn.js +3 -2
  371. package/dist/lib/cn.js.map +1 -1
  372. package/dist/rhf/fields.d.ts +8 -3
  373. package/dist/rhf/fields.js +1 -1
  374. package/dist/rhf/fields.js.map +1 -1
  375. package/dist/rhf/form.d.ts +8 -3
  376. package/dist/rhf/form.js +2 -2
  377. package/dist/rhf/form.js.map +1 -1
  378. package/dist/rhf.d.ts +8 -3
  379. package/dist/search/command-palette.d.ts +1 -1
  380. package/dist/search/command-palette.js +9 -8
  381. package/dist/search/command-palette.js.map +1 -1
  382. package/dist/settings/settings-layout.js +2 -1
  383. package/dist/settings/settings-layout.js.map +1 -1
  384. package/dist/settings/settings-section.d.ts +8 -3
  385. package/dist/settings/use-account-appearance.d.ts +92 -0
  386. package/dist/settings/use-account-appearance.js +63 -0
  387. package/dist/settings/use-account-appearance.js.map +1 -0
  388. package/dist/settings/use-settings-route.d.ts +1 -1
  389. package/dist/settings/use-settings-route.js +2 -3
  390. package/dist/settings/use-settings-route.js.map +1 -1
  391. package/dist/shell/app-shell.d.ts +12 -171
  392. package/dist/shell/app-shell.js +144 -28
  393. package/dist/shell/app-shell.js.map +1 -1
  394. package/dist/shell/auth-layout.js +1 -1
  395. package/dist/shell/auth-layout.js.map +1 -1
  396. package/dist/shell/option-switcher-menu.js +1 -1
  397. package/dist/shell/option-switcher-menu.js.map +1 -1
  398. package/dist/shell/top-bar-brand.d.ts +8 -3
  399. package/dist/shell/top-bar-brand.js +3 -1
  400. package/dist/shell/top-bar-brand.js.map +1 -1
  401. package/dist/shell/topbar-action-menu.d.ts +4 -0
  402. package/dist/shell/topbar-action-menu.js +3 -3
  403. package/dist/shell/topbar-action-menu.js.map +1 -1
  404. package/dist/shell/topbar-controls.js +4 -4
  405. package/dist/shell/topbar-controls.js.map +1 -1
  406. package/dist/shell.d.ts +9 -5
  407. package/dist/theme/contrast-tokens.d.ts +41 -0
  408. package/dist/theme/contrast-tokens.js +59 -0
  409. package/dist/theme/contrast-tokens.js.map +1 -0
  410. package/dist/theme/contrast.d.ts +110 -0
  411. package/dist/theme/contrast.js +128 -0
  412. package/dist/theme/contrast.js.map +1 -0
  413. package/dist/theme/palette-presets.d.ts +12 -1
  414. package/dist/theme/palette-presets.js +9 -4
  415. package/dist/theme/palette-presets.js.map +1 -1
  416. package/dist/theme/palette-store.js.map +1 -1
  417. package/dist/theme/text-size.d.ts +173 -0
  418. package/dist/theme/text-size.js +121 -0
  419. package/dist/theme/text-size.js.map +1 -0
  420. package/dist/wizard/stepper-nav.d.ts +8 -3
  421. package/dist/wizard/wizard-summary.js +1 -1
  422. package/dist/wizard/wizard-summary.js.map +1 -1
  423. package/dist/wizard.d.ts +8 -3
  424. package/package.json +1 -1
  425. package/src/admin/admin-action-log.tsx +6 -1
  426. package/src/admin/invitations-panel.tsx +36 -29
  427. package/src/admin/user-roster.tsx +17 -72
  428. package/src/auth/auth-errors.ts +68 -7
  429. package/src/billing/billing-banner.tsx +205 -0
  430. package/src/billing/billing-labels.ts +240 -0
  431. package/src/billing/billing-lock.ts +75 -0
  432. package/src/billing/billing-standing.ts +72 -0
  433. package/src/billing/plan-card.tsx +458 -0
  434. package/src/billing/plan-limit-notice.tsx +145 -0
  435. package/src/billing/plan-limit.ts +53 -0
  436. package/src/billing/plan-price.ts +101 -0
  437. package/src/billing/subscription-actions.tsx +76 -0
  438. package/src/billing/subscription-status-chip.tsx +95 -0
  439. package/src/chart.ts +2 -0
  440. package/src/components/account-chips.tsx +36 -10
  441. package/src/components/alert-banner.tsx +16 -7
  442. package/src/components/amount-input.tsx +5 -5
  443. package/src/components/appearance-labels.ts +47 -0
  444. package/src/components/autocomplete.tsx +2 -2
  445. package/src/components/breadcrumbs.tsx +3 -3
  446. package/src/components/bulk-action-bar.tsx +11 -14
  447. package/src/components/button-group.tsx +21 -0
  448. package/src/components/calculator.tsx +4 -3
  449. package/src/components/calendar-heatmap.tsx +5 -5
  450. package/src/components/chart-height.ts +30 -0
  451. package/src/components/chart-zoom.tsx +6 -1
  452. package/src/components/checkbox-group.tsx +2 -2
  453. package/src/components/checkbox.tsx +62 -20
  454. package/src/components/chip.tsx +139 -30
  455. package/src/components/choice-card.tsx +16 -11
  456. package/src/components/column-mapper.tsx +1 -1
  457. package/src/components/combobox-core.tsx +8 -7
  458. package/src/components/combobox.tsx +8 -8
  459. package/src/components/country-select.tsx +1 -1
  460. package/src/components/currency-select.tsx +3 -3
  461. package/src/components/data-table-filter-popover.tsx +7 -1
  462. package/src/components/data-table-pagination.tsx +60 -31
  463. package/src/components/data-table.tsx +164 -43
  464. package/src/components/date-picker.tsx +44 -10
  465. package/src/components/description-list.tsx +2 -2
  466. package/src/components/dialog-frame.tsx +2 -1
  467. package/src/components/disclosure.tsx +3 -2
  468. package/src/components/dropdown.tsx +10 -2
  469. package/src/components/facing-pair.ts +12 -5
  470. package/src/components/field-parts.tsx +190 -7
  471. package/src/components/field-sync.tsx +17 -3
  472. package/src/components/field.tsx +2 -2
  473. package/src/components/floating-panel.tsx +7 -4
  474. package/src/components/focus-ring.ts +12 -0
  475. package/src/components/form-actions.tsx +55 -18
  476. package/src/components/full-bleed-dialog.tsx +18 -4
  477. package/src/components/image-grid.tsx +1 -1
  478. package/src/components/inline-edit-field.tsx +26 -4
  479. package/src/components/legal-page.tsx +28 -2
  480. package/src/components/legal.tsx +19 -1
  481. package/src/components/lightbox.tsx +24 -2
  482. package/src/components/line-items.tsx +7 -1
  483. package/src/components/list.tsx +36 -10
  484. package/src/components/measured-grid.tsx +49 -24
  485. package/src/components/menu-item.tsx +2 -2
  486. package/src/components/mini-calendar.tsx +8 -4
  487. package/src/components/month-picker.tsx +15 -6
  488. package/src/components/nav-pills.tsx +1 -1
  489. package/src/components/number-input.tsx +14 -7
  490. package/src/components/numpad-sheet.tsx +2 -2
  491. package/src/components/one-time-code-input.tsx +2 -2
  492. package/src/components/page-contents.tsx +2 -2
  493. package/src/components/phone-input.tsx +9 -4
  494. package/src/components/pie-chart.tsx +19 -9
  495. package/src/components/popover.tsx +22 -5
  496. package/src/components/row-actions.tsx +450 -0
  497. package/src/components/scroll-area.tsx +1 -1
  498. package/src/components/search-field.tsx +4 -2
  499. package/src/components/series-chart-budget.ts +40 -8
  500. package/src/components/series-chart-ticks.ts +10 -0
  501. package/src/components/series-chart.tsx +123 -72
  502. package/src/components/settings-fields.tsx +89 -5
  503. package/src/components/share-card.tsx +31 -2
  504. package/src/components/sign-chip.tsx +28 -4
  505. package/src/components/signature-pad.tsx +13 -7
  506. package/src/components/slider.tsx +22 -9
  507. package/src/components/sparkline.tsx +11 -3
  508. package/src/components/stat-tile.tsx +70 -14
  509. package/src/components/status-dot.tsx +25 -2
  510. package/src/components/swipeable-row.tsx +10 -2
  511. package/src/components/switch.tsx +57 -23
  512. package/src/components/table.tsx +2 -2
  513. package/src/components/text-link.tsx +1 -1
  514. package/src/components/text.tsx +11 -6
  515. package/src/components/tile-radio.tsx +4 -1
  516. package/src/components/toast.tsx +27 -7
  517. package/src/components/toggle-group.tsx +115 -14
  518. package/src/components/toggle-legend.tsx +53 -8
  519. package/src/components/translation-review-editor.tsx +1 -1
  520. package/src/components/translation-review.tsx +11 -5
  521. package/src/components/tree-view.tsx +3 -2
  522. package/src/components/treemap.tsx +27 -10
  523. package/src/components/ui.tsx +353 -153
  524. package/src/components/user-avatar.tsx +1 -1
  525. package/src/components/wizard-stepper.tsx +5 -1
  526. package/src/demo/demo-banner.tsx +5 -0
  527. package/src/feedback/feedback-dialog.tsx +3 -3
  528. package/src/feedback/feedback-inbox.tsx +2 -2
  529. package/src/feedback/feedback-thread.tsx +3 -3
  530. package/src/hooks/use-anchored-panel.ts +67 -5
  531. package/src/hooks/use-breakpoint.ts +132 -0
  532. package/src/hooks/use-large-text.ts +41 -0
  533. package/src/hooks/use-windowed-rows.ts +168 -12
  534. package/src/i18n/defaults.ts +8 -0
  535. package/src/i18n/german.ts +107 -0
  536. package/src/i18n/kit-labels.tsx +12 -0
  537. package/src/i18n/locales/es.ts +98 -0
  538. package/src/i18n/locales/fr.ts +102 -0
  539. package/src/i18n/locales/hu.ts +100 -0
  540. package/src/i18n/locales/it.ts +103 -0
  541. package/src/i18n/locales/zh.ts +94 -0
  542. package/src/i18n/review.ts +14 -0
  543. package/src/index.ts +76 -6
  544. package/src/landing/landing-sections.tsx +7 -3
  545. package/src/landing/public-header.tsx +4 -4
  546. package/src/lib/cn.ts +13 -2
  547. package/src/rhf/fields.tsx +1 -1
  548. package/src/rhf/form.tsx +2 -2
  549. package/src/search/command-palette.tsx +10 -9
  550. package/src/settings/settings-layout.tsx +2 -1
  551. package/src/settings/use-account-appearance.ts +148 -0
  552. package/src/settings/use-settings-route.ts +3 -4
  553. package/src/shell/app-shell.tsx +278 -54
  554. package/src/shell/auth-layout.tsx +1 -1
  555. package/src/shell/option-switcher-menu.tsx +1 -1
  556. package/src/shell/top-bar-brand.tsx +3 -1
  557. package/src/shell/topbar-action-menu.tsx +3 -3
  558. package/src/shell/topbar-controls.tsx +4 -4
  559. package/src/theme/contrast-tokens.ts +112 -0
  560. package/src/theme/contrast.ts +225 -0
  561. package/src/theme/palette-presets.ts +24 -4
  562. package/src/theme/palette-store.ts +6 -1
  563. package/src/theme/text-size.ts +283 -0
  564. package/src/wizard/wizard-summary.tsx +4 -2
  565. package/tokens.css +158 -8
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/form-actions.tsx"],"sourcesContent":["import { isValidElement, useCallback, useEffect, useRef } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode, Ref } from \"react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { isApplePlatform } from \"../hooks/use-hotkey\";\nimport { Button, Spinner, type ButtonProps, type ButtonSize, type ButtonVariant } from \"./ui\";\nimport { useWriteLock } from \"./write-lock\";\n\n/** The words {@link FormActions} renders on its own behalf — the `form` namespace. */\nexport interface FormActionsLabels {\n save: string;\n cancel: string;\n /**\n * The {@link FormActionsProps.submitShortcut} hint beside Save's text, when\n * {@link FormActionsProps.submitShortcutHint} shows it. `apple` is true on a Mac,\n * iPhone or iPad, where the key is Cmd: \"⌘ Enter\" there, \"Ctrl+Enter\" elsewhere\n * (a German keyboard says \"Strg\").\n */\n submitShortcut: (apple: boolean) => string;\n}\n\nexport const DEFAULT_FORM_ACTIONS_LABELS: FormActionsLabels = {\n save: \"Save\",\n cancel: \"Cancel\",\n submitShortcut: (apple) => (apple ? \"⌘ Enter\" : \"Ctrl+Enter\"),\n};\n\n/** See {@link FormActionsProps.submitShortcut}. */\nexport type FormActionsSubmitShortcut = \"mod-enter\";\n\nexport type FormActionsAlign = \"start\" | \"center\" | \"end\" | \"between\";\n\n/**\n * Where the row sits:\n * - `inline` (default): under the form's last field, `pt-4` above it.\n * - `sticky`: stuck to the bottom of the scrolling page or pane, on the surface\n * colour with a rule above, clear of the phone's home indicator — for a long form\n * whose Save should not be a scroll away.\n * - `dialog`: no spacing of its own, for a dialog's footer slot (DialogFrame,\n * FullBleedDialog's `footer`), which already pads and rules it.\n */\nexport type FormActionsPlacement = \"inline\" | \"sticky\" | \"dialog\";\n\n/**\n * A placement per breakpoint, mobile first — `{ base: \"sticky\", md: \"inline\" }` is a\n * Save row stuck to the bottom of a phone's long form and in the flow under the last\n * field from 768px up, where the form fits. The breakpoints are Tailwind's (sm 640,\n * md 768, lg 1024, xl 1280px), as `BulkActionBar`'s `variant` breakpoints are, and\n * resolved the same way, in JS: the sticky row is positioned by inline style, which a\n * `md:` class cannot reach. keksdose switched `placement` on its own media query at\n * every long form for want of this. The breakpoints are the VIEWPORT's, not the\n * container's: a form in a narrow pane on a wide screen resolves to `md`.\n */\nexport interface ResponsiveFormActionsPlacement {\n base: FormActionsPlacement;\n sm?: FormActionsPlacement;\n md?: FormActionsPlacement;\n lg?: FormActionsPlacement;\n xl?: FormActionsPlacement;\n}\n\nconst BREAKPOINTS = [\n [\"xl\", \"(min-width: 1280px)\"],\n [\"lg\", \"(min-width: 1024px)\"],\n [\"md\", \"(min-width: 768px)\"],\n [\"sm\", \"(min-width: 640px)\"],\n] as const;\n\n/** The placement in force: the widest breakpoint that matches AND names one, else\n * `base`. The queries are subscribed unconditionally (hooks cannot be skipped); a\n * plain string ignores them. Without `matchMedia` (SSR, tests) none match, so the row\n * renders its `base`, the phone's. The same resolution as BulkActionBar's variant. */\nfunction useResolvedPlacement(\n placement: FormActionsPlacement | ResponsiveFormActionsPlacement,\n): FormActionsPlacement {\n const matches = {\n xl: useMediaQuery(BREAKPOINTS[0][1], false),\n lg: useMediaQuery(BREAKPOINTS[1][1], false),\n md: useMediaQuery(BREAKPOINTS[2][1], false),\n sm: useMediaQuery(BREAKPOINTS[3][1], false),\n };\n if (typeof placement === \"string\") return placement;\n for (const [key] of BREAKPOINTS) {\n const p = placement[key];\n if (matches[key] && p !== undefined) return p;\n }\n return placement.base;\n}\n\n/** A CSS length: a number is pixels. */\nfunction cssLength(value: string | number): string {\n return typeof value === \"number\" ? `${value}px` : value;\n}\n\n/**\n * What a `sticky` row sticks to — see {@link FormActionsProps.stickyWithin}.\n * - `viewport` (default): the page's (or AppShell pane's) bottom edge, lifted onto\n * the phone's bottom nav by `--app-nav-h`.\n * - `container`: the bottom of whatever scroll container it is in — a dialog body, a\n * side pane, a panel with `overflow-y-auto` — at `bottom: 0`, with no nav offset.\n */\nexport type FormActionsStickyWithin = \"viewport\" | \"container\";\n\n/**\n * Extra attributes for the save button — an `id`, `data-*` hooks for tests or\n * analytics, an `aria-describedby`. The props FormActions drives itself (type, click,\n * disabled, variant, form, children, the busy state) are not among them.\n */\nexport type FormActionsSubmitProps = Omit<\n ButtonProps,\n \"type\" | \"onClick\" | \"disabled\" | \"disabledReason\" | \"variant\" | \"form\" | \"children\" | \"aria-busy\" | \"commit\"\n> & { [key: `data-${string}`]: string | number | boolean | undefined };\n\n/** The start-side action of {@link FormActionsProps.destructive}, as data. */\nexport interface FormActionsDestructive {\n label: ReactNode;\n onClick: () => void;\n disabled?: boolean;\n /** See Button's `disabledReason`. */\n disabledReason?: ReactNode;\n}\n\nconst ALIGN_CLASS: Record<FormActionsAlign, string> = {\n start: \"justify-start\",\n center: \"justify-center\",\n end: \"justify-end\",\n between: \"justify-between\",\n};\n\nconst PLACEMENT_CLASS: Record<FormActionsPlacement, string> = {\n inline: \"pt-4\",\n sticky: \"sticky bottom-0 z-10 border-t border-[var(--border)] bg-[var(--bg-surface)] pt-3\",\n dialog: \"\",\n};\n\nexport interface FormActionsProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n /** Called by Cancel. Omitted: no Cancel button (a form that only saves). */\n onCancel?: () => void;\n /**\n * Given: the save button is `type=\"button\"` and calls this — for a dialog or a row\n * that saves from a handler. Omitted: it is `type=\"submit\"` and submits the\n * enclosing `<form>` (or the one named by {@link form}).\n */\n onSubmit?: () => void;\n /** The save button's text. Default `form.save` from the provider, else \"Save\". */\n submitLabel?: ReactNode;\n /** The cancel button's text. Default `form.cancel` from the provider, else \"Cancel\". */\n cancelLabel?: ReactNode;\n /** The save button's text while `pending` (\"Saving…\"). Default: `submitLabel`. */\n pendingLabel?: ReactNode;\n /**\n * A Lucide icon before the save button's text. While `pending` the spinner takes\n * ITS place rather than being added in front of it — an icon written into\n * `submitLabel` itself stays, so the button would show the spinner AND the glyph,\n * two statuses side by side (keksdose's crop Apply needed a `pendingLabel` copy of\n * its text only to drop the check mark).\n */\n submitIcon?: LucideIcon;\n /** Attributes passed through to the save button: `id`, `data-*`, `aria-*`, a\n * `className`. See {@link FormActionsSubmitProps}. */\n submitProps?: FormActionsSubmitProps;\n /**\n * The save is running: a spinner in the save button, `aria-busy` on it, and the\n * button disabled, so a second click cannot send the form twice. Cancel stays\n * usable. The spinner goes in front of the text, or in place of {@link submitIcon}.\n */\n pending?: boolean;\n /** Disable save for a reason of the form's own (an unbalanced entry). */\n submitDisabled?: boolean;\n /** Why save is disabled — see Button's `disabledReason`. Keeps it focusable. */\n submitDisabledReason?: ReactNode;\n /**\n * Save is a commit: under a locked {@link WriteLockProvider} it is disabled with the\n * lock's reason (over `submitDisabledReason`), focusable, the reason in its tooltip —\n * Button's `commit`. The `{ label, onClick }` form of {@link destructive} is a\n * commit too and is locked with it; Cancel and `children` are not (closing a form\n * writes nothing). No provider, or an unlocked one: no effect. Default `false`.\n *\n * keksdose wrote `submitDisabled={lock.locked || …}` and\n * `submitDisabledReason={lock.locked ? lock.reason : undefined}` at each form; this is\n * that pair, read from the provider.\n */\n commit?: boolean;\n /** The save button's variant. Default `brand`; `danger` for a save that destroys\n * (kastlan's `destructive` flag on its own FormActions). */\n submitVariant?: ButtonVariant;\n /**\n * The Cancel button's variant. Default `secondary` (the outlined button, as before).\n * `ghost` for a row where Cancel should step back further than an outline — keksdose\n * G3: a quiet inline row (a confirm under a table row, a card's own small editor)\n * whose Cancel was a ghost button before it moved onto FormActions, which drew every\n * Cancel outlined.\n */\n cancelVariant?: ButtonVariant;\n /**\n * The size of every button the row draws itself — Save, Cancel and the\n * `{ label, onClick }` form of {@link destructive}: Button's `size`. Default `md`.\n * `sm` (12px text, `px-2 py-1`) for a dense row: a confirm inside a table's expansion\n * row, a toolbar card. keksdose G3: two such rows had small hand-built buttons and lost\n * them on adopting FormActions, which had no size. At `sm` the spinner and\n * {@link submitIcon} shrink with the text.\n *\n * Your own elements (`children`, an element passed as `destructive`, `start`) are\n * yours to size — pass them `size=\"sm\"` too. A `size` in {@link submitProps} still\n * wins for Save, as it always has.\n */\n size?: ButtonSize;\n /**\n * A destructive action at the START of the row, apart from Save and Cancel — the\n * \"Delete\" of an edit dialog. Pass `{ label, onClick }` for the kit's quiet danger\n * button, or an element of your own (a {@link DangerConfirm}, say). With it the row\n * is `between`-aligned unless `align` says otherwise.\n */\n destructive?: ReactNode | FormActionsDestructive;\n /**\n * Something neutral at the START of the row — a caption (\"Last saved 2 min ago\",\n * \"Drag the corners to crop\"), a \"View activity\" link. After {@link destructive}\n * when both are given; like it, makes the row `between`-aligned unless `align` says\n * otherwise. `children` are for further ACTIONS, beside Cancel.\n */\n start?: ReactNode;\n /** Horizontal alignment. Default `end` (`between` with a `destructive` action). */\n align?: FormActionsAlign;\n /** See {@link FormActionsPlacement}, or one per breakpoint — see\n * {@link ResponsiveFormActionsPlacement}. Default `inline`. */\n placement?: FormActionsPlacement | ResponsiveFormActionsPlacement;\n /**\n * While the row is `sticky`: pull it out over its container's inline padding by this\n * much (a CSS length, a number in px) and give the same back as padding — so the\n * rule above it and its surface run edge to edge, while the buttons stay on the\n * content's line. Without it a sticky row in a padded card or pane is a strip inset\n * from both sides, with the content scrolling past it in the gutters (keksdose wrote\n * `@max-md:-mx-3 @max-md:px-3` by hand). Pass the container's padding (`\"0.75rem\"`,\n * `12`, `\"var(--pane-px)\"`). Applied only while the resolved placement is sticky, so\n * `placement={{ base: \"sticky\", md: \"inline\" }}` bleeds on the phone alone. The margin\n * is `calc(-1 * X)`; jsdom folds that to `calc(-X)`, so match it loosely in tests.\n */\n bleed?: string | number;\n /**\n * With `placement=\"sticky\"`: what the row sticks to. Default `viewport`.\n *\n * CSS sticky always sticks to the NEAREST scroll container, so the default row\n * already holds inside a scrolling pane — but it offsets itself by `--app-nav-h`,\n * which AppShell publishes on the document: inside a pane or a portalled dialog\n * body on a phone it then floats a nav's height above that container's bottom,\n * with the content scrolling through the gap. `container` drops that offset.\n *\n * What neither value can fix, because it is the layout, not the row:\n * - sticky moves only within its PARENT box. Inside a DataTable expansion row the\n * parent is the expansion cell, so the row pins to the table's own scroller\n * (`overflow-auto`, bounded by `maxBodyHeight`) and only while the cell spans\n * that scroller's bottom edge — not to the page.\n * - any `overflow` other than `visible`/`clip` between the row and the scroller\n * you mean (an `overflow-x-auto` wrapper, an `overflow-hidden` card) becomes the\n * scroll container; if THAT box does not scroll, the row never sticks.\n * - the offset is resolved against the scroller's content box, so a scroller with\n * bottom padding leaves that strip uncovered; give the row a negative `bottom`\n * through `style` and the same amount back as padding.\n */\n stickyWithin?: FormActionsStickyWithin;\n /** The `id` of the `<form>` the save button submits, when the row is rendered\n * outside it (a dialog's footer slot). */\n form?: string;\n /**\n * A keyboard shortcut that presses Save from anywhere in the form. `\"mod-enter\"`:\n * Ctrl+Enter, or Cmd+Enter on a Mac — both are accepted everywhere, as keksdose's\n * editors always have. keksdose K14 (inputs audit 2026-10-02, S5): its transaction\n * editor, category editor and create card each wrote this as an `onKeyDown` on a\n * container, re-stating `!pending && !saveDisabled && !lock.locked` every time, and\n * the one that forgot the lock let the demo's refused save through from the keyboard.\n *\n * What it does is what pressing Save does — the same button is clicked, so a form\n * submits with its validation and its submitter, and `onSubmit` is called in the\n * `type=\"button\"` mode — and it does NOTHING while Save could not be pressed:\n * `pending`, `submitDisabled`, or a `commit` under a locked {@link WriteLockProvider}.\n *\n * Scoped to the form Save submits (the `form` prop's, else the enclosing `<form>`):\n * the chord counts only when the focus is inside it, so two editors on one page each\n * answer for their own fields. Without a form (an `onSubmit` row in a plain container)\n * the scope is the nearest `role=\"dialog\"`, else the row's parent element — the\n * container the row ends.\n *\n * Listened for on the document, so it runs AFTER every React handler: a field that\n * takes Ctrl+Enter for itself calls `preventDefault()` or `stopPropagation()` and\n * the row stays out of it (keksdose live #202 — the create card's Ctrl+Shift+Enter\n * must create ONE row; the chord here is exactly Ctrl/Cmd+Enter, never with Shift or\n * Alt). Not during IME composition, and not on a held key's repeats.\n *\n * The save button announces it with `aria-keyshortcuts`; {@link submitShortcutHint}\n * also shows it.\n */\n submitShortcut?: FormActionsSubmitShortcut;\n /** Show the {@link submitShortcut} beside Save's text (\"Ctrl+Enter\", \"⌘ Enter\"; the\n * `form.submitShortcut` label), dimmed. Hidden on a touch-only device, which has no\n * keyboard to press it on. Default `false`. */\n submitShortcutHint?: boolean;\n /** Extra actions, placed before Cancel. */\n children?: ReactNode;\n}\n\n/** Hand `el` to a caller's ref, whichever kind it is. */\nfunction assignRef<E>(ref: Ref<E> | undefined, el: E | null): void {\n if (typeof ref === \"function\") ref(el);\n else if (ref) (ref as { current: E | null }).current = el;\n}\n\n/**\n * Where {@link FormActionsProps.submitShortcut} listens: the form Save submits (named\n * by `formId`, else the enclosing one), else the nearest dialog, else the row's parent.\n */\nfunction shortcutScope(row: HTMLElement | null, formId: string | undefined): Element | null {\n if (!row) return null;\n const named = formId ? row.ownerDocument.getElementById(formId) : null;\n if (named instanceof HTMLFormElement) return named;\n return row.closest(\"form\") ?? row.closest('[role=\"dialog\"]') ?? row.parentElement;\n}\n\nfunction isDestructiveData(value: unknown): value is FormActionsDestructive {\n return typeof value === \"object\" && value !== null && !isValidElement(value) && \"onClick\" in value;\n}\n\n/**\n * The Save / Cancel row every form, wizard-step form and dialog ends in — kastlan's\n * `FormActions`, with the labels from the provider, a pending state and a place for\n * the destructive action.\n *\n * ```tsx\n * <form onSubmit={form.handleSubmit(save)}>\n * …\n * <FormActions onCancel={close} pending={mutation.isPending} />\n * </form>\n * ```\n *\n * The spinner is the kit's {@link Spinner}, drawn inside the button with `label={null}`\n * so the button's name stays its text; `aria-busy` is what says it is working.\n */\nexport function FormActions({\n onCancel,\n onSubmit,\n submitLabel,\n cancelLabel,\n pendingLabel,\n submitIcon: SubmitIcon,\n submitProps,\n pending = false,\n submitDisabled = false,\n submitDisabledReason,\n commit = false,\n submitVariant = \"brand\",\n cancelVariant = \"secondary\",\n size = \"md\",\n destructive,\n start: startSlot,\n align,\n placement: placementProp = \"inline\",\n stickyWithin = \"viewport\",\n bleed,\n form,\n submitShortcut,\n submitShortcutHint = false,\n children,\n className,\n style,\n ...rest\n}: FormActionsProps) {\n const labels = useKitLabels(\"form\", DEFAULT_FORM_ACTIONS_LABELS);\n const lock = useWriteLock();\n const rootRef = useRef<HTMLDivElement>(null);\n const saveRef = useRef<HTMLButtonElement | null>(null);\n // The caller's own `submitProps.ref` still gets the element.\n const callerSaveRef = submitProps?.ref;\n const setSaveRef = useCallback(\n (el: HTMLButtonElement | null) => {\n saveRef.current = el;\n assignRef(callerSaveRef, el);\n },\n [callerSaveRef],\n );\n // The save settled and the form is still here — it failed, and the user retries. The\n // button was natively disabled while it ran, and a focused button that turns disabled\n // drops the focus to <body> (DangerConfirm's 0.24 fix, keksdose). Only after a press\n // of THIS button (`pressed`: a click or the shortcut), so a `pending` that comes and\n // goes on its own never pulls the page's focus in.\n const pressed = useRef(false);\n const wasPending = useRef(pending);\n useEffect(() => {\n const settled = wasPending.current && !pending;\n wasPending.current = pending;\n if (!settled) return;\n const ours = pressed.current;\n pressed.current = false;\n if (!ours) return;\n if (document.activeElement === null || document.activeElement === document.body) saveRef.current?.focus();\n }, [pending]);\n // Read by the shortcut's listener at the moment of the key press, so the listener\n // itself is attached once and never sees a stale `pending`.\n const canSave = !pending && !submitDisabled && !(commit && lock.locked);\n const canSaveRef = useRef(canSave);\n useEffect(() => {\n canSaveRef.current = canSave;\n });\n useEffect(() => {\n if (submitShortcut !== \"mod-enter\") return;\n const onKeyDown = (e: KeyboardEvent) => {\n if (e.key !== \"Enter\" || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;\n if (e.repeat || e.isComposing || e.defaultPrevented || !canSaveRef.current) return;\n const scope = shortcutScope(rootRef.current, form);\n if (!scope || !(e.target instanceof Node) || !scope.contains(e.target)) return;\n e.preventDefault();\n saveRef.current?.click();\n };\n document.addEventListener(\"keydown\", onKeyDown);\n return () => document.removeEventListener(\"keydown\", onKeyDown);\n }, [submitShortcut, form]);\n const apple = submitShortcut ? isApplePlatform() : false;\n const placement = useResolvedPlacement(placementProp);\n const bleedLength = bleed === undefined || bleed === \"\" ? undefined : cssLength(bleed);\n const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;\n const hasStart = startSlot !== undefined && startSlot !== null && startSlot !== false;\n const justify = align ?? (hasDestructive || hasStart ? \"between\" : \"end\");\n const destructiveNode = !hasDestructive ? null : isDestructiveData(destructive) ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n tone=\"danger\"\n size={size}\n onClick={destructive.onClick}\n disabled={destructive.disabled}\n disabledReason={destructive.disabledReason}\n commit={commit}\n >\n {destructive.label}\n </Button>\n ) : (\n destructive\n );\n const saveLabel = submitLabel ?? labels.save;\n // The glyphs follow the text: 16px beside `md`'s 14px text, 14px beside `sm`'s 12px.\n const saveSize = submitProps?.size ?? size;\n const glyphClass = saveSize === \"sm\" ? \"size-3.5\" : \"size-4\";\n\n return (\n <div\n ref={rootRef}\n data-slot=\"form-actions\"\n data-placement={placement}\n data-sticky-within={placement === \"sticky\" ? stickyWithin : undefined}\n {...rest}\n style={\n placement === \"sticky\"\n ? {\n paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\",\n // On the phone's bottom nav, not behind it (keksdose live #361): `bottom-0`\n // pinned the row under AppShell's nav, which covers the page's last ~56px.\n // `--app-nav-h` is the nav's measured height (0px from md up, and outside an\n // AppShell); the 1px overlaps the two top borders into one line. In a\n // container the nav is not underneath: `--app-nav-h` is the document's,\n // and would hold the row that far above the container's own edge.\n bottom: stickyWithin === \"container\" ? 0 : \"max(0px, calc(var(--app-nav-h, 0px) - 1px))\",\n ...(bleedLength !== undefined && {\n marginInline: `calc(-1 * ${bleedLength})`,\n paddingInline: bleedLength,\n }),\n ...style,\n }\n : style\n }\n className={cn(\"flex flex-wrap items-center gap-2\", ALIGN_CLASS[justify], PLACEMENT_CLASS[placement], className)}\n >\n {(hasDestructive || hasStart) && (\n <div className=\"me-auto flex min-w-0 flex-wrap items-center gap-2\">\n {destructiveNode}\n {hasStart && startSlot}\n </div>\n )}\n <div className=\"flex flex-wrap items-center gap-2\">\n {children}\n {onCancel && (\n <Button type=\"button\" variant={cancelVariant} size={size} onClick={onCancel}>\n {cancelLabel ?? labels.cancel}\n </Button>\n )}\n <Button\n {...submitProps}\n ref={setSaveRef}\n aria-keyshortcuts={\n submitShortcut === \"mod-enter\" ? \"Control+Enter Meta+Enter\" : submitProps?.[\"aria-keyshortcuts\"]\n }\n type={onSubmit ? \"button\" : \"submit\"}\n form={form}\n onClick={() => {\n pressed.current = true;\n onSubmit?.();\n }}\n variant={submitVariant}\n size={saveSize}\n disabled={pending || submitDisabled}\n disabledReason={pending ? undefined : submitDisabledReason}\n // Not while pending: the save already left, and the lock's look over the\n // spinner would say it had not.\n commit={commit && !pending}\n aria-busy={pending || undefined}\n >\n {pending ? (\n <Spinner label={null} className={glyphClass} />\n ) : (\n SubmitIcon && <SubmitIcon aria-hidden className={cn(glyphClass, \"shrink-0\")} />\n )}\n {pending && pendingLabel !== undefined ? pendingLabel : saveLabel}\n {submitShortcut && submitShortcutHint && (\n // Hidden from the accessibility tree: `aria-keyshortcuts` already says it,\n // and in the name it would make the button \"Save Ctrl+Enter\".\n <kbd\n aria-hidden\n className=\"ms-1 font-sans text-[0.85em] font-normal whitespace-nowrap opacity-70 pointer-coarse:hidden\"\n >\n {labels.submitShortcut(apple)}\n </kbd>\n )}\n </Button>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/form-actions.tsx"],"sourcesContent":["import { isValidElement, useCallback, useEffect, useRef } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode, Ref } from \"react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { useBreakpoint, usePhoneLayout } from \"../hooks/use-breakpoint\";\nimport { useLargeText } from \"../hooks/use-large-text\";\nimport { isApplePlatform } from \"../hooks/use-hotkey\";\nimport { Button, Spinner, type ButtonProps, type ButtonSize, type ButtonVariant } from \"./ui\";\nimport { useWriteLock } from \"./write-lock\";\n\n/** The words {@link FormActions} renders on its own behalf — the `form` namespace. */\nexport interface FormActionsLabels {\n save: string;\n cancel: string;\n /**\n * The {@link FormActionsProps.submitShortcut} hint beside Save's text, when\n * {@link FormActionsProps.submitShortcutHint} shows it. `apple` is true on a Mac,\n * iPhone or iPad, where the key is Cmd: \"⌘ Enter\" there, \"Ctrl+Enter\" elsewhere\n * (a German keyboard says \"Strg\").\n */\n submitShortcut: (apple: boolean) => string;\n}\n\nexport const DEFAULT_FORM_ACTIONS_LABELS: FormActionsLabels = {\n save: \"Save\",\n cancel: \"Cancel\",\n submitShortcut: (apple) => (apple ? \"⌘ Enter\" : \"Ctrl+Enter\"),\n};\n\n/** See {@link FormActionsProps.submitShortcut}. */\nexport type FormActionsSubmitShortcut = \"mod-enter\";\n\nexport type FormActionsAlign = \"start\" | \"center\" | \"end\" | \"between\";\n\n/**\n * Where the row sits:\n * - `inline` (default): under the form's last field, `pt-4` above it.\n * - `sticky`: stuck to the bottom of the scrolling page or pane, on the surface\n * colour with a rule above, clear of the phone's home indicator — for a long form\n * whose Save should not be a scroll away.\n * - `dialog`: no spacing of its own, for a dialog's footer slot (DialogFrame,\n * FullBleedDialog's `footer`), which already pads and rules it.\n */\nexport type FormActionsPlacement = \"inline\" | \"sticky\" | \"dialog\";\n\n/**\n * When the row's buttons STACK, one per line at the row's full width (0.32,\n * docs/text-size-harmonization.md §4 \"FormActions stack full width on a phone layout\"):\n *\n * - `\"auto\"` (default): on the phone layout (`usePhoneLayout()`, `max-md:` at the text\n * size in force) at Large and Extra large. There a phone is 312 or 260 px of text\n * wide, and Cancel and a \"Save changes\" side by side wrap into a ragged pair with\n * one 48 px target squeezed against the other. Normal keeps the row the apps tuned.\n * - `\"phone\"`: on the phone layout at every text size.\n * - `\"never\"`: always the row.\n *\n * Stacked, the order is the DOM's — the start group (the destructive action), the extra\n * actions, Cancel, then Save at the bottom, under the thumb — so the visual order and\n * the tab order stay one.\n */\nexport type FormActionsStack = \"auto\" | \"phone\" | \"never\";\n\n/**\n * A placement per breakpoint, mobile first — `{ base: \"sticky\", md: \"inline\" }` is a\n * Save row stuck to the bottom of a phone's long form and in the flow under the last\n * field from 768px up, where the form fits. The breakpoints are the classes' (sm 640,\n * md 768, lg 1024, xl 1280px at Normal text size, and 1.25 / 1.5 times that at Large /\n * Extra large, as `md:` is since 0.32), as `BulkActionBar`'s `variant` breakpoints are, and\n * resolved the same way, in JS: the sticky row is positioned by inline style, which a\n * `md:` class cannot reach. keksdose switched `placement` on its own media query at\n * every long form for want of this. The breakpoints are the VIEWPORT's, not the\n * container's: a form in a narrow pane on a wide screen resolves to `md`.\n */\nexport interface ResponsiveFormActionsPlacement {\n base: FormActionsPlacement;\n sm?: FormActionsPlacement;\n md?: FormActionsPlacement;\n lg?: FormActionsPlacement;\n xl?: FormActionsPlacement;\n}\n\n/** Widest first: the first that matches and names a value wins. Each is the class's own\n * breakpoint at the text size in force (`useBreakpoint`), so `md` here is `md:` there. */\nconst BREAKPOINTS = [\"xl\", \"lg\", \"md\", \"sm\"] as const;\n\n/** The placement in force: the widest breakpoint that matches AND names one, else\n * `base`. The queries are subscribed unconditionally (hooks cannot be skipped); a\n * plain string ignores them. Without `matchMedia` (SSR, tests) none match, so the row\n * renders its `base`, the phone's. The same resolution as BulkActionBar's variant. */\nfunction useResolvedPlacement(\n placement: FormActionsPlacement | ResponsiveFormActionsPlacement,\n): FormActionsPlacement {\n const matches = {\n xl: useBreakpoint(\"xl\"),\n lg: useBreakpoint(\"lg\"),\n md: useBreakpoint(\"md\"),\n sm: useBreakpoint(\"sm\"),\n };\n if (typeof placement === \"string\") return placement;\n for (const key of BREAKPOINTS) {\n const p = placement[key];\n if (matches[key] && p !== undefined) return p;\n }\n return placement.base;\n}\n\n/** A CSS length: a number is pixels. */\nfunction cssLength(value: string | number): string {\n return typeof value === \"number\" ? `${value}px` : value;\n}\n\n/**\n * What a `sticky` row sticks to — see {@link FormActionsProps.stickyWithin}.\n * - `viewport` (default): the page's (or AppShell pane's) bottom edge, lifted onto\n * the phone's bottom nav by `--app-nav-h`.\n * - `container`: the bottom of whatever scroll container it is in — a dialog body, a\n * side pane, a panel with `overflow-y-auto` — at `bottom: 0`, with no nav offset.\n */\nexport type FormActionsStickyWithin = \"viewport\" | \"container\";\n\n/**\n * Extra attributes for the save button — an `id`, `data-*` hooks for tests or\n * analytics, an `aria-describedby`. The props FormActions drives itself (type, click,\n * disabled, variant, form, children, the busy state) are not among them.\n */\nexport type FormActionsSubmitProps = Omit<\n ButtonProps,\n \"type\" | \"onClick\" | \"disabled\" | \"disabledReason\" | \"variant\" | \"form\" | \"children\" | \"aria-busy\" | \"commit\"\n> & { [key: `data-${string}`]: string | number | boolean | undefined };\n\n/** The start-side action of {@link FormActionsProps.destructive}, as data. */\nexport interface FormActionsDestructive {\n label: ReactNode;\n onClick: () => void;\n disabled?: boolean;\n /** See Button's `disabledReason`. */\n disabledReason?: ReactNode;\n}\n\nconst ALIGN_CLASS: Record<FormActionsAlign, string> = {\n start: \"justify-start\",\n center: \"justify-center\",\n end: \"justify-end\",\n between: \"justify-between\",\n};\n\nconst PLACEMENT_CLASS: Record<FormActionsPlacement, string> = {\n inline: \"pt-4\",\n sticky: \"sticky bottom-0 z-10 border-t border-[var(--border)] bg-[var(--bg-surface)] pt-3\",\n dialog: \"\",\n};\n\nexport interface FormActionsProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n /** Called by Cancel. Omitted: no Cancel button (a form that only saves). */\n onCancel?: () => void;\n /**\n * Given: the save button is `type=\"button\"` and calls this — for a dialog or a row\n * that saves from a handler. Omitted: it is `type=\"submit\"` and submits the\n * enclosing `<form>` (or the one named by {@link form}).\n */\n onSubmit?: () => void;\n /** The save button's text. Default `form.save` from the provider, else \"Save\". */\n submitLabel?: ReactNode;\n /** The cancel button's text. Default `form.cancel` from the provider, else \"Cancel\". */\n cancelLabel?: ReactNode;\n /** The save button's text while `pending` (\"Saving…\"). Default: `submitLabel`. */\n pendingLabel?: ReactNode;\n /**\n * A Lucide icon before the save button's text. While `pending` the spinner takes\n * ITS place rather than being added in front of it — an icon written into\n * `submitLabel` itself stays, so the button would show the spinner AND the glyph,\n * two statuses side by side (keksdose's crop Apply needed a `pendingLabel` copy of\n * its text only to drop the check mark).\n */\n submitIcon?: LucideIcon;\n /** Attributes passed through to the save button: `id`, `data-*`, `aria-*`, a\n * `className`. See {@link FormActionsSubmitProps}. */\n submitProps?: FormActionsSubmitProps;\n /**\n * The save is running: a spinner in the save button, `aria-busy` on it, and the\n * button disabled, so a second click cannot send the form twice. Cancel stays\n * usable. The spinner goes in front of the text, or in place of {@link submitIcon}.\n */\n pending?: boolean;\n /** Disable save for a reason of the form's own (an unbalanced entry). */\n submitDisabled?: boolean;\n /** Why save is disabled — see Button's `disabledReason`. Keeps it focusable. */\n submitDisabledReason?: ReactNode;\n /**\n * Save is a commit: under a locked {@link WriteLockProvider} it is disabled with the\n * lock's reason (over `submitDisabledReason`), focusable, the reason in its tooltip —\n * Button's `commit`. The `{ label, onClick }` form of {@link destructive} is a\n * commit too and is locked with it; Cancel and `children` are not (closing a form\n * writes nothing). No provider, or an unlocked one: no effect. Default `false`.\n *\n * keksdose wrote `submitDisabled={lock.locked || …}` and\n * `submitDisabledReason={lock.locked ? lock.reason : undefined}` at each form; this is\n * that pair, read from the provider.\n */\n commit?: boolean;\n /** The save button's variant. Default `brand`; `danger` for a save that destroys\n * (kastlan's `destructive` flag on its own FormActions). */\n submitVariant?: ButtonVariant;\n /**\n * The Cancel button's variant. Default `secondary` (the outlined button, as before).\n * `ghost` for a row where Cancel should step back further than an outline — keksdose\n * G3: a quiet inline row (a confirm under a table row, a card's own small editor)\n * whose Cancel was a ghost button before it moved onto FormActions, which drew every\n * Cancel outlined.\n */\n cancelVariant?: ButtonVariant;\n /**\n * The size of every button the row draws itself — Save, Cancel and the\n * `{ label, onClick }` form of {@link destructive}: Button's `size`. Default `md`.\n * `sm` (12px text, `px-2 py-1`) for a dense row: a confirm inside a table's expansion\n * row, a toolbar card. keksdose G3: two such rows had small hand-built buttons and lost\n * them on adopting FormActions, which had no size. At `sm` the spinner and\n * {@link submitIcon} shrink with the text.\n *\n * Your own elements (`children`, an element passed as `destructive`, `start`) are\n * yours to size — pass them `size=\"sm\"` too. A `size` in {@link submitProps} still\n * wins for Save, as it always has.\n */\n size?: ButtonSize;\n /**\n * A destructive action at the START of the row, apart from Save and Cancel — the\n * \"Delete\" of an edit dialog. Pass `{ label, onClick }` for the kit's quiet danger\n * button, or an element of your own (a {@link DangerConfirm}, say). With it the row\n * is `between`-aligned unless `align` says otherwise.\n */\n destructive?: ReactNode | FormActionsDestructive;\n /**\n * Something neutral at the START of the row — a caption (\"Last saved 2 min ago\",\n * \"Drag the corners to crop\"), a \"View activity\" link. After {@link destructive}\n * when both are given; like it, makes the row `between`-aligned unless `align` says\n * otherwise. `children` are for further ACTIONS, beside Cancel.\n */\n start?: ReactNode;\n /** Horizontal alignment. Default `end` (`between` with a `destructive` action). */\n align?: FormActionsAlign;\n /** See {@link FormActionsPlacement}, or one per breakpoint — see\n * {@link ResponsiveFormActionsPlacement}. Default `inline`. */\n placement?: FormActionsPlacement | ResponsiveFormActionsPlacement;\n /**\n * While the row is `sticky`: pull it out over its container's inline padding by this\n * much (a CSS length, a number in px) and give the same back as padding — so the\n * rule above it and its surface run edge to edge, while the buttons stay on the\n * content's line. Without it a sticky row in a padded card or pane is a strip inset\n * from both sides, with the content scrolling past it in the gutters (keksdose wrote\n * `@max-md:-mx-3 @max-md:px-3` by hand). Pass the container's padding (`\"0.75rem\"`,\n * `12`, `\"var(--pane-px)\"`). Applied only while the resolved placement is sticky, so\n * `placement={{ base: \"sticky\", md: \"inline\" }}` bleeds on the phone alone. The margin\n * is `calc(-1 * X)`; jsdom folds that to `calc(-X)`, so match it loosely in tests.\n */\n bleed?: string | number;\n /**\n * With `placement=\"sticky\"`: what the row sticks to. Default `viewport`.\n *\n * CSS sticky always sticks to the NEAREST scroll container, so the default row\n * already holds inside a scrolling pane — but it offsets itself by `--app-nav-h`,\n * which AppShell publishes on the document: inside a pane or a portalled dialog\n * body on a phone it then floats a nav's height above that container's bottom,\n * with the content scrolling through the gap. `container` drops that offset.\n *\n * What neither value can fix, because it is the layout, not the row:\n * - sticky moves only within its PARENT box. Inside a DataTable expansion row the\n * parent is the expansion cell, so the row pins to the table's own scroller\n * (`overflow-auto`, bounded by `maxBodyHeight`) and only while the cell spans\n * that scroller's bottom edge — not to the page.\n * - any `overflow` other than `visible`/`clip` between the row and the scroller\n * you mean (an `overflow-x-auto` wrapper, an `overflow-hidden` card) becomes the\n * scroll container; if THAT box does not scroll, the row never sticks.\n * - the offset is resolved against the scroller's content box, so a scroller with\n * bottom padding leaves that strip uncovered; give the row a negative `bottom`\n * through `style` and the same amount back as padding.\n */\n stickyWithin?: FormActionsStickyWithin;\n /** The `id` of the `<form>` the save button submits, when the row is rendered\n * outside it (a dialog's footer slot). */\n form?: string;\n /**\n * A keyboard shortcut that presses Save from anywhere in the form. `\"mod-enter\"`:\n * Ctrl+Enter, or Cmd+Enter on a Mac — both are accepted everywhere, as keksdose's\n * editors always have. keksdose K14 (inputs audit 2026-10-02, S5): its transaction\n * editor, category editor and create card each wrote this as an `onKeyDown` on a\n * container, re-stating `!pending && !saveDisabled && !lock.locked` every time, and\n * the one that forgot the lock let the demo's refused save through from the keyboard.\n *\n * What it does is what pressing Save does — the same button is clicked, so a form\n * submits with its validation and its submitter, and `onSubmit` is called in the\n * `type=\"button\"` mode — and it does NOTHING while Save could not be pressed:\n * `pending`, `submitDisabled`, or a `commit` under a locked {@link WriteLockProvider}.\n *\n * Scoped to the form Save submits (the `form` prop's, else the enclosing `<form>`):\n * the chord counts only when the focus is inside it, so two editors on one page each\n * answer for their own fields. Without a form (an `onSubmit` row in a plain container)\n * the scope is the nearest `role=\"dialog\"`, else the row's parent element — the\n * container the row ends.\n *\n * Listened for on the document, so it runs AFTER every React handler: a field that\n * takes Ctrl+Enter for itself calls `preventDefault()` or `stopPropagation()` and\n * the row stays out of it (keksdose live #202 — the create card's Ctrl+Shift+Enter\n * must create ONE row; the chord here is exactly Ctrl/Cmd+Enter, never with Shift or\n * Alt). Not during IME composition, and not on a held key's repeats.\n *\n * The save button announces it with `aria-keyshortcuts`; {@link submitShortcutHint}\n * also shows it.\n */\n submitShortcut?: FormActionsSubmitShortcut;\n /** Show the {@link submitShortcut} beside Save's text (\"Ctrl+Enter\", \"⌘ Enter\"; the\n * `form.submitShortcut` label), dimmed. Hidden on a touch-only device, which has no\n * keyboard to press it on. Default `false`. */\n submitShortcutHint?: boolean;\n /** When the buttons stack full width — see {@link FormActionsStack}. Default `auto`:\n * on the phone layout at Large and Extra large. */\n stack?: FormActionsStack;\n /** Extra actions, placed before Cancel. */\n children?: ReactNode;\n}\n\n/** Hand `el` to a caller's ref, whichever kind it is. */\nfunction assignRef<E>(ref: Ref<E> | undefined, el: E | null): void {\n if (typeof ref === \"function\") ref(el);\n else if (ref) (ref as { current: E | null }).current = el;\n}\n\n/**\n * Where {@link FormActionsProps.submitShortcut} listens: the form Save submits (named\n * by `formId`, else the enclosing one), else the nearest dialog, else the row's parent.\n */\nfunction shortcutScope(row: HTMLElement | null, formId: string | undefined): Element | null {\n if (!row) return null;\n const named = formId ? row.ownerDocument.getElementById(formId) : null;\n if (named instanceof HTMLFormElement) return named;\n return row.closest(\"form\") ?? row.closest('[role=\"dialog\"]') ?? row.parentElement;\n}\n\nfunction isDestructiveData(value: unknown): value is FormActionsDestructive {\n return typeof value === \"object\" && value !== null && !isValidElement(value) && \"onClick\" in value;\n}\n\n/**\n * The Save / Cancel row every form, wizard-step form and dialog ends in — kastlan's\n * `FormActions`, with the labels from the provider, a pending state and a place for\n * the destructive action.\n *\n * ```tsx\n * <form onSubmit={form.handleSubmit(save)}>\n * …\n * <FormActions onCancel={close} pending={mutation.isPending} />\n * </form>\n * ```\n *\n * The spinner is the kit's {@link Spinner}, drawn inside the button with `label={null}`\n * so the button's name stays its text; `aria-busy` is what says it is working.\n */\nexport function FormActions({\n onCancel,\n onSubmit,\n submitLabel,\n cancelLabel,\n pendingLabel,\n submitIcon: SubmitIcon,\n submitProps,\n pending = false,\n submitDisabled = false,\n submitDisabledReason,\n commit = false,\n submitVariant = \"brand\",\n cancelVariant = \"secondary\",\n size = \"md\",\n destructive,\n start: startSlot,\n align,\n placement: placementProp = \"inline\",\n stickyWithin = \"viewport\",\n bleed,\n form,\n submitShortcut,\n submitShortcutHint = false,\n stack = \"auto\",\n children,\n className,\n style,\n ...rest\n}: FormActionsProps) {\n const phone = usePhoneLayout();\n const large = useLargeText();\n const stacked = stack !== \"never\" && phone && (stack === \"phone\" || large);\n // Stacked, a button fills its line — also inside the reason line's wrapper, which the\n // column stretches but which keeps its control at the control's own width.\n const full = stacked ? \"w-full\" : undefined;\n const labels = useKitLabels(\"form\", DEFAULT_FORM_ACTIONS_LABELS);\n const lock = useWriteLock();\n const rootRef = useRef<HTMLDivElement>(null);\n const saveRef = useRef<HTMLButtonElement | null>(null);\n // The caller's own `submitProps.ref` still gets the element.\n const callerSaveRef = submitProps?.ref;\n const setSaveRef = useCallback(\n (el: HTMLButtonElement | null) => {\n saveRef.current = el;\n assignRef(callerSaveRef, el);\n },\n [callerSaveRef],\n );\n // The save settled and the form is still here — it failed, and the user retries. The\n // button was natively disabled while it ran, and a focused button that turns disabled\n // drops the focus to <body> (DangerConfirm's 0.24 fix, keksdose). Only after a press\n // of THIS button (`pressed`: a click or the shortcut), so a `pending` that comes and\n // goes on its own never pulls the page's focus in.\n const pressed = useRef(false);\n const wasPending = useRef(pending);\n useEffect(() => {\n const settled = wasPending.current && !pending;\n wasPending.current = pending;\n if (!settled) return;\n const ours = pressed.current;\n pressed.current = false;\n if (!ours) return;\n if (document.activeElement === null || document.activeElement === document.body) saveRef.current?.focus();\n }, [pending]);\n // Read by the shortcut's listener at the moment of the key press, so the listener\n // itself is attached once and never sees a stale `pending`.\n const canSave = !pending && !submitDisabled && !(commit && lock.locked);\n const canSaveRef = useRef(canSave);\n useEffect(() => {\n canSaveRef.current = canSave;\n });\n useEffect(() => {\n if (submitShortcut !== \"mod-enter\") return;\n const onKeyDown = (e: KeyboardEvent) => {\n if (e.key !== \"Enter\" || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;\n if (e.repeat || e.isComposing || e.defaultPrevented || !canSaveRef.current) return;\n const scope = shortcutScope(rootRef.current, form);\n if (!scope || !(e.target instanceof Node) || !scope.contains(e.target)) return;\n e.preventDefault();\n saveRef.current?.click();\n };\n document.addEventListener(\"keydown\", onKeyDown);\n return () => document.removeEventListener(\"keydown\", onKeyDown);\n }, [submitShortcut, form]);\n const apple = submitShortcut ? isApplePlatform() : false;\n const placement = useResolvedPlacement(placementProp);\n const bleedLength = bleed === undefined || bleed === \"\" ? undefined : cssLength(bleed);\n const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;\n const hasStart = startSlot !== undefined && startSlot !== null && startSlot !== false;\n const justify = align ?? (hasDestructive || hasStart ? \"between\" : \"end\");\n const destructiveNode = !hasDestructive ? null : isDestructiveData(destructive) ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n tone=\"danger\"\n size={size}\n onClick={destructive.onClick}\n disabled={destructive.disabled}\n disabledReason={destructive.disabledReason}\n commit={commit}\n className={full}\n >\n {destructive.label}\n </Button>\n ) : (\n destructive\n );\n const saveLabel = submitLabel ?? labels.save;\n // The glyphs follow the text: 16px beside `md`'s 14px text, 14px beside `sm`'s 12px.\n const saveSize = submitProps?.size ?? size;\n const glyphClass = saveSize === \"sm\" ? \"size-3.5\" : \"size-4\";\n\n return (\n <div\n ref={rootRef}\n data-slot=\"form-actions\"\n data-placement={placement}\n data-sticky-within={placement === \"sticky\" ? stickyWithin : undefined}\n {...rest}\n style={\n placement === \"sticky\"\n ? {\n paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\",\n // On the phone's bottom nav, not behind it (keksdose live #361): `bottom-0`\n // pinned the row under AppShell's nav, which covers the page's last ~56px.\n // `--app-nav-h` is the nav's measured height (0px from md up, and outside an\n // AppShell); the 1px overlaps the two top borders into one line. In a\n // container the nav is not underneath: `--app-nav-h` is the document's,\n // and would hold the row that far above the container's own edge.\n bottom: stickyWithin === \"container\" ? 0 : \"max(0px, calc(var(--app-nav-h, 0px) - 1px))\",\n ...(bleedLength !== undefined && {\n marginInline: `calc(-1 * ${bleedLength})`,\n paddingInline: bleedLength,\n }),\n ...style,\n }\n : style\n }\n data-stacked={stacked || undefined}\n className={cn(\n stacked ? \"flex flex-col items-stretch gap-2\" : cn(\"flex flex-wrap items-center gap-2\", ALIGN_CLASS[justify]),\n PLACEMENT_CLASS[placement],\n className,\n )}\n >\n {(hasDestructive || hasStart) && (\n <div\n className={\n stacked ? \"flex min-w-0 flex-col items-stretch gap-2\" : \"me-auto flex min-w-0 flex-wrap items-center gap-2\"\n }\n >\n {destructiveNode}\n {hasStart && startSlot}\n </div>\n )}\n <div className={stacked ? \"flex flex-col items-stretch gap-2\" : \"flex flex-wrap items-center gap-2\"}>\n {children}\n {onCancel && (\n <Button type=\"button\" variant={cancelVariant} size={size} onClick={onCancel} className={full}>\n {cancelLabel ?? labels.cancel}\n </Button>\n )}\n <Button\n {...submitProps}\n ref={setSaveRef}\n aria-keyshortcuts={\n submitShortcut === \"mod-enter\" ? \"Control+Enter Meta+Enter\" : submitProps?.[\"aria-keyshortcuts\"]\n }\n type={onSubmit ? \"button\" : \"submit\"}\n form={form}\n onClick={() => {\n pressed.current = true;\n onSubmit?.();\n }}\n variant={submitVariant}\n size={saveSize}\n disabled={pending || submitDisabled}\n disabledReason={pending ? undefined : submitDisabledReason}\n // Not while pending: the save already left, and the lock's look over the\n // spinner would say it had not.\n commit={commit && !pending}\n aria-busy={pending || undefined}\n className={cn(submitProps?.className, full)}\n >\n {pending ? (\n <Spinner label={null} className={glyphClass} />\n ) : (\n SubmitIcon && <SubmitIcon aria-hidden className={cn(glyphClass, \"shrink-0\")} />\n )}\n {pending && pendingLabel !== undefined ? pendingLabel : saveLabel}\n {submitShortcut && submitShortcutHint && (\n // Hidden from the accessibility tree: `aria-keyshortcuts` already says it,\n // and in the name it would make the button \"Save Ctrl+Enter\".\n <kbd\n aria-hidden\n className=\"ms-1 font-sans text-[0.85em] font-normal whitespace-nowrap opacity-70 pointer-coarse:hidden\"\n >\n {labels.submitShortcut(apple)}\n </kbd>\n )}\n </Button>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,16 @@
1
1
  import * as react from 'react';
2
2
  import { ComponentPropsWithoutRef, ReactNode } from 'react';
3
3
 
4
+ /**
5
+ * The padding around the dialog's scrolling body, as a CSS length (0.32,
6
+ * docs/text-size-harmonization.md §10.11). Exported because a caller's sticky footer has
7
+ * to reach across it: Chrome resolves a sticky offset against the scrollport's CONTENT
8
+ * box, so a pinned row sits this far short of the panel's edge unless it cancels it
9
+ * (keksdose's editor footer). In rem, so it grows with the text size — keksdose's px
10
+ * copy (`SCROLLER_GUTTER_PX = 12`) was 3–6 px off at 125/150 %. Use it in `calc()`:
11
+ * `bottom: calc(${inset}px - ${DIALOG_GUTTER})`.
12
+ */
13
+ declare const DIALOG_GUTTER = "0.75rem";
4
14
  /**
5
15
  * Named and exported, because `ComponentProps<typeof FullBleedDialog>` was the only way
6
16
  * to say "the props of this" and five call sites across keksdose and kastlan had already
@@ -95,4 +105,4 @@ interface FullBleedDialogProps extends Omit<ComponentPropsWithoutRef<"div">, "ti
95
105
  */
96
106
  declare function FullBleedDialog({ open, onClose, title, headingAs: Heading, header, closeLabel, children, backCloses, className, footer, onMouseDown, onMouseUp, onKeyDown, ...rest }: FullBleedDialogProps): react.ReactPortal | null;
97
107
 
98
- export { FullBleedDialog, type FullBleedDialogProps };
108
+ export { DIALOG_GUTTER, FullBleedDialog, type FullBleedDialogProps };
@@ -9,6 +9,7 @@ import { useBodyScrollLock } from "../hooks/use-body-scroll-lock.js";
9
9
  import { useOverlayHistory } from "../hooks/use-overlay-history.js";
10
10
  import { useCloseTransition } from "../hooks/use-close-transition.js";
11
11
  import { useFocusTrap } from "../hooks/use-focus-trap.js";
12
+ const DIALOG_GUTTER = "0.75rem";
12
13
  function FullBleedDialog({
13
14
  open,
14
15
  onClose,
@@ -95,7 +96,7 @@ function FullBleedDialog({
95
96
  }
96
97
  )
97
98
  ] }),
98
- /* @__PURE__ */ jsx("div", { className: "min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3", children }),
99
+ /* @__PURE__ */ jsx("div", { className: "min-h-0 flex-1 overflow-y-auto overscroll-contain", style: { padding: DIALOG_GUTTER }, children }),
99
100
  footer !== void 0 && footer !== null && footer !== false && /* @__PURE__ */ jsx(
100
101
  "div",
101
102
  {
@@ -114,6 +115,7 @@ function FullBleedDialog({
114
115
  );
115
116
  }
116
117
  export {
118
+ DIALOG_GUTTER,
117
119
  FullBleedDialog
118
120
  };
119
121
  //# sourceMappingURL=full-bleed-dialog.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/full-bleed-dialog.tsx"],"sourcesContent":["import { useId, useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\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\";\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}\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 ...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 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 useFocusTrap(dialogRef, { active: open });\n\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 the key, Escape included.\n // Only there: a descendant's preventDefault does not count. A SearchField in the\n // header prevents Escape to stop Chromium clearing a type=search input, and in\n // 0.8.0 that silently stopped Escape from closing the dialog (keksdose).\n const preventedBefore = e.defaultPrevented;\n onKeyDown?.(e);\n const claimedByCaller = !preventedBefore && e.defaultPrevented;\n if (claimedByCaller || e.key !== \"Escape\") return;\n // On the panel, the way `Modal` does it, and NOT through `useEscapeKey`: this\n // dialog is the thing a `PickerSheet` opens on top of, and a document-level\n // listener cannot tell which of the two the user meant. One press would dismiss\n // the sheet AND throw away the edit behind it — Keksdose live #309 (*\"Mouse Back\n // does not only close the select but also the whole edit or create dialog\"*) on a\n // different key. The sheet's own handler stops the event here, so the innermost\n // overlay is the one that closes, which is only true because focus is now in it.\n e.stopPropagation();\n requestClose();\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={dialogRef}\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-[var(--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-[var(--border)] 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-[var(--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-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <X className=\"size-5\" />\n </button>\n </div>\n {/* px-3, not px-4: every pixel of chrome here is width the form fields lose\n on a phone (feedback #32). The body is the only thing that scrolls, so the\n header stays put and the page 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 px-3 py-3\">{children}</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-[var(--border)] 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":";AAsOY,SACE,KADF;AAtOZ,SAAS,OAAO,cAAc;AAC9B,SAAS,oBAAoB;AAE7B,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,wBAAwB;AACjC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;AA+FtB,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW,UAAU;AAAA,EACrB;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAyB;AAIvB,QAAM,EAAE,SAAS,aAAa,IAAI,mBAAmB,OAAO;AAC5D,QAAM,gBAAgB,iBAAiB,YAAY;AAEnD,oBAAkB,IAAI;AACtB,oBAAkB,QAAQ,YAAY,YAAY;AAElD,QAAM,YAAY,OAAuB,IAAI;AAC7C,QAAM,UAAU,MAAM;AACtB,QAAM,WAAW,UAAU,UAAa,UAAU,QAAQ,UAAU;AACpE,QAAM,YAAY,WAAW,UAAa,WAAW,QAAQ,WAAW;AAYxE,eAAa,WAAW,EAAE,QAAQ,KAAK,CAAC;AAExC,QAAM,gBAAgB,CAAC,MAAqC;AAM1D,UAAM,kBAAkB,EAAE;AAC1B,gBAAY,CAAC;AACb,UAAM,kBAAkB,CAAC,mBAAmB,EAAE;AAC9C,QAAI,mBAAmB,EAAE,QAAQ,SAAU;AAQ3C,MAAE,gBAAgB;AAClB,iBAAa;AAAA,EACf;AAEA,MAAI,CAAC,QAAQ,OAAO,aAAa,YAAa,QAAO;AAErD,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA,IAKL;AAAA,MAAC;AAAA;AAAA,QAcE,GAAG;AAAA,QACJ,WAAW;AAAA,UACT;AAAA,UACA,UAAU,wBAAwB;AAAA,QACpC;AAAA,QACA,MAAK;AAAA,QACL,cAAW;AAAA,QAIX,mBAAiB,KAAK,iBAAiB,MAAM,YAAY,CAAC,KAAK,YAAY,IAAI,UAAU;AAAA,QAGzF,UAAU;AAAA,QACV,KAAK;AAAA,QACL,WAAW;AAAA,QAQX,aAAa,CAAC,MAAM;AAClB,wBAAc,CAAC;AACf,wBAAc,YAAY,CAAC;AAAA,QAC7B;AAAA,QACA,WAAW,CAAC,MAAM;AAChB,sBAAY,CAAC;AACb,wBAAc,UAAU,CAAC;AAAA,QAC3B;AAAA,QAEA;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,UAAU,sBAAsB;AAAA,cAChC;AAAA,YACF;AAAA,YAEA;AAAA,mCAAC,SAAI,WAAU,qFACZ;AAAA,2BACC,qBAAC,SAAI,WAAU,0CACb;AAAA,sCAAC,WAAQ,IAAI,SAAS,WAAU,uFAC7B,iBACH;AAAA,kBACC,aAAa,oBAAC,SAAI,WAAU,8BAA8B,kBAAO;AAAA,mBACpE,IAEA,oBAAC,SAAI,WAAU,8BAA8B,kBAAO;AAAA,gBAEtD;AAAA,kBAAC;AAAA;AAAA,oBACC,MAAK;AAAA,oBACL,SAAS;AAAA,oBACT,cAAY;AAAA,oBACZ,WAAU;AAAA,oBAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,gBACxB;AAAA,iBACF;AAAA,cAMA,oBAAC,SAAI,WAAU,+DAA+D,UAAS;AAAA,cACtF,WAAW,UAAa,WAAW,QAAQ,WAAW,SACrD;AAAA,gBAAC;AAAA;AAAA,kBACC,0BAAuB;AAAA,kBACvB,WAAU;AAAA,kBACV,OAAO,EAAE,eAAe,4CAA4C;AAAA,kBAEnE;AAAA;AAAA,cACH;AAAA;AAAA;AAAA,QAEJ;AAAA;AAAA,IACF;AAAA,IACA,SAAS;AAAA,EACX;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/components/full-bleed-dialog.tsx"],"sourcesContent":["import { useId, useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\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\";\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}\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 ...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 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 useFocusTrap(dialogRef, { active: open });\n\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 the key, Escape included.\n // Only there: a descendant's preventDefault does not count. A SearchField in the\n // header prevents Escape to stop Chromium clearing a type=search input, and in\n // 0.8.0 that silently stopped Escape from closing the dialog (keksdose).\n const preventedBefore = e.defaultPrevented;\n onKeyDown?.(e);\n const claimedByCaller = !preventedBefore && e.defaultPrevented;\n if (claimedByCaller || e.key !== \"Escape\") return;\n // On the panel, the way `Modal` does it, and NOT through `useEscapeKey`: this\n // dialog is the thing a `PickerSheet` opens on top of, and a document-level\n // listener cannot tell which of the two the user meant. One press would dismiss\n // the sheet AND throw away the edit behind it — Keksdose live #309 (*\"Mouse Back\n // does not only close the select but also the whole edit or create dialog\"*) on a\n // different key. The sheet's own handler stops the event here, so the innermost\n // overlay is the one that closes, which is only true because focus is now in it.\n e.stopPropagation();\n requestClose();\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={dialogRef}\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-[var(--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-[var(--border)] 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-[var(--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-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--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-[var(--border)] 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,17 +1,19 @@
1
1
  import 'react';
2
2
  import '../lib/iban.js';
3
- export { cu as DEFAULT_IBAN_INPUT_LABELS, dr as IbanInput, ds as IbanInputLabels, dt as IbanInputProps } from '../delete-account-setting-CpIhzxxZ.js';
3
+ export { cD as DEFAULT_IBAN_INPUT_LABELS, dC as IbanInput, dD as IbanInputLabels, dE as IbanInputProps } from '../delete-account-setting-FLyJf-kZ.js';
4
4
  import './tooltip.js';
5
5
  import '../hooks/use-anchored-rect.js';
6
6
  import '../lib/clipping.js';
7
- import '../data-table-labels-DUELeXpa.js';
7
+ import '../data-table-labels-kpAAVi6g.js';
8
8
  import './data-table-sort.js';
9
9
  import './swipeable-row.js';
10
10
  import './mini-calendar.js';
11
11
  import './calendar-heatmap.js';
12
12
  import './popover.js';
13
+ import '../hooks/use-breakpoint.js';
13
14
  import './chip.js';
14
15
  import 'lucide-react';
16
+ import './field-parts.js';
15
17
  import './field-sync.js';
16
18
  import '../wizard/types.js';
17
19
  import '../tour/tour.js';
@@ -80,6 +82,9 @@ import '../admin/admin-parts.js';
80
82
  import '../admin/admin-action-log.js';
81
83
  import '../settings/settings-labels.js';
82
84
  import '../demo/demo-labels.js';
85
+ import './appearance-labels.js';
86
+ import '../theme/contrast.js';
87
+ import '../theme/text-size.js';
83
88
  import './reauth-dialog.js';
84
89
  import './server-wake.js';
85
90
  import '../lib/server-wake.js';
@@ -92,4 +97,4 @@ import './sign-chip.js';
92
97
  import './column-mapper.js';
93
98
  import '../lib/column-mapping.js';
94
99
  import '../lib/table-text.js';
95
- import './field-parts.js';
100
+ import './focus-ring.js';
@@ -63,7 +63,7 @@ function ImageGrid({
63
63
  onClick: () => onOpen ? onOpen(i) : setOpenIndex(i),
64
64
  className: cn(
65
65
  "block w-full overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-hover)]",
66
- "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--brand)]",
66
+ "focus-visible:outline-[length:var(--focus-ring-width)] focus-visible:outline-offset-2 focus-visible:outline-[var(--brand)]",
67
67
  ASPECT_CLASS[aspect]
68
68
  ),
69
69
  children: kind === "file" ? /* @__PURE__ */ jsxs("span", { className: "flex h-full w-full flex-col items-center justify-center gap-1 p-2 text-center text-xs text-[var(--text-secondary)]", children: [
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/image-grid.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from \"react\";\nimport { FileText } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport type { AuthedFetcher } from \"../hooks/use-authed-src\";\nimport { AuthedImage } from \"./authed-image\";\nimport { Lightbox, imageItemKind } from \"./lightbox\";\nimport type { ImageItem, LightboxLabels } from \"./lightbox\";\n\nexport interface ImageGridLabels {\n /** The list's accessible name. */\n list: string;\n /** An open button's name when an item's `alt` is empty: \"Image 3 of 7\". */\n item: (index: number, count: number) => string;\n /** The name of the group of per-image actions, given the image's name. */\n actions: (name: string) => string;\n}\n\nexport const DEFAULT_IMAGE_GRID_LABELS: ImageGridLabels = {\n list: \"Images\",\n item: (index, count) => `Image ${index} of ${count}`,\n actions: (name) => `Actions for ${name}`,\n};\n\nexport interface ImageGridProps extends Omit<ComponentPropsWithoutRef<\"ul\">, \"children\"> {\n items: readonly ImageItem[];\n /** The app's authenticated GET — thumbnails and the lightbox fetch through it. Only\n * images are fetched for the grid; a file (PDF) tile shows its icon and name. */\n fetcher?: AuthedFetcher | null;\n /**\n * Per-image actions — a delete, an edit. Rendered BESIDE the open button (on top of\n * the thumbnail's corner), never inside it: a button inside a button is invalid HTML\n * and a screen reader flattens it into the outer one's name, where it cannot be\n * reached. Use `IconButton variant=\"overlay\"` for controls that sit on a photo.\n */\n renderActions?: (item: ImageItem, index: number) => ReactNode;\n /**\n * What a tile's click does. Left out, the grid opens its own {@link Lightbox} on that\n * image. Given, the grid opens nothing — for a caller that keeps the lightbox's\n * index in the URL, or that opens something else.\n */\n onOpen?: (index: number) => void;\n /**\n * A fixed column count. Left out, the grid is responsive: as many columns of at\n * least {@link minTileSize} px as fit, so the same grid is three across in a card on\n * a phone and eight across on a desktop page — no breakpoints to choose.\n */\n columns?: number;\n /** The smallest a tile may be in the responsive layout. Default 96 (px). */\n minTileSize?: number;\n /** `square` (default) crops to a square; `video` to 16:9; `auto` keeps each image's\n * own proportions (a floor plan), within a square-ish box. */\n aspect?: \"square\" | \"video\" | \"auto\";\n /** Props for the built-in lightbox (ignored with {@link onOpen}). */\n lightbox?: {\n loop?: boolean;\n zoom?: boolean;\n download?: boolean;\n onDownload?: (item: ImageItem, index: number) => void;\n labels?: Partial<LightboxLabels>;\n };\n labels?: Partial<ImageGridLabels>;\n}\n\nconst ASPECT_CLASS = {\n square: \"aspect-square\",\n video: \"aspect-video\",\n auto: \"aspect-[4/3]\",\n} as const;\n\n/**\n * A file name as stem and extension, so a tile can cut the stem and keep the\n * extension whole: \"floor-plan-level-2.p / df\" (a break inside the extension) told the\n * reader less than \"floor-plan-le….pdf\" does. A leading dot (\".env\") or an\n * \"extension\" longer than five characters is not treated as one.\n */\nexport function splitFileName(name: string): [stem: string, ext: string] {\n const dot = name.lastIndexOf(\".\");\n if (dot <= 0 || name.length - dot > 6) return [name, \"\"];\n return [name.slice(0, dot), name.slice(dot)];\n}\n\n/**\n * A grid of thumbnails that open in a {@link Lightbox} — kastlan's room and defect\n * photos (room-inspector, unit-rooms-tab) and its floor plans (unit-floor-plan), each\n * of which drew its own `grid grid-cols-N` of `<img>` that nothing could open, with no\n * loading state and nowhere to put a delete.\n *\n * Each tile is a real `<button>` named by the image's alt, with the actions beside it\n * and the caption below it — outside the button, so the name stays the alt and the\n * caption is read once. Placeholders come from {@link AuthedImage}: a skeleton while\n * loading, a \"couldn't load\" tile on failure.\n */\nexport function ImageGrid({\n items,\n fetcher,\n renderActions,\n onOpen,\n columns,\n minTileSize = 96,\n aspect = \"square\",\n lightbox,\n labels: labelsProp,\n className,\n style,\n ...rest\n}: ImageGridProps) {\n const labels = useKitLabels(\"imageGrid\", DEFAULT_IMAGE_GRID_LABELS, labelsProp);\n const [openIndex, setOpenIndex] = useState<number | null>(null);\n const count = items.length;\n\n const gridStyle: CSSProperties = {\n gridTemplateColumns:\n columns !== undefined\n ? `repeat(${columns}, minmax(0, 1fr))`\n : `repeat(auto-fill, minmax(min(${minTileSize}px, 100%), 1fr))`,\n ...style,\n };\n\n return (\n <>\n <ul\n aria-label={labels.list}\n {...rest}\n className={cn(\"grid gap-2\", className)}\n style={gridStyle}\n >\n {items.map((item, i) => {\n const name = item.alt || labels.item(i + 1, count);\n const kind = imageItemKind(item);\n const actions = renderActions?.(item, i);\n return (\n <li key={item.key ?? item.src} className=\"relative min-w-0\">\n <button\n type=\"button\"\n aria-label={name}\n onClick={() => (onOpen ? onOpen(i) : setOpenIndex(i))}\n className={cn(\n \"block w-full overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-hover)]\",\n \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--brand)]\",\n ASPECT_CLASS[aspect],\n )}\n >\n {kind === \"file\" ? (\n <span className=\"flex h-full w-full flex-col items-center justify-center gap-1 p-2 text-center text-xs text-[var(--text-secondary)]\">\n <FileText aria-hidden className=\"size-6 shrink-0 text-[var(--text-muted)]\" />\n <FileNameLabel name={item.fileName ?? item.alt ?? \"\"} />\n </span>\n ) : (\n <AuthedImage\n src={item.thumbnailSrc ?? item.src}\n // The button is named already; the picture inside it is decoration\n // to a reader, or the name would be read twice.\n alt=\"\"\n fetcher={fetcher}\n loading=\"lazy\"\n wrapperClassName=\"h-full w-full\"\n className={cn(\"h-full w-full\", aspect === \"auto\" ? \"object-contain\" : \"object-cover\")}\n />\n )}\n </button>\n {actions !== undefined && actions !== null && actions !== false && (\n <div\n role=\"group\"\n aria-label={labels.actions(name)}\n // A backdrop behind the whole group, in the inverse token the overlay\n // buttons use: a translucent disc alone took most of its colour from\n // the picture, and on a pale photo or the light file tile the glyphs\n // washed out. Two layers of the inverse keep them readable over any\n // picture, in both themes.\n className=\"absolute end-1 top-1 flex gap-1 rounded-full bg-[color-mix(in_srgb,var(--bg-inverse)_40%,transparent)] p-0.5 backdrop-blur-sm\"\n >\n {actions}\n </div>\n )}\n {item.caption !== undefined && item.caption !== null && item.caption !== false && (\n <div className=\"mt-1 line-clamp-2 text-xs text-[var(--text-secondary)]\">{item.caption}</div>\n )}\n </li>\n );\n })}\n </ul>\n {!onOpen && (\n <Lightbox\n open={openIndex !== null}\n onClose={() => setOpenIndex(null)}\n items={items}\n index={openIndex ?? 0}\n onIndexChange={setOpenIndex}\n fetcher={fetcher}\n {...lightbox}\n />\n )}\n </>\n );\n}\n\n/** One line: the stem truncates, the extension never does. */\nfunction FileNameLabel({ name }: { name: string }) {\n const [stem, ext] = splitFileName(name);\n return (\n <span aria-hidden className=\"flex max-w-full min-w-0\">\n <span className=\"min-w-0 truncate\">{stem}</span>\n {ext && <span className=\"shrink-0\">{ext}</span>}\n </span>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/image-grid.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from \"react\";\nimport { FileText } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport type { AuthedFetcher } from \"../hooks/use-authed-src\";\nimport { AuthedImage } from \"./authed-image\";\nimport { Lightbox, imageItemKind } from \"./lightbox\";\nimport type { ImageItem, LightboxLabels } from \"./lightbox\";\n\nexport interface ImageGridLabels {\n /** The list's accessible name. */\n list: string;\n /** An open button's name when an item's `alt` is empty: \"Image 3 of 7\". */\n item: (index: number, count: number) => string;\n /** The name of the group of per-image actions, given the image's name. */\n actions: (name: string) => string;\n}\n\nexport const DEFAULT_IMAGE_GRID_LABELS: ImageGridLabels = {\n list: \"Images\",\n item: (index, count) => `Image ${index} of ${count}`,\n actions: (name) => `Actions for ${name}`,\n};\n\nexport interface ImageGridProps extends Omit<ComponentPropsWithoutRef<\"ul\">, \"children\"> {\n items: readonly ImageItem[];\n /** The app's authenticated GET — thumbnails and the lightbox fetch through it. Only\n * images are fetched for the grid; a file (PDF) tile shows its icon and name. */\n fetcher?: AuthedFetcher | null;\n /**\n * Per-image actions — a delete, an edit. Rendered BESIDE the open button (on top of\n * the thumbnail's corner), never inside it: a button inside a button is invalid HTML\n * and a screen reader flattens it into the outer one's name, where it cannot be\n * reached. Use `IconButton variant=\"overlay\"` for controls that sit on a photo.\n */\n renderActions?: (item: ImageItem, index: number) => ReactNode;\n /**\n * What a tile's click does. Left out, the grid opens its own {@link Lightbox} on that\n * image. Given, the grid opens nothing — for a caller that keeps the lightbox's\n * index in the URL, or that opens something else.\n */\n onOpen?: (index: number) => void;\n /**\n * A fixed column count. Left out, the grid is responsive: as many columns of at\n * least {@link minTileSize} px as fit, so the same grid is three across in a card on\n * a phone and eight across on a desktop page — no breakpoints to choose.\n */\n columns?: number;\n /** The smallest a tile may be in the responsive layout. Default 96 (px). */\n minTileSize?: number;\n /** `square` (default) crops to a square; `video` to 16:9; `auto` keeps each image's\n * own proportions (a floor plan), within a square-ish box. */\n aspect?: \"square\" | \"video\" | \"auto\";\n /** Props for the built-in lightbox (ignored with {@link onOpen}). */\n lightbox?: {\n loop?: boolean;\n zoom?: boolean;\n download?: boolean;\n onDownload?: (item: ImageItem, index: number) => void;\n labels?: Partial<LightboxLabels>;\n };\n labels?: Partial<ImageGridLabels>;\n}\n\nconst ASPECT_CLASS = {\n square: \"aspect-square\",\n video: \"aspect-video\",\n auto: \"aspect-[4/3]\",\n} as const;\n\n/**\n * A file name as stem and extension, so a tile can cut the stem and keep the\n * extension whole: \"floor-plan-level-2.p / df\" (a break inside the extension) told the\n * reader less than \"floor-plan-le….pdf\" does. A leading dot (\".env\") or an\n * \"extension\" longer than five characters is not treated as one.\n */\nexport function splitFileName(name: string): [stem: string, ext: string] {\n const dot = name.lastIndexOf(\".\");\n if (dot <= 0 || name.length - dot > 6) return [name, \"\"];\n return [name.slice(0, dot), name.slice(dot)];\n}\n\n/**\n * A grid of thumbnails that open in a {@link Lightbox} — kastlan's room and defect\n * photos (room-inspector, unit-rooms-tab) and its floor plans (unit-floor-plan), each\n * of which drew its own `grid grid-cols-N` of `<img>` that nothing could open, with no\n * loading state and nowhere to put a delete.\n *\n * Each tile is a real `<button>` named by the image's alt, with the actions beside it\n * and the caption below it — outside the button, so the name stays the alt and the\n * caption is read once. Placeholders come from {@link AuthedImage}: a skeleton while\n * loading, a \"couldn't load\" tile on failure.\n */\nexport function ImageGrid({\n items,\n fetcher,\n renderActions,\n onOpen,\n columns,\n minTileSize = 96,\n aspect = \"square\",\n lightbox,\n labels: labelsProp,\n className,\n style,\n ...rest\n}: ImageGridProps) {\n const labels = useKitLabels(\"imageGrid\", DEFAULT_IMAGE_GRID_LABELS, labelsProp);\n const [openIndex, setOpenIndex] = useState<number | null>(null);\n const count = items.length;\n\n const gridStyle: CSSProperties = {\n gridTemplateColumns:\n columns !== undefined\n ? `repeat(${columns}, minmax(0, 1fr))`\n : `repeat(auto-fill, minmax(min(${minTileSize}px, 100%), 1fr))`,\n ...style,\n };\n\n return (\n <>\n <ul\n aria-label={labels.list}\n {...rest}\n className={cn(\"grid gap-2\", className)}\n style={gridStyle}\n >\n {items.map((item, i) => {\n const name = item.alt || labels.item(i + 1, count);\n const kind = imageItemKind(item);\n const actions = renderActions?.(item, i);\n return (\n <li key={item.key ?? item.src} className=\"relative min-w-0\">\n <button\n type=\"button\"\n aria-label={name}\n onClick={() => (onOpen ? onOpen(i) : setOpenIndex(i))}\n className={cn(\n \"block w-full overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-hover)]\",\n \"focus-visible:outline-[length:var(--focus-ring-width)] focus-visible:outline-offset-2 focus-visible:outline-[var(--brand)]\",\n ASPECT_CLASS[aspect],\n )}\n >\n {kind === \"file\" ? (\n <span className=\"flex h-full w-full flex-col items-center justify-center gap-1 p-2 text-center text-xs text-[var(--text-secondary)]\">\n <FileText aria-hidden className=\"size-6 shrink-0 text-[var(--text-muted)]\" />\n <FileNameLabel name={item.fileName ?? item.alt ?? \"\"} />\n </span>\n ) : (\n <AuthedImage\n src={item.thumbnailSrc ?? item.src}\n // The button is named already; the picture inside it is decoration\n // to a reader, or the name would be read twice.\n alt=\"\"\n fetcher={fetcher}\n loading=\"lazy\"\n wrapperClassName=\"h-full w-full\"\n className={cn(\"h-full w-full\", aspect === \"auto\" ? \"object-contain\" : \"object-cover\")}\n />\n )}\n </button>\n {actions !== undefined && actions !== null && actions !== false && (\n <div\n role=\"group\"\n aria-label={labels.actions(name)}\n // A backdrop behind the whole group, in the inverse token the overlay\n // buttons use: a translucent disc alone took most of its colour from\n // the picture, and on a pale photo or the light file tile the glyphs\n // washed out. Two layers of the inverse keep them readable over any\n // picture, in both themes.\n className=\"absolute end-1 top-1 flex gap-1 rounded-full bg-[color-mix(in_srgb,var(--bg-inverse)_40%,transparent)] p-0.5 backdrop-blur-sm\"\n >\n {actions}\n </div>\n )}\n {item.caption !== undefined && item.caption !== null && item.caption !== false && (\n <div className=\"mt-1 line-clamp-2 text-xs text-[var(--text-secondary)]\">{item.caption}</div>\n )}\n </li>\n );\n })}\n </ul>\n {!onOpen && (\n <Lightbox\n open={openIndex !== null}\n onClose={() => setOpenIndex(null)}\n items={items}\n index={openIndex ?? 0}\n onIndexChange={setOpenIndex}\n fetcher={fetcher}\n {...lightbox}\n />\n )}\n </>\n );\n}\n\n/** One line: the stem truncates, the extension never does. */\nfunction FileNameLabel({ name }: { name: string }) {\n const [stem, ext] = splitFileName(name);\n return (\n <span aria-hidden className=\"flex max-w-full min-w-0\">\n <span className=\"min-w-0 truncate\">{stem}</span>\n {ext && <span className=\"shrink-0\">{ext}</span>}\n </span>\n );\n}\n"],"mappings":";AA0HI,mBAyBgB,KADF,YAxBd;AA1HJ,SAAS,gBAAgB;AAEzB,SAAS,gBAAgB;AAEzB,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAE7B,SAAS,mBAAmB;AAC5B,SAAS,UAAU,qBAAqB;AAYjC,MAAM,4BAA6C;AAAA,EACxD,MAAM;AAAA,EACN,MAAM,CAAC,OAAO,UAAU,SAAS,KAAK,OAAO,KAAK;AAAA,EAClD,SAAS,CAAC,SAAS,eAAe,IAAI;AACxC;AA0CA,MAAM,eAAe;AAAA,EACnB,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,MAAM;AACR;AAQO,SAAS,cAAc,MAA2C;AACvE,QAAM,MAAM,KAAK,YAAY,GAAG;AAChC,MAAI,OAAO,KAAK,KAAK,SAAS,MAAM,EAAG,QAAO,CAAC,MAAM,EAAE;AACvD,SAAO,CAAC,KAAK,MAAM,GAAG,GAAG,GAAG,KAAK,MAAM,GAAG,CAAC;AAC7C;AAaO,SAAS,UAAU;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,SAAS;AAAA,EACT;AAAA,EACA,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAmB;AACjB,QAAM,SAAS,aAAa,aAAa,2BAA2B,UAAU;AAC9E,QAAM,CAAC,WAAW,YAAY,IAAI,SAAwB,IAAI;AAC9D,QAAM,QAAQ,MAAM;AAEpB,QAAM,YAA2B;AAAA,IAC/B,qBACE,YAAY,SACR,UAAU,OAAO,sBACjB,gCAAgC,WAAW;AAAA,IACjD,GAAG;AAAA,EACL;AAEA,SACE,iCACE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,cAAY,OAAO;AAAA,QAClB,GAAG;AAAA,QACJ,WAAW,GAAG,cAAc,SAAS;AAAA,QACrC,OAAO;AAAA,QAEN,gBAAM,IAAI,CAAC,MAAM,MAAM;AACtB,gBAAM,OAAO,KAAK,OAAO,OAAO,KAAK,IAAI,GAAG,KAAK;AACjD,gBAAM,OAAO,cAAc,IAAI;AAC/B,gBAAM,UAAU,gBAAgB,MAAM,CAAC;AACvC,iBACE,qBAAC,QAA8B,WAAU,oBACvC;AAAA;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,cAAY;AAAA,gBACZ,SAAS,MAAO,SAAS,OAAO,CAAC,IAAI,aAAa,CAAC;AAAA,gBACnD,WAAW;AAAA,kBACT;AAAA,kBACA;AAAA,kBACA,aAAa,MAAM;AAAA,gBACrB;AAAA,gBAEC,mBAAS,SACR,qBAAC,UAAK,WAAU,sHACd;AAAA,sCAAC,YAAS,eAAW,MAAC,WAAU,4CAA2C;AAAA,kBAC3E,oBAAC,iBAAc,MAAM,KAAK,YAAY,KAAK,OAAO,IAAI;AAAA,mBACxD,IAEA;AAAA,kBAAC;AAAA;AAAA,oBACC,KAAK,KAAK,gBAAgB,KAAK;AAAA,oBAG/B,KAAI;AAAA,oBACJ;AAAA,oBACA,SAAQ;AAAA,oBACR,kBAAiB;AAAA,oBACjB,WAAW,GAAG,iBAAiB,WAAW,SAAS,mBAAmB,cAAc;AAAA;AAAA,gBACtF;AAAA;AAAA,YAEJ;AAAA,YACC,YAAY,UAAa,YAAY,QAAQ,YAAY,SACxD;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,cAAY,OAAO,QAAQ,IAAI;AAAA,gBAM/B,WAAU;AAAA,gBAET;AAAA;AAAA,YACH;AAAA,YAED,KAAK,YAAY,UAAa,KAAK,YAAY,QAAQ,KAAK,YAAY,SACvE,oBAAC,SAAI,WAAU,0DAA0D,eAAK,SAAQ;AAAA,eA5CjF,KAAK,OAAO,KAAK,GA8C1B;AAAA,QAEJ,CAAC;AAAA;AAAA,IACH;AAAA,IACC,CAAC,UACA;AAAA,MAAC;AAAA;AAAA,QACC,MAAM,cAAc;AAAA,QACpB,SAAS,MAAM,aAAa,IAAI;AAAA,QAChC;AAAA,QACA,OAAO,aAAa;AAAA,QACpB,eAAe;AAAA,QACf;AAAA,QACC,GAAG;AAAA;AAAA,IACN;AAAA,KAEJ;AAEJ;AAGA,SAAS,cAAc,EAAE,KAAK,GAAqB;AACjD,QAAM,CAAC,MAAM,GAAG,IAAI,cAAc,IAAI;AACtC,SACE,qBAAC,UAAK,eAAW,MAAC,WAAU,2BAC1B;AAAA,wBAAC,UAAK,WAAU,oBAAoB,gBAAK;AAAA,IACxC,OAAO,oBAAC,UAAK,WAAU,YAAY,eAAI;AAAA,KAC1C;AAEJ;","names":[]}
@@ -5,6 +5,7 @@ import { cn } from "../lib/cn.js";
5
5
  import { useAnnounce } from "../hooks/use-announce.js";
6
6
  import { useKitLabels } from "../i18n/kit-labels.js";
7
7
  import { Input, Spinner } from "./ui.js";
8
+ import { FOCUS_RING } from "./focus-ring.js";
8
9
  import { Tooltip } from "./tooltip.js";
9
10
  import { useCommitReason } from "./write-lock.js";
10
11
  const DEFAULT_INLINE_EDIT_LABELS = {
@@ -18,6 +19,7 @@ function hasContent(node) {
18
19
  function RenderEditor({ render, ...props }) {
19
20
  return render(props);
20
21
  }
22
+ const VALUE_WRAP_AT_LARGE = "large:whitespace-normal large:break-words";
21
23
  function asPromise(result) {
22
24
  return result && typeof result.then === "function" ? result : null;
23
25
  }
@@ -193,7 +195,7 @@ function InlineEditField(props) {
193
195
  const alignClass = align === "end" ? "text-end" : "text-start";
194
196
  let body;
195
197
  if (readOnly) {
196
- body = /* @__PURE__ */ jsx("span", { ...private_, className: cn("block min-w-0 truncate", alignClass), children: content });
198
+ body = /* @__PURE__ */ jsx("span", { ...private_, className: cn("block min-w-0 truncate", VALUE_WRAP_AT_LARGE, alignClass), children: content });
197
199
  } else if (editing) {
198
200
  const describedBy = failed ? errorId : void 0;
199
201
  const editorProps = {
@@ -239,7 +241,7 @@ function InlineEditField(props) {
239
241
  ) }),
240
242
  pending && /* @__PURE__ */ jsx(Spinner, { label: null, className: "size-4 shrink-0" })
241
243
  ] }),
242
- failed && /* @__PURE__ */ jsx("p", { id: errorId, className: "mt-1 text-[11px] leading-tight text-[var(--danger)]", children: failure })
244
+ failed && /* @__PURE__ */ jsx("p", { id: errorId, className: "mt-1 text-caption leading-tight text-[var(--danger)]", children: failure })
243
245
  ]
244
246
  }
245
247
  );
@@ -255,7 +257,9 @@ function InlineEditField(props) {
255
257
  "aria-describedby": reasonId,
256
258
  onClick: (e) => e.preventDefault(),
257
259
  className: cn(
258
- "block w-full min-w-0 cursor-not-allowed truncate rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
260
+ "block w-full min-w-0 cursor-not-allowed truncate rounded-sm focus:outline-none",
261
+ VALUE_WRAP_AT_LARGE,
262
+ FOCUS_RING,
259
263
  alignClass
260
264
  ),
261
265
  children: content
@@ -274,7 +278,9 @@ function InlineEditField(props) {
274
278
  ...private_,
275
279
  onClick: () => setOpen(true),
276
280
  className: cn(
277
- "block w-full min-w-0 truncate rounded-sm underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
281
+ "block w-full min-w-0 truncate rounded-sm underline-offset-2 hover:underline focus:outline-none",
282
+ VALUE_WRAP_AT_LARGE,
283
+ FOCUS_RING,
278
284
  alignClass
279
285
  ),
280
286
  children: content
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/inline-edit-field.tsx"],"sourcesContent":["import { useEffect, useId, useRef, useState } from \"react\";\nimport type { FocusEvent, InputHTMLAttributes, KeyboardEvent, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { Input, Spinner } from \"./ui\";\nimport { Tooltip } from \"./tooltip\";\nimport { useCommitReason } from \"./write-lock\";\n\n/**\n * Every string {@link InlineEditField} says on its own behalf — the `inlineEdit`\n * namespace of `<UiKitProvider labels>`, overridable per instance through `labels`.\n */\nexport interface InlineEditLabels {\n /** The display button's tooltip and description — what pressing it does. Receives\n * the field's `label` (\"Budget name\" → \"Edit Budget name\"). */\n edit: (label: string) => string;\n /** Shown (and spoken) when `onCommit` rejects and no `formatError` says better. */\n failed: string;\n /** What the display shows for an empty value, when no `placeholder` is given. */\n empty: string;\n}\n\nexport const DEFAULT_INLINE_EDIT_LABELS: InlineEditLabels = {\n edit: (label) => `Edit ${label}`,\n failed: \"The change could not be saved.\",\n empty: \"Empty\",\n};\n\n/** What an `editor` render prop is handed: the draft and the means to end the edit. */\nexport interface InlineEditorProps<T> {\n /** The draft — seeded from `value` each time the editor opens. */\n value: T;\n /** Replace the draft (every keystroke). */\n onChange: (next: T) => void;\n /**\n * End the edit and save. Without an argument it saves the draft; WITH one it saves\n * that — for an editor whose own commit hands over a resolved value (`NumberInput`'s\n * `onCommit` gives \"1050\" for a typed \"1100-50\"). Called twice (the editor's own\n * Enter, then the field's), the second call does nothing.\n */\n commit: (next?: T) => void;\n /** End the edit and drop the draft — what Escape does. */\n cancel: () => void;\n /** The save is in flight: make the control read-only (not disabled — it has focus). */\n pending: boolean;\n /** The field's `label`, for the control's accessible name. */\n label: string;\n /** The last save failed and the draft has not been touched since. */\n invalid: boolean;\n /** The id of the failure message while there is one, for `aria-describedby`. */\n describedBy: string | undefined;\n /** The four above as attributes — spread them on a kit field that takes the DOM\n * spelling: `<AmountInput {...p.inputProps} … />`. */\n inputProps: {\n \"aria-label\": string;\n \"aria-invalid\": true | undefined;\n \"aria-describedby\": string | undefined;\n readOnly: boolean;\n };\n}\n\ninterface InlineEditFieldBaseProps<T> {\n /** The saved value — what the display shows, and what the editor opens on. */\n value: T;\n /**\n * Save `next`. Return a promise and the field waits: the editor stays open and\n * read-only with a spinner until it settles, closes when it resolves, and STAYS OPEN\n * with the failure under it when it rejects (or throws) — the typed value is kept for\n * a retry. Not called when the draft equals `value` (see `isEqual`): opening the\n * editor and leaving it unchanged is not a write.\n */\n onCommit: (next: T) => void | Promise<unknown>;\n /** What the value is (\"Budget name\", \"Assigned\") — the editor's accessible name and\n * the subject of the display's \"Edit …\" description. Not drawn: inline, the row or\n * the column header is the visible label. */\n label: string;\n /** The value as the display shows it — a formatted amount, a date. Default: the\n * value as text. */\n display?: (value: T) => ReactNode;\n /** Whether the draft is unchanged, so leaving the editor is not a write. Default\n * `Object.is`. keksdose compares assigned amounts as NUMBERS — \"1100\" is the\n * server's \"1100.0000\". */\n isEqual?: (a: T, b: T) => boolean;\n /** What the display shows for an empty value (`\"\"`, `null`, an empty `display`).\n * Default `labels.empty`. */\n placeholder?: ReactNode;\n /** `\"end\"` for a figure in a right-aligned money column. Default `\"start\"`. */\n align?: \"start\" | \"end\";\n /** Look, do not touch — a guest's view: the value as plain text, no button. */\n readOnly?: boolean;\n /**\n * Why it cannot be edited now — the write lock's sentence, a closed period. The\n * value stays a BUTTON (focusable, `aria-disabled`) with the reason in the kit\n * Tooltip and its description; it opens no editor. An editor that is open when the\n * reason arrives closes, its draft dropped: a field you can type into must be one\n * whose value can be saved (keksdose dev#496).\n */\n disabledReason?: ReactNode;\n /** This field COMMITS — under a locked {@link WriteLockProvider} it takes the\n * `disabledReason` path with the lock's reason (which wins over its own). No\n * provider, or an unlocked one: no effect. */\n commit?: boolean;\n /** The value is the user's own data: `data-private` on the display and the editor,\n * so the app's demo-mode blur covers both. */\n redact?: boolean;\n /** Turns a rejection into words. Default `labels.failed`. */\n formatError?: (error: unknown) => ReactNode;\n /** Controlled open state — a row's swipe action that opens the editor from outside\n * (keksdose's assigned cell). Left out, the field keeps its own. */\n editing?: boolean;\n /** Called with the next open state: on open, on a save that resolved, on cancel. */\n onEditingChange?: (editing: boolean) => void;\n /** Attributes for the DEFAULT text editor's `<input>` — `maxLength`, `inputMode`,\n * `autoComplete`. Ignored with `editor`. */\n inputProps?: Omit<InputHTMLAttributes<HTMLInputElement>, \"value\" | \"defaultValue\" | \"onChange\" | \"type\">;\n /** Classes for the root. */\n className?: string;\n labels?: Partial<InlineEditLabels>;\n}\n\ntype EditorRender<T> = (props: InlineEditorProps<T>) => ReactNode;\n\n/**\n * The editor. Optional for a string value — the kit's `Input`, compact — and\n * REQUIRED for anything else, since only the caller knows how to edit a number or an\n * amount. Render a kit field from the props it is handed:\n *\n * ```tsx\n * editor={(p) => (\n * <NumberInput calculator={false} ariaLabel={p.label} value={p.value} onChange={p.onChange}\n * onCommit={p.commit} aria-invalid={p.invalid || undefined} aria-describedby={p.describedBy} />\n * )}\n * ```\n *\n * The field itself handles Enter (save), Escape (cancel) and focus leaving the editor\n * (save) around whatever is rendered — a key the editor has already handled\n * (`preventDefault`, as a listbox does for its Enter) is left to it. Focus counts as\n * \"inside\" across a portal, so a picker or a phone keypad the editor opens does not\n * end the edit.\n */\nexport type InlineEditFieldProps<T = string> = InlineEditFieldBaseProps<T> &\n ([T] extends [string] ? { editor?: EditorRender<T> } : { editor: EditorRender<T> });\n\nfunction hasContent(node: ReactNode): boolean {\n return node !== undefined && node !== null && node !== false && node !== \"\";\n}\n\n/** Calls the `editor` render prop as a component of its own: the props it is handed\n * close over the field's refs, and a render prop called in the field's own render\n * would read like a ref read during render. Module-level, so the type is stable and\n * the editor never remounts. */\nfunction RenderEditor<T>({ render, ...props }: InlineEditorProps<T> & { render: EditorRender<T> }) {\n return render(props);\n}\n\n/** `onCommit`'s result, if it is a promise. */\nfunction asPromise(result: unknown): Promise<unknown> | null {\n return result && typeof (result as Promise<unknown>).then === \"function\" ? (result as Promise<unknown>) : null;\n}\n\n/**\n * A value that edits in place: a quiet button showing the value; click it (or Enter\n * on it) and it becomes a field; Enter or leaving the field saves, Escape cancels\n * (keksdose K9).\n *\n * keksdose has three of them by hand — the budget's assigned cell (budget-cells.tsx),\n * the invoice line's value cells (invoice-lines-table.tsx `EditableCell`) and the\n * budget rename (budgets-page.tsx, an `Input` + Save + Cancel with no Escape) — and\n * each had to learn the same rules:\n *\n * - **The display's accessible name is the VALUE.** A cell named \"Edit\" reads a table\n * of \"edit, edit, edit\"; the action is the description instead (\"Edit Assigned\"),\n * in the kit Tooltip on hover and focus.\n * - **An unchanged draft is not a write.** Opening the editor and leaving it was a PUT\n * in keksdose until `isEqual` compared numbers, and on a sealed budget an outbox row\n * for a change nobody made.\n * - **A failed save keeps the editor.** The typed value is the user's work; the\n * failure is shown under it (attached with `aria-describedby`, and spoken), and the\n * next keystroke clears it. Escape still drops the draft.\n * - **Locked means a disabled BUTTON, not a disabled field** (keksdose dev#496): at\n * rest a real row shows this button, so a locked page shows the same, with the\n * reason — never a field that takes keys it cannot save.\n *\n * Inside a {@link DataTable} cell it needs nothing extra: the table already leaves a\n * click or a key on a control of the cell to that control. Focus returns to the value\n * after Enter or Escape; after a save that came from focus leaving, it stays where the\n * user put it.\n *\n * ```tsx\n * <InlineEditField label={t(\"budgets.name\")} value={budget.name}\n * onCommit={(name) => rename.mutateAsync({ id: budget.id, name })} inputProps={{ maxLength: 120 }} />\n * ```\n */\nexport function InlineEditField<T = string>(props: InlineEditFieldProps<T>) {\n const {\n value,\n onCommit,\n label,\n display,\n isEqual = Object.is,\n placeholder,\n align = \"start\",\n readOnly = false,\n disabledReason: ownDisabledReason,\n commit,\n redact = false,\n formatError,\n editing: editingProp,\n onEditingChange,\n inputProps,\n className,\n labels: labelsProp,\n } = props;\n const editor = props.editor as EditorRender<T> | undefined;\n const labels = useKitLabels(\"inlineEdit\", DEFAULT_INLINE_EDIT_LABELS, labelsProp);\n const disabledReason = useCommitReason(commit, ownDisabledReason);\n const locked = hasContent(disabledReason);\n\n const [openState, setOpenState] = useState(false);\n const open = editingProp ?? openState;\n // Locked (or read-only) wins over an open flag: a stale `editing` must never bring a\n // live commit-on-blur field back on a page that cannot save it.\n const editing = open && !locked && !readOnly;\n const [draft, setDraft] = useState<T>(value);\n const [pending, setPending] = useState(false);\n const [failure, setFailure] = useState<ReactNode>(null);\n const failed = hasContent(failure);\n\n const errorId = useId();\n const reasonId = useId();\n const displayRef = useRef<HTMLButtonElement>(null);\n const editorRef = useRef<HTMLDivElement>(null);\n const alert = useAnnounce({ politeness: \"assertive\" });\n\n // One edit ends once: Enter in a NumberInput commits through its own `onCommit` and\n // then reaches the field's Enter; a save that closes the editor unmounts the focused\n // control, which some browsers answer with a blur. The second ending is a no-op.\n const ended = useRef(false);\n const blurTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const mounted = useRef(true);\n // The latest draft for the deferred blur check, which runs after a render.\n const draftRef = useRef(draft);\n useEffect(() => {\n draftRef.current = draft;\n });\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n if (blurTimer.current) clearTimeout(blurTimer.current);\n };\n }, []);\n\n // Seed the draft when the editor opens — from wherever the open came (the button, a\n // controlled `editing`). During render, like DangerConfirm's wipe, so no frame shows\n // the editor holding the previous edit's text.\n const [wasEditing, setWasEditing] = useState(editing);\n if (wasEditing !== editing) {\n setWasEditing(editing);\n if (editing) {\n setDraft(value);\n setFailure(null);\n setPending(false);\n }\n }\n // An open editor that a lock (or `readOnly`) took away is closed for good, not\n // merely hidden until the lock lifts.\n if (openState && !editing && editingProp === undefined) setOpenState(false);\n\n // Focus follows the edit: into the editor when it opens, back to the value when it\n // closes — but only if focus went down with the editor (Enter, Escape, a save that\n // resolved under it). A save that came from focus LEAVING keeps focus where it went.\n const prevEditing = useRef(editing);\n useEffect(() => {\n const changed = prevEditing.current !== editing;\n prevEditing.current = editing;\n if (!changed) return;\n if (editing) {\n // A fresh edit, which may end once. Here rather than in the seed above: a ref is\n // not written during render, and no event can end the edit before this runs.\n ended.current = false;\n const control = editorRef.current?.querySelector<HTMLElement>(\n \"input:not([type=hidden]), textarea, select, [contenteditable='true'], [tabindex]:not([tabindex='-1']), button\",\n );\n control?.focus();\n if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) control.select();\n return;\n }\n // However it closed — a save, Escape, a lock taking it away — a blur check still\n // queued from the editor has nothing left to save.\n ended.current = true;\n if (blurTimer.current) clearTimeout(blurTimer.current);\n blurTimer.current = null;\n const lost = document.activeElement === null || document.activeElement === document.body;\n if (lost) displayRef.current?.focus();\n }, [editing]);\n\n const setOpen = (next: boolean) => {\n if (editingProp === undefined) setOpenState(next);\n onEditingChange?.(next);\n };\n\n const fail = (caught: unknown) => {\n ended.current = false;\n const message = formatError ? formatError(caught) : labels.failed;\n setFailure(hasContent(message) ? message : labels.failed);\n alert.announce(typeof message === \"string\" && message !== \"\" ? message : labels.failed);\n // Focus back into the editor if the failure left it nowhere (a pending field that\n // the browser let go of); never pulled back from somewhere the user went.\n const active = document.activeElement;\n if (active === null || active === document.body) {\n editorRef.current?.querySelector<HTMLElement>(\"input, textarea, select, [tabindex]\")?.focus();\n }\n };\n\n const save = (next: T = draftRef.current) => {\n if (!editing || ended.current || pending) return;\n ended.current = true;\n if (next !== draftRef.current) setDraft(next);\n if (isEqual(next, value)) {\n setOpen(false);\n return;\n }\n let result: unknown;\n try {\n result = onCommit(next);\n } catch (caught) {\n fail(caught);\n return;\n }\n const promise = asPromise(result);\n if (!promise) {\n setOpen(false);\n return;\n }\n setPending(true);\n setFailure(null);\n promise.then(\n () => {\n if (!mounted.current) return;\n setPending(false);\n setOpen(false);\n },\n (caught: unknown) => {\n if (!mounted.current) return;\n setPending(false);\n fail(caught);\n },\n );\n };\n\n const cancel = () => {\n // A request that has left cannot be called back; Escape waits for it.\n if (!editing || pending) return;\n ended.current = true;\n setOpen(false);\n };\n\n const change = (next: T) => {\n if (pending) return;\n setDraft(next);\n // Now, not after the render: a blur check scheduled before that render reads it.\n draftRef.current = next;\n // The failure described the draft that was sent; once edited it describes nothing\n // on screen, and the next save (or blur) is a fresh attempt.\n setFailure(null);\n };\n\n const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.defaultPrevented) return;\n if (e.key === \"Escape\") {\n // Ours, not the dialog's or the popover's around the field.\n e.preventDefault();\n e.stopPropagation();\n cancel();\n return;\n }\n // Not mid-composition: Enter there picks the IME's candidate (Japanese, Chinese).\n if (e.key === \"Enter\" && !e.nativeEvent.isComposing && !e.shiftKey) {\n e.preventDefault();\n save();\n }\n };\n\n // Leaving the editor saves. Deferred one task, and cancelled by any focus that lands\n // back inside: React reports focus moves through PORTALS to the component tree, so a\n // calculator popover or a phone keypad the editor opened counts as inside, which a\n // DOM `contains` check would get wrong. A failure on screen holds the editor: the\n // user is looking at why, and moving away is not a retry.\n const onBlur = (e: FocusEvent<HTMLDivElement>) => {\n if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;\n if (blurTimer.current) clearTimeout(blurTimer.current);\n blurTimer.current = setTimeout(() => {\n blurTimer.current = null;\n if (!mounted.current || failed) return;\n save();\n }, 0);\n };\n const onFocus = () => {\n if (blurTimer.current) clearTimeout(blurTimer.current);\n blurTimer.current = null;\n };\n\n const private_ = redact ? { \"data-private\": true } : undefined;\n const shown = display ? display(value) : value === null || value === undefined ? \"\" : String(value);\n const content = hasContent(shown) ? (\n shown\n ) : (\n <span className=\"text-[var(--text-muted)]\">{placeholder ?? labels.empty}</span>\n );\n const alignClass = align === \"end\" ? \"text-end\" : \"text-start\";\n\n let body: ReactNode;\n if (readOnly) {\n body = (\n <span {...private_} className={cn(\"block min-w-0 truncate\", alignClass)}>\n {content}\n </span>\n );\n } else if (editing) {\n const describedBy = failed ? errorId : undefined;\n const editorProps: InlineEditorProps<T> = {\n value: draft,\n onChange: change,\n commit: save,\n cancel,\n pending,\n label,\n invalid: failed,\n describedBy,\n inputProps: {\n \"aria-label\": label,\n \"aria-invalid\": failed || undefined,\n \"aria-describedby\": describedBy,\n readOnly: pending,\n },\n };\n body = (\n // A wrapper for the keys and the focus, not a control: the keys belong to the\n // field inside it, which is what has focus.\n // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- see above\n <div\n ref={editorRef}\n {...private_}\n onKeyDown={onKeyDown}\n onBlur={onBlur}\n onFocus={onFocus}\n aria-busy={pending || undefined}\n className=\"min-w-0\"\n >\n <div className=\"flex min-w-0 items-center gap-1.5\">\n <div className=\"min-w-0 flex-1\">\n {editor ? (\n <RenderEditor render={editor} {...editorProps} />\n ) : (\n <Input\n {...inputProps}\n {...editorProps.inputProps}\n value={String(draft ?? \"\")}\n onChange={(e) => change(e.target.value as unknown as T)}\n className={cn(\"px-2 py-1\", alignClass, inputProps?.className)}\n />\n )}\n </div>\n {/* Decorative: `aria-busy` on the wrapper says it. */}\n {pending && <Spinner label={null} className=\"size-4 shrink-0\" />}\n </div>\n {failed && (\n <p id={errorId} className=\"mt-1 text-[11px] leading-tight text-[var(--danger)]\">\n {failure}\n </p>\n )}\n </div>\n );\n } else if (locked) {\n // Button's `disabledReason` path, spelled out for a button that must look like\n // the value rather than like a Button: focusable, `aria-disabled`, every\n // activation swallowed, the reason in the kit Tooltip and in a `hidden` copy that\n // describes it. The button in a FRAGMENT so the Tooltip does not add the bubble as\n // a second description of the same sentence.\n body = (\n <Tooltip label={disabledReason} className=\"flex w-full min-w-0\">\n <>\n <button\n ref={displayRef}\n type=\"button\"\n {...private_}\n aria-disabled\n aria-describedby={reasonId}\n onClick={(e) => e.preventDefault()}\n className={cn(\n \"block w-full min-w-0 cursor-not-allowed truncate rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]\",\n alignClass,\n )}\n >\n {content}\n </button>\n <span id={reasonId} hidden>\n {disabledReason}\n </span>\n </>\n </Tooltip>\n );\n } else {\n body = (\n // Lazy: a table of these mounts no bubble until one is hovered or focused, and\n // the closed bubble's text is in no ancestor's accessible name.\n <Tooltip label={labels.edit(label)} lazy className=\"flex w-full min-w-0\">\n <button\n ref={displayRef}\n type=\"button\"\n {...private_}\n onClick={() => setOpen(true)}\n className={cn(\n \"block w-full min-w-0 truncate rounded-sm underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]\",\n alignClass,\n )}\n >\n {content}\n </button>\n </Tooltip>\n );\n }\n\n return (\n <div className={cn(\"min-w-0\", className)} data-editing={editing || undefined}>\n {body}\n {/* Always mounted: a live region that mounts with its message is usually missed. */}\n <span {...alert.regionProps} />\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/inline-edit-field.tsx"],"sourcesContent":["import { useEffect, useId, useRef, useState } from \"react\";\nimport type { FocusEvent, InputHTMLAttributes, KeyboardEvent, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { Input, Spinner } from \"./ui\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { Tooltip } from \"./tooltip\";\nimport { useCommitReason } from \"./write-lock\";\n\n/**\n * Every string {@link InlineEditField} says on its own behalf — the `inlineEdit`\n * namespace of `<UiKitProvider labels>`, overridable per instance through `labels`.\n */\nexport interface InlineEditLabels {\n /** The display button's tooltip and description — what pressing it does. Receives\n * the field's `label` (\"Budget name\" → \"Edit Budget name\"). */\n edit: (label: string) => string;\n /** Shown (and spoken) when `onCommit` rejects and no `formatError` says better. */\n failed: string;\n /** What the display shows for an empty value, when no `placeholder` is given. */\n empty: string;\n}\n\nexport const DEFAULT_INLINE_EDIT_LABELS: InlineEditLabels = {\n edit: (label) => `Edit ${label}`,\n failed: \"The change could not be saved.\",\n empty: \"Empty\",\n};\n\n/** What an `editor` render prop is handed: the draft and the means to end the edit. */\nexport interface InlineEditorProps<T> {\n /** The draft — seeded from `value` each time the editor opens. */\n value: T;\n /** Replace the draft (every keystroke). */\n onChange: (next: T) => void;\n /**\n * End the edit and save. Without an argument it saves the draft; WITH one it saves\n * that — for an editor whose own commit hands over a resolved value (`NumberInput`'s\n * `onCommit` gives \"1050\" for a typed \"1100-50\"). Called twice (the editor's own\n * Enter, then the field's), the second call does nothing.\n */\n commit: (next?: T) => void;\n /** End the edit and drop the draft — what Escape does. */\n cancel: () => void;\n /** The save is in flight: make the control read-only (not disabled — it has focus). */\n pending: boolean;\n /** The field's `label`, for the control's accessible name. */\n label: string;\n /** The last save failed and the draft has not been touched since. */\n invalid: boolean;\n /** The id of the failure message while there is one, for `aria-describedby`. */\n describedBy: string | undefined;\n /** The four above as attributes — spread them on a kit field that takes the DOM\n * spelling: `<AmountInput {...p.inputProps} … />`. */\n inputProps: {\n \"aria-label\": string;\n \"aria-invalid\": true | undefined;\n \"aria-describedby\": string | undefined;\n readOnly: boolean;\n };\n}\n\ninterface InlineEditFieldBaseProps<T> {\n /** The saved value — what the display shows, and what the editor opens on. */\n value: T;\n /**\n * Save `next`. Return a promise and the field waits: the editor stays open and\n * read-only with a spinner until it settles, closes when it resolves, and STAYS OPEN\n * with the failure under it when it rejects (or throws) — the typed value is kept for\n * a retry. Not called when the draft equals `value` (see `isEqual`): opening the\n * editor and leaving it unchanged is not a write.\n */\n onCommit: (next: T) => void | Promise<unknown>;\n /** What the value is (\"Budget name\", \"Assigned\") — the editor's accessible name and\n * the subject of the display's \"Edit …\" description. Not drawn: inline, the row or\n * the column header is the visible label. */\n label: string;\n /** The value as the display shows it — a formatted amount, a date. Default: the\n * value as text. */\n display?: (value: T) => ReactNode;\n /** Whether the draft is unchanged, so leaving the editor is not a write. Default\n * `Object.is`. keksdose compares assigned amounts as NUMBERS — \"1100\" is the\n * server's \"1100.0000\". */\n isEqual?: (a: T, b: T) => boolean;\n /** What the display shows for an empty value (`\"\"`, `null`, an empty `display`).\n * Default `labels.empty`. */\n placeholder?: ReactNode;\n /** `\"end\"` for a figure in a right-aligned money column. Default `\"start\"`. */\n align?: \"start\" | \"end\";\n /** Look, do not touch — a guest's view: the value as plain text, no button. */\n readOnly?: boolean;\n /**\n * Why it cannot be edited now — the write lock's sentence, a closed period. The\n * value stays a BUTTON (focusable, `aria-disabled`) with the reason in the kit\n * Tooltip and its description; it opens no editor. An editor that is open when the\n * reason arrives closes, its draft dropped: a field you can type into must be one\n * whose value can be saved (keksdose dev#496).\n */\n disabledReason?: ReactNode;\n /** This field COMMITS — under a locked {@link WriteLockProvider} it takes the\n * `disabledReason` path with the lock's reason (which wins over its own). No\n * provider, or an unlocked one: no effect. */\n commit?: boolean;\n /** The value is the user's own data: `data-private` on the display and the editor,\n * so the app's demo-mode blur covers both. */\n redact?: boolean;\n /** Turns a rejection into words. Default `labels.failed`. */\n formatError?: (error: unknown) => ReactNode;\n /** Controlled open state — a row's swipe action that opens the editor from outside\n * (keksdose's assigned cell). Left out, the field keeps its own. */\n editing?: boolean;\n /** Called with the next open state: on open, on a save that resolved, on cancel. */\n onEditingChange?: (editing: boolean) => void;\n /** Attributes for the DEFAULT text editor's `<input>` — `maxLength`, `inputMode`,\n * `autoComplete`. Ignored with `editor`. */\n inputProps?: Omit<InputHTMLAttributes<HTMLInputElement>, \"value\" | \"defaultValue\" | \"onChange\" | \"type\">;\n /** Classes for the root. */\n className?: string;\n labels?: Partial<InlineEditLabels>;\n}\n\ntype EditorRender<T> = (props: InlineEditorProps<T>) => ReactNode;\n\n/**\n * The editor. Optional for a string value — the kit's `Input`, compact — and\n * REQUIRED for anything else, since only the caller knows how to edit a number or an\n * amount. Render a kit field from the props it is handed:\n *\n * ```tsx\n * editor={(p) => (\n * <NumberInput calculator={false} ariaLabel={p.label} value={p.value} onChange={p.onChange}\n * onCommit={p.commit} aria-invalid={p.invalid || undefined} aria-describedby={p.describedBy} />\n * )}\n * ```\n *\n * The field itself handles Enter (save), Escape (cancel) and focus leaving the editor\n * (save) around whatever is rendered — a key the editor has already handled\n * (`preventDefault`, as a listbox does for its Enter) is left to it. Focus counts as\n * \"inside\" across a portal, so a picker or a phone keypad the editor opens does not\n * end the edit.\n */\nexport type InlineEditFieldProps<T = string> = InlineEditFieldBaseProps<T> &\n ([T] extends [string] ? { editor?: EditorRender<T> } : { editor: EditorRender<T> });\n\nfunction hasContent(node: ReactNode): boolean {\n return node !== undefined && node !== null && node !== false && node !== \"\";\n}\n\n/** Calls the `editor` render prop as a component of its own: the props it is handed\n * close over the field's refs, and a render prop called in the field's own render\n * would read like a ref read during render. Module-level, so the type is stable and\n * the editor never remounts. */\nfunction RenderEditor<T>({ render, ...props }: InlineEditorProps<T> & { render: EditorRender<T> }) {\n return render(props);\n}\n\n/**\n * The value at Large and Extra large (0.32.1, keksdose's 0.32 report;\n * docs/text-size-harmonization.md §4 \"nothing truncates\"): the display button, the\n * locked one and the read-only text wrap instead of ending in \"…\", and break a word too\n * long for the cell, so an IBAN or an e-mail address shows to its last character. The\n * value IS the button's accessible name and the thing being edited: a reader who needs\n * big type cannot recover a cut-off end by squinting. A `large:` class, so a table of\n * these keeps its one-line cells at Normal.\n *\n * `break-words`, not `[overflow-wrap:anywhere]`: these live in table cells (keksdose's\n * budget and invoice lines), and `anywhere` lowers the value's min-content width to one\n * letter, so an auto-sized column could squeeze an amount to a digit per line. With\n * `break-word` a column keeps each word whole; the button is `w-full`, so a word wider\n * than a sized cell still breaks.\n */\nconst VALUE_WRAP_AT_LARGE = \"large:whitespace-normal large:break-words\";\n\n/** `onCommit`'s result, if it is a promise. */\nfunction asPromise(result: unknown): Promise<unknown> | null {\n return result && typeof (result as Promise<unknown>).then === \"function\" ? (result as Promise<unknown>) : null;\n}\n\n/**\n * A value that edits in place: a quiet button showing the value; click it (or Enter\n * on it) and it becomes a field; Enter or leaving the field saves, Escape cancels\n * (keksdose K9).\n *\n * keksdose has three of them by hand — the budget's assigned cell (budget-cells.tsx),\n * the invoice line's value cells (invoice-lines-table.tsx `EditableCell`) and the\n * budget rename (budgets-page.tsx, an `Input` + Save + Cancel with no Escape) — and\n * each had to learn the same rules:\n *\n * - **The display's accessible name is the VALUE.** A cell named \"Edit\" reads a table\n * of \"edit, edit, edit\"; the action is the description instead (\"Edit Assigned\"),\n * in the kit Tooltip on hover and focus.\n * - **An unchanged draft is not a write.** Opening the editor and leaving it was a PUT\n * in keksdose until `isEqual` compared numbers, and on a sealed budget an outbox row\n * for a change nobody made.\n * - **A failed save keeps the editor.** The typed value is the user's work; the\n * failure is shown under it (attached with `aria-describedby`, and spoken), and the\n * next keystroke clears it. Escape still drops the draft.\n * - **Locked means a disabled BUTTON, not a disabled field** (keksdose dev#496): at\n * rest a real row shows this button, so a locked page shows the same, with the\n * reason — never a field that takes keys it cannot save.\n *\n * Inside a {@link DataTable} cell it needs nothing extra: the table already leaves a\n * click or a key on a control of the cell to that control. Focus returns to the value\n * after Enter or Escape; after a save that came from focus leaving, it stays where the\n * user put it.\n *\n * ```tsx\n * <InlineEditField label={t(\"budgets.name\")} value={budget.name}\n * onCommit={(name) => rename.mutateAsync({ id: budget.id, name })} inputProps={{ maxLength: 120 }} />\n * ```\n */\nexport function InlineEditField<T = string>(props: InlineEditFieldProps<T>) {\n const {\n value,\n onCommit,\n label,\n display,\n isEqual = Object.is,\n placeholder,\n align = \"start\",\n readOnly = false,\n disabledReason: ownDisabledReason,\n commit,\n redact = false,\n formatError,\n editing: editingProp,\n onEditingChange,\n inputProps,\n className,\n labels: labelsProp,\n } = props;\n const editor = props.editor as EditorRender<T> | undefined;\n const labels = useKitLabels(\"inlineEdit\", DEFAULT_INLINE_EDIT_LABELS, labelsProp);\n const disabledReason = useCommitReason(commit, ownDisabledReason);\n const locked = hasContent(disabledReason);\n\n const [openState, setOpenState] = useState(false);\n const open = editingProp ?? openState;\n // Locked (or read-only) wins over an open flag: a stale `editing` must never bring a\n // live commit-on-blur field back on a page that cannot save it.\n const editing = open && !locked && !readOnly;\n const [draft, setDraft] = useState<T>(value);\n const [pending, setPending] = useState(false);\n const [failure, setFailure] = useState<ReactNode>(null);\n const failed = hasContent(failure);\n\n const errorId = useId();\n const reasonId = useId();\n const displayRef = useRef<HTMLButtonElement>(null);\n const editorRef = useRef<HTMLDivElement>(null);\n const alert = useAnnounce({ politeness: \"assertive\" });\n\n // One edit ends once: Enter in a NumberInput commits through its own `onCommit` and\n // then reaches the field's Enter; a save that closes the editor unmounts the focused\n // control, which some browsers answer with a blur. The second ending is a no-op.\n const ended = useRef(false);\n const blurTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const mounted = useRef(true);\n // The latest draft for the deferred blur check, which runs after a render.\n const draftRef = useRef(draft);\n useEffect(() => {\n draftRef.current = draft;\n });\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n if (blurTimer.current) clearTimeout(blurTimer.current);\n };\n }, []);\n\n // Seed the draft when the editor opens — from wherever the open came (the button, a\n // controlled `editing`). During render, like DangerConfirm's wipe, so no frame shows\n // the editor holding the previous edit's text.\n const [wasEditing, setWasEditing] = useState(editing);\n if (wasEditing !== editing) {\n setWasEditing(editing);\n if (editing) {\n setDraft(value);\n setFailure(null);\n setPending(false);\n }\n }\n // An open editor that a lock (or `readOnly`) took away is closed for good, not\n // merely hidden until the lock lifts.\n if (openState && !editing && editingProp === undefined) setOpenState(false);\n\n // Focus follows the edit: into the editor when it opens, back to the value when it\n // closes — but only if focus went down with the editor (Enter, Escape, a save that\n // resolved under it). A save that came from focus LEAVING keeps focus where it went.\n const prevEditing = useRef(editing);\n useEffect(() => {\n const changed = prevEditing.current !== editing;\n prevEditing.current = editing;\n if (!changed) return;\n if (editing) {\n // A fresh edit, which may end once. Here rather than in the seed above: a ref is\n // not written during render, and no event can end the edit before this runs.\n ended.current = false;\n const control = editorRef.current?.querySelector<HTMLElement>(\n \"input:not([type=hidden]), textarea, select, [contenteditable='true'], [tabindex]:not([tabindex='-1']), button\",\n );\n control?.focus();\n if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) control.select();\n return;\n }\n // However it closed — a save, Escape, a lock taking it away — a blur check still\n // queued from the editor has nothing left to save.\n ended.current = true;\n if (blurTimer.current) clearTimeout(blurTimer.current);\n blurTimer.current = null;\n const lost = document.activeElement === null || document.activeElement === document.body;\n if (lost) displayRef.current?.focus();\n }, [editing]);\n\n const setOpen = (next: boolean) => {\n if (editingProp === undefined) setOpenState(next);\n onEditingChange?.(next);\n };\n\n const fail = (caught: unknown) => {\n ended.current = false;\n const message = formatError ? formatError(caught) : labels.failed;\n setFailure(hasContent(message) ? message : labels.failed);\n alert.announce(typeof message === \"string\" && message !== \"\" ? message : labels.failed);\n // Focus back into the editor if the failure left it nowhere (a pending field that\n // the browser let go of); never pulled back from somewhere the user went.\n const active = document.activeElement;\n if (active === null || active === document.body) {\n editorRef.current?.querySelector<HTMLElement>(\"input, textarea, select, [tabindex]\")?.focus();\n }\n };\n\n const save = (next: T = draftRef.current) => {\n if (!editing || ended.current || pending) return;\n ended.current = true;\n if (next !== draftRef.current) setDraft(next);\n if (isEqual(next, value)) {\n setOpen(false);\n return;\n }\n let result: unknown;\n try {\n result = onCommit(next);\n } catch (caught) {\n fail(caught);\n return;\n }\n const promise = asPromise(result);\n if (!promise) {\n setOpen(false);\n return;\n }\n setPending(true);\n setFailure(null);\n promise.then(\n () => {\n if (!mounted.current) return;\n setPending(false);\n setOpen(false);\n },\n (caught: unknown) => {\n if (!mounted.current) return;\n setPending(false);\n fail(caught);\n },\n );\n };\n\n const cancel = () => {\n // A request that has left cannot be called back; Escape waits for it.\n if (!editing || pending) return;\n ended.current = true;\n setOpen(false);\n };\n\n const change = (next: T) => {\n if (pending) return;\n setDraft(next);\n // Now, not after the render: a blur check scheduled before that render reads it.\n draftRef.current = next;\n // The failure described the draft that was sent; once edited it describes nothing\n // on screen, and the next save (or blur) is a fresh attempt.\n setFailure(null);\n };\n\n const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.defaultPrevented) return;\n if (e.key === \"Escape\") {\n // Ours, not the dialog's or the popover's around the field.\n e.preventDefault();\n e.stopPropagation();\n cancel();\n return;\n }\n // Not mid-composition: Enter there picks the IME's candidate (Japanese, Chinese).\n if (e.key === \"Enter\" && !e.nativeEvent.isComposing && !e.shiftKey) {\n e.preventDefault();\n save();\n }\n };\n\n // Leaving the editor saves. Deferred one task, and cancelled by any focus that lands\n // back inside: React reports focus moves through PORTALS to the component tree, so a\n // calculator popover or a phone keypad the editor opened counts as inside, which a\n // DOM `contains` check would get wrong. A failure on screen holds the editor: the\n // user is looking at why, and moving away is not a retry.\n const onBlur = (e: FocusEvent<HTMLDivElement>) => {\n if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;\n if (blurTimer.current) clearTimeout(blurTimer.current);\n blurTimer.current = setTimeout(() => {\n blurTimer.current = null;\n if (!mounted.current || failed) return;\n save();\n }, 0);\n };\n const onFocus = () => {\n if (blurTimer.current) clearTimeout(blurTimer.current);\n blurTimer.current = null;\n };\n\n const private_ = redact ? { \"data-private\": true } : undefined;\n const shown = display ? display(value) : value === null || value === undefined ? \"\" : String(value);\n const content = hasContent(shown) ? (\n shown\n ) : (\n <span className=\"text-[var(--text-muted)]\">{placeholder ?? labels.empty}</span>\n );\n const alignClass = align === \"end\" ? \"text-end\" : \"text-start\";\n\n let body: ReactNode;\n if (readOnly) {\n body = (\n <span {...private_} className={cn(\"block min-w-0 truncate\", VALUE_WRAP_AT_LARGE, alignClass)}>\n {content}\n </span>\n );\n } else if (editing) {\n const describedBy = failed ? errorId : undefined;\n const editorProps: InlineEditorProps<T> = {\n value: draft,\n onChange: change,\n commit: save,\n cancel,\n pending,\n label,\n invalid: failed,\n describedBy,\n inputProps: {\n \"aria-label\": label,\n \"aria-invalid\": failed || undefined,\n \"aria-describedby\": describedBy,\n readOnly: pending,\n },\n };\n body = (\n // A wrapper for the keys and the focus, not a control: the keys belong to the\n // field inside it, which is what has focus.\n // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- see above\n <div\n ref={editorRef}\n {...private_}\n onKeyDown={onKeyDown}\n onBlur={onBlur}\n onFocus={onFocus}\n aria-busy={pending || undefined}\n className=\"min-w-0\"\n >\n <div className=\"flex min-w-0 items-center gap-1.5\">\n <div className=\"min-w-0 flex-1\">\n {editor ? (\n <RenderEditor render={editor} {...editorProps} />\n ) : (\n <Input\n {...inputProps}\n {...editorProps.inputProps}\n value={String(draft ?? \"\")}\n onChange={(e) => change(e.target.value as unknown as T)}\n className={cn(\"px-2 py-1\", alignClass, inputProps?.className)}\n />\n )}\n </div>\n {/* Decorative: `aria-busy` on the wrapper says it. */}\n {pending && <Spinner label={null} className=\"size-4 shrink-0\" />}\n </div>\n {failed && (\n <p id={errorId} className=\"mt-1 text-caption leading-tight text-[var(--danger)]\">\n {failure}\n </p>\n )}\n </div>\n );\n } else if (locked) {\n // Button's `disabledReason` path, spelled out for a button that must look like\n // the value rather than like a Button: focusable, `aria-disabled`, every\n // activation swallowed, the reason in the kit Tooltip and in a `hidden` copy that\n // describes it. The button in a FRAGMENT so the Tooltip does not add the bubble as\n // a second description of the same sentence.\n body = (\n <Tooltip label={disabledReason} className=\"flex w-full min-w-0\">\n <>\n <button\n ref={displayRef}\n type=\"button\"\n {...private_}\n aria-disabled\n aria-describedby={reasonId}\n onClick={(e) => e.preventDefault()}\n className={cn(\n \"block w-full min-w-0 cursor-not-allowed truncate rounded-sm focus:outline-none\",\n VALUE_WRAP_AT_LARGE,\n FOCUS_RING,\n alignClass,\n )}\n >\n {content}\n </button>\n <span id={reasonId} hidden>\n {disabledReason}\n </span>\n </>\n </Tooltip>\n );\n } else {\n body = (\n // Lazy: a table of these mounts no bubble until one is hovered or focused, and\n // the closed bubble's text is in no ancestor's accessible name.\n <Tooltip label={labels.edit(label)} lazy className=\"flex w-full min-w-0\">\n <button\n ref={displayRef}\n type=\"button\"\n {...private_}\n onClick={() => setOpen(true)}\n className={cn(\n \"block w-full min-w-0 truncate rounded-sm underline-offset-2 hover:underline focus:outline-none\",\n VALUE_WRAP_AT_LARGE,\n FOCUS_RING,\n alignClass,\n )}\n >\n {content}\n </button>\n </Tooltip>\n );\n }\n\n return (\n <div className={cn(\"min-w-0\", className)} data-editing={editing || undefined}>\n {body}\n {/* Always mounted: a live region that mounts with its message is usually missed. */}\n <span {...alert.regionProps} />\n </div>\n );\n}\n"],"mappings":"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","names":[]}