@eifi1/ui-kit 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (382) hide show
  1. package/README.md +66 -37
  2. package/dist/chart.d.ts +3 -1
  3. package/dist/chart.js +2 -0
  4. package/dist/chart.js.map +1 -1
  5. package/dist/components/account-settings-labels.d.ts +105 -0
  6. package/dist/components/account-settings-labels.js +81 -0
  7. package/dist/components/account-settings-labels.js.map +1 -0
  8. package/dist/components/account-settings.d.ts +36 -52
  9. package/dist/components/account-settings.js +44 -8
  10. package/dist/components/account-settings.js.map +1 -1
  11. package/dist/components/alert-banner.d.ts +26 -1
  12. package/dist/components/alert-banner.js +10 -7
  13. package/dist/components/alert-banner.js.map +1 -1
  14. package/dist/components/amount-input.d.ts +26 -4
  15. package/dist/components/amount-input.js +5 -2
  16. package/dist/components/amount-input.js.map +1 -1
  17. package/dist/components/authed-image.d.ts +68 -0
  18. package/dist/components/authed-image.js +117 -0
  19. package/dist/components/authed-image.js.map +1 -0
  20. package/dist/components/breadcrumbs.d.ts +2 -1
  21. package/dist/components/breadcrumbs.js +5 -2
  22. package/dist/components/breadcrumbs.js.map +1 -1
  23. package/dist/components/bulk-action-bar.d.ts +51 -5
  24. package/dist/components/bulk-action-bar.js +88 -41
  25. package/dist/components/bulk-action-bar.js.map +1 -1
  26. package/dist/components/button-group.d.ts +107 -3
  27. package/dist/components/button-group.js +60 -5
  28. package/dist/components/button-group.js.map +1 -1
  29. package/dist/components/calculator.d.ts +17 -4
  30. package/dist/components/calendar-heatmap.d.ts +162 -0
  31. package/dist/components/calendar-heatmap.js +336 -0
  32. package/dist/components/calendar-heatmap.js.map +1 -0
  33. package/dist/components/chip.d.ts +2 -1
  34. package/dist/components/chip.js +6 -3
  35. package/dist/components/chip.js.map +1 -1
  36. package/dist/components/choice-card.d.ts +66 -2
  37. package/dist/components/choice-card.js +108 -1
  38. package/dist/components/choice-card.js.map +1 -1
  39. package/dist/components/combobox-core.js +2 -0
  40. package/dist/components/combobox-core.js.map +1 -1
  41. package/dist/components/combobox.d.ts +2 -2
  42. package/dist/components/combobox.js +31 -9
  43. package/dist/components/combobox.js.map +1 -1
  44. package/dist/components/copy-button.d.ts +52 -70
  45. package/dist/components/copy-button.js +1 -1
  46. package/dist/components/copy-button.js.map +1 -1
  47. package/dist/components/data-table-cells.d.ts +63 -0
  48. package/dist/components/data-table-cells.js +64 -0
  49. package/dist/components/data-table-cells.js.map +1 -0
  50. package/dist/components/data-table-filter-popover.d.ts +1 -1
  51. package/dist/components/data-table-filters.d.ts +1 -1
  52. package/dist/components/data-table-filters.js +31 -1
  53. package/dist/components/data-table-filters.js.map +1 -1
  54. package/dist/components/data-table-labels.d.ts +13 -0
  55. package/dist/components/data-table-labels.js +8 -1
  56. package/dist/components/data-table-labels.js.map +1 -1
  57. package/dist/components/data-table.d.ts +1 -1
  58. package/dist/components/data-table.js +104 -12
  59. package/dist/components/data-table.js.map +1 -1
  60. package/dist/components/date-picker.d.ts +3 -1
  61. package/dist/components/date-picker.js +15 -2
  62. package/dist/components/date-picker.js.map +1 -1
  63. package/dist/components/description-list.d.ts +23 -3
  64. package/dist/components/description-list.js +23 -3
  65. package/dist/components/description-list.js.map +1 -1
  66. package/dist/components/dialog-frame.d.ts +1 -0
  67. package/dist/components/entity-combobox.d.ts +1 -1
  68. package/dist/components/entity-combobox.js +18 -3
  69. package/dist/components/entity-combobox.js.map +1 -1
  70. package/dist/components/error-boundary.d.ts +92 -0
  71. package/dist/components/error-boundary.js +111 -0
  72. package/dist/components/error-boundary.js.map +1 -0
  73. package/dist/components/field.d.ts +69 -0
  74. package/dist/components/field.js +75 -0
  75. package/dist/components/field.js.map +1 -0
  76. package/dist/components/file-button.d.ts +51 -208
  77. package/dist/components/file-dropzone.d.ts +50 -2
  78. package/dist/components/floating-panel.d.ts +156 -4
  79. package/dist/components/floating-panel.js +193 -29
  80. package/dist/components/floating-panel.js.map +1 -1
  81. package/dist/components/form-actions.d.ts +53 -0
  82. package/dist/components/form-actions.js +98 -0
  83. package/dist/components/form-actions.js.map +1 -0
  84. package/dist/components/image-grid.d.ts +67 -0
  85. package/dist/components/image-grid.js +112 -0
  86. package/dist/components/image-grid.js.map +1 -0
  87. package/dist/components/lightbox.d.ts +104 -0
  88. package/dist/components/lightbox.js +229 -0
  89. package/dist/components/lightbox.js.map +1 -0
  90. package/dist/components/line-items.d.ts +114 -0
  91. package/dist/components/line-items.js +238 -0
  92. package/dist/components/line-items.js.map +1 -0
  93. package/dist/components/list.d.ts +37 -3
  94. package/dist/components/list.js +39 -14
  95. package/dist/components/list.js.map +1 -1
  96. package/dist/components/loading-state.d.ts +24 -0
  97. package/dist/components/loading-state.js +32 -0
  98. package/dist/components/loading-state.js.map +1 -0
  99. package/dist/components/menu-item.d.ts +13 -2
  100. package/dist/components/menu-item.js +11 -2
  101. package/dist/components/menu-item.js.map +1 -1
  102. package/dist/components/mini-calendar.d.ts +82 -5
  103. package/dist/components/mini-calendar.js +212 -64
  104. package/dist/components/mini-calendar.js.map +1 -1
  105. package/dist/components/modal.d.ts +20 -1
  106. package/dist/components/modal.js +26 -2
  107. package/dist/components/modal.js.map +1 -1
  108. package/dist/components/month-picker.d.ts +21 -1
  109. package/dist/components/month-picker.js +139 -26
  110. package/dist/components/month-picker.js.map +1 -1
  111. package/dist/components/multi-entity-combobox.d.ts +1 -1
  112. package/dist/components/multi-entity-combobox.js +19 -3
  113. package/dist/components/multi-entity-combobox.js.map +1 -1
  114. package/dist/components/nav-pills.d.ts +67 -0
  115. package/dist/components/nav-pills.js +69 -0
  116. package/dist/components/nav-pills.js.map +1 -0
  117. package/dist/components/number-field.d.ts +26 -5
  118. package/dist/components/number-field.js +5 -7
  119. package/dist/components/number-field.js.map +1 -1
  120. package/dist/components/number-input.d.ts +27 -5
  121. package/dist/components/number-input.js +7 -1
  122. package/dist/components/number-input.js.map +1 -1
  123. package/dist/components/numpad-sheet.d.ts +17 -4
  124. package/dist/components/passkeys-setting.d.ts +67 -0
  125. package/dist/components/passkeys-setting.js +214 -0
  126. package/dist/components/passkeys-setting.js.map +1 -0
  127. package/dist/components/picker-sheet.js +2 -1
  128. package/dist/components/picker-sheet.js.map +1 -1
  129. package/dist/components/pie-chart-labels.d.ts +20 -0
  130. package/dist/components/pie-chart-labels.js +12 -0
  131. package/dist/components/pie-chart-labels.js.map +1 -0
  132. package/dist/components/pie-chart.d.ts +101 -0
  133. package/dist/components/pie-chart.js +283 -0
  134. package/dist/components/pie-chart.js.map +1 -0
  135. package/dist/components/progress-bar.d.ts +73 -3
  136. package/dist/components/progress-bar.js +110 -8
  137. package/dist/components/progress-bar.js.map +1 -1
  138. package/dist/components/qr-code.d.ts +34 -0
  139. package/dist/components/qr-code.js +57 -0
  140. package/dist/components/qr-code.js.map +1 -0
  141. package/dist/components/search-field.d.ts +15 -3
  142. package/dist/components/search-field.js +5 -1
  143. package/dist/components/search-field.js.map +1 -1
  144. package/dist/components/series-chart.d.ts +16 -0
  145. package/dist/components/series-chart.js +4 -1
  146. package/dist/components/series-chart.js.map +1 -1
  147. package/dist/components/settings-fields.d.ts +50 -1
  148. package/dist/components/signed-amount.d.ts +121 -0
  149. package/dist/components/signed-amount.js +152 -0
  150. package/dist/components/signed-amount.js.map +1 -0
  151. package/dist/components/skeleton.d.ts +10 -3
  152. package/dist/components/skeleton.js +12 -2
  153. package/dist/components/skeleton.js.map +1 -1
  154. package/dist/components/stat-tile.d.ts +2 -1
  155. package/dist/components/stat-tile.js +5 -2
  156. package/dist/components/stat-tile.js.map +1 -1
  157. package/dist/components/status-dot.d.ts +6 -2
  158. package/dist/components/status-dot.js +6 -1
  159. package/dist/components/status-dot.js.map +1 -1
  160. package/dist/components/table.d.ts +77 -11
  161. package/dist/components/table.js +85 -17
  162. package/dist/components/table.js.map +1 -1
  163. package/dist/components/text-link.d.ts +176 -0
  164. package/dist/components/text-link.js +109 -0
  165. package/dist/components/text-link.js.map +1 -0
  166. package/dist/components/text.d.ts +8 -1
  167. package/dist/components/text.js +1 -0
  168. package/dist/components/text.js.map +1 -1
  169. package/dist/components/time-input.d.ts +50 -1
  170. package/dist/components/toggle-group.d.ts +28 -0
  171. package/dist/components/toggle-group.js +58 -9
  172. package/dist/components/toggle-group.js.map +1 -1
  173. package/dist/components/toggle-legend.d.ts +36 -3
  174. package/dist/components/toggle-legend.js +49 -0
  175. package/dist/components/toggle-legend.js.map +1 -1
  176. package/dist/components/tooltip.d.ts +11 -3
  177. package/dist/components/tooltip.js +2 -0
  178. package/dist/components/tooltip.js.map +1 -1
  179. package/dist/components/ui.d.ts +52 -729
  180. package/dist/components/ui.js +296 -27
  181. package/dist/components/ui.js.map +1 -1
  182. package/dist/components/use-table-state.d.ts +3 -52
  183. package/dist/components/use-table-state.js +128 -30
  184. package/dist/components/use-table-state.js.map +1 -1
  185. package/dist/components/user-avatar.d.ts +25 -3
  186. package/dist/components/user-avatar.js +32 -3
  187. package/dist/components/user-avatar.js.map +1 -1
  188. package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
  189. package/dist/data-table.d.ts +2 -1
  190. package/dist/data-table.js +5 -1
  191. package/dist/data-table.js.map +1 -1
  192. package/dist/feedback/feedback-dialog.d.ts +26 -3
  193. package/dist/feedback/feedback-dialog.js +16 -2
  194. package/dist/feedback/feedback-dialog.js.map +1 -1
  195. package/dist/feedback/feedback-thread.d.ts +133 -0
  196. package/dist/feedback/feedback-thread.js +237 -0
  197. package/dist/feedback/feedback-thread.js.map +1 -0
  198. package/dist/feedback.d.ts +2 -1
  199. package/dist/feedback.js +1 -0
  200. package/dist/feedback.js.map +1 -1
  201. package/dist/hooks/use-authed-src.d.ts +71 -0
  202. package/dist/hooks/use-authed-src.js +63 -0
  203. package/dist/hooks/use-authed-src.js.map +1 -0
  204. package/dist/hooks/use-file-drop.d.ts +50 -2
  205. package/dist/hooks/use-hotkey.d.ts +59 -0
  206. package/dist/hooks/use-hotkey.js +105 -0
  207. package/dist/hooks/use-hotkey.js.map +1 -0
  208. package/dist/hooks/use-search-param-state.d.ts +83 -0
  209. package/dist/hooks/use-search-param-state.js +94 -0
  210. package/dist/hooks/use-search-param-state.js.map +1 -0
  211. package/dist/i18n/defaults.d.ts +17 -4
  212. package/dist/i18n/defaults.js +33 -1
  213. package/dist/i18n/defaults.js.map +1 -1
  214. package/dist/i18n/kit-labels.d.ts +47 -310
  215. package/dist/i18n/kit-labels.js +20 -5
  216. package/dist/i18n/kit-labels.js.map +1 -1
  217. package/dist/i18n/locales/de-CH-informal.d.ts +17 -4
  218. package/dist/i18n/locales/de-CH.d.ts +17 -4
  219. package/dist/i18n/locales/de-informal.d.ts +17 -4
  220. package/dist/i18n/locales/de.d.ts +17 -4
  221. package/dist/i18n/locales/de.js +165 -4
  222. package/dist/i18n/locales/de.js.map +1 -1
  223. package/dist/i18n/locales/es.d.ts +17 -4
  224. package/dist/i18n/locales/es.js +162 -4
  225. package/dist/i18n/locales/es.js.map +1 -1
  226. package/dist/i18n/locales/fr.d.ts +17 -4
  227. package/dist/i18n/locales/fr.js +162 -4
  228. package/dist/i18n/locales/fr.js.map +1 -1
  229. package/dist/i18n/locales/hu.d.ts +17 -4
  230. package/dist/i18n/locales/hu.js +162 -4
  231. package/dist/i18n/locales/hu.js.map +1 -1
  232. package/dist/i18n/locales/it.d.ts +17 -4
  233. package/dist/i18n/locales/it.js +162 -4
  234. package/dist/i18n/locales/it.js.map +1 -1
  235. package/dist/i18n/locales/zh.d.ts +17 -4
  236. package/dist/i18n/locales/zh.js +162 -4
  237. package/dist/i18n/locales/zh.js.map +1 -1
  238. package/dist/index.d.ts +45 -21
  239. package/dist/index.js +43 -1
  240. package/dist/index.js.map +1 -1
  241. package/dist/kit-labels-v3biUF1L.d.ts +1645 -0
  242. package/dist/lib/clipping.d.ts +21 -5
  243. package/dist/lib/clipping.js +3 -0
  244. package/dist/lib/clipping.js.map +1 -1
  245. package/dist/lib/dates.d.ts +17 -1
  246. package/dist/lib/dates.js +19 -0
  247. package/dist/lib/dates.js.map +1 -1
  248. package/dist/lib/format.d.ts +144 -0
  249. package/dist/lib/format.js +149 -0
  250. package/dist/lib/format.js.map +1 -0
  251. package/dist/lib/qr-encode.d.ts +44 -0
  252. package/dist/lib/qr-encode.js +338 -0
  253. package/dist/lib/qr-encode.js.map +1 -0
  254. package/dist/rhf/fields.d.ts +241 -0
  255. package/dist/rhf/fields.js +601 -0
  256. package/dist/rhf/fields.js.map +1 -0
  257. package/dist/rhf/form.d.ts +50 -1
  258. package/dist/rhf/line-items.d.ts +31 -0
  259. package/dist/rhf/line-items.js +59 -0
  260. package/dist/rhf/line-items.js.map +1 -0
  261. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  262. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  263. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  264. package/dist/rhf.d.ts +63 -1
  265. package/dist/rhf.js +3 -0
  266. package/dist/rhf.js.map +1 -1
  267. package/dist/shell/app-shell.d.ts +99 -2
  268. package/dist/shell/app-shell.js +58 -3
  269. package/dist/shell/app-shell.js.map +1 -1
  270. package/dist/shell/auth-layout.d.ts +74 -0
  271. package/dist/shell/auth-layout.js +97 -0
  272. package/dist/shell/auth-layout.js.map +1 -0
  273. package/dist/shell/top-bar-brand.d.ts +81 -0
  274. package/dist/shell/top-bar-brand.js +47 -0
  275. package/dist/shell/top-bar-brand.js.map +1 -0
  276. package/dist/shell/topbar-action-menu.d.ts +84 -3
  277. package/dist/shell/topbar-action-menu.js +115 -27
  278. package/dist/shell/topbar-action-menu.js.map +1 -1
  279. package/dist/shell.d.ts +58 -2
  280. package/dist/shell.js +2 -0
  281. package/dist/shell.js.map +1 -1
  282. package/dist/wizard/stepper-nav.d.ts +49 -1
  283. package/dist/wizard/wizard-context.d.ts +30 -1
  284. package/dist/wizard/wizard-context.js +23 -2
  285. package/dist/wizard/wizard-context.js.map +1 -1
  286. package/dist/wizard/wizard-step.d.ts +20 -5
  287. package/dist/wizard/wizard-step.js +16 -3
  288. package/dist/wizard/wizard-step.js.map +1 -1
  289. package/dist/wizard.d.ts +51 -3
  290. package/package.json +1 -1
  291. package/src/chart.ts +4 -0
  292. package/src/components/account-settings-labels.ts +190 -0
  293. package/src/components/account-settings.tsx +110 -63
  294. package/src/components/alert-banner.tsx +36 -7
  295. package/src/components/amount-input.tsx +13 -1
  296. package/src/components/authed-image.tsx +190 -0
  297. package/src/components/breadcrumbs.tsx +8 -2
  298. package/src/components/bulk-action-bar.tsx +163 -57
  299. package/src/components/button-group.tsx +129 -4
  300. package/src/components/calendar-heatmap.tsx +599 -0
  301. package/src/components/chip.tsx +8 -4
  302. package/src/components/choice-card.tsx +208 -1
  303. package/src/components/combobox-core.tsx +5 -0
  304. package/src/components/combobox.tsx +31 -9
  305. package/src/components/copy-button.tsx +3 -2
  306. package/src/components/data-table-cells.tsx +119 -0
  307. package/src/components/data-table-filters.ts +77 -0
  308. package/src/components/data-table-labels.ts +23 -0
  309. package/src/components/data-table.tsx +231 -13
  310. package/src/components/date-picker.tsx +19 -3
  311. package/src/components/description-list.tsx +52 -3
  312. package/src/components/entity-combobox.tsx +18 -3
  313. package/src/components/error-boundary.tsx +216 -0
  314. package/src/components/field.tsx +188 -0
  315. package/src/components/floating-panel.tsx +380 -16
  316. package/src/components/form-actions.tsx +191 -0
  317. package/src/components/image-grid.tsx +181 -0
  318. package/src/components/lightbox.tsx +374 -0
  319. package/src/components/line-items.tsx +385 -0
  320. package/src/components/list.tsx +93 -18
  321. package/src/components/loading-state.tsx +47 -0
  322. package/src/components/menu-item.tsx +29 -3
  323. package/src/components/mini-calendar.tsx +317 -57
  324. package/src/components/modal.tsx +61 -3
  325. package/src/components/month-picker.tsx +181 -26
  326. package/src/components/multi-entity-combobox.tsx +19 -3
  327. package/src/components/nav-pills.tsx +152 -0
  328. package/src/components/number-field.tsx +15 -10
  329. package/src/components/number-input.tsx +16 -1
  330. package/src/components/passkeys-setting.tsx +331 -0
  331. package/src/components/picker-sheet.tsx +7 -1
  332. package/src/components/pie-chart-labels.ts +32 -0
  333. package/src/components/pie-chart.tsx +479 -0
  334. package/src/components/progress-bar.tsx +241 -13
  335. package/src/components/qr-code.tsx +83 -0
  336. package/src/components/search-field.tsx +20 -4
  337. package/src/components/series-chart.tsx +21 -0
  338. package/src/components/signed-amount.tsx +286 -0
  339. package/src/components/skeleton.tsx +21 -3
  340. package/src/components/stat-tile.tsx +8 -2
  341. package/src/components/status-dot.tsx +10 -2
  342. package/src/components/table.tsx +186 -23
  343. package/src/components/text-link.tsx +248 -0
  344. package/src/components/text.tsx +9 -1
  345. package/src/components/toggle-group.tsx +106 -13
  346. package/src/components/toggle-legend.tsx +87 -2
  347. package/src/components/tooltip.tsx +17 -3
  348. package/src/components/ui.tsx +572 -44
  349. package/src/components/use-table-state.ts +265 -33
  350. package/src/components/user-avatar.tsx +57 -3
  351. package/src/data-table.ts +10 -0
  352. package/src/feedback/feedback-dialog.tsx +42 -3
  353. package/src/feedback/feedback-thread.tsx +434 -0
  354. package/src/feedback.ts +1 -0
  355. package/src/hooks/use-authed-src.ts +161 -0
  356. package/src/hooks/use-hotkey.ts +172 -0
  357. package/src/hooks/use-search-param-state.ts +187 -0
  358. package/src/i18n/defaults.ts +32 -0
  359. package/src/i18n/kit-labels.tsx +104 -8
  360. package/src/i18n/locales/de.ts +162 -0
  361. package/src/i18n/locales/es.ts +159 -0
  362. package/src/i18n/locales/fr.ts +162 -0
  363. package/src/i18n/locales/hu.ts +160 -0
  364. package/src/i18n/locales/it.ts +160 -0
  365. package/src/i18n/locales/zh.ts +159 -0
  366. package/src/index.ts +64 -0
  367. package/src/lib/clipping.ts +22 -4
  368. package/src/lib/dates.ts +37 -0
  369. package/src/lib/format.ts +325 -0
  370. package/src/lib/qr-encode.ts +420 -0
  371. package/src/rhf/fields.tsx +1054 -0
  372. package/src/rhf/line-items.tsx +130 -0
  373. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  374. package/src/rhf.ts +8 -0
  375. package/src/shell/app-shell.tsx +147 -2
  376. package/src/shell/auth-layout.tsx +190 -0
  377. package/src/shell/top-bar-brand.tsx +76 -0
  378. package/src/shell/topbar-action-menu.tsx +260 -49
  379. package/src/shell.ts +2 -0
  380. package/src/wizard/wizard-context.tsx +50 -1
  381. package/src/wizard/wizard-step.tsx +43 -6
  382. package/tokens.css +12 -0
@@ -1,18 +1,39 @@
1
1
  import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
- import type { ButtonHTMLAttributes, CSSProperties, KeyboardEvent, ReactNode, Ref } from "react";
3
+ import type {
4
+ AnchorHTMLAttributes,
5
+ ButtonHTMLAttributes,
6
+ CSSProperties,
7
+ HTMLAttributes,
8
+ KeyboardEvent,
9
+ MouseEvent,
10
+ ReactElement,
11
+ ReactNode,
12
+ Ref,
13
+ } from "react";
4
14
  import { X } from "lucide-react";
5
15
 
6
16
  import { cn } from "../lib/cn";
7
17
  import { dirOf, type Direction } from "../lib/direction";
8
- import { useKitLabels } from "../i18n/kit-labels";
18
+ import { useKitLabels, useKitLink } from "../i18n/kit-labels";
19
+ import { pickLinkRenderer } from "./text-link";
20
+ import { Tooltip, type TooltipSide } from "./tooltip";
9
21
 
10
22
  export interface FloatingPanelLabels {
11
23
  /** Accessible name of the panel header's X. */
12
24
  close: string;
25
+ /**
26
+ * What a {@link FloatingAction}'s count badge adds to its accessible name ("3 new"),
27
+ * read after the label: "Invoices, 3 new". The dot itself is hidden from a reader —
28
+ * a bare "3" read out of context says nothing about what is counted.
29
+ */
30
+ badge: (count: number) => string;
13
31
  }
14
32
 
15
- export const DEFAULT_FLOATING_PANEL_LABELS: FloatingPanelLabels = { close: "Close" };
33
+ export const DEFAULT_FLOATING_PANEL_LABELS: FloatingPanelLabels = {
34
+ close: "Close",
35
+ badge: (count) => `${count} new`,
36
+ };
16
37
 
17
38
  /** A LOGICAL corner: `bottom-end` is bottom-right in LTR and bottom-left in RTL. */
18
39
  export type FloatingCorner = "bottom-end" | "bottom-start";
@@ -89,6 +110,49 @@ export interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTM
89
110
  * in both states; `aria-pressed` already says which one it is in.
90
111
  */
91
112
  pressed?: boolean;
113
+ /**
114
+ * Show `label` beside the icon: the EXTENDED FAB, a pill rather than a disc. For a
115
+ * corner control whose words are the point — kastlan's offline indicator
116
+ * (shared/offline/offline-indicator.tsx:39) is a pill saying "Offline — 2 photo(s)
117
+ * queued" / "Syncing…", hand-placed at `fixed bottom-4 left-4`, i.e. over the
118
+ * bottom nav and on the wrong side in RTL. Same height as the disc (3rem), so a
119
+ * {@link FloatingPanel} card above it still clears it.
120
+ */
121
+ extended?: boolean;
122
+ /**
123
+ * Announce `label` whenever it changes, through a polite `role="status"` region — for
124
+ * an extended FAB that REPORTS something (kastlan's offline/sync state), where a
125
+ * sighted user sees the words change and a screen reader user otherwise hears
126
+ * nothing until they happen to land on the button.
127
+ *
128
+ * ⚠️ A live region announces changes, not its own arrival: a FAB that mounts already
129
+ * saying "Offline" says it to nobody. Keep it mounted and pass `hidden` while there
130
+ * is nothing to report (kastlan returns `null` when online and idle); the region
131
+ * stays in the page, and the first real status is heard.
132
+ */
133
+ live?: boolean;
134
+ /**
135
+ * `primary` (default): the solid brand disc of a page's main action. `surface`: the
136
+ * page's surface with a hairline border — for a FAB that is a status rather than a
137
+ * call to action, as kastlan's offline pill is (a brand pill would shout "press me"
138
+ * at a user who is merely offline). Ignored while `pressed` is set; a toggle has its
139
+ * own two looks.
140
+ */
141
+ variant?: "primary" | "surface";
142
+ /**
143
+ * Hover and focus label in the kit {@link Tooltip}, instead of the native `title` —
144
+ * keksdose has ONE tooltip (dev#523) and wrapped its assistant launcher by hand,
145
+ * which cannot work: the tooltip's anchor is a `relative inline-flex` span, and this
146
+ * button is `fixed` and portalled. With `tooltip` the FAB positions a fixed wrapper
147
+ * instead and the button sits in the tooltip inside it. `true` shows the `label`;
148
+ * any other content shows that. Implies no native `title` (whatever `nativeTitle`
149
+ * says): one hover hint, not two. `style` stays on the button; the corner and
150
+ * `offset` move to the wrapper.
151
+ */
152
+ tooltip?: ReactNode;
153
+ /** Where the {@link tooltip} opens. Default `top`; the bubble is portalled and kept on
154
+ * screen, so a corner button's label is not pushed off the edge. */
155
+ tooltipSide?: TooltipSide;
92
156
  ref?: Ref<HTMLButtonElement>;
93
157
  }
94
158
 
@@ -116,6 +180,11 @@ export function FloatingActionButton({
116
180
  offset = "1rem",
117
181
  nativeTitle = true,
118
182
  pressed,
183
+ extended = false,
184
+ live = false,
185
+ variant = "primary",
186
+ tooltip,
187
+ tooltipSide = "top",
119
188
  className,
120
189
  style,
121
190
  type = "button",
@@ -124,13 +193,12 @@ export function FloatingActionButton({
124
193
  }: FloatingActionButtonProps) {
125
194
  const [marker, dir] = usePortalDir();
126
195
  const toggle = pressed !== undefined;
127
- // An empty title is "no tooltip", not an attribute with nothing in it.
128
- const nativeTip = nativeTitle && title !== "" ? (title ?? label) : undefined;
129
- return (
130
- <>
131
- <span ref={marker} hidden />
132
- {typeof document !== "undefined" &&
133
- createPortal(
196
+ const tip = tooltip === true ? label : tooltip === false || tooltip === "" ? undefined : tooltip;
197
+ const wrapped = tip !== undefined && tip !== null;
198
+ // An empty title is "no tooltip", not an attribute with nothing in it. The kit
199
+ // tooltip replaces it outright.
200
+ const nativeTip = !wrapped && nativeTitle && title !== "" ? (title ?? label) : undefined;
201
+ const button = (
134
202
  <button
135
203
  {...rest}
136
204
  type={type}
@@ -138,11 +206,19 @@ export function FloatingActionButton({
138
206
  aria-label={label}
139
207
  aria-pressed={pressed ?? rest["aria-pressed"]}
140
208
  title={nativeTip}
141
- style={{ ...cornerStyle(corner, offset), ...style }}
209
+ style={wrapped ? style : { ...cornerStyle(corner, offset), ...style }}
142
210
  className={cn(
143
- "fixed z-40 inline-flex size-12 items-center justify-center rounded-full",
144
- "shadow-lg transition-colors",
145
- !toggle && "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]",
211
+ // Wrapped, the wrapper is the fixed thing and the button sits in it.
212
+ !wrapped && "fixed z-40",
213
+ "inline-flex items-center justify-center rounded-full",
214
+ extended ? "h-12 gap-2 px-4 text-sm font-medium [&_svg]:size-5" : "size-12 [&_svg]:size-6",
215
+ "shadow-lg transition-colors disabled:cursor-default",
216
+ !toggle &&
217
+ variant === "primary" &&
218
+ "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]",
219
+ !toggle &&
220
+ variant === "surface" &&
221
+ "border border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text-primary)] hover:bg-[var(--bg-hover)] disabled:hover:bg-[var(--bg-surface)]",
146
222
  // A toggle sits on the page's surface — a solid brand disc would read as
147
223
  // "on" in both states — and says "on" in the brand family.
148
224
  toggle && "border border-[var(--border)]",
@@ -151,12 +227,48 @@ export function FloatingActionButton({
151
227
  pressed === true &&
152
228
  "border-[var(--brand)] bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)]",
153
229
  "outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2",
154
- "[&_svg]:size-6",
155
230
  className,
156
231
  )}
157
232
  >
158
233
  {icon}
159
- </button>,
234
+ {/* Visible words, not the name: `aria-label` already is the name, and the
235
+ span would only repeat it. */}
236
+ {extended && (
237
+ <span aria-hidden className="whitespace-nowrap">
238
+ {label}
239
+ </span>
240
+ )}
241
+ </button>
242
+ );
243
+ return (
244
+ <>
245
+ <span ref={marker} hidden />
246
+ {typeof document !== "undefined" &&
247
+ createPortal(
248
+ <>
249
+ {wrapped ? (
250
+ <div
251
+ dir={dir}
252
+ hidden={rest.hidden}
253
+ style={cornerStyle(corner, offset)}
254
+ className="fixed z-40 inline-flex"
255
+ >
256
+ <Tooltip label={tip} side={tooltipSide} portal>
257
+ {/* A fragment when the bubble says the name again, so the tooltip
258
+ does not describe the button with its own label — a reader would
259
+ hear it twice. Other text is a real description. */}
260
+ {tip === label ? <>{button}</> : button}
261
+ </Tooltip>
262
+ </div>
263
+ ) : (
264
+ button
265
+ )}
266
+ {live && (
267
+ <span role="status" className="sr-only">
268
+ {label}
269
+ </span>
270
+ )}
271
+ </>,
160
272
  document.body,
161
273
  )}
162
274
  </>
@@ -183,6 +295,10 @@ export interface FloatingPanelProps {
183
295
  /** The FAB's native `title` — see {@link FloatingActionButtonProps.nativeTitle}.
184
296
  * `false` for keksdose's assistant launcher, under its one-tooltip rule (dev#523). */
185
297
  fabNativeTitle?: boolean;
298
+ /** The FAB's kit tooltip — see {@link FloatingActionButtonProps.tooltip}. `true` for
299
+ * keksdose's assistant launcher, which wanted the kit bubble and had no way to
300
+ * put one on a fixed, portalled button (dev#523). */
301
+ fabTooltip?: ReactNode;
186
302
  /** Default: `floatingPanel.close` from the {@link UiKitProvider}, else "Close". */
187
303
  closeLabel?: string;
188
304
  /**
@@ -239,6 +355,7 @@ export function FloatingPanel({
239
355
  corner = "bottom-end",
240
356
  offset = "1rem",
241
357
  fabNativeTitle,
358
+ fabTooltip,
242
359
  closeLabel,
243
360
  initialFocus,
244
361
  className,
@@ -308,6 +425,7 @@ export function FloatingPanel({
308
425
  corner={corner}
309
426
  offset={offset}
310
427
  nativeTitle={fabNativeTitle}
428
+ tooltip={fabTooltip}
311
429
  aria-expanded={open}
312
430
  aria-controls={open ? panelId : undefined}
313
431
  onClick={() => {
@@ -383,3 +501,249 @@ export function FloatingPanel({
383
501
  </>
384
502
  );
385
503
  }
504
+
505
+ export interface FloatingActionGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, "role"> {
506
+ /** Name the group ("Register actions"); a reader then introduces the members as one
507
+ * set. A group with no name is one more unlabelled wrapper. */
508
+ "aria-label"?: string;
509
+ /** Default `bottom-end`. */
510
+ corner?: FloatingCorner;
511
+ /** Distance above the nav / home indicator — see {@link FloatingActionButtonProps.offset}. */
512
+ offset?: string;
513
+ /** {@link FloatingAction}s, in reading order. */
514
+ children: ReactNode;
515
+ ref?: Ref<HTMLDivElement>;
516
+ }
517
+
518
+ /**
519
+ * A floating pill of {@link FloatingAction}s — one to four `size-12` toggles, actions
520
+ * or links with hairline dividers — pinned to a corner exactly as the
521
+ * {@link FloatingActionButton} is: `--app-nav-h` and the home indicator cleared, a
522
+ * logical corner, portalled.
523
+ *
524
+ * keksdose built it twice by hand: the /transactions phone toolbar
525
+ * (transactions-page.tsx:644 — upcoming and pending toggles, an invoices link with a
526
+ * pending count, and the brand "+") and the feedback page's "awaiting only" toggle
527
+ * (feedback-page.tsx:854, one button in the group anyway, so the two pages look
528
+ * alike). Both at `right-4 bottom-20` — `right`, not `end`, so over the page's leading
529
+ * edge in RTL — with `divide-x`, slate hovers, and each member's `Tooltip` spelled out.
530
+ *
531
+ * `role="group"`, NOT a toolbar: every member keeps its own tab stop, as the
532
+ * hand-built ones did, and there is no arrow-key roving to learn. Hide it where it
533
+ * does not belong with a class (`md:hidden`, as both keksdose pages do).
534
+ */
535
+ export function FloatingActionGroup({
536
+ corner = "bottom-end",
537
+ offset = "1rem",
538
+ className,
539
+ style,
540
+ children,
541
+ ...rest
542
+ }: FloatingActionGroupProps) {
543
+ const [marker, dir] = usePortalDir();
544
+ return (
545
+ <>
546
+ <span ref={marker} hidden />
547
+ {typeof document !== "undefined" &&
548
+ createPortal(
549
+ <div
550
+ {...rest}
551
+ role="group"
552
+ dir={dir}
553
+ data-corner={corner}
554
+ style={{ ...cornerStyle(corner, offset), ...style }}
555
+ className={cn(
556
+ "fixed z-40 inline-flex flex-row overflow-hidden rounded-2xl",
557
+ "border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg",
558
+ // Dividers on the logical start edge of every member but the first, so
559
+ // they sit between the same two members in RTL. The member is the
560
+ // tooltip's wrapper span, which is the direct child.
561
+ "[&>*:not(:first-child)]:border-s [&>*]:border-[var(--border)]",
562
+ className,
563
+ )}
564
+ >
565
+ {children}
566
+ </div>,
567
+ document.body,
568
+ )}
569
+ </>
570
+ );
571
+ }
572
+
573
+ /** What {@link FloatingActionProps.renderLink} is handed. Spread it onto your router's
574
+ * link, mapping `href` — `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`. */
575
+ export interface FloatingActionLinkProps {
576
+ href: string;
577
+ /** The member's whole look — keep it. */
578
+ className: string;
579
+ children: ReactNode;
580
+ "aria-label": string;
581
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
582
+ [key: `aria-${string}`]: string | boolean | number | undefined;
583
+ [key: `data-${string}`]: unknown;
584
+ }
585
+
586
+ export interface FloatingActionProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onClick"> {
587
+ /** The accessible name, and the tooltip's text. Keep it the same in both states of a
588
+ * toggle; `aria-pressed` says which one it is in. */
589
+ label: string;
590
+ icon: ReactNode;
591
+ /** Make it a toggle: `aria-pressed` and the brand "on" look (the brand glyph on the
592
+ * quiet brand fill), as {@link FloatingActionButtonProps.pressed}. Ignored on a link.
593
+ * For the state without the tint, see {@link pressedStyle}. */
594
+ pressed?: boolean;
595
+ /**
596
+ * How `pressed` looks. `tint` (default): the brand "on" look. `plain`: the member
597
+ * keeps its quiet look in both states and only `aria-pressed` changes — for a toggle
598
+ * whose icon already says which state it is in (keksdose's /transactions "Upcoming"
599
+ * swaps two icons; a tint on top said it twice). The same as passing `aria-pressed`
600
+ * without `pressed`, which also rides through untinted.
601
+ */
602
+ pressedStyle?: "tint" | "plain";
603
+ /**
604
+ * `primary`: the brand-filled member, for the group's main action (the /transactions
605
+ * "+"). Default: the quiet one.
606
+ */
607
+ variant?: "default" | "primary";
608
+ /**
609
+ * A count in a dot at the member's top end corner — the /transactions invoices link's
610
+ * pending count. Nothing is drawn for `0` or less; above 99 it reads `99+`. The count
611
+ * joins the accessible name through `floatingPanel.badge` ("Invoices, 3 new"), or
612
+ * `badgeLabel` when passed.
613
+ */
614
+ badge?: number;
615
+ /** The badge's words for a reader, replacing `floatingPanel.badge` — "3 to review". */
616
+ badgeLabel?: string;
617
+ /** The dot's colour. Default `warning`, the pending count's. */
618
+ badgeTone?: "warning" | "danger" | "brand";
619
+ /** Show `label` in the kit {@link Tooltip} on hover and focus. Default `true`: the
620
+ * members are icon-only and this is their only visible label. */
621
+ tooltip?: boolean;
622
+ /** Where the tooltip opens. Default `top` — the group sits at the bottom of the screen. */
623
+ tooltipSide?: TooltipSide;
624
+ /** Make it a link — the /transactions invoices member. Drawn by `renderLink`, else
625
+ * the `<UiKitProvider linkComponent>` (an in-app `href`), else a plain `<a>`. */
626
+ href?: string;
627
+ /** Render the link through your router's `Link` — see {@link FloatingActionLinkProps}. */
628
+ renderLink?: (props: FloatingActionLinkProps) => ReactElement;
629
+ onClick?: (event: MouseEvent<HTMLElement>) => void;
630
+ ref?: Ref<HTMLButtonElement>;
631
+ }
632
+
633
+ const BADGE_TONE: Record<NonNullable<FloatingActionProps["badgeTone"]>, string> = {
634
+ warning: "bg-[var(--warning)] text-[var(--text-inverse)]",
635
+ danger: "bg-[var(--danger)] text-[var(--danger-contrast)]",
636
+ brand: "bg-[var(--brand)] text-[var(--brand-contrast)]",
637
+ };
638
+
639
+ /**
640
+ * One member of a {@link FloatingActionGroup}: a `size-12` icon button, toggle or link
641
+ * with its label in a styled tooltip — what each keksdose member wrapped by hand in
642
+ * `<Tooltip portal>`. The tooltip is portalled because the group clips (`overflow-hidden`
643
+ * keeps the brand member's fill inside the pill's corners), and the focus ring is inset
644
+ * for the same reason.
645
+ */
646
+ export function FloatingAction({
647
+ label,
648
+ icon,
649
+ pressed,
650
+ pressedStyle = "tint",
651
+ variant = "default",
652
+ badge,
653
+ badgeLabel,
654
+ badgeTone = "warning",
655
+ tooltip = true,
656
+ tooltipSide = "top",
657
+ href,
658
+ renderLink,
659
+ className,
660
+ type = "button",
661
+ onClick,
662
+ ref,
663
+ ...rest
664
+ }: FloatingActionProps) {
665
+ const labels = useKitLabels("floatingPanel", DEFAULT_FLOATING_PANEL_LABELS);
666
+ const kitLink = useKitLink();
667
+ const counted = badge !== undefined && badge > 0;
668
+ const name = counted ? `${label}, ${badgeLabel ?? labels.badge(badge)}` : label;
669
+ const isLink = href !== undefined;
670
+ const look = cn(
671
+ "relative flex size-12 items-center justify-center transition-colors",
672
+ "outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]",
673
+ "disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-5",
674
+ variant === "primary"
675
+ ? "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]"
676
+ : pressed && !isLink && pressedStyle === "tint"
677
+ ? "bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)]"
678
+ : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]",
679
+ className,
680
+ );
681
+ const body = (
682
+ <>
683
+ {icon}
684
+ {counted && (
685
+ <span
686
+ aria-hidden
687
+ data-badge=""
688
+ className={cn(
689
+ "pointer-events-none absolute end-1 top-1 h-4 min-w-4 rounded-full px-1 text-center text-[10px] leading-4 font-semibold",
690
+ BADGE_TONE[badgeTone],
691
+ )}
692
+ >
693
+ {badge > 99 ? "99+" : badge}
694
+ </span>
695
+ )}
696
+ </>
697
+ );
698
+
699
+ let member: ReactElement;
700
+ if (isLink) {
701
+ // The button-only attributes stay behind; `aria-*`, `data-*` and the like ride along.
702
+ const { disabled: _disabled, form: _form, value: _value, name: _name, ...anchorRest } = rest;
703
+ const linkProps: FloatingActionLinkProps = {
704
+ ...(anchorRest as AnchorHTMLAttributes<HTMLAnchorElement>),
705
+ href,
706
+ "aria-label": name,
707
+ onClick,
708
+ className: look,
709
+ children: body,
710
+ };
711
+ const { children: content, ...anchor } = linkProps;
712
+ const render = pickLinkRenderer(renderLink, kitLink, href);
713
+ member = render ? (
714
+ <RenderedFloatingLink render={render} {...linkProps} />
715
+ ) : (
716
+ <a {...anchor}>{content}</a>
717
+ );
718
+ } else {
719
+ member = (
720
+ <button
721
+ {...rest}
722
+ ref={ref}
723
+ type={type}
724
+ aria-label={name}
725
+ aria-pressed={pressed ?? rest["aria-pressed"]}
726
+ onClick={onClick}
727
+ className={look}
728
+ >
729
+ {body}
730
+ </button>
731
+ );
732
+ }
733
+ if (!tooltip) return member;
734
+ return (
735
+ // A fragment: the bubble says the member's name, and a description that repeats
736
+ // the name is read twice.
737
+ <Tooltip label={label} side={tooltipSide} portal>
738
+ <>{member}</>
739
+ </Tooltip>
740
+ );
741
+ }
742
+
743
+ /** Calls `renderLink` as a component, so its hooks belong to the link, not to us. */
744
+ function RenderedFloatingLink({
745
+ render,
746
+ ...props
747
+ }: FloatingActionLinkProps & { render: (props: FloatingActionLinkProps) => ReactElement }) {
748
+ return render(props);
749
+ }
@@ -0,0 +1,191 @@
1
+ import { isValidElement } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { useKitLabels } from "../i18n/kit-labels";
5
+ import { Button, Spinner, type ButtonVariant } from "./ui";
6
+
7
+ /** The words {@link FormActions} renders on its own behalf — the `form` namespace. */
8
+ export interface FormActionsLabels {
9
+ save: string;
10
+ cancel: string;
11
+ }
12
+
13
+ export const DEFAULT_FORM_ACTIONS_LABELS: FormActionsLabels = {
14
+ save: "Save",
15
+ cancel: "Cancel",
16
+ };
17
+
18
+ export type FormActionsAlign = "start" | "center" | "end" | "between";
19
+
20
+ /**
21
+ * Where the row sits:
22
+ * - `inline` (default): under the form's last field, `pt-4` above it.
23
+ * - `sticky`: stuck to the bottom of the scrolling page or pane, on the surface
24
+ * colour with a rule above, clear of the phone's home indicator — for a long form
25
+ * whose Save should not be a scroll away.
26
+ * - `dialog`: no spacing of its own, for a dialog's footer slot (DialogFrame,
27
+ * FullBleedDialog's `footer`), which already pads and rules it.
28
+ */
29
+ export type FormActionsPlacement = "inline" | "sticky" | "dialog";
30
+
31
+ /** The start-side action of {@link FormActionsProps.destructive}, as data. */
32
+ export interface FormActionsDestructive {
33
+ label: ReactNode;
34
+ onClick: () => void;
35
+ disabled?: boolean;
36
+ /** See Button's `disabledReason`. */
37
+ disabledReason?: ReactNode;
38
+ }
39
+
40
+ const ALIGN_CLASS: Record<FormActionsAlign, string> = {
41
+ start: "justify-start",
42
+ center: "justify-center",
43
+ end: "justify-end",
44
+ between: "justify-between",
45
+ };
46
+
47
+ const PLACEMENT_CLASS: Record<FormActionsPlacement, string> = {
48
+ inline: "pt-4",
49
+ sticky: "sticky bottom-0 z-10 border-t border-[var(--border)] bg-[var(--bg-surface)] pt-3",
50
+ dialog: "",
51
+ };
52
+
53
+ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
54
+ /** Called by Cancel. Omitted: no Cancel button (a form that only saves). */
55
+ onCancel?: () => void;
56
+ /**
57
+ * Given: the save button is `type="button"` and calls this — for a dialog or a row
58
+ * that saves from a handler. Omitted: it is `type="submit"` and submits the
59
+ * enclosing `<form>` (or the one named by {@link form}).
60
+ */
61
+ onSubmit?: () => void;
62
+ /** The save button's text. Default `form.save` from the provider, else "Save". */
63
+ submitLabel?: ReactNode;
64
+ /** The cancel button's text. Default `form.cancel` from the provider, else "Cancel". */
65
+ cancelLabel?: ReactNode;
66
+ /** The save button's text while `pending` ("Saving…"). Default: `submitLabel`. */
67
+ pendingLabel?: ReactNode;
68
+ /**
69
+ * The save is running: a spinner in the save button, `aria-busy` on it, and the
70
+ * button disabled, so a second click cannot send the form twice. Cancel stays
71
+ * usable.
72
+ */
73
+ pending?: boolean;
74
+ /** Disable save for a reason of the form's own (an unbalanced entry). */
75
+ submitDisabled?: boolean;
76
+ /** Why save is disabled — see Button's `disabledReason`. Keeps it focusable. */
77
+ submitDisabledReason?: ReactNode;
78
+ /** The save button's variant. Default `brand`; `danger` for a save that destroys
79
+ * (kastlan's `destructive` flag on its own FormActions). */
80
+ submitVariant?: ButtonVariant;
81
+ /**
82
+ * A destructive action at the START of the row, apart from Save and Cancel — the
83
+ * "Delete" of an edit dialog. Pass `{ label, onClick }` for the kit's quiet danger
84
+ * button, or an element of your own (a {@link DangerConfirm}, say). With it the row
85
+ * is `between`-aligned unless `align` says otherwise.
86
+ */
87
+ destructive?: ReactNode | FormActionsDestructive;
88
+ /** Horizontal alignment. Default `end` (`between` with a `destructive` action). */
89
+ align?: FormActionsAlign;
90
+ /** See {@link FormActionsPlacement}. Default `inline`. */
91
+ placement?: FormActionsPlacement;
92
+ /** The `id` of the `<form>` the save button submits, when the row is rendered
93
+ * outside it (a dialog's footer slot). */
94
+ form?: string;
95
+ /** Extra actions, placed before Cancel. */
96
+ children?: ReactNode;
97
+ }
98
+
99
+ function isDestructiveData(value: unknown): value is FormActionsDestructive {
100
+ return typeof value === "object" && value !== null && !isValidElement(value) && "onClick" in value;
101
+ }
102
+
103
+ /**
104
+ * The Save / Cancel row every form, wizard-step form and dialog ends in — kastlan's
105
+ * `FormActions`, with the labels from the provider, a pending state and a place for
106
+ * the destructive action.
107
+ *
108
+ * ```tsx
109
+ * <form onSubmit={form.handleSubmit(save)}>
110
+ * …
111
+ * <FormActions onCancel={close} pending={mutation.isPending} />
112
+ * </form>
113
+ * ```
114
+ *
115
+ * The spinner is the kit's {@link Spinner}, drawn inside the button with `label={null}`
116
+ * so the button's name stays its text; `aria-busy` is what says it is working.
117
+ */
118
+ export function FormActions({
119
+ onCancel,
120
+ onSubmit,
121
+ submitLabel,
122
+ cancelLabel,
123
+ pendingLabel,
124
+ pending = false,
125
+ submitDisabled = false,
126
+ submitDisabledReason,
127
+ submitVariant = "brand",
128
+ destructive,
129
+ align,
130
+ placement = "inline",
131
+ form,
132
+ children,
133
+ className,
134
+ style,
135
+ ...rest
136
+ }: FormActionsProps) {
137
+ const labels = useKitLabels("form", DEFAULT_FORM_ACTIONS_LABELS);
138
+ const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;
139
+ const justify = align ?? (hasDestructive ? "between" : "end");
140
+ const start = !hasDestructive ? null : isDestructiveData(destructive) ? (
141
+ <Button
142
+ type="button"
143
+ variant="ghost"
144
+ tone="danger"
145
+ onClick={destructive.onClick}
146
+ disabled={destructive.disabled}
147
+ disabledReason={destructive.disabledReason}
148
+ >
149
+ {destructive.label}
150
+ </Button>
151
+ ) : (
152
+ destructive
153
+ );
154
+ const saveLabel = submitLabel ?? labels.save;
155
+
156
+ return (
157
+ <div
158
+ data-slot="form-actions"
159
+ data-placement={placement}
160
+ {...rest}
161
+ style={
162
+ placement === "sticky"
163
+ ? { paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))", ...style }
164
+ : style
165
+ }
166
+ className={cn("flex flex-wrap items-center gap-2", ALIGN_CLASS[justify], PLACEMENT_CLASS[placement], className)}
167
+ >
168
+ {start && <div className="me-auto flex items-center gap-2">{start}</div>}
169
+ <div className="flex flex-wrap items-center gap-2">
170
+ {children}
171
+ {onCancel && (
172
+ <Button type="button" variant="secondary" onClick={onCancel}>
173
+ {cancelLabel ?? labels.cancel}
174
+ </Button>
175
+ )}
176
+ <Button
177
+ type={onSubmit ? "button" : "submit"}
178
+ form={form}
179
+ onClick={onSubmit}
180
+ variant={submitVariant}
181
+ disabled={pending || submitDisabled}
182
+ disabledReason={pending ? undefined : submitDisabledReason}
183
+ aria-busy={pending || undefined}
184
+ >
185
+ {pending && <Spinner label={null} className="size-4" />}
186
+ {pending && pendingLabel !== undefined ? pendingLabel : saveLabel}
187
+ </Button>
188
+ </div>
189
+ </div>
190
+ );
191
+ }