@eifi1/ui-kit 0.11.0 → 0.13.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 (397) hide show
  1. package/README.md +59 -34
  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/amount-input.d.ts +40 -4
  12. package/dist/components/amount-input.js +50 -12
  13. package/dist/components/amount-input.js.map +1 -1
  14. package/dist/components/authed-image.d.ts +68 -0
  15. package/dist/components/authed-image.js +117 -0
  16. package/dist/components/authed-image.js.map +1 -0
  17. package/dist/components/breadcrumbs.d.ts +2 -1
  18. package/dist/components/breadcrumbs.js +5 -2
  19. package/dist/components/breadcrumbs.js.map +1 -1
  20. package/dist/components/bulk-action-bar.d.ts +10 -2
  21. package/dist/components/bulk-action-bar.js.map +1 -1
  22. package/dist/components/button-group.d.ts +51 -2
  23. package/dist/components/button-group.js +5 -1
  24. package/dist/components/button-group.js.map +1 -1
  25. package/dist/components/calculator.d.ts +15 -4
  26. package/dist/components/calendar-heatmap.d.ts +41 -3
  27. package/dist/components/calendar-heatmap.js +48 -7
  28. package/dist/components/calendar-heatmap.js.map +1 -1
  29. package/dist/components/chart.js +14 -8
  30. package/dist/components/chart.js.map +1 -1
  31. package/dist/components/chip.d.ts +2 -1
  32. package/dist/components/chip.js +26 -18
  33. package/dist/components/chip.js.map +1 -1
  34. package/dist/components/choice-card.d.ts +35 -3
  35. package/dist/components/choice-card.js +83 -25
  36. package/dist/components/choice-card.js.map +1 -1
  37. package/dist/components/combobox-core.d.ts +3 -1
  38. package/dist/components/combobox-core.js +18 -5
  39. package/dist/components/combobox-core.js.map +1 -1
  40. package/dist/components/combobox.d.ts +2 -2
  41. package/dist/components/combobox.js +31 -9
  42. package/dist/components/combobox.js.map +1 -1
  43. package/dist/components/copy-button.d.ts +51 -70
  44. package/dist/components/copy-button.js +1 -1
  45. package/dist/components/copy-button.js.map +1 -1
  46. package/dist/components/data-table-cells.d.ts +63 -0
  47. package/dist/components/data-table-cells.js +64 -0
  48. package/dist/components/data-table-cells.js.map +1 -0
  49. package/dist/components/data-table-filter-popover.d.ts +1 -1
  50. package/dist/components/data-table-filter-popover.js +8 -1
  51. package/dist/components/data-table-filter-popover.js.map +1 -1
  52. package/dist/components/data-table-filters.d.ts +1 -1
  53. package/dist/components/data-table-filters.js +31 -1
  54. package/dist/components/data-table-filters.js.map +1 -1
  55. package/dist/components/data-table-labels.d.ts +13 -0
  56. package/dist/components/data-table-labels.js +8 -1
  57. package/dist/components/data-table-labels.js.map +1 -1
  58. package/dist/components/data-table.d.ts +1 -1
  59. package/dist/components/data-table.js +125 -27
  60. package/dist/components/data-table.js.map +1 -1
  61. package/dist/components/date-picker.d.ts +3 -1
  62. package/dist/components/date-picker.js +15 -2
  63. package/dist/components/date-picker.js.map +1 -1
  64. package/dist/components/description-list.d.ts +23 -3
  65. package/dist/components/description-list.js +23 -3
  66. package/dist/components/description-list.js.map +1 -1
  67. package/dist/components/dialog-frame.d.ts +1 -0
  68. package/dist/components/entity-combobox.d.ts +1 -1
  69. package/dist/components/entity-combobox.js +40 -20
  70. package/dist/components/entity-combobox.js.map +1 -1
  71. package/dist/components/error-boundary.d.ts +281 -0
  72. package/dist/components/error-boundary.js +302 -0
  73. package/dist/components/error-boundary.js.map +1 -0
  74. package/dist/components/field-sync.js +90 -83
  75. package/dist/components/field-sync.js.map +1 -1
  76. package/dist/components/field.d.ts +30 -4
  77. package/dist/components/field.js +40 -17
  78. package/dist/components/field.js.map +1 -1
  79. package/dist/components/file-button.d.ts +50 -208
  80. package/dist/components/file-dropzone.d.ts +49 -2
  81. package/dist/components/floating-panel.d.ts +13 -4
  82. package/dist/components/floating-panel.js +7 -3
  83. package/dist/components/floating-panel.js.map +1 -1
  84. package/dist/components/form-actions.d.ts +53 -0
  85. package/dist/components/form-actions.js +98 -0
  86. package/dist/components/form-actions.js.map +1 -0
  87. package/dist/components/full-bleed-dialog.js +4 -0
  88. package/dist/components/full-bleed-dialog.js.map +1 -1
  89. package/dist/components/hover-menu.js +64 -56
  90. package/dist/components/hover-menu.js.map +1 -1
  91. package/dist/components/image-grid.d.ts +67 -0
  92. package/dist/components/image-grid.js +112 -0
  93. package/dist/components/image-grid.js.map +1 -0
  94. package/dist/components/lightbox.d.ts +104 -0
  95. package/dist/components/lightbox.js +229 -0
  96. package/dist/components/lightbox.js.map +1 -0
  97. package/dist/components/line-items.d.ts +114 -0
  98. package/dist/components/line-items.js +238 -0
  99. package/dist/components/line-items.js.map +1 -0
  100. package/dist/components/list.d.ts +37 -4
  101. package/dist/components/list.js +35 -14
  102. package/dist/components/list.js.map +1 -1
  103. package/dist/components/loading-state.d.ts +24 -0
  104. package/dist/components/loading-state.js +32 -0
  105. package/dist/components/loading-state.js.map +1 -0
  106. package/dist/components/menu-item.d.ts +2 -1
  107. package/dist/components/menu-item.js +5 -1
  108. package/dist/components/menu-item.js.map +1 -1
  109. package/dist/components/mini-calendar.d.ts +13 -1
  110. package/dist/components/mini-calendar.js +46 -2
  111. package/dist/components/mini-calendar.js.map +1 -1
  112. package/dist/components/modal.d.ts +20 -1
  113. package/dist/components/modal.js +26 -2
  114. package/dist/components/modal.js.map +1 -1
  115. package/dist/components/month-picker.d.ts +21 -1
  116. package/dist/components/month-picker.js +139 -26
  117. package/dist/components/month-picker.js.map +1 -1
  118. package/dist/components/multi-entity-combobox.d.ts +1 -1
  119. package/dist/components/multi-entity-combobox.js +42 -20
  120. package/dist/components/multi-entity-combobox.js.map +1 -1
  121. package/dist/components/nav-pills.d.ts +2 -1
  122. package/dist/components/nav-pills.js +5 -1
  123. package/dist/components/nav-pills.js.map +1 -1
  124. package/dist/components/number-field.d.ts +24 -5
  125. package/dist/components/number-field.js +5 -7
  126. package/dist/components/number-field.js.map +1 -1
  127. package/dist/components/number-input.d.ts +25 -5
  128. package/dist/components/number-input.js +7 -1
  129. package/dist/components/number-input.js.map +1 -1
  130. package/dist/components/numpad-sheet.d.ts +20 -5
  131. package/dist/components/numpad-sheet.js +3 -2
  132. package/dist/components/numpad-sheet.js.map +1 -1
  133. package/dist/components/page-header.d.ts +35 -6
  134. package/dist/components/page-header.js +7 -1
  135. package/dist/components/page-header.js.map +1 -1
  136. package/dist/components/passkeys-setting.d.ts +67 -0
  137. package/dist/components/passkeys-setting.js +214 -0
  138. package/dist/components/passkeys-setting.js.map +1 -0
  139. package/dist/components/picker-sheet.js +5 -1
  140. package/dist/components/picker-sheet.js.map +1 -1
  141. package/dist/components/pie-chart-labels.d.ts +20 -0
  142. package/dist/components/pie-chart-labels.js +12 -0
  143. package/dist/components/pie-chart-labels.js.map +1 -0
  144. package/dist/components/pie-chart.d.ts +101 -0
  145. package/dist/components/pie-chart.js +283 -0
  146. package/dist/components/pie-chart.js.map +1 -0
  147. package/dist/components/popover.js +3 -0
  148. package/dist/components/popover.js.map +1 -1
  149. package/dist/components/progress-bar.d.ts +44 -3
  150. package/dist/components/progress-bar.js +58 -22
  151. package/dist/components/progress-bar.js.map +1 -1
  152. package/dist/components/qr-code.d.ts +34 -0
  153. package/dist/components/qr-code.js +57 -0
  154. package/dist/components/qr-code.js.map +1 -0
  155. package/dist/components/search-field.d.ts +15 -3
  156. package/dist/components/search-field.js +5 -1
  157. package/dist/components/search-field.js.map +1 -1
  158. package/dist/components/settings-fields.d.ts +49 -1
  159. package/dist/components/signed-amount.d.ts +122 -0
  160. package/dist/components/signed-amount.js +152 -0
  161. package/dist/components/signed-amount.js.map +1 -0
  162. package/dist/components/skeleton.d.ts +10 -3
  163. package/dist/components/skeleton.js +12 -2
  164. package/dist/components/skeleton.js.map +1 -1
  165. package/dist/components/stat-tile.d.ts +2 -1
  166. package/dist/components/stat-tile.js +6 -3
  167. package/dist/components/stat-tile.js.map +1 -1
  168. package/dist/components/swipeable-row.js +4 -3
  169. package/dist/components/swipeable-row.js.map +1 -1
  170. package/dist/components/table.d.ts +39 -5
  171. package/dist/components/table.js +45 -11
  172. package/dist/components/table.js.map +1 -1
  173. package/dist/components/text-link.d.ts +238 -0
  174. package/dist/components/text-link.js +144 -0
  175. package/dist/components/text-link.js.map +1 -0
  176. package/dist/components/text.d.ts +26 -2
  177. package/dist/components/text.js +16 -2
  178. package/dist/components/text.js.map +1 -1
  179. package/dist/components/time-input.d.ts +49 -1
  180. package/dist/components/toggle-group.d.ts +35 -0
  181. package/dist/components/toggle-group.js +43 -14
  182. package/dist/components/toggle-group.js.map +1 -1
  183. package/dist/components/toggle-legend.d.ts +53 -3
  184. package/dist/components/toggle-legend.js +54 -1
  185. package/dist/components/toggle-legend.js.map +1 -1
  186. package/dist/components/tooltip.d.ts +51 -1
  187. package/dist/components/tooltip.js +59 -35
  188. package/dist/components/tooltip.js.map +1 -1
  189. package/dist/components/ui.d.ts +51 -779
  190. package/dist/components/ui.js +364 -34
  191. package/dist/components/ui.js.map +1 -1
  192. package/dist/components/use-table-state.d.ts +3 -52
  193. package/dist/components/use-table-state.js +128 -30
  194. package/dist/components/use-table-state.js.map +1 -1
  195. package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
  196. package/dist/data-table.d.ts +2 -1
  197. package/dist/data-table.js +5 -1
  198. package/dist/data-table.js.map +1 -1
  199. package/dist/feedback/feedback-attachment.js +4 -6
  200. package/dist/feedback/feedback-attachment.js.map +1 -1
  201. package/dist/feedback/feedback-dialog.d.ts +26 -3
  202. package/dist/feedback/feedback-dialog.js +21 -5
  203. package/dist/feedback/feedback-dialog.js.map +1 -1
  204. package/dist/feedback/feedback-inbox.js +86 -75
  205. package/dist/feedback/feedback-inbox.js.map +1 -1
  206. package/dist/feedback/feedback-thread.d.ts +173 -0
  207. package/dist/feedback/feedback-thread.js +264 -0
  208. package/dist/feedback/feedback-thread.js.map +1 -0
  209. package/dist/feedback.d.ts +2 -1
  210. package/dist/feedback.js +1 -0
  211. package/dist/feedback.js.map +1 -1
  212. package/dist/hooks/use-authed-src.d.ts +71 -0
  213. package/dist/hooks/use-authed-src.js +63 -0
  214. package/dist/hooks/use-authed-src.js.map +1 -0
  215. package/dist/hooks/use-file-drop.d.ts +49 -2
  216. package/dist/hooks/use-hotkey.d.ts +59 -0
  217. package/dist/hooks/use-hotkey.js +105 -0
  218. package/dist/hooks/use-hotkey.js.map +1 -0
  219. package/dist/hooks/use-search-param-state.d.ts +83 -0
  220. package/dist/hooks/use-search-param-state.js +94 -0
  221. package/dist/hooks/use-search-param-state.js.map +1 -0
  222. package/dist/i18n/defaults.d.ts +15 -4
  223. package/dist/i18n/defaults.js +31 -1
  224. package/dist/i18n/defaults.js.map +1 -1
  225. package/dist/i18n/kit-labels.d.ts +46 -312
  226. package/dist/i18n/kit-labels.js +20 -5
  227. package/dist/i18n/kit-labels.js.map +1 -1
  228. package/dist/i18n/locales/de-CH-informal.d.ts +15 -4
  229. package/dist/i18n/locales/de-CH.d.ts +15 -4
  230. package/dist/i18n/locales/de-informal.d.ts +15 -4
  231. package/dist/i18n/locales/de.d.ts +15 -4
  232. package/dist/i18n/locales/de.js +164 -3
  233. package/dist/i18n/locales/de.js.map +1 -1
  234. package/dist/i18n/locales/es.d.ts +15 -4
  235. package/dist/i18n/locales/es.js +161 -3
  236. package/dist/i18n/locales/es.js.map +1 -1
  237. package/dist/i18n/locales/fr.d.ts +15 -4
  238. package/dist/i18n/locales/fr.js +161 -3
  239. package/dist/i18n/locales/fr.js.map +1 -1
  240. package/dist/i18n/locales/hu.d.ts +15 -4
  241. package/dist/i18n/locales/hu.js +161 -3
  242. package/dist/i18n/locales/hu.js.map +1 -1
  243. package/dist/i18n/locales/it.d.ts +15 -4
  244. package/dist/i18n/locales/it.js +161 -3
  245. package/dist/i18n/locales/it.js.map +1 -1
  246. package/dist/i18n/locales/zh.d.ts +15 -4
  247. package/dist/i18n/locales/zh.js +161 -3
  248. package/dist/i18n/locales/zh.js.map +1 -1
  249. package/dist/index.d.ts +39 -19
  250. package/dist/index.js +40 -1
  251. package/dist/index.js.map +1 -1
  252. package/dist/kit-labels-Mz8zZHIF.d.ts +1744 -0
  253. package/dist/lib/format.d.ts +162 -0
  254. package/dist/lib/format.js +151 -0
  255. package/dist/lib/format.js.map +1 -0
  256. package/dist/lib/qr-encode.d.ts +44 -0
  257. package/dist/lib/qr-encode.js +338 -0
  258. package/dist/lib/qr-encode.js.map +1 -0
  259. package/dist/rhf/fields.d.ts +283 -0
  260. package/dist/rhf/fields.js +631 -0
  261. package/dist/rhf/fields.js.map +1 -0
  262. package/dist/rhf/form.d.ts +49 -1
  263. package/dist/rhf/line-items.d.ts +31 -0
  264. package/dist/rhf/line-items.js +59 -0
  265. package/dist/rhf/line-items.js.map +1 -0
  266. package/dist/rhf.d.ts +61 -1
  267. package/dist/rhf.js +2 -0
  268. package/dist/rhf.js.map +1 -1
  269. package/dist/search/command-palette.js +20 -14
  270. package/dist/search/command-palette.js.map +1 -1
  271. package/dist/shell/app-shell.d.ts +99 -2
  272. package/dist/shell/app-shell.js +120 -57
  273. package/dist/shell/app-shell.js.map +1 -1
  274. package/dist/shell/auth-layout.d.ts +74 -0
  275. package/dist/shell/auth-layout.js +97 -0
  276. package/dist/shell/auth-layout.js.map +1 -0
  277. package/dist/shell/top-bar-brand.d.ts +81 -0
  278. package/dist/shell/top-bar-brand.js +47 -0
  279. package/dist/shell/top-bar-brand.js.map +1 -0
  280. package/dist/shell/topbar-action-menu.d.ts +21 -4
  281. package/dist/shell/topbar-action-menu.js +18 -2
  282. package/dist/shell/topbar-action-menu.js.map +1 -1
  283. package/dist/shell.d.ts +56 -1
  284. package/dist/shell.js +2 -0
  285. package/dist/shell.js.map +1 -1
  286. package/dist/tour/tour.js +10 -4
  287. package/dist/tour/tour.js.map +1 -1
  288. package/dist/wizard/stepper-nav.d.ts +48 -1
  289. package/dist/wizard/use-wizard.js +4 -2
  290. package/dist/wizard/use-wizard.js.map +1 -1
  291. package/dist/wizard/wizard-context.d.ts +24 -1
  292. package/dist/wizard/wizard-context.js +19 -2
  293. package/dist/wizard/wizard-context.js.map +1 -1
  294. package/dist/wizard/wizard-step.d.ts +30 -5
  295. package/dist/wizard/wizard-step.js +34 -3
  296. package/dist/wizard/wizard-step.js.map +1 -1
  297. package/dist/wizard.d.ts +50 -3
  298. package/package.json +6 -6
  299. package/src/chart.ts +4 -0
  300. package/src/components/account-settings-labels.ts +190 -0
  301. package/src/components/account-settings.tsx +110 -63
  302. package/src/components/amount-input.tsx +109 -10
  303. package/src/components/authed-image.tsx +190 -0
  304. package/src/components/breadcrumbs.tsx +8 -2
  305. package/src/components/bulk-action-bar.tsx +16 -3
  306. package/src/components/button-group.tsx +7 -2
  307. package/src/components/calendar-heatmap.tsx +105 -10
  308. package/src/components/chart.tsx +19 -8
  309. package/src/components/chip.tsx +14 -4
  310. package/src/components/choice-card.tsx +136 -25
  311. package/src/components/combobox-core.tsx +33 -9
  312. package/src/components/combobox.tsx +33 -9
  313. package/src/components/copy-button.tsx +3 -2
  314. package/src/components/data-table-cells.tsx +119 -0
  315. package/src/components/data-table-filter-popover.tsx +13 -5
  316. package/src/components/data-table-filters.ts +77 -0
  317. package/src/components/data-table-labels.ts +23 -0
  318. package/src/components/data-table.tsx +230 -13
  319. package/src/components/date-picker.tsx +19 -3
  320. package/src/components/description-list.tsx +52 -3
  321. package/src/components/entity-combobox.tsx +25 -7
  322. package/src/components/error-boundary.tsx +640 -0
  323. package/src/components/field-sync.tsx +48 -40
  324. package/src/components/field.tsx +56 -5
  325. package/src/components/floating-panel.tsx +20 -7
  326. package/src/components/form-actions.tsx +191 -0
  327. package/src/components/full-bleed-dialog.tsx +4 -0
  328. package/src/components/hover-menu.tsx +26 -17
  329. package/src/components/image-grid.tsx +181 -0
  330. package/src/components/lightbox.tsx +374 -0
  331. package/src/components/line-items.tsx +385 -0
  332. package/src/components/list.tsx +82 -19
  333. package/src/components/loading-state.tsx +47 -0
  334. package/src/components/menu-item.tsx +7 -2
  335. package/src/components/mini-calendar.tsx +72 -3
  336. package/src/components/modal.tsx +65 -3
  337. package/src/components/month-picker.tsx +181 -26
  338. package/src/components/multi-entity-combobox.tsx +27 -7
  339. package/src/components/nav-pills.tsx +8 -1
  340. package/src/components/number-field.tsx +15 -10
  341. package/src/components/number-input.tsx +17 -1
  342. package/src/components/numpad-sheet.tsx +6 -1
  343. package/src/components/page-header.tsx +42 -5
  344. package/src/components/passkeys-setting.tsx +331 -0
  345. package/src/components/picker-sheet.tsx +10 -1
  346. package/src/components/pie-chart-labels.ts +32 -0
  347. package/src/components/pie-chart.tsx +479 -0
  348. package/src/components/popover.tsx +3 -0
  349. package/src/components/progress-bar.tsx +129 -19
  350. package/src/components/qr-code.tsx +83 -0
  351. package/src/components/search-field.tsx +20 -4
  352. package/src/components/signed-amount.tsx +295 -0
  353. package/src/components/skeleton.tsx +21 -3
  354. package/src/components/stat-tile.tsx +11 -3
  355. package/src/components/swipeable-row.tsx +7 -7
  356. package/src/components/table.tsx +102 -13
  357. package/src/components/text-link.tsx +356 -0
  358. package/src/components/text.tsx +46 -2
  359. package/src/components/toggle-group.tsx +93 -20
  360. package/src/components/toggle-legend.tsx +108 -2
  361. package/src/components/tooltip.tsx +79 -8
  362. package/src/components/ui.tsx +707 -49
  363. package/src/components/use-table-state.ts +265 -33
  364. package/src/data-table.ts +10 -0
  365. package/src/feedback/feedback-attachment.tsx +10 -7
  366. package/src/feedback/feedback-dialog.tsx +49 -7
  367. package/src/feedback/feedback-inbox.tsx +7 -0
  368. package/src/feedback/feedback-thread.tsx +510 -0
  369. package/src/feedback.ts +1 -0
  370. package/src/hooks/use-authed-src.ts +161 -0
  371. package/src/hooks/use-hotkey.ts +172 -0
  372. package/src/hooks/use-search-param-state.ts +187 -0
  373. package/src/i18n/defaults.ts +30 -0
  374. package/src/i18n/kit-labels.tsx +118 -8
  375. package/src/i18n/locales/de.ts +161 -0
  376. package/src/i18n/locales/es.ts +158 -0
  377. package/src/i18n/locales/fr.ts +161 -0
  378. package/src/i18n/locales/hu.ts +159 -0
  379. package/src/i18n/locales/it.ts +159 -0
  380. package/src/i18n/locales/zh.ts +158 -0
  381. package/src/index.ts +59 -0
  382. package/src/lib/format.ts +348 -0
  383. package/src/lib/qr-encode.ts +420 -0
  384. package/src/rhf/fields.tsx +1135 -0
  385. package/src/rhf/line-items.tsx +130 -0
  386. package/src/rhf.ts +6 -0
  387. package/src/search/command-palette.tsx +29 -19
  388. package/src/shell/app-shell.tsx +153 -3
  389. package/src/shell/auth-layout.tsx +190 -0
  390. package/src/shell/top-bar-brand.tsx +76 -0
  391. package/src/shell/topbar-action-menu.tsx +32 -4
  392. package/src/shell.ts +2 -0
  393. package/src/tour/tour.tsx +16 -5
  394. package/src/wizard/use-wizard.ts +8 -4
  395. package/src/wizard/wizard-context.tsx +41 -1
  396. package/src/wizard/wizard-step.tsx +75 -6
  397. 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, CSSProperties, 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, replacingClick, routerLinkNavigation } 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,266 @@ 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
+ replace?: never;
218
+ reloadDocument?: never;
219
+ }
220
+
221
+ /**
222
+ * `<Button href>`: a link that looks like a button — for an action that NAVIGATES.
223
+ *
224
+ * kastlan paints `buttonClasses(…)` onto router Links by hand in six places (the
225
+ * invoice / lease / unit preview dialogs' "Open" at invoice-preview-dialog.tsx:36,
226
+ * platform-companies-page.tsx:111, verify-email-page.tsx:86's "Log in",
227
+ * billing-history-page.tsx:85's PDF download), and has 26 `<Button onClick={() =>
228
+ * navigate(…)}>` that are links in all but markup: no middle-click, no "open in new
229
+ * tab", no URL on hover, and a screen reader announces a button that then changes the
230
+ * page. With `href` it is an `<a>`: the provider's router link
231
+ * ({@link UiKitProvider}'s `linkComponent`), or `renderLink` if given, else a plain one.
232
+ *
233
+ * `type` and `form` are typed out: they are a `<button>`'s, and a link never submits.
234
+ */
235
+ export interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "type" | "href"> {
236
+ href: string;
237
+ variant?: ButtonVariant;
238
+ size?: ButtonSize;
239
+ stretch?: boolean;
240
+ tone?: ButtonTone;
241
+ /** The in-app router link, over the provider's `linkComponent` (see
242
+ * {@link KitLinkComponent}). Ignored when `external`. */
243
+ renderLink?: KitLinkComponent;
244
+ /** Leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`, never the
245
+ * router link, and "(opens in a new tab)" read after the label — billing-history's
246
+ * Stripe PDF. A `target="_blank"` of your own is announced the same way. */
247
+ external?: boolean;
248
+ /**
249
+ * Navigate by replacing the current history entry, so Back does not return here —
250
+ * keksdose F1: the "Continue" out of a one-shot page (an emailed confirmation link, a
251
+ * payment return URL) that must not be landed on again. Handed to the router link as
252
+ * `replace` (see `KitLinkProps.replace` for mapping it); on a plain `<a>` a plain click
253
+ * becomes `location.replace(href)`. Ignored when `external`.
254
+ */
255
+ replace?: boolean;
256
+ /**
257
+ * A plain `<a>` for an in-app `href`, so the browser loads the whole document instead
258
+ * of the router swapping the view — keksdose F1: a route the SPA does not own (a
259
+ * server-rendered export, a logout that must drop every in-memory cache). Wins over
260
+ * `renderLink` and the provider's `linkComponent`.
261
+ */
262
+ reloadDocument?: boolean;
263
+ /** A link cannot be `disabled`, so this renders an `<a>` with NO `href` —
264
+ * `role="link"` and `aria-disabled`, out of the tab order like a disabled button,
265
+ * with the disabled look. The router link is not used: it needs somewhere to go. */
266
+ disabled?: boolean;
267
+ ref?: Ref<HTMLAnchorElement>;
268
+ type?: never;
269
+ form?: never;
270
+ pending?: never;
271
+ pressed?: never;
272
+ disabledReason?: never;
175
273
  }
176
274
 
177
275
  function hasContent(node: ReactNode): boolean {
178
276
  return node !== undefined && node !== null && node !== false && node !== "";
179
277
  }
180
278
 
181
- export function Button({
279
+ /** The button look shared by the `<button>` and the `<a>` form. */
280
+ function buttonLook(variant: ButtonVariant, size: ButtonSize, stretch: boolean | undefined, tone: ButtonTone) {
281
+ return cn(
282
+ BUTTON_BASE,
283
+ BUTTON_SIZES[size],
284
+ // In a flex row next to a taller labelled field, `stretch` makes the button
285
+ // fill the field's height so the two line up (self-stretch overrides the row's
286
+ // align-items). No effect outside a flex row / when it's already the tallest.
287
+ stretch && "self-stretch",
288
+ buttonVariantClasses[variant],
289
+ tone !== "default" && TONED_VARIANTS.has(variant) && BUTTON_TONES[tone],
290
+ tone === "danger" && BUTTON_BOXED_DANGER[variant],
291
+ );
292
+ }
293
+
294
+ // Declared link-first so `<Button href>` picks the link props; the LAST signature is
295
+ // what `ComponentProps<typeof Button>` reads, so wrappers (FileButton) still see the
296
+ // `<button>` props they always did.
297
+ export function Button(props: ButtonLinkProps): ReactElement;
298
+ export function Button(props: ButtonProps): ReactElement;
299
+ export function Button(props: ButtonProps | ButtonLinkProps): ReactElement {
300
+ if (props.href !== undefined) return <ButtonLink {...(props as ButtonLinkProps)} />;
301
+ return <ButtonElement {...(props as ButtonProps)} />;
302
+ }
303
+
304
+ /** Calls a `renderLink` / provider link as a function, as ListItem and Chip do — an
305
+ * inline `renderLink` arrow would otherwise be a new component type every render. */
306
+ function RenderedButtonLink({ render, ...props }: KitLinkProps & { render: KitLinkComponent }) {
307
+ return render(props);
308
+ }
309
+
310
+ function ButtonLink({
311
+ href,
312
+ renderLink,
313
+ external,
314
+ replace,
315
+ reloadDocument,
316
+ disabled,
317
+ variant = "primary",
318
+ size = "md",
319
+ stretch,
320
+ tone = "default",
321
+ className,
322
+ children,
323
+ target,
324
+ rel,
325
+ onClick,
326
+ ...rest
327
+ }: ButtonLinkProps) {
328
+ const kitLink = useKitLink();
329
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
330
+ // The same rule as every kit link (`pickLinkRenderer`): an external `href` or an
331
+ // in-page `#anchor` is never handed to the provider's router link, even without
332
+ // `external`.
333
+ // `reloadDocument` is the caller saying "not the router" for this one link.
334
+ const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);
335
+ const newTab = !disabled && (external || target === "_blank");
336
+ const cls = cn(
337
+ buttonLook(variant, size, stretch, tone),
338
+ // `relative` holds the sr-only note (see sr-only-containment.test).
339
+ newTab && "relative",
340
+ // BUTTON_BASE's `disabled:` classes never match an `<a>`; no pointer events also
341
+ // keeps the hover fill from lighting up on something that refuses the click.
342
+ disabled && "pointer-events-none cursor-not-allowed opacity-50",
343
+ className,
344
+ );
345
+ const body = (
346
+ <>
347
+ {children}
348
+ {/* The space OUTSIDE the sr-only span: a name computation trims each child's
349
+ text, so one inside it was lost ("Download(opens…"). In a flex box a bare
350
+ space draws nothing. */}
351
+ {newTab && " "}
352
+ {newTab && <span className="sr-only">({common.opensInNewTab})</span>}
353
+ </>
354
+ );
355
+ if (disabled) {
356
+ return (
357
+ <a {...rest} role="link" aria-disabled="true" className={cls}>
358
+ {body}
359
+ </a>
360
+ );
361
+ }
362
+ if (external || !Link) {
363
+ return (
364
+ <a
365
+ {...rest}
366
+ href={href}
367
+ target={external ? "_blank" : target}
368
+ rel={external ? (rel ?? "noopener noreferrer") : rel}
369
+ onClick={external ? onClick : replacingClick(onClick, href, replace, target)}
370
+ className={cls}
371
+ >
372
+ {body}
373
+ </a>
374
+ );
375
+ }
376
+ return (
377
+ <RenderedButtonLink
378
+ render={Link}
379
+ {...rest}
380
+ {...routerLinkNavigation(replace)}
381
+ href={href}
382
+ target={target}
383
+ rel={rel}
384
+ onClick={onClick}
385
+ className={cls}
386
+ >
387
+ {body}
388
+ </RenderedButtonLink>
389
+ );
390
+ }
391
+
392
+ function ButtonElement({
182
393
  variant = "primary",
183
394
  size = "md",
184
395
  stretch,
185
396
  tone = "default",
186
397
  pressed,
187
398
  disabledReason,
399
+ pending,
188
400
  className,
189
401
  onClick,
402
+ children,
403
+ href: _href,
404
+ renderLink: _renderLink,
405
+ external: _external,
406
+ replace: _replace,
407
+ reloadDocument: _reloadDocument,
190
408
  ...rest
191
409
  }: ButtonProps) {
192
410
  const reasonId = useId();
193
411
  const locked = hasContent(disabledReason);
412
+ const inert = locked || Boolean(pending);
194
413
  const ownDescribedBy = rest["aria-describedby"];
195
414
  const button = (
196
415
  <button
197
416
  {...rest}
198
- disabled={locked ? undefined : rest.disabled}
199
- aria-disabled={locked || rest["aria-disabled"]}
417
+ disabled={inert ? undefined : rest.disabled}
418
+ aria-disabled={inert || rest["aria-disabled"]}
419
+ aria-busy={pending || rest["aria-busy"]}
200
420
  aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
201
421
  aria-pressed={pressed ?? rest["aria-pressed"]}
202
422
  onClick={(e) => {
203
423
  // `preventDefault` as well as not calling through: a locked submit button must
204
424
  // not submit its form, and the browser does that after this handler returns.
205
- if (locked) {
425
+ if (inert) {
206
426
  e.preventDefault();
207
427
  return;
208
428
  }
209
429
  onClick?.(e);
210
430
  }}
211
431
  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],
432
+ buttonLook(variant, size, stretch, tone),
220
433
  pressed && (variant === "link" ? BUTTON_LINK_PRESSED : ICON_BUTTON_PRESSED),
221
434
  // The disabled look for the focusable kind of disabled.
222
435
  locked && "cursor-not-allowed opacity-50",
436
+ // No dimming while pending: the spinner IS the state, and at half opacity it
437
+ // would be the faintest thing on the button. `relative` anchors it.
438
+ pending && "relative cursor-progress",
223
439
  className,
224
440
  )}
225
- />
441
+ >
442
+ {pending ? (
443
+ <>
444
+ {/* Transparent, not hidden: it keeps the width AND stays the accessible
445
+ name (`visibility: hidden` would drop it from the name). `gap-[inherit]`
446
+ keeps an icon + text spaced as the button spaces them. */}
447
+ <span className="inline-flex items-center justify-center gap-[inherit] opacity-0">{children}</span>
448
+ {/* Decorative: `aria-busy` says it, as on FileButton. */}
449
+ <Spinner
450
+ label={null}
451
+ className="absolute inset-0 m-auto size-4 border-current/30 border-t-current"
452
+ />
453
+ </>
454
+ ) : (
455
+ children
456
+ )}
457
+ </button>
226
458
  );
227
459
  if (!locked) return button;
228
460
  return (
@@ -254,12 +486,15 @@ export function Button({
254
486
  const ICON_BUTTON_BASE =
255
487
  "inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
256
488
 
257
- export type IconButtonSize = "xl" | "lg" | "md" | "sm" | "xs" | "2xs";
489
+ export type IconButtonSize = "2xl" | "xl" | "lg" | "md" | "sm" | "xs" | "2xs";
258
490
 
259
491
  // Box and glyph together, so a 24px chip action cannot end up holding a 20px icon
260
492
  // that touches its edges. The two small steps are lenkbank's list-row (28px) and
261
493
  // chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.
262
494
  const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
495
+ // 64px with a 28px glyph: a camera's shutter (keksdose invoices/camera-capture.tsx:248,
496
+ // `size-16` by hand) — the one control of a full-screen capture view, thumb-sized.
497
+ "2xl": "size-16 [&_svg]:size-7",
263
498
  // 48px with a 24px glyph: the one primary action of a phone screen or sheet, a step
264
499
  // past the 44px minimum rather than on it.
265
500
  xl: "size-12 [&_svg]:size-6",
@@ -276,6 +511,7 @@ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
276
511
  // stated — `size-*` sets a height that `self-stretch` cannot override, so the box is
277
512
  // written as a width plus a minimum height (the square it would otherwise be).
278
513
  const ICON_BUTTON_STRETCH_SIZES: Record<IconButtonSize, string> = {
514
+ "2xl": "w-16 min-h-16 self-stretch [&_svg]:size-7",
279
515
  xl: "w-12 min-h-12 self-stretch [&_svg]:size-6",
280
516
  lg: "w-11 min-h-11 self-stretch [&_svg]:size-5",
281
517
  md: "w-9 min-h-9 self-stretch [&_svg]:size-5",
@@ -363,7 +599,7 @@ function iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined):
363
599
  // and would out-rank a caller's plain `hover:` override), each variant pins its
364
600
  // RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity
365
601
  // and only ever matches a disabled button.
366
- const ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | "overlay", string> = {
602
+ const ICON_BUTTON_DISABLED_REST: Record<IconButtonVariant, string> = {
367
603
  primary: "disabled:hover:bg-[var(--bg-surface-2)]",
368
604
  secondary: "disabled:hover:bg-transparent",
369
605
  ghost: "disabled:hover:bg-transparent",
@@ -371,6 +607,7 @@ const ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | "overlay", string> = {
371
607
  brand: "disabled:hover:bg-[var(--brand)]",
372
608
  link: "disabled:hover:bg-transparent disabled:hover:no-underline",
373
609
  overlay: "disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]",
610
+ shutter: "disabled:hover:bg-[var(--media-scrim)]",
374
611
  };
375
612
 
376
613
  // The quiet looks change the glyph on hover, so they pin their resting glyph the
@@ -393,15 +630,69 @@ const ICON_BUTTON_PRESSED =
393
630
  const ICON_BUTTON_OVERLAY =
394
631
  "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)]";
395
632
 
633
+ // `variant="shutter"`: the camera's release — keksdose's camera-capture.tsx:248 draws
634
+ // it by hand as a 64px circle with a thick white ring round a translucent fill. It sits
635
+ // on a live camera picture, which is dark whatever the theme, so it uses the MEDIA
636
+ // tokens (tokens.css), white ink on a dark scrim in both themes — the inverse pair
637
+ // `overlay` uses would give a dark ring on a dark viewfinder in dark mode. The focus ring stands off by 2px, or it would
638
+ // merge into the ring that is part of the look.
639
+ const ICON_BUTTON_SHUTTER =
640
+ "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)]";
641
+
642
+ export type IconButtonVariant = ButtonVariant | "overlay" | "shutter";
643
+
644
+ /**
645
+ * The glyph's size in px, over the one the box size implies — see
646
+ * {@link IconButtonProps.glyphSize}.
647
+ */
648
+ export type IconButtonGlyphSize = 12 | 14 | 16 | 20 | 24 | 28;
649
+
650
+ const ICON_BUTTON_GLYPH_SIZES: Record<IconButtonGlyphSize, string> = {
651
+ 12: "[&_svg]:size-3",
652
+ 14: "[&_svg]:size-3.5",
653
+ 16: "[&_svg]:size-4",
654
+ 20: "[&_svg]:size-5",
655
+ 24: "[&_svg]:size-6",
656
+ 28: "[&_svg]:size-7",
657
+ };
658
+
396
659
  export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
397
- /** Any {@link ButtonVariant}, or `overlay` — a round translucent disc for use over
398
- * an image (see `ICON_BUTTON_OVERLAY`). */
399
- variant?: ButtonVariant | "overlay";
400
- /** Box size: lg = 44px (a phone's touch target), md = 36px (matches the top bar),
660
+ /** Any {@link ButtonVariant}; `overlay` — a round translucent disc for use over
661
+ * an image (see `ICON_BUTTON_OVERLAY`); or `shutter` — a camera's release, a ringed
662
+ * disc over the viewfinder (see `ICON_BUTTON_SHUTTER`; pair it with `size="2xl"`). */
663
+ variant?: IconButtonVariant;
664
+ /** Box size: 2xl = 64px with a 28px icon (a camera shutter), xl = 48px with a 24px
665
+ * icon, lg = 44px (a phone's touch target), md = 36px (matches the top bar),
401
666
  * sm = 32px — all three with a 20px icon;
402
667
  * xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px
403
- * icon (an action on a chip or a tab). */
668
+ * icon (an action on a chip or a tab). `glyphSize` overrides the icon. */
404
669
  size?: IconButtonSize;
670
+ /**
671
+ * The icon's size in px, when the box's own is not the one wanted. keksdose's sync
672
+ * chip (app/sync-status-indicator.tsx:142) is a 24px round chip round a 20px cloud —
673
+ * `size="2xs" glyphSize={20} shape="round"` — where `2xs` alone draws 14px. A number
674
+ * over a new size step because the box and the glyph are two independent choices; a
675
+ * step per combination (`"sm-lg"`) would have to be named for every one.
676
+ */
677
+ glyphSize?: IconButtonGlyphSize;
678
+ /**
679
+ * A small mark on the button's corner — the sync chip's 12px check over its cloud
680
+ * ("all good", feedback #67). Rendered OUTSIDE the `<button>`, in a wrapper beside
681
+ * it, so the box's `[&_svg]:size-*` rule that sizes the glyph does not blow the badge
682
+ * up to the glyph's size; a bare `<Check />` comes out 12px, on a
683
+ * `--bg-surface` disc that keeps it legible over the glyph. Decorative
684
+ * (`aria-hidden`): the name (`label` / `aria-label`) must already carry the state.
685
+ * With a badge, `className` still lands on the button; the wrapper is the flex item.
686
+ */
687
+ badge?: ReactNode;
688
+ /**
689
+ * `keep`: a disabled button keeps its full colour (no 50% drop) and an ordinary
690
+ * cursor. For a STATUS that is also an action — the sync chip shows "in sync" in
691
+ * green and refreshes on click, and is disabled while offline or syncing, when the
692
+ * state it shows matters most; dimmed it read as broken (keksdose wrote
693
+ * `disabled:cursor-default` by hand and left out the dimming). Default `dim`.
694
+ */
695
+ disabledStyle?: "dim" | "keep";
405
696
  /**
406
697
  * Fill the height of the flex row it stands in, keeping its width — the delete at
407
698
  * the end of keksdose's split line (transaction-fields:162), level with the labelled
@@ -419,8 +710,12 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
419
710
  * state re-colours the glyph, its hover fill and its focus ring together. Passing it
420
711
  * implies `tone="custom"`. Or leave it out and set the variable yourself from a class
421
712
  * (`className="[--icon-button-tone:var(--success)]"`).
713
+ *
714
+ * Or a `{ light, dark }` pair, one colour per theme — keksdose: a raw colour that reads
715
+ * on the light surface (`#be123c`) is too dark on the dark one, and an inline style
716
+ * cannot say "in dark mode". See {@link IconButtonToneColor}.
422
717
  */
423
- toneColor?: string;
718
+ toneColor?: IconButtonToneColor;
424
719
  /** Glyph colour over the variant. `muted`: placeholder grey, full text colour on
425
720
  * hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
426
721
  * remove/delete that repeats down a list. `warning`: amber at rest — a flag that
@@ -528,9 +823,272 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
528
823
  * reason nobody can see is not one. Wins over `disabled`, as on Button.
529
824
  */
530
825
  disabledReason?: ReactNode;
826
+ /** The `<button>` element — a prop in React 19, as on {@link Button}. */
827
+ ref?: Ref<HTMLButtonElement>;
828
+ /** Only on the link form — see {@link IconButtonLinkProps}. */
829
+ href?: never;
830
+ renderLink?: never;
831
+ external?: never;
832
+ replace?: never;
833
+ reloadDocument?: never;
834
+ }
835
+
836
+ /**
837
+ * `<IconButton href>`: an icon-only LINK — keksdose F2 and kastlan 45, whose icon
838
+ * actions that navigate (a row's "open", a card's "edit" that goes to the edit page, a
839
+ * top bar's settings cog) were `<IconButton onClick={() => navigate(…)}>`: a button that
840
+ * changes the page, with no middle click, no "open in new tab" and no URL on hover.
841
+ *
842
+ * The same rule as {@link Button}'s `href` (see {@link ButtonLinkProps}): an in-app
843
+ * `href` goes through `renderLink`, else the provider's `linkComponent`; an external one
844
+ * or an in-page `#anchor` stays a plain `<a>`; `external` opens a new tab and says so to
845
+ * a screen reader; `replace` and `reloadDocument` as on Button. The look, the `label` →
846
+ * `aria-label` + Tooltip, the badge, `toneColor` and `stopPropagation` are the button's.
847
+ *
848
+ * `disabled` renders an inert `<a>` with no `href` — `role="link"`, `aria-disabled`, out
849
+ * of the tab order, dimmed — like Button's disabled link. `pressed`, `disabledReason` and
850
+ * `disabledStyle` are a button's and are typed out: a link is not a toggle, and a link
851
+ * that explains why it cannot be followed is a job for a disabled BUTTON.
852
+ */
853
+ export interface IconButtonLinkProps
854
+ extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "type" | "href">,
855
+ Pick<
856
+ IconButtonProps,
857
+ | "variant"
858
+ | "size"
859
+ | "glyphSize"
860
+ | "badge"
861
+ | "stretch"
862
+ | "shape"
863
+ | "toneColor"
864
+ | "tone"
865
+ | "quiet"
866
+ | "stopPropagation"
867
+ | "label"
868
+ | "tooltip"
869
+ | "tooltipSide"
870
+ | "tooltipPortal"
871
+ > {
872
+ href: string;
873
+ /** See {@link ButtonLinkProps.renderLink}. */
874
+ renderLink?: KitLinkComponent;
875
+ /** See {@link ButtonLinkProps.external}. */
876
+ external?: boolean;
877
+ /** See {@link ButtonLinkProps.replace}. */
878
+ replace?: boolean;
879
+ /** See {@link ButtonLinkProps.reloadDocument}. */
880
+ reloadDocument?: boolean;
881
+ /** See {@link ButtonLinkProps.disabled}. */
882
+ disabled?: boolean;
883
+ ref?: Ref<HTMLAnchorElement>;
884
+ type?: never;
885
+ form?: never;
886
+ pressed?: never;
887
+ disabledReason?: never;
888
+ disabledStyle?: never;
889
+ }
890
+
891
+ /**
892
+ * `toneColor`: one CSS colour, or one per theme.
893
+ *
894
+ * The pair is two custom properties on the element — `--icon-button-tone-light` and
895
+ * `--icon-button-tone-dark` — and a class that points `--icon-button-tone` at one or the
896
+ * other by the kit's `dark` variant (`.dark` on an ancestor, tokens.css), so the colour
897
+ * follows a theme switch in CSS alone, with no re-render and no JS reading the theme.
898
+ * Both values are any CSS colour; a token that already flips (`var(--warning)`) needs no
899
+ * pair.
900
+ */
901
+ export type IconButtonToneColor = string | { light: string; dark: string };
902
+
903
+ const ICON_BUTTON_TONE_PAIR =
904
+ "[--icon-button-tone:var(--icon-button-tone-light)] dark:[--icon-button-tone:var(--icon-button-tone-dark)]";
905
+
906
+ /** The inline custom properties `toneColor` sets over the caller's `style`. */
907
+ function toneColorStyle(toneColor: IconButtonToneColor | undefined, style: CSSProperties | undefined) {
908
+ if (toneColor === undefined) return style;
909
+ const vars: Record<string, string> =
910
+ typeof toneColor === "string"
911
+ ? { "--icon-button-tone": toneColor }
912
+ : { "--icon-button-tone-light": toneColor.light, "--icon-button-tone-dark": toneColor.dark };
913
+ return { ...style, ...vars } as CSSProperties;
914
+ }
915
+
916
+ /** Everything but the element: the look both forms of IconButton share. */
917
+ function iconButtonLook({
918
+ variant,
919
+ size,
920
+ stretch,
921
+ glyphSize,
922
+ shape,
923
+ tone,
924
+ quiet,
925
+ toneColor,
926
+ }: {
927
+ variant: IconButtonVariant;
928
+ size: IconButtonSize;
929
+ stretch: boolean | undefined;
930
+ glyphSize: IconButtonGlyphSize | undefined;
931
+ shape: "square" | "round";
932
+ tone: IconButtonTone;
933
+ quiet: boolean | undefined;
934
+ toneColor: IconButtonToneColor | undefined;
935
+ }) {
936
+ return cn(
937
+ ICON_BUTTON_BASE,
938
+ stretch ? ICON_BUTTON_STRETCH_SIZES[size] : ICON_BUTTON_SIZES[size],
939
+ glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],
940
+ // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
941
+ variant === "overlay"
942
+ ? ICON_BUTTON_OVERLAY
943
+ : variant === "shutter"
944
+ ? ICON_BUTTON_SHUTTER
945
+ : buttonVariantClasses[variant],
946
+ shape === "round" && "rounded-full",
947
+ ICON_BUTTON_DISABLED_REST[variant],
948
+ iconButtonToneClass(tone, quiet),
949
+ toneColor !== undefined && typeof toneColor !== "string" && ICON_BUTTON_TONE_PAIR,
950
+ );
951
+ }
952
+
953
+ /** The badge beside the control, not in it — see `badge`. The wrapper is only a
954
+ * positioning box: the control inside it keeps its own focus ring and hit area. */
955
+ function withIconButtonBadge(control: ReactElement, badge: ReactNode, stretch: boolean | undefined) {
956
+ if (!hasContent(badge)) return control;
957
+ return (
958
+ <span className={cn("relative inline-flex shrink-0", stretch && "self-stretch")}>
959
+ {control}
960
+ <span
961
+ aria-hidden
962
+ data-slot="icon-button-badge"
963
+ className="pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-[var(--bg-surface)] [&_svg]:size-3"
964
+ >
965
+ {badge}
966
+ </span>
967
+ </span>
968
+ );
969
+ }
970
+
971
+ // Declared link-first, as Button is, so `<IconButton href>` picks the link props and
972
+ // `ComponentProps<typeof IconButton>` (the LAST signature) is still the button's.
973
+ export function IconButton(props: IconButtonLinkProps): ReactElement;
974
+ export function IconButton(props: IconButtonProps): ReactElement;
975
+ export function IconButton(props: IconButtonProps | IconButtonLinkProps): ReactElement {
976
+ if (props.href !== undefined) return <IconButtonLink {...(props as IconButtonLinkProps)} />;
977
+ return <IconButtonElement {...(props as IconButtonProps)} />;
978
+ }
979
+ IconButton.displayName = "IconButton";
980
+
981
+ function IconButtonLink({
982
+ href,
983
+ renderLink,
984
+ external,
985
+ replace,
986
+ reloadDocument,
987
+ disabled,
988
+ variant = "ghost",
989
+ size = "md",
990
+ tone: toneProp,
991
+ quiet,
992
+ stopPropagation,
993
+ stretch,
994
+ shape = "square",
995
+ toneColor,
996
+ label,
997
+ tooltip = true,
998
+ tooltipSide,
999
+ tooltipPortal,
1000
+ glyphSize,
1001
+ badge,
1002
+ className,
1003
+ style,
1004
+ target,
1005
+ rel,
1006
+ onClick,
1007
+ onKeyDown,
1008
+ children,
1009
+ ref,
1010
+ ...rest
1011
+ }: IconButtonLinkProps) {
1012
+ const kitLink = useKitLink();
1013
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
1014
+ const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
1015
+ // Button's rule, word for word: `reloadDocument` bypasses the router, an external
1016
+ // href or an in-page `#anchor` never reaches it.
1017
+ const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);
1018
+ const newTab = !disabled && (external || target === "_blank");
1019
+ const name = rest["aria-label"] ?? label;
1020
+ // "(opens in a new tab)" joins the NAME here, not the body: an icon link has no text
1021
+ // for an sr-only span to follow, and `aria-label` would override one anyway.
1022
+ const ariaLabel = name !== undefined && newTab ? `${name} (${common.opensInNewTab})` : name;
1023
+ const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
1024
+ if (stopPropagation) e.stopPropagation();
1025
+ onClick?.(e);
1026
+ };
1027
+ const handleKeyDown = (e: KeyboardEvent<HTMLAnchorElement>) => {
1028
+ // A link activates on Enter only; that key still bubbles to a clickable row.
1029
+ if (stopPropagation && e.key === "Enter") e.stopPropagation();
1030
+ onKeyDown?.(e);
1031
+ };
1032
+ const cls = cn(
1033
+ iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor }),
1034
+ // BASE's `disabled:` classes never match an `<a>` — Button's disabled link look.
1035
+ disabled && "pointer-events-none cursor-not-allowed opacity-50",
1036
+ className,
1037
+ );
1038
+ const look = { style: toneColorStyle(toneColor, style), className: cls };
1039
+ let control: ReactElement;
1040
+ if (disabled) {
1041
+ control = (
1042
+ <a {...rest} {...look} ref={ref} role="link" aria-disabled="true" aria-label={ariaLabel}>
1043
+ {children}
1044
+ </a>
1045
+ );
1046
+ } else if (external || !Link) {
1047
+ control = (
1048
+ <a
1049
+ {...rest}
1050
+ {...look}
1051
+ ref={ref}
1052
+ href={href}
1053
+ aria-label={ariaLabel}
1054
+ target={external ? "_blank" : target}
1055
+ rel={external ? (rel ?? "noopener noreferrer") : rel}
1056
+ onClick={external ? handleClick : replacingClick(handleClick, href, replace, target)}
1057
+ onKeyDown={handleKeyDown}
1058
+ >
1059
+ {children}
1060
+ </a>
1061
+ );
1062
+ } else {
1063
+ control = (
1064
+ <RenderedButtonLink
1065
+ render={Link}
1066
+ {...rest}
1067
+ {...look}
1068
+ {...routerLinkNavigation(replace)}
1069
+ ref={ref}
1070
+ href={href}
1071
+ aria-label={ariaLabel}
1072
+ target={target}
1073
+ rel={rel}
1074
+ onClick={handleClick}
1075
+ onKeyDown={handleKeyDown}
1076
+ >
1077
+ {children}
1078
+ </RenderedButtonLink>
1079
+ );
1080
+ }
1081
+ control = withIconButtonBadge(control, badge, stretch);
1082
+ if (!tooltip || label === undefined || label === "") return control;
1083
+ return (
1084
+ // A fragment, as on the button: the bubble says what `aria-label` already does.
1085
+ <Tooltip label={label} side={tooltipSide} portal={tooltipPortal} className={stretch ? "self-stretch" : undefined}>
1086
+ <>{control}</>
1087
+ </Tooltip>
1088
+ );
531
1089
  }
532
1090
 
533
- export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
1091
+ function IconButtonElement(
534
1092
  {
535
1093
  variant = "ghost",
536
1094
  size = "md",
@@ -546,15 +1104,24 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
546
1104
  tooltipSide,
547
1105
  tooltipPortal,
548
1106
  disabledReason,
1107
+ glyphSize,
1108
+ badge,
1109
+ disabledStyle = "dim",
549
1110
  className,
550
1111
  style,
551
1112
  onClick,
552
1113
  onKeyDown,
1114
+ href: _href,
1115
+ renderLink: _renderLink,
1116
+ external: _external,
1117
+ replace: _replace,
1118
+ reloadDocument: _reloadDocument,
1119
+ ref,
553
1120
  ...rest
554
- },
555
- ref,
1121
+ }: IconButtonProps,
556
1122
  ) {
557
1123
  const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
1124
+ const keep = disabledStyle === "keep";
558
1125
  const reasonId = useId();
559
1126
  const locked = hasContent(disabledReason);
560
1127
  const ownDescribedBy = rest["aria-describedby"];
@@ -567,7 +1134,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
567
1134
  aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
568
1135
  aria-label={rest["aria-label"] ?? label}
569
1136
  aria-pressed={pressed ?? rest["aria-pressed"]}
570
- style={toneColor !== undefined ? { ...style, ["--icon-button-tone" as string]: toneColor } : style}
1137
+ style={toneColorStyle(toneColor, style)}
571
1138
  onClick={(e) => {
572
1139
  if (stopPropagation) e.stopPropagation();
573
1140
  // `preventDefault` too: a locked submit must not submit its form.
@@ -584,20 +1151,16 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
584
1151
  onKeyDown?.(e);
585
1152
  }}
586
1153
  className={cn(
587
- ICON_BUTTON_BASE,
588
- stretch ? ICON_BUTTON_STRETCH_SIZES[size] : ICON_BUTTON_SIZES[size],
589
- // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
590
- variant === "overlay" ? ICON_BUTTON_OVERLAY : buttonVariantClasses[variant],
591
- shape === "round" && "rounded-full",
592
- ICON_BUTTON_DISABLED_REST[variant],
593
- iconButtonToneClass(tone, quiet),
1154
+ iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor }),
594
1155
  pressed && ICON_BUTTON_PRESSED,
595
1156
  // The disabled look for the focusable kind of disabled, as on Button.
596
- locked && "cursor-not-allowed opacity-50",
1157
+ locked && (keep ? "cursor-default" : "cursor-not-allowed opacity-50"),
1158
+ keep && "disabled:cursor-default disabled:opacity-100",
597
1159
  className,
598
1160
  )}
599
1161
  />
600
1162
  );
1163
+ const control = withIconButtonBadge(button, badge, stretch);
601
1164
  if (locked) {
602
1165
  return (
603
1166
  // Button's shape: the button and the hidden reason in a FRAGMENT, so Tooltip does
@@ -609,7 +1172,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
609
1172
  className={stretch ? "self-stretch" : undefined}
610
1173
  >
611
1174
  <>
612
- {button}
1175
+ {control}
613
1176
  <span id={reasonId} hidden>
614
1177
  {disabledReason}
615
1178
  </span>
@@ -617,16 +1180,15 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
617
1180
  </Tooltip>
618
1181
  );
619
1182
  }
620
- if (!tooltip || label === undefined || label === "") return button;
1183
+ if (!tooltip || label === undefined || label === "") return control;
621
1184
  return (
622
1185
  // A fragment, so Tooltip leaves the button's description alone: the bubble says
623
1186
  // exactly what `aria-label` already does.
624
1187
  <Tooltip label={label} side={tooltipSide} portal={tooltipPortal} className={stretch ? "self-stretch" : undefined}>
625
- <>{button}</>
1188
+ <>{control}</>
626
1189
  </Tooltip>
627
1190
  );
628
- });
629
- IconButton.displayName = "IconButton";
1191
+ }
630
1192
 
631
1193
  export const FIELD_BASE =
632
1194
  "block w-full rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-3 py-2 text-sm text-[var(--text-primary)] shadow-sm placeholder:text-[var(--text-placeholder)] focus:border-[var(--brand)] focus:ring-[var(--brand)] " +
@@ -1503,17 +2065,82 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
1503
2065
  * those copies wanted the same one; a caller's `p-*` still wins. `flush` is ignored:
1504
2066
  * an inset panel never runs edge to edge.
1505
2067
  */
1506
- variant?: "default" | "inset";
2068
+ variant?: "default" | "inset" | "outline";
2069
+ /**
2070
+ * The card's own padding: `none`, `sm` (`p-3`) or `md` (`p-4`).
2071
+ *
2072
+ * `outline` is the bordered box with no shadow — a row or a block INSIDE a section
2073
+ * rather than a card on the page: kastlan's rent breakdown rows
2074
+ * (tenancy/components/breakdown-row.tsx, `rounded-lg border p-4`) and the meters
2075
+ * list's inline add row (meters/components/meter-add-row.tsx). It takes `md` by
2076
+ * default, as every one of those copies does; `sm` is the dense row.
2077
+ *
2078
+ * Left out, the default card keeps no padding (its CardHeader / CardContent own the
2079
+ * rhythm) and `inset` keeps its `p-3`; passing it sets theirs too. A caller's `p-*`
2080
+ * in `className` still wins.
2081
+ */
2082
+ padding?: "none" | "sm" | "md";
2083
+ /**
2084
+ * A card that is a WARNING (or other status) as a whole: the border in the tone's
2085
+ * `-border` colour and the {@link CardTitle} in the tone's text colour. kastlan's
2086
+ * dunning summary (invoice-detail-page.tsx:231-235) writes both by hand —
2087
+ * `border-[var(--warning-border)]` on the Card, `text-[var(--warning)]` on the title
2088
+ * — and the next status card would copy the pair (`outline` takes it the same way).
2089
+ * An `inset` panel has no border, so it takes the tone's quiet `-bg` fill instead.
2090
+ * `data-tone` is set for a caller's own
2091
+ * selectors.
2092
+ */
2093
+ tone?: CardTone;
1507
2094
  }
1508
2095
 
1509
- export function Card({ className, children, flush, variant = "default", ...rest }: CardProps) {
2096
+ export type CardTone = "warning" | "danger" | "info" | "success";
2097
+
2098
+ // The title is reached through its `data-slot`, so the tone needs no context and a
2099
+ // caller's `className` on CardTitle still wins (it is on the element itself).
2100
+ const CARD_TONES: Record<CardTone, { border: string; fill: string; title: string }> = {
2101
+ warning: {
2102
+ border: "border-[var(--warning-border)]",
2103
+ fill: "bg-[var(--warning-bg)]",
2104
+ title: "[&_[data-slot=card-title]]:text-[var(--warning)]",
2105
+ },
2106
+ danger: {
2107
+ border: "border-[var(--danger-border)]",
2108
+ fill: "bg-[var(--danger-bg)]",
2109
+ title: "[&_[data-slot=card-title]]:text-[var(--danger)]",
2110
+ },
2111
+ info: {
2112
+ border: "border-[var(--info-border)]",
2113
+ fill: "bg-[var(--info-bg)]",
2114
+ title: "[&_[data-slot=card-title]]:text-[var(--info)]",
2115
+ },
2116
+ success: {
2117
+ border: "border-[var(--success-border)]",
2118
+ fill: "bg-[var(--success-bg)]",
2119
+ title: "[&_[data-slot=card-title]]:text-[var(--success)]",
2120
+ },
2121
+ };
2122
+
2123
+ const CARD_PADDING: Record<NonNullable<CardProps["padding"]>, string> = {
2124
+ none: "p-0",
2125
+ sm: "p-3",
2126
+ md: "p-4",
2127
+ };
2128
+
2129
+ export function Card({ className, children, flush, variant = "default", tone, padding, ...rest }: CardProps) {
2130
+ const toned = tone ? CARD_TONES[tone] : undefined;
2131
+ // An outline card is bordered like the default one, so a tone recolours its border.
2132
+ const bordered = variant !== "inset";
1510
2133
  return (
1511
2134
  <div
2135
+ data-tone={tone}
1512
2136
  {...rest}
1513
2137
  className={cn(
1514
2138
  variant === "inset"
1515
2139
  ? "rounded-md bg-[var(--bg-surface-2)] p-3"
1516
- : cn(
2140
+ : variant === "outline"
2141
+ ? // No shadow and no `flush`: it sits on a surface that already has both.
2142
+ "rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] p-4"
2143
+ : cn(
1517
2144
  // Surface + border are theme tokens so the palette switcher (feedback
1518
2145
  // #307) can re-skin every card; a caller's own bg-*/border-* override
1519
2146
  // still wins via tailwind-merge.
@@ -1522,6 +2149,9 @@ export function Card({ className, children, flush, variant = "default", ...rest
1522
2149
  ? "border-y border-[var(--border)] md:rounded-lg md:border md:shadow-sm"
1523
2150
  : "rounded-lg border border-[var(--border)] shadow-sm",
1524
2151
  ),
2152
+ toned && (bordered ? toned.border : toned.fill),
2153
+ toned?.title,
2154
+ padding && CARD_PADDING[padding],
1525
2155
  className,
1526
2156
  )}
1527
2157
  >
@@ -1623,6 +2253,19 @@ export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "ch
1623
2253
  * one both announce).
1624
2254
  */
1625
2255
  label?: string | null;
2256
+ /**
2257
+ * Show the label as TEXT beside the ring, not only to a screen reader. kastlan's
2258
+ * LoadingState (feedback/loading-state.tsx:14) builds exactly this by hand — a
2259
+ * `role="status"` wrapper, the spinner at `label={null}`, a `<p>` of text — so it
2260
+ * has to get the three-part aria arrangement right itself. Here the visible text IS
2261
+ * the live region's content (the label stays the accessible name, said once), and
2262
+ * the ring is decorative. `className` still sizes the ring; the other props go on
2263
+ * the wrapper. Ignored with `label={null}`: there are no words to show.
2264
+ */
2265
+ showLabel?: boolean;
2266
+ /** Where the shown label sits: `end` (default) — beside the ring, for a row or a
2267
+ * button; `below` — under it, centred, for a panel's loading state. */
2268
+ labelPosition?: "end" | "below";
1626
2269
  }
1627
2270
 
1628
2271
  /**
@@ -1640,7 +2283,7 @@ export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "ch
1640
2283
  * `label={null}`: announcing is right for a spinner standing alone and wrong for one
1641
2284
  * beside text, inside a button, or inside the app's own live region.
1642
2285
  */
1643
- export function Spinner({ className, label, ...rest }: SpinnerProps) {
2286
+ export function Spinner({ className, label, showLabel, labelPosition = "end", ...rest }: SpinnerProps) {
1644
2287
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS, {
1645
2288
  loading: label ?? undefined,
1646
2289
  });
@@ -1649,6 +2292,21 @@ export function Spinner({ className, label, ...rest }: SpinnerProps) {
1649
2292
  className,
1650
2293
  );
1651
2294
  if (label === null) return <span aria-hidden {...rest} className={ring} />;
2295
+ if (showLabel) {
2296
+ return (
2297
+ <span
2298
+ role="status"
2299
+ {...rest}
2300
+ className={cn(
2301
+ "inline-flex items-center text-sm text-[var(--text-secondary)]",
2302
+ labelPosition === "below" ? "flex-col gap-2 text-center" : "gap-2",
2303
+ )}
2304
+ >
2305
+ <span aria-hidden className={ring} />
2306
+ <span>{common.loading}</span>
2307
+ </span>
2308
+ );
2309
+ }
1652
2310
  return (
1653
2311
  <span role="status" {...rest} className={ring}>
1654
2312
  {/* The trailing space is a separator for the case the caller forgot `label={null}`