@eifi1/ui-kit 0.4.1 → 0.5.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 (410) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +498 -57
  3. package/dist/chart.d.ts +10 -0
  4. package/dist/chart.js +10 -0
  5. package/dist/chart.js.map +1 -0
  6. package/dist/components/account-settings.d.ts +11 -0
  7. package/dist/components/account-settings.js +19 -12
  8. package/dist/components/account-settings.js.map +1 -1
  9. package/dist/components/alert-banner.d.ts +10 -6
  10. package/dist/components/alert-banner.js +20 -16
  11. package/dist/components/alert-banner.js.map +1 -1
  12. package/dist/components/amount-input.d.ts +30 -4
  13. package/dist/components/amount-input.js +36 -14
  14. package/dist/components/amount-input.js.map +1 -1
  15. package/dist/components/calculator.d.ts +24 -7
  16. package/dist/components/calculator.js +37 -20
  17. package/dist/components/calculator.js.map +1 -1
  18. package/dist/components/chart-zoom.d.ts +156 -0
  19. package/dist/components/chart-zoom.js +262 -0
  20. package/dist/components/chart-zoom.js.map +1 -0
  21. package/dist/components/chart.d.ts +28 -3
  22. package/dist/components/chart.js +71 -32
  23. package/dist/components/chart.js.map +1 -1
  24. package/dist/components/checkbox.d.ts +51 -0
  25. package/dist/components/checkbox.js +123 -0
  26. package/dist/components/checkbox.js.map +1 -0
  27. package/dist/components/chip.d.ts +128 -0
  28. package/dist/components/chip.js +357 -0
  29. package/dist/components/chip.js.map +1 -0
  30. package/dist/components/choice-parts.d.ts +24 -0
  31. package/dist/components/choice-parts.js +18 -0
  32. package/dist/components/choice-parts.js.map +1 -0
  33. package/dist/components/combobox-core.d.ts +52 -9
  34. package/dist/components/combobox-core.js +89 -19
  35. package/dist/components/combobox-core.js.map +1 -1
  36. package/dist/components/combobox.d.ts +43 -33
  37. package/dist/components/combobox.js +409 -340
  38. package/dist/components/combobox.js.map +1 -1
  39. package/dist/components/currency-select.d.ts +48 -8
  40. package/dist/components/currency-select.js +107 -65
  41. package/dist/components/currency-select.js.map +1 -1
  42. package/dist/components/data-table-filter-popover.d.ts +15 -3
  43. package/dist/components/data-table-filter-popover.js +23 -17
  44. package/dist/components/data-table-filter-popover.js.map +1 -1
  45. package/dist/components/data-table-filters.d.ts +1 -1
  46. package/dist/components/data-table-filters.js +3 -2
  47. package/dist/components/data-table-filters.js.map +1 -1
  48. package/dist/components/data-table-labels.d.ts +53 -2
  49. package/dist/components/data-table-labels.js +31 -2
  50. package/dist/components/data-table-labels.js.map +1 -1
  51. package/dist/components/data-table-pagination.d.ts +3 -1
  52. package/dist/components/data-table-pagination.js +16 -10
  53. package/dist/components/data-table-pagination.js.map +1 -1
  54. package/dist/components/data-table-sort.js +1 -0
  55. package/dist/components/data-table-sort.js.map +1 -1
  56. package/dist/components/data-table.d.ts +1 -1
  57. package/dist/components/data-table.js +201 -85
  58. package/dist/components/data-table.js.map +1 -1
  59. package/dist/components/date-picker.d.ts +25 -9
  60. package/dist/components/date-picker.js +232 -94
  61. package/dist/components/date-picker.js.map +1 -1
  62. package/dist/components/dropdown.d.ts +74 -19
  63. package/dist/components/dropdown.js +72 -22
  64. package/dist/components/dropdown.js.map +1 -1
  65. package/dist/components/entity-combobox.d.ts +5 -4
  66. package/dist/components/entity-combobox.js +107 -76
  67. package/dist/components/entity-combobox.js.map +1 -1
  68. package/dist/components/facing-pair.d.ts +36 -0
  69. package/dist/components/facing-pair.js +40 -0
  70. package/dist/components/facing-pair.js.map +1 -0
  71. package/dist/components/field-sync.d.ts +164 -0
  72. package/dist/components/field-sync.js +264 -0
  73. package/dist/components/field-sync.js.map +1 -0
  74. package/dist/components/file-dropzone.d.ts +21 -7
  75. package/dist/components/file-dropzone.js +28 -7
  76. package/dist/components/file-dropzone.js.map +1 -1
  77. package/dist/components/full-bleed-dialog.d.ts +37 -26
  78. package/dist/components/full-bleed-dialog.js +20 -5
  79. package/dist/components/full-bleed-dialog.js.map +1 -1
  80. package/dist/components/grouped-picker.d.ts +21 -10
  81. package/dist/components/grouped-picker.js +66 -52
  82. package/dist/components/grouped-picker.js.map +1 -1
  83. package/dist/components/hover-menu.d.ts +20 -4
  84. package/dist/components/hover-menu.js +15 -4
  85. package/dist/components/hover-menu.js.map +1 -1
  86. package/dist/components/mini-calendar.d.ts +102 -14
  87. package/dist/components/mini-calendar.js +256 -70
  88. package/dist/components/mini-calendar.js.map +1 -1
  89. package/dist/components/modal.d.ts +9 -3
  90. package/dist/components/modal.js +34 -45
  91. package/dist/components/modal.js.map +1 -1
  92. package/dist/components/month-picker.d.ts +79 -0
  93. package/dist/components/month-picker.js +342 -0
  94. package/dist/components/month-picker.js.map +1 -0
  95. package/dist/components/multi-entity-combobox.d.ts +7 -6
  96. package/dist/components/multi-entity-combobox.js +112 -72
  97. package/dist/components/multi-entity-combobox.js.map +1 -1
  98. package/dist/components/multi-select.d.ts +13 -11
  99. package/dist/components/multi-select.js +161 -84
  100. package/dist/components/multi-select.js.map +1 -1
  101. package/dist/components/number-field.d.ts +92 -0
  102. package/dist/components/number-field.js +111 -0
  103. package/dist/components/number-field.js.map +1 -0
  104. package/dist/components/number-input.d.ts +21 -2
  105. package/dist/components/number-input.js +2 -1
  106. package/dist/components/number-input.js.map +1 -1
  107. package/dist/components/numpad-sheet.d.ts +25 -4
  108. package/dist/components/numpad-sheet.js +30 -13
  109. package/dist/components/numpad-sheet.js.map +1 -1
  110. package/dist/components/page-contents.d.ts +90 -0
  111. package/dist/components/page-contents.js +170 -0
  112. package/dist/components/page-contents.js.map +1 -0
  113. package/dist/components/password-strength.d.ts +106 -0
  114. package/dist/components/password-strength.js +132 -0
  115. package/dist/components/password-strength.js.map +1 -0
  116. package/dist/components/picker-sheet.d.ts +23 -15
  117. package/dist/components/picker-sheet.js +34 -14
  118. package/dist/components/picker-sheet.js.map +1 -1
  119. package/dist/components/popover.d.ts +62 -4
  120. package/dist/components/popover.js +52 -2
  121. package/dist/components/popover.js.map +1 -1
  122. package/dist/components/search-field.d.ts +25 -7
  123. package/dist/components/search-field.js +7 -5
  124. package/dist/components/search-field.js.map +1 -1
  125. package/dist/components/series-chart-labels.d.ts +14 -0
  126. package/dist/components/series-chart-labels.js +11 -0
  127. package/dist/components/series-chart-labels.js.map +1 -0
  128. package/dist/components/series-chart.d.ts +207 -0
  129. package/dist/components/series-chart.js +223 -0
  130. package/dist/components/series-chart.js.map +1 -0
  131. package/dist/components/settings-fields.d.ts +14 -4
  132. package/dist/components/settings-fields.js +19 -8
  133. package/dist/components/settings-fields.js.map +1 -1
  134. package/dist/components/signature-pad.d.ts +119 -0
  135. package/dist/components/signature-pad.js +405 -0
  136. package/dist/components/signature-pad.js.map +1 -0
  137. package/dist/components/slider.d.ts +80 -0
  138. package/dist/components/slider.js +155 -0
  139. package/dist/components/slider.js.map +1 -0
  140. package/dist/components/sparkline.d.ts +93 -0
  141. package/dist/components/sparkline.js +205 -0
  142. package/dist/components/sparkline.js.map +1 -0
  143. package/dist/components/stat-tile.d.ts +151 -0
  144. package/dist/components/stat-tile.js +236 -0
  145. package/dist/components/stat-tile.js.map +1 -0
  146. package/dist/components/swipeable-row.d.ts +25 -2
  147. package/dist/components/swipeable-row.js +31 -0
  148. package/dist/components/swipeable-row.js.map +1 -1
  149. package/dist/components/switch.d.ts +60 -0
  150. package/dist/components/switch.js +81 -0
  151. package/dist/components/switch.js.map +1 -0
  152. package/dist/components/time-input.d.ts +58 -0
  153. package/dist/components/time-input.js +50 -0
  154. package/dist/components/time-input.js.map +1 -0
  155. package/dist/components/toggle-group.d.ts +18 -3
  156. package/dist/components/toggle-group.js +10 -6
  157. package/dist/components/toggle-group.js.map +1 -1
  158. package/dist/components/toggle-legend.d.ts +115 -0
  159. package/dist/components/toggle-legend.js +114 -0
  160. package/dist/components/toggle-legend.js.map +1 -0
  161. package/dist/components/tooltip.d.ts +40 -11
  162. package/dist/components/tooltip.js +77 -20
  163. package/dist/components/tooltip.js.map +1 -1
  164. package/dist/components/treemap.d.ts +132 -0
  165. package/dist/components/treemap.js +179 -0
  166. package/dist/components/treemap.js.map +1 -0
  167. package/dist/components/ui.d.ts +159 -46
  168. package/dist/components/ui.js +220 -115
  169. package/dist/components/ui.js.map +1 -1
  170. package/dist/components/use-mobile-reveal.js +1 -0
  171. package/dist/components/use-mobile-reveal.js.map +1 -1
  172. package/dist/components/use-table-state.d.ts +1 -1
  173. package/dist/components/use-table-state.js +38 -17
  174. package/dist/components/use-table-state.js.map +1 -1
  175. package/dist/components/user-avatar.d.ts +5 -3
  176. package/dist/components/user-avatar.js +4 -2
  177. package/dist/components/user-avatar.js.map +1 -1
  178. package/dist/components/wizard-stepper.js +3 -2
  179. package/dist/components/wizard-stepper.js.map +1 -1
  180. package/dist/{data-table-filters-aXyf0Xub.d.ts → data-table-filters-DM1auUfH.d.ts} +27 -5
  181. package/dist/data-table.d.ts +7 -0
  182. package/dist/data-table.js +12 -0
  183. package/dist/data-table.js.map +1 -0
  184. package/dist/feedback/feedback-attachment.js +12 -11
  185. package/dist/feedback/feedback-attachment.js.map +1 -1
  186. package/dist/feedback/feedback-dialog.js +2 -1
  187. package/dist/feedback/feedback-dialog.js.map +1 -1
  188. package/dist/feedback/feedback-inbox.d.ts +33 -7
  189. package/dist/feedback/feedback-inbox.js +49 -37
  190. package/dist/feedback/feedback-inbox.js.map +1 -1
  191. package/dist/feedback.d.ts +5 -0
  192. package/dist/feedback.js +5 -0
  193. package/dist/feedback.js.map +1 -0
  194. package/dist/hooks/use-anchored-panel.js +1 -0
  195. package/dist/hooks/use-anchored-panel.js.map +1 -1
  196. package/dist/hooks/use-anchored-rect.js +1 -0
  197. package/dist/hooks/use-anchored-rect.js.map +1 -1
  198. package/dist/hooks/use-announce.d.ts +46 -0
  199. package/dist/hooks/use-announce.js +37 -0
  200. package/dist/hooks/use-announce.js.map +1 -0
  201. package/dist/hooks/use-body-scroll-lock.js +1 -0
  202. package/dist/hooks/use-body-scroll-lock.js.map +1 -1
  203. package/dist/hooks/use-close-transition.js +1 -0
  204. package/dist/hooks/use-close-transition.js.map +1 -1
  205. package/dist/hooks/use-dismiss.js +1 -0
  206. package/dist/hooks/use-dismiss.js.map +1 -1
  207. package/dist/hooks/use-focus-trap.d.ts +16 -0
  208. package/dist/hooks/use-focus-trap.js +67 -0
  209. package/dist/hooks/use-focus-trap.js.map +1 -0
  210. package/dist/hooks/use-media-query.js +1 -0
  211. package/dist/hooks/use-media-query.js.map +1 -1
  212. package/dist/hooks/use-overlay-history.js +27 -1
  213. package/dist/hooks/use-overlay-history.js.map +1 -1
  214. package/dist/hooks/use-row-swipe.js +1 -0
  215. package/dist/hooks/use-row-swipe.js.map +1 -1
  216. package/dist/i18n/defaults.d.ts +31 -0
  217. package/dist/i18n/defaults.js +63 -0
  218. package/dist/i18n/defaults.js.map +1 -0
  219. package/dist/i18n/kit-labels.d.ts +250 -0
  220. package/dist/i18n/kit-labels.js +184 -0
  221. package/dist/i18n/kit-labels.js.map +1 -0
  222. package/dist/index.d.ts +47 -22
  223. package/dist/index.js +28 -0
  224. package/dist/index.js.map +1 -1
  225. package/dist/lib/calc.js +1 -0
  226. package/dist/lib/calc.js.map +1 -1
  227. package/dist/lib/cn.js +1 -0
  228. package/dist/lib/cn.js.map +1 -1
  229. package/dist/lib/dates.js +1 -0
  230. package/dist/lib/dates.js.map +1 -1
  231. package/dist/lib/logger.js +1 -0
  232. package/dist/lib/logger.js.map +1 -1
  233. package/dist/lib/safe-storage.js +1 -0
  234. package/dist/lib/safe-storage.js.map +1 -1
  235. package/dist/search/command-palette.d.ts +11 -1
  236. package/dist/search/command-palette.js +21 -15
  237. package/dist/search/command-palette.js.map +1 -1
  238. package/dist/search.d.ts +2 -0
  239. package/dist/search.js +3 -0
  240. package/dist/search.js.map +1 -0
  241. package/dist/shell/app-shell.d.ts +33 -4
  242. package/dist/shell/app-shell.js +275 -78
  243. package/dist/shell/app-shell.js.map +1 -1
  244. package/dist/shell/option-switcher-menu.js +3 -2
  245. package/dist/shell/option-switcher-menu.js.map +1 -1
  246. package/dist/shell/role-switcher.d.ts +6 -0
  247. package/dist/shell/role-switcher.js +7 -4
  248. package/dist/shell/role-switcher.js.map +1 -1
  249. package/dist/shell/top-bar.d.ts +15 -7
  250. package/dist/shell/top-bar.js +3 -5
  251. package/dist/shell/top-bar.js.map +1 -1
  252. package/dist/shell/topbar-action-menu.js +3 -2
  253. package/dist/shell/topbar-action-menu.js.map +1 -1
  254. package/dist/shell/topbar-controls.d.ts +5 -2
  255. package/dist/shell/topbar-controls.js +20 -15
  256. package/dist/shell/topbar-controls.js.map +1 -1
  257. package/dist/shell.d.ts +13 -0
  258. package/dist/shell.js +8 -0
  259. package/dist/shell.js.map +1 -0
  260. package/dist/theme/chart-palette.d.ts +19 -12
  261. package/dist/theme/chart-palette.js +24 -30
  262. package/dist/theme/chart-palette.js.map +1 -1
  263. package/dist/theme/color.d.ts +78 -0
  264. package/dist/theme/color.js +148 -0
  265. package/dist/theme/color.js.map +1 -0
  266. package/dist/theme/palette-derive.d.ts +140 -0
  267. package/dist/theme/palette-derive.js +376 -0
  268. package/dist/theme/palette-derive.js.map +1 -0
  269. package/dist/theme/palette-presets.d.ts +14 -1
  270. package/dist/theme/palette-presets.js +231 -3
  271. package/dist/theme/palette-presets.js.map +1 -1
  272. package/dist/theme/palette-store.js +1 -0
  273. package/dist/theme/palette-store.js.map +1 -1
  274. package/dist/theme/theme-store.js +1 -0
  275. package/dist/theme/theme-store.js.map +1 -1
  276. package/dist/tour/tour.d.ts +2 -1
  277. package/dist/tour/tour.js +15 -9
  278. package/dist/tour/tour.js.map +1 -1
  279. package/dist/tour.d.ts +2 -0
  280. package/dist/tour.js +3 -0
  281. package/dist/tour.js.map +1 -0
  282. package/dist/wizard/stepper-nav.js +10 -8
  283. package/dist/wizard/stepper-nav.js.map +1 -1
  284. package/dist/wizard/types.d.ts +18 -2
  285. package/dist/wizard/types.js +4 -1
  286. package/dist/wizard/types.js.map +1 -1
  287. package/dist/wizard/use-wizard.js +12 -4
  288. package/dist/wizard/use-wizard.js.map +1 -1
  289. package/dist/wizard/validation.js +1 -0
  290. package/dist/wizard/validation.js.map +1 -1
  291. package/dist/wizard/wizard-context.js +1 -0
  292. package/dist/wizard/wizard-context.js.map +1 -1
  293. package/dist/wizard/wizard-step.js +1 -0
  294. package/dist/wizard/wizard-step.js.map +1 -1
  295. package/dist/wizard/wizard-summary.js +5 -3
  296. package/dist/wizard/wizard-summary.js.map +1 -1
  297. package/dist/wizard.d.ts +8 -0
  298. package/dist/wizard.js +9 -0
  299. package/dist/wizard.js.map +1 -0
  300. package/package.json +66 -13
  301. package/src/chart.ts +22 -0
  302. package/src/components/account-settings.tsx +275 -0
  303. package/src/components/alert-banner.tsx +75 -0
  304. package/src/components/amount-input.tsx +471 -0
  305. package/src/components/calculator.tsx +252 -0
  306. package/src/components/chart-zoom.tsx +499 -0
  307. package/src/components/chart.tsx +363 -0
  308. package/src/components/checkbox.tsx +222 -0
  309. package/src/components/chip.tsx +570 -0
  310. package/src/components/choice-parts.ts +33 -0
  311. package/src/components/combobox-core.tsx +540 -0
  312. package/src/components/combobox.tsx +1026 -0
  313. package/src/components/currency-select.tsx +269 -0
  314. package/src/components/data-table-filter-popover.tsx +252 -0
  315. package/src/components/data-table-filters.ts +176 -0
  316. package/src/components/data-table-labels.ts +190 -0
  317. package/src/components/data-table-pagination.tsx +126 -0
  318. package/src/components/data-table-sort.ts +70 -0
  319. package/src/components/data-table.tsx +1740 -0
  320. package/src/components/date-picker.tsx +631 -0
  321. package/src/components/dropdown.tsx +341 -0
  322. package/src/components/entity-combobox.tsx +215 -0
  323. package/src/components/facing-pair.ts +86 -0
  324. package/src/components/field-sync.tsx +472 -0
  325. package/src/components/file-dropzone.tsx +181 -0
  326. package/src/components/full-bleed-dialog.tsx +179 -0
  327. package/src/components/grouped-picker.tsx +157 -0
  328. package/src/components/hover-menu.tsx +183 -0
  329. package/src/components/mini-calendar.tsx +554 -0
  330. package/src/components/modal.tsx +287 -0
  331. package/src/components/month-picker.tsx +549 -0
  332. package/src/components/multi-entity-combobox.tsx +226 -0
  333. package/src/components/multi-select.tsx +303 -0
  334. package/src/components/number-field.tsx +225 -0
  335. package/src/components/number-input.tsx +211 -0
  336. package/src/components/numpad-sheet.tsx +264 -0
  337. package/src/components/page-contents.tsx +289 -0
  338. package/src/components/password-strength.tsx +258 -0
  339. package/src/components/picker-sheet.tsx +251 -0
  340. package/src/components/popover.tsx +195 -0
  341. package/src/components/search-field.tsx +146 -0
  342. package/src/components/series-chart-labels.ts +26 -0
  343. package/src/components/series-chart.tsx +481 -0
  344. package/src/components/settings-fields.tsx +68 -0
  345. package/src/components/signature-pad.tsx +630 -0
  346. package/src/components/slider.tsx +313 -0
  347. package/src/components/sparkline.tsx +329 -0
  348. package/src/components/stat-tile.tsx +448 -0
  349. package/src/components/swipeable-row.tsx +271 -0
  350. package/src/components/switch.tsx +170 -0
  351. package/src/components/time-input.tsx +107 -0
  352. package/src/components/toggle-group.tsx +153 -0
  353. package/src/components/toggle-legend.tsx +229 -0
  354. package/src/components/tooltip.tsx +477 -0
  355. package/src/components/treemap.tsx +390 -0
  356. package/src/components/ui.tsx +1284 -0
  357. package/src/components/use-mobile-reveal.ts +54 -0
  358. package/src/components/use-table-state.ts +295 -0
  359. package/src/components/user-avatar.tsx +61 -0
  360. package/src/components/wizard-stepper.tsx +37 -0
  361. package/src/data-table.ts +19 -0
  362. package/src/feedback/feedback-attachment.tsx +246 -0
  363. package/src/feedback/feedback-dialog.tsx +209 -0
  364. package/src/feedback/feedback-inbox.tsx +615 -0
  365. package/src/feedback.ts +8 -0
  366. package/src/hooks/use-anchored-panel.ts +169 -0
  367. package/src/hooks/use-anchored-rect.ts +78 -0
  368. package/src/hooks/use-announce.ts +85 -0
  369. package/src/hooks/use-body-scroll-lock.ts +94 -0
  370. package/src/hooks/use-close-transition.ts +99 -0
  371. package/src/hooks/use-dismiss.ts +77 -0
  372. package/src/hooks/use-focus-trap.ts +160 -0
  373. package/src/hooks/use-media-query.ts +23 -0
  374. package/src/hooks/use-overlay-history.ts +345 -0
  375. package/src/hooks/use-row-swipe.ts +212 -0
  376. package/src/i18n/defaults.ts +68 -0
  377. package/src/i18n/kit-labels.tsx +447 -0
  378. package/src/index.ts +169 -0
  379. package/src/lib/calc.ts +309 -0
  380. package/src/lib/cn.ts +5 -0
  381. package/src/lib/dates.ts +187 -0
  382. package/src/lib/logger.ts +73 -0
  383. package/src/lib/safe-storage.ts +37 -0
  384. package/src/search/command-palette.tsx +342 -0
  385. package/src/search.ts +3 -0
  386. package/src/shell/app-shell.tsx +772 -0
  387. package/src/shell/option-switcher-menu.tsx +72 -0
  388. package/src/shell/role-switcher.tsx +46 -0
  389. package/src/shell/top-bar.tsx +39 -0
  390. package/src/shell/topbar-action-menu.tsx +91 -0
  391. package/src/shell/topbar-controls.tsx +229 -0
  392. package/src/shell.ts +10 -0
  393. package/src/theme/chart-palette.ts +145 -0
  394. package/src/theme/color.ts +266 -0
  395. package/src/theme/palette-derive.ts +681 -0
  396. package/src/theme/palette-presets.ts +760 -0
  397. package/src/theme/palette-store.ts +94 -0
  398. package/src/theme/theme-store.ts +145 -0
  399. package/src/tour/tour.tsx +586 -0
  400. package/src/tour.ts +3 -0
  401. package/src/vite-env.d.ts +26 -0
  402. package/src/wizard/stepper-nav.tsx +221 -0
  403. package/src/wizard/types.ts +178 -0
  404. package/src/wizard/use-wizard.ts +345 -0
  405. package/src/wizard/validation.ts +37 -0
  406. package/src/wizard/wizard-context.tsx +29 -0
  407. package/src/wizard/wizard-step.tsx +14 -0
  408. package/src/wizard/wizard-summary.tsx +69 -0
  409. package/src/wizard.ts +14 -0
  410. package/tokens.css +222 -20
@@ -0,0 +1,1284 @@
1
+ import { forwardRef, useId, useState } from "react";
2
+ import { ChevronDown, Eye, EyeOff, HelpCircle } from "lucide-react";
3
+ import type { ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
4
+ import { cn } from "../lib/cn";
5
+ import { useMediaQuery } from "../hooks/use-media-query";
6
+ import { Tooltip } from "./tooltip";
7
+ import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
8
+
9
+ export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand";
10
+
11
+ // Shared base ring for every button-styled element. Kept as a named const so the
12
+ // <Button> component and the {@link buttonClasses} helper draw from one source and
13
+ // can never drift apart.
14
+ const BUTTON_BASE =
15
+ "inline-flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
16
+
17
+ // Warm, palette-token-driven so buttons blend with the fields + cards in every theme.
18
+ // Actions default to a warm bordered look (primary = filled warm chip, secondary =
19
+ // outline); `brand` stays the solid accent for the rare strong CTA; `danger` takes the
20
+ // semantic `--danger` family (destructive semantics), so a consumer can re-point the
21
+ // destructive hue instead of inheriting a hard-coded red. Focus rings follow each
22
+ // variant's own accent: brand for the four neutral ones, danger for `danger`.
23
+ const buttonVariantClasses: Record<ButtonVariant, string> = {
24
+ primary:
25
+ "border border-[var(--border)] bg-[var(--bg-surface-2)] text-[var(--text-primary)] hover:bg-[var(--border)] focus:ring-[var(--brand)]",
26
+ secondary:
27
+ "border border-[var(--border)] bg-transparent text-[var(--text-primary)] hover:bg-[var(--bg-surface-2)] focus:ring-[var(--brand)]",
28
+ ghost:
29
+ "bg-transparent text-[var(--text-primary)] hover:bg-[var(--bg-surface-2)] focus:ring-[var(--brand)]",
30
+ danger:
31
+ "bg-[var(--danger)] text-[var(--danger-contrast)] hover:bg-[var(--danger-hover)] focus:ring-[var(--danger-border)]",
32
+ brand:
33
+ "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)] focus:ring-[var(--brand)]",
34
+ };
35
+
36
+ /**
37
+ * Button classes for the rare case where the styling must land on a non-`<button>`
38
+ * element that {@link Button} can't render — e.g. a router `<Link>` or a Radix
39
+ * AlertDialog Action/Cancel (which must stay the Radix element). Everywhere a real
40
+ * button works, prefer `<Button>`. Draws from the same base + variant maps as
41
+ * `<Button>`, so the two stay in lockstep.
42
+ */
43
+ export function buttonClasses(variant: ButtonVariant = "primary", className?: string): string {
44
+ return cn(BUTTON_BASE, buttonVariantClasses[variant], className);
45
+ }
46
+
47
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
48
+ variant?: ButtonVariant;
49
+ /** In a flex row next to a taller labelled field, fill the field's height so the
50
+ * two line up. No effect outside a flex row. */
51
+ stretch?: boolean;
52
+ }
53
+
54
+ export function Button({ variant = "primary", stretch, className, ...rest }: ButtonProps) {
55
+ return (
56
+ <button
57
+ {...rest}
58
+ className={cn(
59
+ BUTTON_BASE,
60
+ // In a flex row next to a taller labelled field, `stretch` makes the button
61
+ // fill the field's height so the two line up (self-stretch overrides the row's
62
+ // align-items). No effect outside a flex row / when it's already the tallest.
63
+ stretch && "self-stretch",
64
+ buttonVariantClasses[variant],
65
+ className,
66
+ )}
67
+ />
68
+ );
69
+ }
70
+
71
+ // Canonical square icon-only button. <Button> carries TEXT geometry — px-3 py-2
72
+ // gap-2 — so rendering a bare glyph through it gives a small icon in a wide,
73
+ // text-shaped box. This fixes a square box instead and, crucially, forces the
74
+ // child icon to 20px via `[&_svg]:size-5` so a caller cannot under-size it. Use
75
+ // it wherever an action is a bare icon (edit/delete/tools) so they all match the
76
+ // top-bar icon buttons and can never drift apart again.
77
+ //
78
+ // Draws its colours from the same `buttonVariantClasses` map as <Button>, so the
79
+ // two re-skin together with the palette.
80
+ const ICON_BUTTON_BASE =
81
+ "inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:size-5";
82
+
83
+ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
84
+ variant?: ButtonVariant;
85
+ /** Box size: md = 36px (matches the top bar), sm = 32px. The icon stays 20px. */
86
+ size?: "sm" | "md";
87
+ }
88
+
89
+ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
90
+ { variant = "ghost", size = "md", className, ...rest },
91
+ ref,
92
+ ) {
93
+ return (
94
+ <button
95
+ ref={ref}
96
+ {...rest}
97
+ className={cn(
98
+ ICON_BUTTON_BASE,
99
+ size === "sm" ? "size-8" : "size-9",
100
+ buttonVariantClasses[variant],
101
+ className,
102
+ )}
103
+ />
104
+ );
105
+ });
106
+ IconButton.displayName = "IconButton";
107
+
108
+ export const FIELD_BASE =
109
+ "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)] " +
110
+ // A field the user cannot change has to LOOK settled. Without this, `disabled`
111
+ // dimmed the floating label and nothing else — FIELD_BASE's own
112
+ // `text-[var(--text-primary)]` overrides the browser's grey — so a read-only value
113
+ // sat there at full body-text strength, indistinguishable from one you could retype
114
+ // (Keksdose dev#455/#474).
115
+ "disabled:cursor-default disabled:bg-[var(--bg-surface-2)] disabled:text-[var(--text-muted)] " +
116
+ // Same for a field that takes focus but refuses keys: `readOnly` is not
117
+ // `disabled`, and a value that cannot be edited should not claim it can be. Opt
118
+ // OUT with {@link FIELD_WRITABLE_LOOK} in the one case where `readOnly` does not
119
+ // mean that — see the two-factor code field, which uses it to block an autofill
120
+ // and drops it the moment you focus the field.
121
+ //
122
+ // ⚠️ The ATTRIBUTE, never the `:read-only` pseudo-class (Keksdose dev#477).
123
+ // `:read-only` does not mean "was marked readonly" — per Selectors 4 it matches
124
+ // everything that is not `:read-write`, and only editable inputs/textareas and
125
+ // contenteditable elements are `:read-write`. So `read-only:` matched every
126
+ // `<select>` in the app and every field-styled `<button>` built on this base —
127
+ // the native selects, the date trigger, the entity/currency/multi pickers — and
128
+ // painted them all in the "you may not edit this" grey. On one transaction form
129
+ // that made three different-looking families out of one field style: plain
130
+ // inputs, grey selects, grey trigger buttons.
131
+ "[&[readonly]]:bg-[var(--bg-surface-2)] [&[readonly]]:text-[var(--text-muted)]";
132
+
133
+ /** Cancels FIELD_BASE's read-only treatment for a field that is `readOnly` for a
134
+ * reason other than "you may not edit this". Attribute-scoped for the same reason
135
+ * FIELD_BASE is — the two have to cancel on the identical selector or twMerge
136
+ * cannot make the later one win. */
137
+ export const FIELD_WRITABLE_LOOK =
138
+ "[&[readonly]]:bg-[var(--bg-surface)] [&[readonly]]:text-[var(--text-primary)]";
139
+
140
+ // Extra top padding leaves room for a label that floats INSIDE the field (the
141
+ // "filled" pattern) — the label sits in the top strip, the value below it. Used by
142
+ // every labelled field (native + custom-dropdown triggers). twMerge lets pt/pb win
143
+ // over FIELD_BASE's py-2.
144
+ export const FIELD_FLOATING_PAD = "pt-4 pb-1";
145
+
146
+ // Error/required highlight for a field that is missing a value — a `--danger`
147
+ // border and matching focus ring so the control itself shows what's wrong, not just
148
+ // a note beside it (feedback #235). Layered after FIELD_BASE so twMerge wins.
149
+ //
150
+ // The `ring-1` is not decoration; it is what makes the highlight SURVIVE display
151
+ // scaling (Keksdose live #295 — *"Account select boundary. It is not highlighted on
152
+ // the sides."*). At 125%, the browser's usual setting on a 2560px screen, a 1px CSS
153
+ // border is 1.25 device pixels: the horizontal edges land on whole rows and paint
154
+ // solid, while one of the two VERTICAL edges lands across a pixel boundary and is
155
+ // spread over two columns at partial coverage. Measured on the receipt's required
156
+ // account field, dark theme, back when this was a hard-coded rose (full =
157
+ // rgb(208,30,78)): left/top/bottom all 208, right 160 then 115 — the side that is
158
+ // supposed to shout, at 55–77% of the others. A border plus a ring is 2 CSS px, so
159
+ // whatever the fraction there is always one fully covered device pixel on every side
160
+ // (re-measured: worst side 241). It is the GEOMETRY that carries that, not the hue,
161
+ // so the fix survives a consumer re-pointing `--danger-border`.
162
+ //
163
+ // A ring rather than `border-2`: a box-shadow adds no layout, so an invalid field
164
+ // stays exactly the size of a valid one and nothing beside it moves when the value
165
+ // arrives.
166
+ export const FIELD_INVALID =
167
+ "border-[var(--danger-border)] ring-1 ring-[var(--danger-border)] focus:border-[var(--danger)] focus:ring-[var(--danger)]";
168
+
169
+ export const FLOATING_INPUT_CLASS = cn(FIELD_BASE, FIELD_FLOATING_PAD, "peer placeholder:text-transparent");
170
+
171
+ /** The phone breakpoint the display treatment below keys off — the same one the
172
+ * numpad sheet uses, kept in one place so the two can't drift apart. */
173
+ export const PHONE_QUERY = "(max-width: 767px)";
174
+
175
+ /**
176
+ * The PHONE display treatment (Keksdose feedback #176, carried across the entry
177
+ * forms by #179): the field chrome removed so the ONE input a form is actually
178
+ * about reads as the thing itself, not as another boxed row in a stack.
179
+ *
180
+ * Every control that takes `variant="display"` — {@link Input}, `NumberInput`,
181
+ * `AmountInput` — means exactly the same thing by it: the treatment applies below
182
+ * {@link PHONE_QUERY} and the field is untouched above it, so a caller never has
183
+ * to ask the viewport, and a form can't end up half-treated across breakpoints.
184
+ *
185
+ * What stays, deliberately:
186
+ * - A hairline baseline. With the box gone something still has to say "you can
187
+ * type here"; it takes the brand colour on focus, where a bordered field would
188
+ * light its whole outline. Each edge is set exactly once (`border-x-0
189
+ * border-t-0 border-b`) so the rule can't hinge on utility order.
190
+ * - The muted placeholder colour, because a display field is usually empty at
191
+ * the moment it matters most and has nothing else to show.
192
+ * - The label, moved to `sr-only` rather than dropped: display type is legible
193
+ * to the eye, not to a screen reader.
194
+ *
195
+ * Size and weight are NOT here — an amount wants display type, a subject line
196
+ * wants a heading — so each control adds its own on top.
197
+ */
198
+ export const FIELD_DISPLAY =
199
+ "block w-full border-x-0 border-t-0 border-b border-[var(--border)] bg-transparent px-0 pt-0 pb-1 text-[var(--text-primary)] shadow-none placeholder:text-[var(--text-placeholder)] focus:border-[var(--brand)] focus:outline-none focus:ring-0 disabled:opacity-60";
200
+
201
+ // A field-styled button trigger for the custom dropdown controls (MultiSelect,
202
+ // CurrencySelect) — the field look (border/bg) as a flex row for the value + chevron,
203
+ // so they don't hand-copy the field classes. Add FIELD_FLOATING_PAD only when the
204
+ // trigger carries a label (unlabelled triggers stay normal height to match buttons /
205
+ // adjacent controls). Pair the labelled case with a static FieldLabel.
206
+ // `relative` so the trigger can host an absolutely-centred FieldChevron / clear
207
+ // button the way the native Select does.
208
+ export const FIELD_TRIGGER = cn(
209
+ FIELD_BASE,
210
+ "relative flex items-center justify-between gap-2 text-left hover:bg-[var(--bg-hover)]",
211
+ );
212
+
213
+ /** An alias rather than an interface: the chevron adds nothing of its own to an
214
+ * `<svg>`'s props, and an interface declaring no members is the same type wearing a
215
+ * name that suggests otherwise. */
216
+ export type FieldChevronProps = Omit<ComponentPropsWithoutRef<"svg">, "children">;
217
+
218
+ /** The dropdown chevron, shared by the native {@link Select} and every custom
219
+ * {@link FIELD_TRIGGER} control.
220
+ *
221
+ * Absolutely positioned and centred on the FIELD box. As an ordinary flex child
222
+ * it centres on the *content* box instead, and `FIELD_FLOATING_PAD` (pt-4 pb-1)
223
+ * pushes that box's midline down — so a labelled currency/multi-select chevron
224
+ * sat visibly lower than the native select's right beside it (feedback #400).
225
+ * Pair it with `pr-9` on the trigger so the value can't run underneath. */
226
+ export function FieldChevron({ className, ...rest }: FieldChevronProps) {
227
+ return (
228
+ <ChevronDown
229
+ {...rest}
230
+ // After the spread: the chevron is decoration beside a control that already has
231
+ // a name, and an `aria-hidden` a caller could switch off by accident is a second
232
+ // announcement of the same field.
233
+ aria-hidden
234
+ className={cn(
235
+ "pointer-events-none absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
236
+ className,
237
+ )}
238
+ />
239
+ );
240
+ }
241
+
242
+ // Animated label that starts centred (as a placeholder) in an empty field and
243
+ // floats up INSIDE the top strip on focus or once the field has a value. Sits on
244
+ // the field's own surface, so no background chip and nothing to mismatch the card.
245
+ export const FLOATING_LABEL_CLASS = cn(
246
+ "pointer-events-none absolute left-3 top-2.5 text-sm text-[var(--text-placeholder)] transition-all",
247
+ "max-w-[calc(100%-1.5rem)] truncate",
248
+ "peer-focus:top-1 peer-focus:text-[11px] peer-focus:leading-tight peer-focus:text-[var(--text-secondary)]",
249
+ "peer-[:not(:placeholder-shown)]:top-1 peer-[:not(:placeholder-shown)]:text-[11px] peer-[:not(:placeholder-shown)]:leading-tight peer-[:not(:placeholder-shown)]:text-[var(--text-secondary)]",
250
+ "peer-disabled:opacity-50",
251
+ );
252
+
253
+ // The animated label again, as a ROW that the label and its "?" share — same
254
+ // placement, same float, same type, but with the type on the row so the label
255
+ // INHERITS it. It has to be that way round: `peer-focus:` compiles to a sibling
256
+ // selector, so the classes have to sit on the element that is actually a sibling
257
+ // of the input, and a label nested inside a wrapper is not one.
258
+ //
259
+ // Wider right clearance than the label alone takes (`3rem` rather than `1.5rem`),
260
+ // because the controls that carry an animated label are the ones with something
261
+ // at the right edge of the field — NumberInput's calculator is the case this was
262
+ // written for. The label truncates a little sooner; the alternative was the "?"
263
+ // sitting on top of a button (steering-design feedback #48).
264
+ const FLOATING_ROW_CLASS = cn(
265
+ "pointer-events-none absolute left-3 top-2.5 flex items-center gap-1 transition-all",
266
+ "max-w-[calc(100%-3rem)] text-sm text-[var(--text-placeholder)]",
267
+ "peer-focus:top-1 peer-focus:text-[11px] peer-focus:leading-tight peer-focus:text-[var(--text-secondary)]",
268
+ "peer-[:not(:placeholder-shown)]:top-1 peer-[:not(:placeholder-shown)]:text-[11px] peer-[:not(:placeholder-shown)]:leading-tight peer-[:not(:placeholder-shown)]:text-[var(--text-secondary)]",
269
+ "peer-disabled:opacity-50",
270
+ );
271
+
272
+ // The TYPE of the small static label, without any placement. Split out so the
273
+ // label and anything sharing its line (see `hint` on {@link FloatingField}) are
274
+ // laid out by one flex row instead of by two absolute offsets guessing at the
275
+ // same baseline — which is what put dev#468's "?" three pixels above the word it
276
+ // belongs to.
277
+ const STATIC_LABEL_TYPE =
278
+ "text-[11px] leading-tight text-[var(--text-muted)] peer-disabled:opacity-50";
279
+
280
+ // A field that always has a value (select / dropdown trigger) keeps the label
281
+ // permanently in the floated position — small, in the top strip, value below.
282
+ export const FLOATING_LABEL_STATIC = cn(
283
+ "pointer-events-none absolute left-3 top-1",
284
+ STATIC_LABEL_TYPE,
285
+ "max-w-[calc(100%-1.5rem)] truncate",
286
+ );
287
+
288
+ /**
289
+ * The one place that assembles a labelled field: a `relative` wrapper around the
290
+ * control (`children`) plus a floating label inside the top strip. Pass `staticLabel`
291
+ * for controls that always have a value (selects); omit it for free-text fields whose
292
+ * label animates from centred→up. Used by Input/Select/Textarea/NumberInput; the
293
+ * dropdown controls that need a ref + menu keep their own wrapper but the same label
294
+ * (via {@link FieldLabel}) and trigger ({@link FIELD_TRIGGER}) styles.
295
+ */
296
+ export interface FloatingFieldProps extends ComponentPropsWithoutRef<"div"> {
297
+ htmlFor?: string;
298
+ label?: ReactNode;
299
+ staticLabel?: boolean;
300
+ /** Keep the label in the accessibility tree but out of the layout — what
301
+ * {@link FIELD_DISPLAY} needs, since a floating label inside a field with no
302
+ * field left would have nothing to float in. */
303
+ srOnlyLabel?: boolean;
304
+ /** Something interactive that belongs to the LABEL rather than to the value —
305
+ * in practice a {@link FieldHint} "?" (dev#468). It is rendered in a flex row
306
+ * with the label, so it is centred on the label's line by the layout instead
307
+ * of by a hand-tuned `top-…`, and it can never drift when the type changes.
308
+ *
309
+ * It rides an ANIMATED label too, and the two cases place it differently on
310
+ * purpose. A static label has a field-wide strip to itself, so the hint sits
311
+ * at the far end of it and a long label truncates into the gap. An animated
312
+ * one belongs to a control with something at the right edge of the field — a
313
+ * calculator, a stepper — so the hint follows the label instead, and it is
314
+ * the label that gives way (steering-design feedback #48). */
315
+ hint?: ReactNode;
316
+ children: ReactNode;
317
+ }
318
+
319
+ export function FloatingField({
320
+ className,
321
+ htmlFor,
322
+ label,
323
+ staticLabel,
324
+ srOnlyLabel,
325
+ hint,
326
+ children,
327
+ ...rest
328
+ }: FloatingFieldProps) {
329
+ const withHint = hint !== undefined && !srOnlyLabel;
330
+ const atEnd = withHint && staticLabel;
331
+ const labelEl = label !== undefined && (
332
+ <label
333
+ htmlFor={htmlFor}
334
+ className={
335
+ srOnlyLabel
336
+ ? "sr-only"
337
+ : atEnd
338
+ ? cn("pointer-events-none min-w-0 truncate", STATIC_LABEL_TYPE)
339
+ : withHint
340
+ ? "pointer-events-none min-w-0 truncate"
341
+ : staticLabel
342
+ ? FLOATING_LABEL_STATIC
343
+ : FLOATING_LABEL_CLASS
344
+ }
345
+ >
346
+ {label}
347
+ </label>
348
+ );
349
+ return (
350
+ // `relative` is the whole contract of this wrapper — the floating label and every
351
+ // control that hangs off it (a reveal toggle, a chevron) are positioned against
352
+ // this box — so it is merged through `cn` after the spread rather than left where
353
+ // a caller's stray `className` or `style` could unset it.
354
+ <div {...rest} className={cn("relative", className)}>
355
+ {children}
356
+ {withHint ? (
357
+ // Static: `inset-x-3` rather than `left-3`, so a long label truncates at
358
+ // the field's own right padding instead of running under the chevron.
359
+ // Animated: the row floats with the label and is only as wide as it needs
360
+ // to be. Either way the hint keeps its width (`shrink-0`) and the label
361
+ // is the one that gives way.
362
+ <div
363
+ className={
364
+ atEnd
365
+ ? "pointer-events-none absolute inset-x-3 top-1 flex items-center gap-1"
366
+ : FLOATING_ROW_CLASS
367
+ }
368
+ >
369
+ {labelEl}
370
+ <span className="pointer-events-auto flex shrink-0 items-center">{hint}</span>
371
+ </div>
372
+ ) : (
373
+ labelEl
374
+ )}
375
+ </div>
376
+ );
377
+ }
378
+
379
+ /**
380
+ * The "?" that explains a field, on the field's own label line (dev#468).
381
+ *
382
+ * Pass it to a labelled {@link Select} / {@link Input} as `hint`. It exists as a
383
+ * component rather than as a snippet each form repeats because the previous
384
+ * version was exactly that snippet — an absolutely-positioned button whose
385
+ * `top-1.5` was one guess at where an 11px label sits — and the reporter's
386
+ * follow-up was *"question mark is not centered. Is it part of the hoc?
387
+ * positioning problems seem quite frequently."* It was not part of the HOC. Now
388
+ * it is, and there is one place left where the answer can be wrong.
389
+ *
390
+ * A `<button>` rather than a bare icon: hover alone puts the explanation out of
391
+ * reach of a keyboard and of every touch device, and the tooltip shows on focus
392
+ * too. The text is also its accessible name, so a screen reader gets it without
393
+ * the bubble ever opening.
394
+ */
395
+ export interface FieldHintProps extends Omit<ComponentPropsWithoutRef<"button">, "children"> {
396
+ /** The explanation. It is both the tooltip's text and, by default, the button's
397
+ * accessible name, so a screen reader gets it without the bubble ever opening. */
398
+ label: string;
399
+ side?: "left" | "right" | "top" | "bottom";
400
+ }
401
+
402
+ export function FieldHint({
403
+ label,
404
+ side = "left",
405
+ className,
406
+ "aria-label": ariaLabel,
407
+ ...rest
408
+ }: FieldHintProps) {
409
+ return (
410
+ <Tooltip label={label} side={side} portal>
411
+ <button
412
+ {...rest}
413
+ // `type` after the spread, not before. These render inside forms — that is the
414
+ // only place a field has a label line — and a hint that defaulted to `submit`
415
+ // because a caller spread a props object at it would save the form on a click
416
+ // meant to explain a field.
417
+ type="button"
418
+ // The explanation names the button unless the caller says otherwise; passing
419
+ // `aria-label` is how you shorten it for a screen reader without shortening
420
+ // what the bubble shows.
421
+ aria-label={ariaLabel ?? label}
422
+ // Nothing to activate: the tooltip opens on hover and on focus, and a
423
+ // click that did something as well would be a second, undiscoverable
424
+ // behaviour on the same target.
425
+ onClick={(e) => e.preventDefault()}
426
+ className={cn(
427
+ "flex text-[var(--text-placeholder)] transition-colors hover:text-[var(--text-secondary)]",
428
+ className,
429
+ )}
430
+ >
431
+ <HelpCircle className="size-3.5" />
432
+ </button>
433
+ </Tooltip>
434
+ );
435
+ }
436
+
437
+ /**
438
+ * The floating label for a custom-dropdown trigger (a `<span>`, since the trigger is a
439
+ * button not a labelable input). Same placement as {@link FloatingField}'s static label,
440
+ * so every labelled field lines up. Render inside a `relative` wrapper, before the trigger.
441
+ */
442
+ export interface FieldLabelProps extends ComponentPropsWithoutRef<"span"> {
443
+ children: ReactNode;
444
+ }
445
+
446
+ export function FieldLabel({ children, className, ...rest }: FieldLabelProps) {
447
+ return (
448
+ <span {...rest} className={cn(FLOATING_LABEL_STATIC, "z-10", className)}>
449
+ {children}
450
+ </span>
451
+ );
452
+ }
453
+
454
+ /**
455
+ * The message under a field that is wrong, and the wiring that attaches it.
456
+ *
457
+ * `invalid` paints the field ({@link FIELD_INVALID}) and sets `aria-invalid`, and
458
+ * there it stopped: a field that announces "invalid" and nothing else has told a
459
+ * screen-reader user only that they are stuck. The message was always on screen —
460
+ * a sibling `<p>` beside the field — and never in the accessibility tree, because
461
+ * attaching it needs an id on the message and an `aria-describedby` on the control,
462
+ * and no caller was going to mint one by hand for every field on a form.
463
+ *
464
+ * Two rules the wiring has to keep:
465
+ *
466
+ * - **Merge, never replace.** A field may already point at a hint ("at least twelve
467
+ * characters"). Overwriting that reference to say the field is wrong trades one
468
+ * half of the answer for the other — and it keeps the half the user has already
469
+ * read. The hint stays first: it is the standing advice, the error is the news.
470
+ * - **`invalid` keeps working alone**, for the forms whose message lives somewhere
471
+ * else entirely (a summary at the top of a dialog). There is nothing to point at
472
+ * then, and a dangling id describes the field as nothing at all.
473
+ *
474
+ * Deliberately NOT `role="alert"`. `aria-describedby` is read when focus reaches the
475
+ * control, which is where a field's own error is wanted; an alert would also interrupt
476
+ * whatever is being read at the time, on every keystroke of a form that re-validates
477
+ * as you type.
478
+ */
479
+ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
480
+
481
+ function useFieldError(
482
+ error: ReactNode,
483
+ invalid: boolean | undefined,
484
+ describedBy: string | undefined,
485
+ ) {
486
+ const errorId = useId();
487
+ // `null`, `false` and `""` are what a caller's `touched && errors.iban` evaluates to
488
+ // on the happy path. None of them is a message, and pointing the control at one
489
+ // would describe it with an empty node.
490
+ const hasError = error !== undefined && error !== null && error !== false && error !== "";
491
+ return {
492
+ /** A field carrying a message that says what is wrong with it IS wrong. */
493
+ isInvalid: Boolean(invalid) || hasError,
494
+ describedBy: hasError ? (describedBy ? `${describedBy} ${errorId}` : errorId) : describedBy,
495
+ errorEl: hasError ? (
496
+ <p id={errorId} className={FIELD_ERROR_CLASS}>
497
+ {error}
498
+ </p>
499
+ ) : null,
500
+ };
501
+ }
502
+
503
+ /**
504
+ * A field and its message as one box.
505
+ *
506
+ * The message cannot go INSIDE the field's own `relative` box. The password reveal
507
+ * toggle is `inset-y-0` and the select chevron is `top-1/2`, so both centre on
508
+ * whatever that box contains — put two lines of message in it and the chevron drifts
509
+ * down out of the field, between the value and the text. So the field keeps its box
510
+ * and this wraps the pair.
511
+ *
512
+ * It renders nothing of its own when there is no message, which is what keeps `error`
513
+ * additive: a field without one is exactly the DOM it was before the prop existed,
514
+ * down to the bare `<input>` an unlabelled {@link Input} drops straight into a
515
+ * caller's flex row. For the same reason `className` is NOT moved out here — it goes
516
+ * on the field, as it always has, so adding a message cannot silently change what
517
+ * that prop styles.
518
+ */
519
+ function FieldGroup({ errorEl, children }: { errorEl: ReactNode; children: ReactNode }) {
520
+ if (errorEl === null) return <>{children}</>;
521
+ return (
522
+ <div>
523
+ {children}
524
+ {errorEl}
525
+ </div>
526
+ );
527
+ }
528
+
529
+ /** The two names the password reveal toggle can wear. See {@link Input}. */
530
+ export interface PasswordRevealLabels {
531
+ /** While the value is hidden — activating the toggle will show it. */
532
+ show: string;
533
+ /** While the value is shown. */
534
+ hide: string;
535
+ }
536
+
537
+ export const DEFAULT_PASSWORD_REVEAL_LABELS: PasswordRevealLabels = {
538
+ show: "Show password",
539
+ hide: "Hide password",
540
+ };
541
+
542
+ /** Caller's labels over the English defaults — the same shape as
543
+ * `resolveDataTableLabels`, so a consumer translates every kit string one way. */
544
+ export function resolvePasswordRevealLabels(
545
+ partial?: Partial<PasswordRevealLabels>,
546
+ ): PasswordRevealLabels {
547
+ if (!partial) return DEFAULT_PASSWORD_REVEAL_LABELS;
548
+ return { ...DEFAULT_PASSWORD_REVEAL_LABELS, ...partial };
549
+ }
550
+
551
+ // Native date/time inputs only reveal the calendar via the tiny trailing icon;
552
+ // open the picker on a click anywhere in the field instead (feedback #224).
553
+ const PICKER_TYPES = new Set(["date", "datetime-local", "month", "time", "week"]);
554
+
555
+ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
556
+ label?: ReactNode;
557
+ /** Classes for the `<input>` itself, as distinct from `className`, which
558
+ * styles the field WRAPPER once a `label` turns this into a FloatingField.
559
+ * Without it a labelled Input had no way to reach its own element — so
560
+ * `tabular-nums` on a numeric text field, which NumberInput has supported
561
+ * all along through the identically named prop, was simply unavailable. */
562
+ inputClassName?: string;
563
+ /** {@link FIELD_DISPLAY} — on a phone, drop the chrome and set the value as a
564
+ * heading. For the one field a form is about (a feedback subject, an account
565
+ * name), never for a stack of them. Labelled fields only: the label is what
566
+ * the placeholder falls back to once it goes `sr-only`. */
567
+ variant?: "field" | "display";
568
+ /** The field is required and unanswered, or holds something that cannot be
569
+ * saved — {@link FIELD_INVALID}, the same rose border/ring `Select` has worn
570
+ * since feedback #235.
571
+ *
572
+ * It exists here because writing `aria-invalid` by hand did NOT do this. The
573
+ * attribute spreads onto the element and nothing styles it — there is no
574
+ * `[aria-invalid]` rule in this package or in either consumer's stylesheet —
575
+ * so three Keksdose dialogs flagged a mismatched passphrase to a screen reader
576
+ * and painted the field exactly as if it were fine. Setting the prop sets the
577
+ * attribute too, so the two can no longer be spelled separately. */
578
+ invalid?: boolean;
579
+ /** What is wrong with the value, in the caller's own words ("That IBAN has 21
580
+ * digits"). Rendered under the field, pointed at by the control's
581
+ * `aria-describedby` — MERGED with any the caller already passed — and implies
582
+ * `invalid`, so the field paints as well as announces. `invalid` alone still
583
+ * covers the case where the message lives elsewhere. See {@link useFieldError}. */
584
+ error?: ReactNode;
585
+ /** Names for the password reveal toggle, English by default — it is the one
586
+ * string this component renders on its own behalf, and a German form was
587
+ * reading it out in English. See {@link PasswordRevealLabels}. */
588
+ passwordLabels?: Partial<PasswordRevealLabels>;
589
+ }
590
+
591
+ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
592
+ {
593
+ className,
594
+ inputClassName,
595
+ label,
596
+ id,
597
+ placeholder,
598
+ type,
599
+ variant = "field",
600
+ invalid,
601
+ error,
602
+ passwordLabels,
603
+ ...rest
604
+ },
605
+ ref,
606
+ ) {
607
+ const generated = useId();
608
+ const fieldId = id ?? generated;
609
+ const { isInvalid, describedBy, errorEl } = useFieldError(
610
+ error,
611
+ invalid,
612
+ rest["aria-describedby"],
613
+ );
614
+ const asDisplay = useMediaQuery(PHONE_QUERY, false) && variant === "display";
615
+ // Password fields get a reveal toggle so users can check what they typed.
616
+ const isPassword = type === "password";
617
+ const [revealed, setRevealed] = useState(false);
618
+ const effectiveType = isPassword && revealed ? "text" : type;
619
+ const handleClick = PICKER_TYPES.has(type ?? "")
620
+ ? (e: MouseEvent<HTMLInputElement>) => {
621
+ rest.onClick?.(e);
622
+ try {
623
+ // showPicker throws if unsupported or not user-activated — a click is
624
+ // a valid activation, so this is safe; guard for older browsers.
625
+ (e.currentTarget as HTMLInputElement & { showPicker?: () => void }).showPicker?.();
626
+ } catch {
627
+ /* ignore */
628
+ }
629
+ }
630
+ : rest.onClick;
631
+ const passwordText = useKitLabels("passwordReveal", DEFAULT_PASSWORD_REVEAL_LABELS, passwordLabels);
632
+ const revealToggle = isPassword ? (
633
+ <button
634
+ type="button"
635
+ // `tabIndex={-1}` sat here, which made this a painted, clickable control that
636
+ // Tab stepped straight over — on the one field whose value cannot be checked by
637
+ // looking at it. There is no mouse-only case for a reveal toggle; the people who
638
+ // cannot see what they typed are exactly who it is for. It is a tab stop now,
639
+ // and it needs a focus ring of its own, because FIELD_BASE's ring belongs to the
640
+ // input underneath and stays put while focus moves onto the button on top of it.
641
+ onClick={() => setRevealed((v) => !v)}
642
+ // …and since it is a tab stop, a field the user may not edit must not hand the
643
+ // keyboard a control that shows what is in it.
644
+ disabled={rest.disabled}
645
+ aria-label={revealed ? passwordText.hide : passwordText.show}
646
+ aria-pressed={revealed}
647
+ className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2.5 text-[var(--text-placeholder)] transition-colors hover:text-[var(--text-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--brand)] disabled:cursor-default disabled:opacity-50"
648
+ >
649
+ {revealed ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
650
+ </button>
651
+ ) : null;
652
+ if (label === undefined) {
653
+ if (!isPassword) {
654
+ return (
655
+ <FieldGroup errorEl={errorEl}>
656
+ <input
657
+ ref={ref}
658
+ id={id}
659
+ type={type}
660
+ placeholder={placeholder}
661
+ {...rest}
662
+ // AFTER the spread, so the prop wins — but OR-ed with whatever the spread
663
+ // carried, or setting `invalid` would have quietly deleted a caller's own
664
+ // `aria-invalid`. The prop is the one that also paints; a bare attribute
665
+ // still announces, which is all it ever did.
666
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
667
+ // Likewise merged rather than replaced — see {@link useFieldError}.
668
+ aria-describedby={describedBy}
669
+ onClick={handleClick}
670
+ className={cn(FIELD_BASE, className, inputClassName, isInvalid && FIELD_INVALID)}
671
+ />
672
+ </FieldGroup>
673
+ );
674
+ }
675
+ return (
676
+ <FieldGroup errorEl={errorEl}>
677
+ <div className={cn("relative", className)}>
678
+ <input
679
+ ref={ref}
680
+ id={id}
681
+ type={effectiveType}
682
+ placeholder={placeholder}
683
+ {...rest}
684
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
685
+ aria-describedby={describedBy}
686
+ className={cn(FIELD_BASE, "pr-9", inputClassName, isInvalid && FIELD_INVALID)}
687
+ />
688
+ {revealToggle}
689
+ </div>
690
+ </FieldGroup>
691
+ );
692
+ }
693
+ return (
694
+ <FieldGroup errorEl={errorEl}>
695
+ <FloatingField className={className} htmlFor={fieldId} label={label} srOnlyLabel={asDisplay}>
696
+ <input
697
+ ref={ref}
698
+ id={fieldId}
699
+ type={effectiveType}
700
+ // A labelled field's placeholder is normally a single space, feeding the
701
+ // floating label's peer-placeholder-shown trick. With the label sr-only
702
+ // there is no float left to drive, and an empty borderless line would say
703
+ // nothing at all — so the label text becomes the placeholder.
704
+ placeholder={asDisplay ? (placeholder ?? (typeof label === "string" ? label : " ")) : " "}
705
+ {...rest}
706
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
707
+ aria-describedby={describedBy}
708
+ onClick={handleClick}
709
+ className={cn(
710
+ asDisplay
711
+ ? cn(FIELD_DISPLAY, "text-xl font-semibold leading-snug")
712
+ : FLOATING_INPUT_CLASS,
713
+ isPassword && "pr-9",
714
+ inputClassName,
715
+ isInvalid && FIELD_INVALID,
716
+ )}
717
+ />
718
+ {revealToggle}
719
+ </FloatingField>
720
+ </FieldGroup>
721
+ );
722
+ });
723
+ Input.displayName = "Input";
724
+
725
+ export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
726
+ label?: ReactNode;
727
+ /** See {@link Input}'s `invalid`. */
728
+ invalid?: boolean;
729
+ /** See {@link Input}'s `error`. */
730
+ error?: ReactNode;
731
+ /** A {@link FieldHint} for the label line — see {@link FloatingField}. */
732
+ hint?: ReactNode;
733
+ }
734
+
735
+ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
736
+ { className, label, id, children, invalid, error, hint, ...rest },
737
+ ref,
738
+ ) {
739
+ const generated = useId();
740
+ const fieldId = id ?? generated;
741
+ const { isInvalid, describedBy, errorEl } = useFieldError(
742
+ error,
743
+ invalid,
744
+ rest["aria-describedby"],
745
+ );
746
+ // Custom chevron (native arrow hidden via appearance-none) so it sits a touch
747
+ // in from the right border and matches both themes — feedback #223. A DISABLED
748
+ // select has no menu to drop, so it drops the chevron too: the arrow is the one
749
+ // thing on the control that promises a choice (Keksdose dev#474, where the
750
+ // account type became read-only and still looked exactly like a picker).
751
+ const chevron = rest.disabled ? null : <FieldChevron />;
752
+ if (label === undefined) {
753
+ return (
754
+ <FieldGroup errorEl={errorEl}>
755
+ <div className={cn("relative", className)}>
756
+ <select
757
+ ref={ref}
758
+ // `id` is destructured out of the props to feed `fieldId`, and this branch
759
+ // never put it back — so an UNLABELLED Select swallowed it and the
760
+ // consumer's own `<label for="…">` pointed at nothing. The control stayed
761
+ // in the tab order with no accessible name at all: reachable, and silent
762
+ // when it was reached. Input and Textarea both forward it here; this is
763
+ // the third one doing the same thing.
764
+ id={id}
765
+ {...rest}
766
+ // OR-ed with the spread for the reason spelled out on Input's copy: this
767
+ // branch wrote `invalid || undefined`, so passing `aria-invalid` by hand
768
+ // to a Select — which is what a caller does when the validity is
769
+ // `"grammar"` or `"spelling"`, or when the paint is not wanted — had the
770
+ // attribute silently dropped. Input has never done that.
771
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
772
+ aria-describedby={describedBy}
773
+ className={cn(FIELD_BASE, "appearance-none pr-9", isInvalid && FIELD_INVALID)}
774
+ >
775
+ {children}
776
+ </select>
777
+ {chevron}
778
+ </div>
779
+ </FieldGroup>
780
+ );
781
+ }
782
+ return (
783
+ <FieldGroup errorEl={errorEl}>
784
+ <FloatingField className={className} htmlFor={fieldId} label={label} staticLabel hint={hint}>
785
+ <select
786
+ ref={ref}
787
+ id={fieldId}
788
+ {...rest}
789
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
790
+ aria-describedby={describedBy}
791
+ className={cn(
792
+ FIELD_BASE,
793
+ FIELD_FLOATING_PAD,
794
+ "peer appearance-none pr-9",
795
+ isInvalid && FIELD_INVALID,
796
+ )}
797
+ >
798
+ {children}
799
+ </select>
800
+ {chevron}
801
+ </FloatingField>
802
+ </FieldGroup>
803
+ );
804
+ });
805
+ Select.displayName = "Select";
806
+
807
+ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
808
+ label?: ReactNode;
809
+ /** See {@link Input}'s `invalid`. */
810
+ invalid?: boolean;
811
+ /** See {@link Input}'s `error`. */
812
+ error?: ReactNode;
813
+ }
814
+
815
+ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
816
+ { className, label, id, placeholder, invalid, error, ...rest },
817
+ ref,
818
+ ) {
819
+ const generated = useId();
820
+ const fieldId = id ?? generated;
821
+ const { isInvalid, describedBy, errorEl } = useFieldError(
822
+ error,
823
+ invalid,
824
+ rest["aria-describedby"],
825
+ );
826
+ if (label === undefined) {
827
+ return (
828
+ <FieldGroup errorEl={errorEl}>
829
+ <textarea
830
+ ref={ref}
831
+ id={id}
832
+ placeholder={placeholder}
833
+ {...rest}
834
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
835
+ aria-describedby={describedBy}
836
+ className={cn(FIELD_BASE, className, isInvalid && FIELD_INVALID)}
837
+ />
838
+ </FieldGroup>
839
+ );
840
+ }
841
+ return (
842
+ <FieldGroup errorEl={errorEl}>
843
+ <FloatingField className={className} htmlFor={fieldId} label={label}>
844
+ <textarea
845
+ ref={ref}
846
+ id={fieldId}
847
+ placeholder=" "
848
+ {...rest}
849
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
850
+ aria-describedby={describedBy}
851
+ className={cn(FLOATING_INPUT_CLASS, isInvalid && FIELD_INVALID)}
852
+ />
853
+ </FloatingField>
854
+ </FieldGroup>
855
+ );
856
+ });
857
+ Textarea.displayName = "Textarea";
858
+
859
+ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
860
+ children: ReactNode;
861
+ /**
862
+ * When true, drop the card chrome (border, rounded corners, shadow) on mobile so the
863
+ * card spans edge-to-edge. Chrome reappears at the `md` breakpoint. Use for primary
864
+ * content cards on data pages; leave off for centered dialog/panel cards.
865
+ */
866
+ flush?: boolean;
867
+ }
868
+
869
+ export function Card({ className, children, flush, ...rest }: CardProps) {
870
+ return (
871
+ <div
872
+ {...rest}
873
+ className={cn(
874
+ // Surface + border are theme tokens so the palette switcher (feedback
875
+ // #307) can re-skin every card; a caller's own bg-*/border-* override
876
+ // still wins via tailwind-merge.
877
+ "bg-[var(--bg-surface)]",
878
+ flush
879
+ ? "border-y border-[var(--border)] md:rounded-lg md:border md:shadow-sm"
880
+ : "rounded-lg border border-[var(--border)] shadow-sm",
881
+ className,
882
+ )}
883
+ >
884
+ {children}
885
+ </div>
886
+ );
887
+ }
888
+
889
+ // Composed shadcn-style Card sub-parts. `Card` stays padding-less (callers set
890
+ // their own padding via className), so these own the padding/rhythm. Token-driven
891
+ // so they re-skin with the palette. Use CardHeader → CardTitle/CardDescription
892
+ // (+ optional CardAction, top-right) → CardContent → CardFooter.
893
+ /** The sub-parts add nothing to a `<div>`'s props — they are the SAME element with a
894
+ * `data-slot` and a padding rhythm — so each name is an alias rather than an empty
895
+ * interface pretending to be more. They exist so a consumer's own wrapper can say
896
+ * `CardHeaderProps` instead of `ComponentProps<typeof CardHeader>`. */
897
+ export type CardHeaderProps = ComponentPropsWithoutRef<"div">;
898
+ export type CardTitleProps = ComponentPropsWithoutRef<"div">;
899
+ export type CardDescriptionProps = ComponentPropsWithoutRef<"div">;
900
+ export type CardActionProps = ComponentPropsWithoutRef<"div">;
901
+ export type CardContentProps = ComponentPropsWithoutRef<"div">;
902
+ export type CardFooterProps = ComponentPropsWithoutRef<"div">;
903
+
904
+ export function CardHeader({ className, ...props }: CardHeaderProps) {
905
+ return (
906
+ <div
907
+ data-slot="card-header"
908
+ className={cn(
909
+ // Grid (not flex) so CardAction can occupy a top-right column; with no
910
+ // action it collapses to one column and title/description stack.
911
+ "grid auto-rows-min items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto]",
912
+ className,
913
+ )}
914
+ {...props}
915
+ />
916
+ );
917
+ }
918
+
919
+ export function CardTitle({ className, ...props }: CardTitleProps) {
920
+ return (
921
+ <div
922
+ data-slot="card-title"
923
+ className={cn("font-semibold leading-none", className)}
924
+ {...props}
925
+ />
926
+ );
927
+ }
928
+
929
+ export function CardDescription({ className, ...props }: CardDescriptionProps) {
930
+ return (
931
+ <div
932
+ data-slot="card-description"
933
+ className={cn("text-sm text-[var(--money-neutral)]", className)}
934
+ {...props}
935
+ />
936
+ );
937
+ }
938
+
939
+ export function CardAction({ className, ...props }: CardActionProps) {
940
+ return (
941
+ <div
942
+ data-slot="card-action"
943
+ className={cn("col-start-2 row-start-1 row-span-2 self-start justify-self-end", className)}
944
+ {...props}
945
+ />
946
+ );
947
+ }
948
+
949
+ export function CardContent({ className, ...props }: CardContentProps) {
950
+ // `last:pb-6`, not a plain `pb-6`: CardHeader owns the top padding and CardFooter
951
+ // owns the bottom one, so a card WITHOUT a footer had nothing closing it off and
952
+ // its last field sat flush against the card edge (kastlan feedback: the language
953
+ // input touching the card bottom on /profile). Scoping to `:last-child` fixes that
954
+ // case and leaves a footered card's rhythm exactly as it was.
955
+ return <div data-slot="card-content" className={cn("px-6 last:pb-6", className)} {...props} />;
956
+ }
957
+
958
+ export function CardFooter({ className, ...props }: CardFooterProps) {
959
+ return (
960
+ <div
961
+ data-slot="card-footer"
962
+ className={cn("flex items-center px-6 pb-6", className)}
963
+ {...props}
964
+ />
965
+ );
966
+ }
967
+
968
+ /** A `<span>`'s props plus the words a reader hears — see {@link CardHeaderProps}.
969
+ * The ring is drawn with a border, so there is nothing inside it to put children in. */
970
+ export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "children"> {
971
+ /** What the spinner means, for a screen reader. Default: `common.loading` from the
972
+ * {@link UiKitProvider}, else "Loading…". */
973
+ label?: string;
974
+ }
975
+
976
+ /**
977
+ * A spinning ring that also SAYS it is loading.
978
+ *
979
+ * It used to be a bordered span and nothing else, so a reader met no element at all
980
+ * where a sighted user saw the page working — or, beside an emptied list, met the
981
+ * empty list and concluded there was nothing there. `role="status"` makes it a polite
982
+ * live region, and the text inside is what that region announces. Text rather than
983
+ * `aria-label`: a live region's announcement is its CONTENT, and several readers
984
+ * ignore a name on one.
985
+ *
986
+ * `relative` so the `sr-only` text has a local containing block (see
987
+ * sr-only-containment.test). The role goes BEFORE the spread: a caller showing the
988
+ * spinner next to text that already says "Loading" can pass `aria-hidden` or its own
989
+ * `role` and have it win.
990
+ */
991
+ export function Spinner({ className, label, ...rest }: SpinnerProps) {
992
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS, { loading: label });
993
+ return (
994
+ <span
995
+ role="status"
996
+ {...rest}
997
+ className={cn(
998
+ "relative inline-block h-5 w-5 animate-spin rounded-full border-2 border-[var(--border)] border-t-[var(--text-primary)]",
999
+ className,
1000
+ )}
1001
+ >
1002
+ <span className="sr-only">{common.loading}</span>
1003
+ </span>
1004
+ );
1005
+ }
1006
+
1007
+ export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
1008
+ /** The box renders `title` and `hint` in its own two-line rhythm, which is what makes
1009
+ * every empty state in three apps look like the same thing — so there is no
1010
+ * `children` slot to put arbitrary content in. */
1011
+ title: string;
1012
+ hint?: string;
1013
+ }
1014
+
1015
+ export function EmptyState({ title, hint, className, ...rest }: EmptyStateProps) {
1016
+ return (
1017
+ <div
1018
+ {...rest}
1019
+ className={cn(
1020
+ "flex flex-col items-center justify-center rounded-lg border border-dashed border-[var(--border-strong)] bg-[var(--bg-surface-2)] px-4 py-10 text-center text-sm text-[var(--text-muted)]",
1021
+ className,
1022
+ )}
1023
+ >
1024
+ <div className="font-medium text-[var(--text-secondary)]">{title}</div>
1025
+ {hint && <div className="mt-1 text-xs">{hint}</div>}
1026
+ </div>
1027
+ );
1028
+ }
1029
+
1030
+ /**
1031
+ * The strip's own props sit on a `<div>`: the tablist IS the root element, so anything
1032
+ * a caller hangs on it — a `data-tour` anchor for the kit's guided tour, a test id, an
1033
+ * `aria-describedby` — lands there. `onChange` is omitted from the div's props because
1034
+ * this component's `onChange` hands over the chosen TAB ID, not a DOM event, and
1035
+ * `children` because the strip renders `tabs` — a caller who wants a PANEL wires it up
1036
+ * through `panelId`, which is the whole point of that prop.
1037
+ */
1038
+ export interface TabsProps<T extends string>
1039
+ extends Omit<ComponentPropsWithoutRef<"div">, "onChange" | "children"> {
1040
+ /** `label` is a ReactNode so tabs can pair an icon with text; `badge` is an
1041
+ * optional trailing node (e.g. a count pill). `href` marks a tab that IS a route:
1042
+ * it renders as an anchor so it can be middle-/⌘-clicked into a new tab (Keksdose
1043
+ * feedback #451), while a plain click still goes through `onChange` and stays
1044
+ * client-side. Tabs that only flip local state leave it unset — a link to a URL
1045
+ * that does not select the tab would be worse than no link. */
1046
+ tabs: { id: T; label: ReactNode; badge?: ReactNode; href?: string }[];
1047
+ active: T;
1048
+ onChange: (id: T) => void;
1049
+ className?: string;
1050
+ /** Let the strip WRAP onto as many rows as it needs below `md`, instead of
1051
+ * scrolling sideways — for a strip carrying more tabs than fit on a phone row.
1052
+ * Keksdose live #262: ten reports on a 406px screen showed about three, and the
1053
+ * round-2 answer (a <select> below `md`) cost a tap to open and a tap to choose.
1054
+ * *"I would rather keep the tabs but have multirow tabs depending on the screen
1055
+ * size, I will not loose the function to see all reports at once and navigate by
1056
+ * single click rather by double click."*
1057
+ *
1058
+ * The active MARKER has to change with the layout, which is why this cannot be
1059
+ * done from a call site with a `className`: the default marker is a `border-b-2`
1060
+ * underline riding the container's own bottom rule, and a tab sitting in a row
1061
+ * that does not touch that rule cannot wear it — every row but the last would
1062
+ * show a stray line floating mid-strip, and `-mb-px` would pull each chip a
1063
+ * pixel into the row beneath it. A wrapped strip marks the active tab with a
1064
+ * filled brand chip instead, which is also the only thing findable at a glance
1065
+ * among ten same-weight labels on three rows.
1066
+ *
1067
+ * From `md` up NOTHING changes: same single-line underline strip, same paddings,
1068
+ * same colours as every other strip in the app.
1069
+ *
1070
+ * Opt-in on purpose. The three-tab strips (invoices, statements, bank imports)
1071
+ * already fit a phone row, and turning those into chips would be an unrequested
1072
+ * redesign of three other pages. */
1073
+ wrap?: boolean;
1074
+ /**
1075
+ * Accessible name for the `role="tablist"` container. Every tab carries its own
1076
+ * text, so this names the GROUP, not the tabs; leave it unset where a visible
1077
+ * heading immediately above already does that job.
1078
+ *
1079
+ * @deprecated Pass `aria-label` instead. This kit had three spellings for one idea
1080
+ * — `ariaLabel`, `aria-label` and this `label` — and settled on the DOM one, which
1081
+ * is also the one that arrives for free now that the strip spreads its rest props.
1082
+ * `label` still works and is unchanged; it names the strip only when `aria-label`
1083
+ * is absent.
1084
+ */
1085
+ label?: string;
1086
+ /**
1087
+ * `id` of the element the caller renders the open tab's content into — the missing
1088
+ * half of `role="tab"`. A tab that controls nothing is a tab in name only: a screen
1089
+ * reader announces "tab, 2 of 5" and then has no way to take the user to what it
1090
+ * opened, and no way back.
1091
+ *
1092
+ * **This component does not render the panel, on purpose.** `Tabs` is the STRIP; the
1093
+ * content lives wherever the caller put it — three Keksdose pages render it in a
1094
+ * sibling `<Card>`, one renders it through a router outlet, and a tab can BE a route
1095
+ * (`href`), in which case the panel is a whole page this component never sees.
1096
+ * Wrapping `children` here would mean either moving that content into the strip's
1097
+ * subtree — a layout change on every page that uses tabs — or shipping a wrapper
1098
+ * that only some callers could use. So the two halves are joined by an id instead.
1099
+ *
1100
+ * Wire the other end yourself:
1101
+ *
1102
+ * ```tsx
1103
+ * <Tabs tabs={tabs} active={active} onChange={setActive} panelId="report-panel" />
1104
+ * <div id="report-panel" role="tabpanel" aria-labelledby="report-panel-tab" tabIndex={-1}>
1105
+ * ```
1106
+ *
1107
+ * `aria-controls` goes on the OPEN tab only, and `${panelId}-tab` is its id — the
1108
+ * closed tabs' panels are not in the document, and a dangling `aria-controls`
1109
+ * describes a tab as opening something that is not there.
1110
+ */
1111
+ panelId?: string;
1112
+ }
1113
+
1114
+ // The two shapes are written out as whole strings rather than as one base plus a
1115
+ // pile of `md:` overrides. The unwrapped pair is character-for-character what the
1116
+ // three strips that do not opt in get, so those cannot drift; the wrapped pair is
1117
+ // authored from scratch, so no unprefixed utility has to be beaten by its own `md:`
1118
+ // twin through tailwind-merge. The only ordering this relies on is Tailwind's own:
1119
+ // unprefixed utilities are emitted first, then `md:` — so from 768px up the wrapped
1120
+ // strip resolves to exactly the paint of the default one. Both themes come free now
1121
+ // that the colours are tokens: there is no `md:dark:` tier left to keep in step.
1122
+ const TABLIST_CLASSES =
1123
+ "flex gap-1 overflow-x-auto overflow-y-hidden border-b border-[var(--border)]";
1124
+ const TABLIST_WRAP_CLASSES =
1125
+ "flex flex-wrap gap-1.5 md:flex-nowrap md:gap-1 md:overflow-x-auto md:overflow-y-hidden md:border-b md:border-[var(--border)]";
1126
+
1127
+ const TAB_CLASSES =
1128
+ "whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none focus:ring-2 focus:ring-[var(--border-strong)]";
1129
+ const TAB_ACTIVE_CLASSES = "border-[var(--text-primary)] text-[var(--text-primary)]";
1130
+ const TAB_INACTIVE_CLASSES =
1131
+ "border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-strong)]";
1132
+
1133
+ // Below `md`, a chip: `py-2` on `text-xs` is exactly 32px tall, and ten German
1134
+ // report labels then land in three rows on a 406px screen (four at `text-sm`). The
1135
+ // active fill is the app's own selected pair, `--brand` / `--brand-contrast`: the
1136
+ // palette store writes both halves of it together for every preset and both themes,
1137
+ // so the contrast holds everywhere (5.1:1 at its worst preset) without a hard-coded
1138
+ // colour. Inactive chips take the raised card surface with full-strength body text —
1139
+ // all ten have to stay readable; it is the FILL, not the text weight, that says
1140
+ // which one is open.
1141
+ const TAB_WRAP_CLASSES =
1142
+ "whitespace-nowrap rounded-md px-2.5 py-2 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--border-strong)] md:rounded-none md:border-b-2 md:-mb-px md:bg-transparent md:px-3 md:py-2 md:text-sm";
1143
+ const TAB_WRAP_ACTIVE_CLASSES =
1144
+ "bg-[var(--brand)] text-[var(--brand-contrast)] md:border-[var(--text-primary)] md:text-[var(--text-primary)]";
1145
+ const TAB_WRAP_INACTIVE_CLASSES =
1146
+ "bg-[var(--bg-surface)] text-[var(--text-primary)] md:border-transparent md:text-[var(--text-muted)] md:hover:border-[var(--border-strong)] md:hover:text-[var(--text-secondary)]";
1147
+
1148
+ export function Tabs<T extends string>({
1149
+ tabs,
1150
+ active,
1151
+ onChange,
1152
+ className,
1153
+ wrap = false,
1154
+ label,
1155
+ panelId,
1156
+ "aria-label": ariaLabel,
1157
+ ...rest
1158
+ }: TabsProps<T>) {
1159
+ // Arrow keys walk the strip in DOM order (ARIA tabs pattern), which is what keeps
1160
+ // a WRAPPED strip navigable: the rows flow in DOM order too, so Right off the end
1161
+ // of row one lands on the first chip of row two rather than nowhere. Home/End jump
1162
+ // to the ends. Focus only — activation stays on click/Enter/Space, because a tab
1163
+ // here can be a real route and moving focus must not navigate.
1164
+ //
1165
+ // On the TABS, not on the tablist. The container carried it, which left a `<div>`
1166
+ // wearing an interactive role and a key handler while being unfocusable —
1167
+ // `jsx-a11y/interactive-supports-focus`, and the only instance of it left in `src/`.
1168
+ // The fix that rule wants is `tabIndex` on the div; the fix the pattern wants is
1169
+ // the handler on the elements that are natively focusable and actually receive the
1170
+ // keystroke. Those are the tabs, so the warning goes away by being right rather
1171
+ // than by being satisfied.
1172
+ const onTabKeyDown = (e: KeyboardEvent<HTMLElement>) => {
1173
+ const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
1174
+ if (step === 0 && e.key !== "Home" && e.key !== "End") return;
1175
+ const strip = e.currentTarget.closest('[role="tablist"]');
1176
+ if (!strip) return;
1177
+ const items = Array.from(strip.querySelectorAll<HTMLElement>('[role="tab"]'));
1178
+ const from = items.indexOf(e.currentTarget);
1179
+ if (from === -1) return;
1180
+ e.preventDefault();
1181
+ const to =
1182
+ e.key === "Home"
1183
+ ? 0
1184
+ : e.key === "End"
1185
+ ? items.length - 1
1186
+ : (from + step + items.length) % items.length;
1187
+ items[to]?.focus();
1188
+ };
1189
+ return (
1190
+ <div
1191
+ // Before the role and the name: a caller's arbitrary attribute is welcome on the
1192
+ // strip, but a `role` or an `aria-label` arriving through a spread props object
1193
+ // must not be able to unmake the tablist the tabs below are registered against.
1194
+ {...rest}
1195
+ role="tablist"
1196
+ // The DOM spelling wins over the deprecated `label`; see {@link TabsProps}.
1197
+ aria-label={ariaLabel ?? label}
1198
+ className={cn(wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES, className)}
1199
+ >
1200
+ {tabs.map((tab) => {
1201
+ const isActive = tab.id === active;
1202
+ // Written once and worn by either tag below, so a routed tab and a
1203
+ // state-only tab stay indistinguishable to the eye and to a screen reader.
1204
+ const shared = {
1205
+ role: "tab" as const,
1206
+ "aria-selected": isActive,
1207
+ // Roving tabindex: the whole strip is ONE stop in the page's tab order, and
1208
+ // it is the open tab. Ten report tabs otherwise cost ten Tab presses to step
1209
+ // over on the way to the table below them (live #262's strip is the extreme
1210
+ // case, but every strip in the app paid it).
1211
+ //
1212
+ // The stop follows the SELECTION, not the focus. Arrowing is a look around —
1213
+ // activation here is manual, because a tab can be a real route — so re-
1214
+ // pointing the stop at a report the user only arrowed past would hand the
1215
+ // strip back on the next visit in a state they never chose. The cost is that
1216
+ // Tab out and back returns to the open tab rather than to the last one
1217
+ // looked at; the ARIA pattern allows either, and this one needs no second
1218
+ // piece of state shadowing `active`.
1219
+ tabIndex: isActive ? 0 : -1,
1220
+ // Only the open tab: see `panelId`.
1221
+ "aria-controls": isActive ? panelId : undefined,
1222
+ id: isActive && panelId ? `${panelId}-tab` : undefined,
1223
+ onKeyDown: onTabKeyDown,
1224
+ className: cn(
1225
+ wrap ? TAB_WRAP_CLASSES : TAB_CLASSES,
1226
+ wrap
1227
+ ? isActive
1228
+ ? TAB_WRAP_ACTIVE_CLASSES
1229
+ : TAB_WRAP_INACTIVE_CLASSES
1230
+ : isActive
1231
+ ? TAB_ACTIVE_CLASSES
1232
+ : TAB_INACTIVE_CLASSES,
1233
+ ),
1234
+ };
1235
+ const inner = (
1236
+ <span className="inline-flex items-center gap-1.5">
1237
+ {tab.label}
1238
+ {tab.badge != null ? tab.badge : null}
1239
+ </span>
1240
+ );
1241
+ return tab.href ? (
1242
+ <a
1243
+ key={tab.id}
1244
+ {...shared}
1245
+ href={tab.href}
1246
+ draggable={false}
1247
+ onClick={(e) => {
1248
+ // Middle-, ⌘/Ctrl-, Shift- and Alt-click belong to the browser
1249
+ // (feedback #451); only the plain left click is ours to cancel and
1250
+ // route through `onChange`, which keeps the switch client-side.
1251
+ if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
1252
+ e.preventDefault();
1253
+ onChange(tab.id);
1254
+ }}
1255
+ // A <button> tab activated on Space; an anchor does not, so becoming a
1256
+ // link (feedback #451) would have quietly cost keyboard users the tab
1257
+ // strip. Enter is deliberately untouched — it is the anchor's own
1258
+ // default and already arrives at `onChange` through the click handler
1259
+ // above, so handling it here too would switch tabs twice. Same three
1260
+ // lines the mobile row card carries, for the same reason; the rejected
1261
+ // alternative — keeping a button and faking the middle click with
1262
+ // window.open — is what the whole item is moving away from.
1263
+ //
1264
+ // Composed with the strip's arrow-key walk rather than replacing it: this
1265
+ // prop overrides the one in `shared`, and an anchor tab that lost the
1266
+ // arrows would be a strip that is navigable only where it is not a link.
1267
+ onKeyDown={(e) => {
1268
+ onTabKeyDown(e);
1269
+ if (e.defaultPrevented || e.key !== " ") return;
1270
+ e.preventDefault();
1271
+ onChange(tab.id);
1272
+ }}
1273
+ >
1274
+ {inner}
1275
+ </a>
1276
+ ) : (
1277
+ <button key={tab.id} {...shared} onClick={() => onChange(tab.id)}>
1278
+ {inner}
1279
+ </button>
1280
+ );
1281
+ })}
1282
+ </div>
1283
+ );
1284
+ }