@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,11 +1,13 @@
1
1
  import { forwardRef, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { ChevronDown, Eye, EyeOff, HelpCircle, Plus, X } from "lucide-react";
3
- import type { ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
3
+ import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { horizontalStep } from "../lib/direction";
6
6
  import { useMediaQuery } from "../hooks/use-media-query";
7
7
  import { Tooltip, type TooltipSide } from "./tooltip";
8
- import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
8
+ import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-labels";
9
+ import type { KitLinkComponent, KitLinkProps } from "../i18n/kit-labels";
10
+ import { pickLinkRenderer } from "./text-link";
9
11
 
10
12
  export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
11
13
 
@@ -70,8 +72,18 @@ const buttonVariantClasses: Record<ButtonVariant, string> = {
70
72
  *
71
73
  * A `tone` rather than a `link-muted` variant, because it is the axis IconButton
72
74
  * already has (`tone="muted"`, `tone="danger"`, same quiet-until-hover meaning), and
73
- * because the same two looks are wanted on `ghost`. The filled and bordered variants
74
- * carry their meaning in the box, not the text, so a tone on them does nothing.
75
+ * because the same two looks are wanted on `ghost`.
76
+ *
77
+ * `danger` also reaches the two NEUTRAL boxed variants, where the box carries it:
78
+ * `secondary` becomes an outlined destructive button (`--danger` text and a
79
+ * `--danger-border` outline, the quiet danger fill on hover) and `primary` a soft one
80
+ * (the quiet fill at rest, solid `--danger` on hover). kastlan's meeting-invitations-
81
+ * tab.tsx "Remove all" is `variant="secondary" className="text-destructive"` — a
82
+ * destructive action that must sit in a row of secondary buttons without shouting
83
+ * like the solid `variant="danger"` would, and whose red text alone left the border
84
+ * and hover saying "neutral". `muted` stays a text look and does nothing on a box, and
85
+ * `danger`, `brand` and `link`'s own colours are left alone: a tone that recoloured a
86
+ * solid danger or brand button would be a second variant under another name.
75
87
  */
76
88
  export type ButtonTone = "default" | "muted" | "danger";
77
89
 
@@ -82,9 +94,19 @@ const BUTTON_TONES: Record<Exclude<ButtonTone, "default">, string> = {
82
94
  "text-[var(--text-secondary)] hover:text-[var(--danger)] focus:ring-[var(--danger-border)] disabled:hover:text-[var(--text-secondary)]",
83
95
  };
84
96
 
85
- /** Only the two transparent variants take a tone; see {@link ButtonTone}. */
97
+ /** Only the two transparent variants take every tone; see {@link ButtonTone}. */
86
98
  const TONED_VARIANTS = new Set<ButtonVariant>(["link", "ghost"]);
87
99
 
100
+ // `tone="danger"` on the neutral boxed variants — see {@link ButtonTone}. Each string
101
+ // restates every colour the variant sets (border, fill, text, hover, ring) so none of
102
+ // the neutral ones survives the merge.
103
+ const BUTTON_BOXED_DANGER: Partial<Record<ButtonVariant, string>> = {
104
+ secondary:
105
+ "border-[var(--danger-border)] bg-transparent text-[var(--danger)] hover:bg-[var(--danger-bg)] focus:ring-[var(--danger-border)]",
106
+ primary:
107
+ "border-[var(--danger-border)] bg-[var(--danger-bg)] text-[var(--danger)] hover:border-[var(--danger)] hover:bg-[var(--danger)] hover:text-[var(--danger-contrast)] focus:ring-[var(--danger-border)] disabled:hover:border-[var(--danger-border)] disabled:hover:bg-[var(--danger-bg)] disabled:hover:text-[var(--danger)]",
108
+ };
109
+
88
110
  // `pressed` on a `link`: the brand colour and a heavier weight, which is exactly what
89
111
  // lenkbank's "All speeds" toggle paints by hand (gear/hysteresis-charts.tsx:482). After
90
112
  // the tone, so a pressed muted link is brand, and an unpressed one is quiet grey.
@@ -140,8 +162,9 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
140
162
  * declared here only because `ButtonHTMLAttributes` does not carry it. */
141
163
  ref?: Ref<HTMLButtonElement>;
142
164
  /** Text colour for `link` and `ghost`: `muted` (quiet, body colour on hover) or
143
- * `danger` (quiet, `--danger` on hover). Ignored by the other variants. See
144
- * {@link ButtonTone}. */
165
+ * `danger` (quiet, `--danger` on hover). `danger` also makes `secondary` an
166
+ * outlined destructive button and `primary` a soft one; ignored by the other
167
+ * variants. See {@link ButtonTone}. */
145
168
  tone?: ButtonTone;
146
169
  /**
147
170
  * Make it a toggle button, as {@link IconButton}'s `pressed` does. `true` sets
@@ -172,57 +195,243 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
172
195
  * DangerConfirm keeps its own.
173
196
  */
174
197
  disabledReason?: ReactNode;
198
+ /**
199
+ * Busy — the save is in flight: a spinner, `aria-busy`, and no second submit.
200
+ *
201
+ * kastlan's FormActions (form-actions.tsx:50) disables its submit and swaps in a
202
+ * `submittingLabel`, so the button jumps width mid-click and a screen reader hears
203
+ * the name change instead of "busy". Here the label STAYS — it is still the
204
+ * accessible name and still holds the button's width — and the spinner is drawn over
205
+ * it in the text colour, so nothing beside the button moves.
206
+ *
207
+ * The same prop {@link FileButton} has (`pending`), with one difference: this one is
208
+ * `aria-disabled`, not `disabled`. The button being pressed is the one that has focus,
209
+ * and a native `disabled` drops that focus to `<body>` the moment the save starts;
210
+ * clicks, and the Enter that submits a form through it, are swallowed instead.
211
+ */
212
+ pending?: boolean;
213
+ /** Only on the link form — see {@link ButtonLinkProps}. */
214
+ href?: never;
215
+ renderLink?: never;
216
+ external?: never;
217
+ }
218
+
219
+ /**
220
+ * `<Button href>`: a link that looks like a button — for an action that NAVIGATES.
221
+ *
222
+ * kastlan paints `buttonClasses(…)` onto router Links by hand in six places (the
223
+ * invoice / lease / unit preview dialogs' "Open" at invoice-preview-dialog.tsx:36,
224
+ * platform-companies-page.tsx:111, verify-email-page.tsx:86's "Log in",
225
+ * billing-history-page.tsx:85's PDF download), and has 26 `<Button onClick={() =>
226
+ * navigate(…)}>` that are links in all but markup: no middle-click, no "open in new
227
+ * tab", no URL on hover, and a screen reader announces a button that then changes the
228
+ * page. With `href` it is an `<a>`: the provider's router link
229
+ * ({@link UiKitProvider}'s `linkComponent`), or `renderLink` if given, else a plain one.
230
+ *
231
+ * `type` and `form` are typed out: they are a `<button>`'s, and a link never submits.
232
+ */
233
+ export interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "type" | "href"> {
234
+ href: string;
235
+ variant?: ButtonVariant;
236
+ size?: ButtonSize;
237
+ stretch?: boolean;
238
+ tone?: ButtonTone;
239
+ /** The in-app router link, over the provider's `linkComponent` (see
240
+ * {@link KitLinkComponent}). Ignored when `external`. */
241
+ renderLink?: KitLinkComponent;
242
+ /** Leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`, never the
243
+ * router link, and "(opens in a new tab)" read after the label — billing-history's
244
+ * Stripe PDF. A `target="_blank"` of your own is announced the same way. */
245
+ external?: boolean;
246
+ /** A link cannot be `disabled`, so this renders an `<a>` with NO `href` —
247
+ * `role="link"` and `aria-disabled`, out of the tab order like a disabled button,
248
+ * with the disabled look. The router link is not used: it needs somewhere to go. */
249
+ disabled?: boolean;
250
+ ref?: Ref<HTMLAnchorElement>;
251
+ type?: never;
252
+ form?: never;
253
+ pending?: never;
254
+ pressed?: never;
255
+ disabledReason?: never;
175
256
  }
176
257
 
177
258
  function hasContent(node: ReactNode): boolean {
178
259
  return node !== undefined && node !== null && node !== false && node !== "";
179
260
  }
180
261
 
181
- export function Button({
262
+ /** The button look shared by the `<button>` and the `<a>` form. */
263
+ function buttonLook(variant: ButtonVariant, size: ButtonSize, stretch: boolean | undefined, tone: ButtonTone) {
264
+ return cn(
265
+ BUTTON_BASE,
266
+ BUTTON_SIZES[size],
267
+ // In a flex row next to a taller labelled field, `stretch` makes the button
268
+ // fill the field's height so the two line up (self-stretch overrides the row's
269
+ // align-items). No effect outside a flex row / when it's already the tallest.
270
+ stretch && "self-stretch",
271
+ buttonVariantClasses[variant],
272
+ tone !== "default" && TONED_VARIANTS.has(variant) && BUTTON_TONES[tone],
273
+ tone === "danger" && BUTTON_BOXED_DANGER[variant],
274
+ );
275
+ }
276
+
277
+ // Declared link-first so `<Button href>` picks the link props; the LAST signature is
278
+ // what `ComponentProps<typeof Button>` reads, so wrappers (FileButton) still see the
279
+ // `<button>` props they always did.
280
+ export function Button(props: ButtonLinkProps): ReactElement;
281
+ export function Button(props: ButtonProps): ReactElement;
282
+ export function Button(props: ButtonProps | ButtonLinkProps): ReactElement {
283
+ if (props.href !== undefined) return <ButtonLink {...(props as ButtonLinkProps)} />;
284
+ return <ButtonElement {...(props as ButtonProps)} />;
285
+ }
286
+
287
+ /** Calls a `renderLink` / provider link as a function, as ListItem and Chip do — an
288
+ * inline `renderLink` arrow would otherwise be a new component type every render. */
289
+ function RenderedButtonLink({ render, ...props }: KitLinkProps & { render: KitLinkComponent }) {
290
+ return render(props);
291
+ }
292
+
293
+ function ButtonLink({
294
+ href,
295
+ renderLink,
296
+ external,
297
+ disabled,
298
+ variant = "primary",
299
+ size = "md",
300
+ stretch,
301
+ tone = "default",
302
+ className,
303
+ children,
304
+ target,
305
+ rel,
306
+ onClick,
307
+ ...rest
308
+ }: ButtonLinkProps) {
309
+ const kitLink = useKitLink();
310
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
311
+ // The same rule as every kit link (`pickLinkRenderer`): an external `href` or an
312
+ // in-page `#anchor` is never handed to the provider's router link, even without
313
+ // `external`.
314
+ const Link = pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);
315
+ const newTab = !disabled && (external || target === "_blank");
316
+ const cls = cn(
317
+ buttonLook(variant, size, stretch, tone),
318
+ // `relative` holds the sr-only note (see sr-only-containment.test).
319
+ newTab && "relative",
320
+ // BUTTON_BASE's `disabled:` classes never match an `<a>`; no pointer events also
321
+ // keeps the hover fill from lighting up on something that refuses the click.
322
+ disabled && "pointer-events-none cursor-not-allowed opacity-50",
323
+ className,
324
+ );
325
+ const body = (
326
+ <>
327
+ {children}
328
+ {/* The space OUTSIDE the sr-only span: a name computation trims each child's
329
+ text, so one inside it was lost ("Download(opens…"). In a flex box a bare
330
+ space draws nothing. */}
331
+ {newTab && " "}
332
+ {newTab && <span className="sr-only">({common.opensInNewTab})</span>}
333
+ </>
334
+ );
335
+ if (disabled) {
336
+ return (
337
+ <a {...rest} role="link" aria-disabled="true" className={cls}>
338
+ {body}
339
+ </a>
340
+ );
341
+ }
342
+ if (external || !Link) {
343
+ return (
344
+ <a
345
+ {...rest}
346
+ href={href}
347
+ target={external ? "_blank" : target}
348
+ rel={external ? (rel ?? "noopener noreferrer") : rel}
349
+ onClick={onClick}
350
+ className={cls}
351
+ >
352
+ {body}
353
+ </a>
354
+ );
355
+ }
356
+ return (
357
+ <RenderedButtonLink
358
+ render={Link}
359
+ {...rest}
360
+ href={href}
361
+ target={target}
362
+ rel={rel}
363
+ onClick={onClick}
364
+ className={cls}
365
+ >
366
+ {body}
367
+ </RenderedButtonLink>
368
+ );
369
+ }
370
+
371
+ function ButtonElement({
182
372
  variant = "primary",
183
373
  size = "md",
184
374
  stretch,
185
375
  tone = "default",
186
376
  pressed,
187
377
  disabledReason,
378
+ pending,
188
379
  className,
189
380
  onClick,
381
+ children,
382
+ href: _href,
383
+ renderLink: _renderLink,
384
+ external: _external,
190
385
  ...rest
191
386
  }: ButtonProps) {
192
387
  const reasonId = useId();
193
388
  const locked = hasContent(disabledReason);
389
+ const inert = locked || Boolean(pending);
194
390
  const ownDescribedBy = rest["aria-describedby"];
195
391
  const button = (
196
392
  <button
197
393
  {...rest}
198
- disabled={locked ? undefined : rest.disabled}
199
- aria-disabled={locked || rest["aria-disabled"]}
394
+ disabled={inert ? undefined : rest.disabled}
395
+ aria-disabled={inert || rest["aria-disabled"]}
396
+ aria-busy={pending || rest["aria-busy"]}
200
397
  aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
201
398
  aria-pressed={pressed ?? rest["aria-pressed"]}
202
399
  onClick={(e) => {
203
400
  // `preventDefault` as well as not calling through: a locked submit button must
204
401
  // not submit its form, and the browser does that after this handler returns.
205
- if (locked) {
402
+ if (inert) {
206
403
  e.preventDefault();
207
404
  return;
208
405
  }
209
406
  onClick?.(e);
210
407
  }}
211
408
  className={cn(
212
- BUTTON_BASE,
213
- BUTTON_SIZES[size],
214
- // In a flex row next to a taller labelled field, `stretch` makes the button
215
- // fill the field's height so the two line up (self-stretch overrides the row's
216
- // align-items). No effect outside a flex row / when it's already the tallest.
217
- stretch && "self-stretch",
218
- buttonVariantClasses[variant],
219
- tone !== "default" && TONED_VARIANTS.has(variant) && BUTTON_TONES[tone],
409
+ buttonLook(variant, size, stretch, tone),
220
410
  pressed && (variant === "link" ? BUTTON_LINK_PRESSED : ICON_BUTTON_PRESSED),
221
411
  // The disabled look for the focusable kind of disabled.
222
412
  locked && "cursor-not-allowed opacity-50",
413
+ // No dimming while pending: the spinner IS the state, and at half opacity it
414
+ // would be the faintest thing on the button. `relative` anchors it.
415
+ pending && "relative cursor-progress",
223
416
  className,
224
417
  )}
225
- />
418
+ >
419
+ {pending ? (
420
+ <>
421
+ {/* Transparent, not hidden: it keeps the width AND stays the accessible
422
+ name (`visibility: hidden` would drop it from the name). `gap-[inherit]`
423
+ keeps an icon + text spaced as the button spaces them. */}
424
+ <span className="inline-flex items-center justify-center gap-[inherit] opacity-0">{children}</span>
425
+ {/* Decorative: `aria-busy` says it, as on FileButton. */}
426
+ <Spinner
427
+ label={null}
428
+ className="absolute inset-0 m-auto size-4 border-current/30 border-t-current"
429
+ />
430
+ </>
431
+ ) : (
432
+ children
433
+ )}
434
+ </button>
226
435
  );
227
436
  if (!locked) return button;
228
437
  return (
@@ -254,12 +463,18 @@ export function Button({
254
463
  const ICON_BUTTON_BASE =
255
464
  "inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
256
465
 
257
- export type IconButtonSize = "lg" | "md" | "sm" | "xs" | "2xs";
466
+ export type IconButtonSize = "2xl" | "xl" | "lg" | "md" | "sm" | "xs" | "2xs";
258
467
 
259
468
  // Box and glyph together, so a 24px chip action cannot end up holding a 20px icon
260
469
  // that touches its edges. The two small steps are lenkbank's list-row (28px) and
261
470
  // chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.
262
471
  const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
472
+ // 64px with a 28px glyph: a camera's shutter (keksdose invoices/camera-capture.tsx:248,
473
+ // `size-16` by hand) — the one control of a full-screen capture view, thumb-sized.
474
+ "2xl": "size-16 [&_svg]:size-7",
475
+ // 48px with a 24px glyph: the one primary action of a phone screen or sheet, a step
476
+ // past the 44px minimum rather than on it.
477
+ xl: "size-12 [&_svg]:size-6",
263
478
  // The 44px touch target (WCAG 2.5.5's size) with the same 20px glyph — keksdose's
264
479
  // bulk-action bars write `size-11` by hand over an `md` button to get it on phones.
265
480
  lg: "size-11 [&_svg]:size-5",
@@ -269,6 +484,19 @@ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
269
484
  "2xs": "size-6 rounded [&_svg]:size-3.5",
270
485
  };
271
486
 
487
+ // `stretch`: the same WIDTH and glyph, and a height taken from the row instead of
488
+ // stated — `size-*` sets a height that `self-stretch` cannot override, so the box is
489
+ // written as a width plus a minimum height (the square it would otherwise be).
490
+ const ICON_BUTTON_STRETCH_SIZES: Record<IconButtonSize, string> = {
491
+ "2xl": "w-16 min-h-16 self-stretch [&_svg]:size-7",
492
+ xl: "w-12 min-h-12 self-stretch [&_svg]:size-6",
493
+ lg: "w-11 min-h-11 self-stretch [&_svg]:size-5",
494
+ md: "w-9 min-h-9 self-stretch [&_svg]:size-5",
495
+ sm: "w-8 min-h-8 self-stretch [&_svg]:size-5",
496
+ xs: "w-7 min-h-7 self-stretch rounded [&_svg]:size-4",
497
+ "2xs": "w-6 min-h-6 self-stretch rounded [&_svg]:size-3.5",
498
+ };
499
+
272
500
  // A tone re-colours the glyph without changing what the variant draws around it.
273
501
  // Each coloured tone comes in two resting looks, picked by `quiet` (see the prop):
274
502
  // QUIET is placeholder grey until the pointer or focus arrives, then the tone's
@@ -279,9 +507,14 @@ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
279
507
  // on the row to notice (keksdose's "needs review" flag, its reconcile action at
280
508
  // accounts-page:867), and quiet grey would hide the very thing they point out.
281
509
  // `muted` is quiet by definition and has no toned look; `default` has no tone.
282
- export type IconButtonTone = "default" | "muted" | "danger" | "warning" | "info";
510
+ //
511
+ // `success` is toned at rest too: the green is the news (keksdose's "in sync" chip).
512
+ // `custom` paints from `--icon-button-tone`, which `toneColor` sets — for a glyph whose
513
+ // colour FOLLOWS STATE across tones (keksdose's sync chip: rose, amber, sky, green as
514
+ // the sync moves), where swapping `tone` would do but the colour is one variable.
515
+ export type IconButtonTone = "default" | "muted" | "danger" | "warning" | "info" | "success" | "custom";
283
516
 
284
- type ColouredTone = "danger" | "warning" | "info";
517
+ type ColouredTone = "danger" | "warning" | "info" | "success" | "custom";
285
518
 
286
519
  const ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }> = {
287
520
  danger: {
@@ -299,13 +532,33 @@ const ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }>
299
532
  "text-[var(--text-placeholder)] hover:bg-[var(--info-bg)] hover:text-[var(--info)] focus:ring-[var(--info-border)]",
300
533
  toned: "text-[var(--info)] hover:bg-[var(--info-bg)] focus:ring-[var(--info-border)]",
301
534
  },
535
+ success: {
536
+ quiet:
537
+ "text-[var(--text-placeholder)] hover:bg-[var(--success-bg)] hover:text-[var(--success)] focus:ring-[var(--success-border)]",
538
+ toned: "text-[var(--success)] hover:bg-[var(--success-bg)] focus:ring-[var(--success-border)]",
539
+ },
540
+ // The hover fill and the ring are MIXED from the one colour, since a custom tone
541
+ // brings no `-bg` / `-border` pair of its own. The fallback is the body colour, so a
542
+ // `custom` tone with no colour set is the default look rather than invisible.
543
+ custom: {
544
+ quiet:
545
+ "text-[var(--text-placeholder)] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] hover:text-[var(--icon-button-tone,var(--text-primary))] focus:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]",
546
+ toned:
547
+ "text-[var(--icon-button-tone,var(--text-primary))] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] focus:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]",
548
+ },
302
549
  };
303
550
 
304
551
  const ICON_BUTTON_MUTED =
305
552
  "text-[var(--text-placeholder)] hover:bg-[var(--bg-surface-2)] hover:text-[var(--text-primary)]";
306
553
 
307
554
  /** Which tones sit quiet at rest when `quiet` is left out. */
308
- const QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = { danger: true, warning: false, info: false };
555
+ const QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = {
556
+ danger: true,
557
+ warning: false,
558
+ info: false,
559
+ success: false,
560
+ custom: false,
561
+ };
309
562
 
310
563
  function iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined): string {
311
564
  if (tone === "default") return "";
@@ -323,7 +576,7 @@ function iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined):
323
576
  // and would out-rank a caller's plain `hover:` override), each variant pins its
324
577
  // RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity
325
578
  // and only ever matches a disabled button.
326
- const ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | "overlay", string> = {
579
+ const ICON_BUTTON_DISABLED_REST: Record<IconButtonVariant, string> = {
327
580
  primary: "disabled:hover:bg-[var(--bg-surface-2)]",
328
581
  secondary: "disabled:hover:bg-transparent",
329
582
  ghost: "disabled:hover:bg-transparent",
@@ -331,6 +584,7 @@ const ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | "overlay", string> = {
331
584
  brand: "disabled:hover:bg-[var(--brand)]",
332
585
  link: "disabled:hover:bg-transparent disabled:hover:no-underline",
333
586
  overlay: "disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]",
587
+ shutter: "disabled:hover:bg-[var(--media-scrim)]",
334
588
  };
335
589
 
336
590
  // The quiet looks change the glyph on hover, so they pin their resting glyph the
@@ -353,20 +607,94 @@ const ICON_BUTTON_PRESSED =
353
607
  const ICON_BUTTON_OVERLAY =
354
608
  "rounded-full bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)] text-[var(--text-inverse)] backdrop-blur-sm hover:bg-[color-mix(in_srgb,var(--bg-inverse)_75%,transparent)] focus:ring-[var(--text-inverse)]";
355
609
 
610
+ // `variant="shutter"`: the camera's release — keksdose's camera-capture.tsx:248 draws
611
+ // it by hand as a 64px circle with a thick white ring round a translucent fill. It sits
612
+ // on a live camera picture, which is dark whatever the theme, so it uses the MEDIA
613
+ // tokens (tokens.css), white ink on a dark scrim in both themes — the inverse pair
614
+ // `overlay` uses would give a dark ring on a dark viewfinder in dark mode. The focus ring stands off by 2px, or it would
615
+ // merge into the ring that is part of the look.
616
+ const ICON_BUTTON_SHUTTER =
617
+ "rounded-full border-4 border-[var(--media-ink)] bg-[var(--media-scrim)] text-[var(--media-ink)] backdrop-blur-sm hover:bg-[var(--media-scrim-hover)] focus:ring-[var(--media-ink)] focus:ring-offset-2 focus:ring-offset-[var(--media-scrim-hover)]";
618
+
619
+ export type IconButtonVariant = ButtonVariant | "overlay" | "shutter";
620
+
621
+ /**
622
+ * The glyph's size in px, over the one the box size implies — see
623
+ * {@link IconButtonProps.glyphSize}.
624
+ */
625
+ export type IconButtonGlyphSize = 12 | 14 | 16 | 20 | 24 | 28;
626
+
627
+ const ICON_BUTTON_GLYPH_SIZES: Record<IconButtonGlyphSize, string> = {
628
+ 12: "[&_svg]:size-3",
629
+ 14: "[&_svg]:size-3.5",
630
+ 16: "[&_svg]:size-4",
631
+ 20: "[&_svg]:size-5",
632
+ 24: "[&_svg]:size-6",
633
+ 28: "[&_svg]:size-7",
634
+ };
635
+
356
636
  export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
357
- /** Any {@link ButtonVariant}, or `overlay` — a round translucent disc for use over
358
- * an image (see `ICON_BUTTON_OVERLAY`). */
359
- variant?: ButtonVariant | "overlay";
360
- /** Box size: lg = 44px (a phone's touch target), md = 36px (matches the top bar),
637
+ /** Any {@link ButtonVariant}; `overlay` — a round translucent disc for use over
638
+ * an image (see `ICON_BUTTON_OVERLAY`); or `shutter` — a camera's release, a ringed
639
+ * disc over the viewfinder (see `ICON_BUTTON_SHUTTER`; pair it with `size="2xl"`). */
640
+ variant?: IconButtonVariant;
641
+ /** Box size: 2xl = 64px with a 28px icon (a camera shutter), xl = 48px with a 24px
642
+ * icon, lg = 44px (a phone's touch target), md = 36px (matches the top bar),
361
643
  * sm = 32px — all three with a 20px icon;
362
644
  * xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px
363
- * icon (an action on a chip or a tab). */
645
+ * icon (an action on a chip or a tab). `glyphSize` overrides the icon. */
364
646
  size?: IconButtonSize;
647
+ /**
648
+ * The icon's size in px, when the box's own is not the one wanted. keksdose's sync
649
+ * chip (app/sync-status-indicator.tsx:142) is a 24px round chip round a 20px cloud —
650
+ * `size="2xs" glyphSize={20} shape="round"` — where `2xs` alone draws 14px. A number
651
+ * over a new size step because the box and the glyph are two independent choices; a
652
+ * step per combination (`"sm-lg"`) would have to be named for every one.
653
+ */
654
+ glyphSize?: IconButtonGlyphSize;
655
+ /**
656
+ * A small mark on the button's corner — the sync chip's 12px check over its cloud
657
+ * ("all good", feedback #67). Rendered OUTSIDE the `<button>`, in a wrapper beside
658
+ * it, so the box's `[&_svg]:size-*` rule that sizes the glyph does not blow the badge
659
+ * up to the glyph's size; a bare `<Check />` comes out 12px, on a
660
+ * `--bg-surface` disc that keeps it legible over the glyph. Decorative
661
+ * (`aria-hidden`): the name (`label` / `aria-label`) must already carry the state.
662
+ * With a badge, `className` still lands on the button; the wrapper is the flex item.
663
+ */
664
+ badge?: ReactNode;
665
+ /**
666
+ * `keep`: a disabled button keeps its full colour (no 50% drop) and an ordinary
667
+ * cursor. For a STATUS that is also an action — the sync chip shows "in sync" in
668
+ * green and refreshes on click, and is disabled while offline or syncing, when the
669
+ * state it shows matters most; dimmed it read as broken (keksdose wrote
670
+ * `disabled:cursor-default` by hand and left out the dimming). Default `dim`.
671
+ */
672
+ disabledStyle?: "dim" | "keep";
673
+ /**
674
+ * Fill the height of the flex row it stands in, keeping its width — the delete at
675
+ * the end of keksdose's split line (transaction-fields:162), level with the labelled
676
+ * fields beside it rather than floating at half their height. {@link Button}'s
677
+ * `stretch`; no effect outside a flex row. With a `label`, the tooltip's wrapper is
678
+ * what stretches.
679
+ */
680
+ stretch?: boolean;
681
+ /** `round`: a circle instead of the rounded square — a compact status chip in a top
682
+ * bar (keksdose's sync indicator). `overlay` is always round. */
683
+ shape?: "square" | "round";
684
+ /**
685
+ * The colour of `tone="custom"`, as any CSS colour — a token (`var(--warning)`),
686
+ * ideally. Sets `--icon-button-tone` on the button, so passing a different one per
687
+ * state re-colours the glyph, its hover fill and its focus ring together. Passing it
688
+ * implies `tone="custom"`. Or leave it out and set the variable yourself from a class
689
+ * (`className="[--icon-button-tone:var(--success)]"`).
690
+ */
691
+ toneColor?: string;
365
692
  /** Glyph colour over the variant. `muted`: placeholder grey, full text colour on
366
693
  * hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
367
694
  * remove/delete that repeats down a list. `warning`: amber at rest — a flag that
368
695
  * wants attention. `info`: sky at rest — a notice-worthy but harmless action
369
- * (keksdose's reconcile). Default: the variant's own colours. See `quiet` for
696
+ * (keksdose's reconcile). `success`: green at rest — a good state worth seeing.
697
+ * `custom`: the colour of `toneColor` (see there). Default: the variant's own colours. See `quiet` for
370
698
  * turning a coloured tone's resting look the other way. */
371
699
  tone?: IconButtonTone;
372
700
  /**
@@ -448,35 +776,77 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
448
776
  tooltipSide?: TooltipSide;
449
777
  /** Passed to the `label` tooltip's `portal`. Left out, Tooltip decides (see there). */
450
778
  tooltipPortal?: boolean;
779
+ /**
780
+ * Why the action is not available — {@link Button}'s `disabledReason`, on the icon
781
+ * button: keksdose's accounts page cannot hide an account with a balance or delete
782
+ * one with bookings (accounts-page:879/913 — "hide requires zero", "delete blocked"),
783
+ * nor its budgets page delete the only budget (budgets-page:330), and each wrapped a
784
+ * natively disabled IconButton in a Tooltip that swaps its label for the reason — a
785
+ * bubble a keyboard never opens, since `disabled` leaves the tab order, and a reason
786
+ * a screen reader never hears.
787
+ *
788
+ * The same contract as Button's: `aria-disabled` instead of `disabled`, so it stays
789
+ * focusable and hoverable; clicks (and the Enter/Space they stand for) are swallowed
790
+ * — `stopPropagation` still applies, so a locked action in a clickable row does not
791
+ * open the row either; the reason shows in the kit {@link Tooltip} and is attached
792
+ * through `aria-describedby` as a `hidden` copy. The NAME stays `aria-label` /
793
+ * `label` — "Delete", described by "The active budget cannot be deleted" — and the
794
+ * bubble shows the reason in place of the label, since the glyph already says what
795
+ * the button is and the reason is the news. Shown even with `tooltip={false}`: a
796
+ * reason nobody can see is not one. Wins over `disabled`, as on Button.
797
+ */
798
+ disabledReason?: ReactNode;
451
799
  }
452
800
 
453
801
  export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
454
802
  {
455
803
  variant = "ghost",
456
804
  size = "md",
457
- tone = "default",
805
+ tone: toneProp,
458
806
  quiet,
459
807
  pressed,
460
808
  stopPropagation,
809
+ stretch,
810
+ shape = "square",
811
+ toneColor,
461
812
  label,
462
813
  tooltip = true,
463
814
  tooltipSide,
464
815
  tooltipPortal,
816
+ disabledReason,
817
+ glyphSize,
818
+ badge,
819
+ disabledStyle = "dim",
465
820
  className,
821
+ style,
466
822
  onClick,
467
823
  onKeyDown,
468
824
  ...rest
469
825
  },
470
826
  ref,
471
827
  ) {
828
+ const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
829
+ const keep = disabledStyle === "keep";
830
+ const reasonId = useId();
831
+ const locked = hasContent(disabledReason);
832
+ const ownDescribedBy = rest["aria-describedby"];
472
833
  const button = (
473
834
  <button
474
835
  ref={ref}
475
836
  {...rest}
837
+ disabled={locked ? undefined : rest.disabled}
838
+ aria-disabled={locked || rest["aria-disabled"]}
839
+ aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
476
840
  aria-label={rest["aria-label"] ?? label}
477
841
  aria-pressed={pressed ?? rest["aria-pressed"]}
842
+ style={toneColor !== undefined ? { ...style, ["--icon-button-tone" as string]: toneColor } : style}
478
843
  onClick={(e) => {
479
844
  if (stopPropagation) e.stopPropagation();
845
+ // `preventDefault` too: a locked submit must not submit its form.
846
+ if (locked) {
847
+ e.preventDefault();
848
+ return;
849
+ }
480
850
  onClick?.(e);
481
851
  }}
482
852
  onKeyDown={(e) => {
@@ -487,22 +857,66 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
487
857
  }}
488
858
  className={cn(
489
859
  ICON_BUTTON_BASE,
490
- ICON_BUTTON_SIZES[size],
860
+ stretch ? ICON_BUTTON_STRETCH_SIZES[size] : ICON_BUTTON_SIZES[size],
861
+ glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],
491
862
  // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
492
- variant === "overlay" ? ICON_BUTTON_OVERLAY : buttonVariantClasses[variant],
863
+ variant === "overlay"
864
+ ? ICON_BUTTON_OVERLAY
865
+ : variant === "shutter"
866
+ ? ICON_BUTTON_SHUTTER
867
+ : buttonVariantClasses[variant],
868
+ shape === "round" && "rounded-full",
493
869
  ICON_BUTTON_DISABLED_REST[variant],
494
870
  iconButtonToneClass(tone, quiet),
495
871
  pressed && ICON_BUTTON_PRESSED,
872
+ // The disabled look for the focusable kind of disabled, as on Button.
873
+ locked && (keep ? "cursor-default" : "cursor-not-allowed opacity-50"),
874
+ keep && "disabled:cursor-default disabled:opacity-100",
496
875
  className,
497
876
  )}
498
877
  />
499
878
  );
500
- if (!tooltip || label === undefined || label === "") return button;
879
+ // The badge beside the button, not in it — see `badge`. The wrapper is only a
880
+ // positioning box: the button inside it keeps its own focus ring and hit area.
881
+ const control = hasContent(badge) ? (
882
+ <span className={cn("relative inline-flex shrink-0", stretch && "self-stretch")}>
883
+ {button}
884
+ <span
885
+ aria-hidden
886
+ data-slot="icon-button-badge"
887
+ className="pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-[var(--bg-surface)] [&_svg]:size-3"
888
+ >
889
+ {badge}
890
+ </span>
891
+ </span>
892
+ ) : (
893
+ button
894
+ );
895
+ if (locked) {
896
+ return (
897
+ // Button's shape: the button and the hidden reason in a FRAGMENT, so Tooltip does
898
+ // not add its bubble to the description as well.
899
+ <Tooltip
900
+ label={disabledReason}
901
+ side={tooltipSide}
902
+ portal={tooltipPortal}
903
+ className={stretch ? "self-stretch" : undefined}
904
+ >
905
+ <>
906
+ {control}
907
+ <span id={reasonId} hidden>
908
+ {disabledReason}
909
+ </span>
910
+ </>
911
+ </Tooltip>
912
+ );
913
+ }
914
+ if (!tooltip || label === undefined || label === "") return control;
501
915
  return (
502
916
  // A fragment, so Tooltip leaves the button's description alone: the bubble says
503
917
  // exactly what `aria-label` already does.
504
- <Tooltip label={label} side={tooltipSide} portal={tooltipPortal}>
505
- <>{button}</>
918
+ <Tooltip label={label} side={tooltipSide} portal={tooltipPortal} className={stretch ? "self-stretch" : undefined}>
919
+ <>{control}</>
506
920
  </Tooltip>
507
921
  );
508
922
  });
@@ -1383,17 +1797,82 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
1383
1797
  * those copies wanted the same one; a caller's `p-*` still wins. `flush` is ignored:
1384
1798
  * an inset panel never runs edge to edge.
1385
1799
  */
1386
- variant?: "default" | "inset";
1800
+ variant?: "default" | "inset" | "outline";
1801
+ /**
1802
+ * The card's own padding: `none`, `sm` (`p-3`) or `md` (`p-4`).
1803
+ *
1804
+ * `outline` is the bordered box with no shadow — a row or a block INSIDE a section
1805
+ * rather than a card on the page: kastlan's rent breakdown rows
1806
+ * (tenancy/components/breakdown-row.tsx, `rounded-lg border p-4`) and the meters
1807
+ * list's inline add row (meters/components/meter-add-row.tsx). It takes `md` by
1808
+ * default, as every one of those copies does; `sm` is the dense row.
1809
+ *
1810
+ * Left out, the default card keeps no padding (its CardHeader / CardContent own the
1811
+ * rhythm) and `inset` keeps its `p-3`; passing it sets theirs too. A caller's `p-*`
1812
+ * in `className` still wins.
1813
+ */
1814
+ padding?: "none" | "sm" | "md";
1815
+ /**
1816
+ * A card that is a WARNING (or other status) as a whole: the border in the tone's
1817
+ * `-border` colour and the {@link CardTitle} in the tone's text colour. kastlan's
1818
+ * dunning summary (invoice-detail-page.tsx:231-235) writes both by hand —
1819
+ * `border-[var(--warning-border)]` on the Card, `text-[var(--warning)]` on the title
1820
+ * — and the next status card would copy the pair (`outline` takes it the same way).
1821
+ * An `inset` panel has no border, so it takes the tone's quiet `-bg` fill instead.
1822
+ * `data-tone` is set for a caller's own
1823
+ * selectors.
1824
+ */
1825
+ tone?: CardTone;
1387
1826
  }
1388
1827
 
1389
- export function Card({ className, children, flush, variant = "default", ...rest }: CardProps) {
1828
+ export type CardTone = "warning" | "danger" | "info" | "success";
1829
+
1830
+ // The title is reached through its `data-slot`, so the tone needs no context and a
1831
+ // caller's `className` on CardTitle still wins (it is on the element itself).
1832
+ const CARD_TONES: Record<CardTone, { border: string; fill: string; title: string }> = {
1833
+ warning: {
1834
+ border: "border-[var(--warning-border)]",
1835
+ fill: "bg-[var(--warning-bg)]",
1836
+ title: "[&_[data-slot=card-title]]:text-[var(--warning)]",
1837
+ },
1838
+ danger: {
1839
+ border: "border-[var(--danger-border)]",
1840
+ fill: "bg-[var(--danger-bg)]",
1841
+ title: "[&_[data-slot=card-title]]:text-[var(--danger)]",
1842
+ },
1843
+ info: {
1844
+ border: "border-[var(--info-border)]",
1845
+ fill: "bg-[var(--info-bg)]",
1846
+ title: "[&_[data-slot=card-title]]:text-[var(--info)]",
1847
+ },
1848
+ success: {
1849
+ border: "border-[var(--success-border)]",
1850
+ fill: "bg-[var(--success-bg)]",
1851
+ title: "[&_[data-slot=card-title]]:text-[var(--success)]",
1852
+ },
1853
+ };
1854
+
1855
+ const CARD_PADDING: Record<NonNullable<CardProps["padding"]>, string> = {
1856
+ none: "p-0",
1857
+ sm: "p-3",
1858
+ md: "p-4",
1859
+ };
1860
+
1861
+ export function Card({ className, children, flush, variant = "default", tone, padding, ...rest }: CardProps) {
1862
+ const toned = tone ? CARD_TONES[tone] : undefined;
1863
+ // An outline card is bordered like the default one, so a tone recolours its border.
1864
+ const bordered = variant !== "inset";
1390
1865
  return (
1391
1866
  <div
1867
+ data-tone={tone}
1392
1868
  {...rest}
1393
1869
  className={cn(
1394
1870
  variant === "inset"
1395
1871
  ? "rounded-md bg-[var(--bg-surface-2)] p-3"
1396
- : cn(
1872
+ : variant === "outline"
1873
+ ? // No shadow and no `flush`: it sits on a surface that already has both.
1874
+ "rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] p-4"
1875
+ : cn(
1397
1876
  // Surface + border are theme tokens so the palette switcher (feedback
1398
1877
  // #307) can re-skin every card; a caller's own bg-*/border-* override
1399
1878
  // still wins via tailwind-merge.
@@ -1402,6 +1881,9 @@ export function Card({ className, children, flush, variant = "default", ...rest
1402
1881
  ? "border-y border-[var(--border)] md:rounded-lg md:border md:shadow-sm"
1403
1882
  : "rounded-lg border border-[var(--border)] shadow-sm",
1404
1883
  ),
1884
+ toned && (bordered ? toned.border : toned.fill),
1885
+ toned?.title,
1886
+ padding && CARD_PADDING[padding],
1405
1887
  className,
1406
1888
  )}
1407
1889
  >
@@ -1503,6 +1985,19 @@ export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "ch
1503
1985
  * one both announce).
1504
1986
  */
1505
1987
  label?: string | null;
1988
+ /**
1989
+ * Show the label as TEXT beside the ring, not only to a screen reader. kastlan's
1990
+ * LoadingState (feedback/loading-state.tsx:14) builds exactly this by hand — a
1991
+ * `role="status"` wrapper, the spinner at `label={null}`, a `<p>` of text — so it
1992
+ * has to get the three-part aria arrangement right itself. Here the visible text IS
1993
+ * the live region's content (the label stays the accessible name, said once), and
1994
+ * the ring is decorative. `className` still sizes the ring; the other props go on
1995
+ * the wrapper. Ignored with `label={null}`: there are no words to show.
1996
+ */
1997
+ showLabel?: boolean;
1998
+ /** Where the shown label sits: `end` (default) — beside the ring, for a row or a
1999
+ * button; `below` — under it, centred, for a panel's loading state. */
2000
+ labelPosition?: "end" | "below";
1506
2001
  }
1507
2002
 
1508
2003
  /**
@@ -1520,7 +2015,7 @@ export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "ch
1520
2015
  * `label={null}`: announcing is right for a spinner standing alone and wrong for one
1521
2016
  * beside text, inside a button, or inside the app's own live region.
1522
2017
  */
1523
- export function Spinner({ className, label, ...rest }: SpinnerProps) {
2018
+ export function Spinner({ className, label, showLabel, labelPosition = "end", ...rest }: SpinnerProps) {
1524
2019
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS, {
1525
2020
  loading: label ?? undefined,
1526
2021
  });
@@ -1529,6 +2024,21 @@ export function Spinner({ className, label, ...rest }: SpinnerProps) {
1529
2024
  className,
1530
2025
  );
1531
2026
  if (label === null) return <span aria-hidden {...rest} className={ring} />;
2027
+ if (showLabel) {
2028
+ return (
2029
+ <span
2030
+ role="status"
2031
+ {...rest}
2032
+ className={cn(
2033
+ "inline-flex items-center text-sm text-[var(--text-secondary)]",
2034
+ labelPosition === "below" ? "flex-col gap-2 text-center" : "gap-2",
2035
+ )}
2036
+ >
2037
+ <span aria-hidden className={ring} />
2038
+ <span>{common.loading}</span>
2039
+ </span>
2040
+ );
2041
+ }
1532
2042
  return (
1533
2043
  <span role="status" {...rest} className={ring}>
1534
2044
  {/* The trailing space is a separator for the case the caller forgot `label={null}`
@@ -1590,6 +2100,16 @@ export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "
1590
2100
  * verdict.
1591
2101
  */
1592
2102
  tone?: "danger" | "success";
2103
+ /**
2104
+ * `inline` only (0.11.0). `sm`: 12px, start-aligned and tight (`py-1`) — the empty
2105
+ * line INSIDE a detail panel, under a panel heading and among `text-xs` rows, where
2106
+ * the default's 14px centred line with `py-4` read as a section of its own.
2107
+ * keksdose's holdings panel (holdings-panel:563), asset-loan panel
2108
+ * (asset-loan-panel:108) and loan-payment panel (loan-payment-panel:191) each wrote
2109
+ * `className="justify-start py-1"` over it and still got body-size text. Default `md`,
2110
+ * the look it had. Ignored by the box.
2111
+ */
2112
+ size?: "sm" | "md";
1593
2113
  }
1594
2114
 
1595
2115
  const EMPTY_STATE_TONE: Record<"danger" | "success", string> = {
@@ -1605,6 +2125,7 @@ export function EmptyState({
1605
2125
  headingAs,
1606
2126
  variant = "box",
1607
2127
  tone,
2128
+ size = "md",
1608
2129
  className,
1609
2130
  ...rest
1610
2131
  }: EmptyStateProps) {
@@ -1612,22 +2133,29 @@ export function EmptyState({
1612
2133
  const toneClass = tone ? EMPTY_STATE_TONE[tone] : undefined;
1613
2134
  const hasHint = hint != null && hint !== false && hint !== "";
1614
2135
  if (variant === "inline") {
2136
+ const sm = size === "sm";
1615
2137
  return (
1616
2138
  <div
1617
2139
  {...rest}
1618
2140
  className={cn(
1619
- "flex flex-wrap items-center justify-center gap-x-2 gap-y-1 py-4 text-center text-sm text-[var(--text-muted)]",
2141
+ "flex flex-wrap items-center text-[var(--text-muted)]",
2142
+ sm
2143
+ ? "justify-start gap-x-1.5 gap-y-0.5 py-1 text-start text-xs"
2144
+ : "justify-center gap-x-2 gap-y-1 py-4 text-center text-sm",
1620
2145
  className,
1621
2146
  )}
1622
2147
  >
1623
2148
  {icon != null && (
1624
- <span aria-hidden className={cn("flex text-[var(--text-placeholder)] [&_svg]:size-4", toneClass)}>
2149
+ <span
2150
+ aria-hidden
2151
+ className={cn("flex text-[var(--text-placeholder)]", sm ? "[&_svg]:size-3.5" : "[&_svg]:size-4", toneClass)}
2152
+ >
1625
2153
  {icon}
1626
2154
  </span>
1627
2155
  )}
1628
2156
  {/* No weight of its own: one muted line is what these sites were, and a
1629
2157
  bold title would turn a quiet "nothing here" into a heading. */}
1630
- <Title className={cn("text-sm", toneClass)}>{title}</Title>
2158
+ <Title className={cn(sm ? "text-xs" : "text-sm", toneClass)}>{title}</Title>
1631
2159
  {hasHint && <span className="text-xs">{hint}</span>}
1632
2160
  {action != null && <span className="flex flex-wrap items-center gap-2">{action}</span>}
1633
2161
  </div>