@eifi1/ui-kit 0.33.1 → 0.35.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 (1559) hide show
  1. package/README.md +512 -306
  2. package/dist/account/account-parts.d.ts +6 -8
  3. package/dist/account/account-parts.js +1 -2
  4. package/dist/account/account-parts.js.map +1 -1
  5. package/dist/{components → account}/account-settings-labels.d.ts +16 -10
  6. package/dist/{components → account}/account-settings-labels.js +3 -1
  7. package/dist/account/account-settings-labels.js.map +1 -0
  8. package/dist/{components → account}/account-settings.d.ts +53 -23
  9. package/dist/{components → account}/account-settings.js +97 -26
  10. package/dist/account/account-settings.js.map +1 -0
  11. package/dist/account/data-export-setting-labels.d.ts +2 -0
  12. package/dist/account/data-export-setting-labels.js +15 -0
  13. package/dist/account/data-export-setting-labels.js.map +1 -0
  14. package/dist/account/data-export-setting.d.ts +9 -11
  15. package/dist/account/data-export-setting.js +11 -27
  16. package/dist/account/data-export-setting.js.map +1 -1
  17. package/dist/account/delete-account-setting-labels.d.ts +2 -0
  18. package/dist/account/delete-account-setting-labels.js +23 -0
  19. package/dist/account/delete-account-setting-labels.js.map +1 -0
  20. package/dist/account/delete-account-setting.d.ts +132 -101
  21. package/dist/account/delete-account-setting.js +7 -23
  22. package/dist/account/delete-account-setting.js.map +1 -1
  23. package/dist/account/email-change-setting-labels.d.ts +2 -0
  24. package/dist/account/email-change-setting-labels.js +28 -0
  25. package/dist/account/email-change-setting-labels.js.map +1 -0
  26. package/dist/account/email-change-setting.d.ts +10 -12
  27. package/dist/account/email-change-setting.js +11 -30
  28. package/dist/account/email-change-setting.js.map +1 -1
  29. package/dist/{components → account}/passkeys-setting.d.ts +9 -12
  30. package/dist/{components → account}/passkeys-setting.js +10 -7
  31. package/dist/account/passkeys-setting.js.map +1 -0
  32. package/dist/account/sessions-setting-labels.d.ts +2 -0
  33. package/dist/account/sessions-setting-labels.js +22 -0
  34. package/dist/account/sessions-setting-labels.js.map +1 -0
  35. package/dist/account/sessions-setting.d.ts +12 -14
  36. package/dist/account/sessions-setting.js +8 -23
  37. package/dist/account/sessions-setting.js.map +1 -1
  38. package/dist/admin/admin-action-confirm-labels.d.ts +2 -0
  39. package/dist/admin/admin-action-confirm-labels.js +16 -0
  40. package/dist/admin/admin-action-confirm-labels.js.map +1 -0
  41. package/dist/admin/admin-action-confirm.d.ts +156 -101
  42. package/dist/admin/admin-action-confirm.js +17 -22
  43. package/dist/admin/admin-action-confirm.js.map +1 -1
  44. package/dist/admin/admin-action-log-labels.d.ts +2 -0
  45. package/dist/admin/admin-action-log-labels.js +73 -0
  46. package/dist/admin/admin-action-log-labels.js.map +1 -0
  47. package/dist/admin/admin-action-log.d.ts +98 -22
  48. package/dist/admin/admin-action-log.js +48 -45
  49. package/dist/admin/admin-action-log.js.map +1 -1
  50. package/dist/admin/admin-parts.d.ts +7 -14
  51. package/dist/admin/admin-parts.js +0 -4
  52. package/dist/admin/admin-parts.js.map +1 -1
  53. package/dist/admin/invitations-panel-labels.d.ts +2 -0
  54. package/dist/admin/invitations-panel-labels.js +29 -0
  55. package/dist/admin/invitations-panel-labels.js.map +1 -0
  56. package/dist/admin/invitations-panel.d.ts +167 -101
  57. package/dist/admin/invitations-panel.js +12 -31
  58. package/dist/admin/invitations-panel.js.map +1 -1
  59. package/dist/admin/plan-change-labels.d.ts +2 -0
  60. package/dist/admin/plan-change-labels.js +22 -0
  61. package/dist/admin/plan-change-labels.js.map +1 -0
  62. package/dist/admin/plan-change.d.ts +244 -101
  63. package/dist/admin/plan-change.js +16 -24
  64. package/dist/admin/plan-change.js.map +1 -1
  65. package/dist/admin/reviewer-scope-editor-labels.d.ts +2 -0
  66. package/dist/admin/reviewer-scope-editor-labels.js +14 -0
  67. package/dist/admin/reviewer-scope-editor-labels.js.map +1 -0
  68. package/dist/admin/reviewer-scope-editor.d.ts +95 -101
  69. package/dist/admin/reviewer-scope-editor.js +5 -13
  70. package/dist/admin/reviewer-scope-editor.js.map +1 -1
  71. package/dist/admin/role-select-labels.d.ts +2 -0
  72. package/dist/admin/role-select-labels.js +15 -0
  73. package/dist/admin/role-select-labels.js.map +1 -0
  74. package/dist/admin/role-select.d.ts +136 -101
  75. package/dist/admin/role-select.js +7 -18
  76. package/dist/admin/role-select.js.map +1 -1
  77. package/dist/admin/transfer-ownership-dialog-labels.d.ts +2 -0
  78. package/dist/admin/transfer-ownership-dialog-labels.js +18 -0
  79. package/dist/admin/transfer-ownership-dialog-labels.js.map +1 -0
  80. package/dist/admin/transfer-ownership-dialog.d.ts +81 -101
  81. package/dist/admin/transfer-ownership-dialog.js +3 -15
  82. package/dist/admin/transfer-ownership-dialog.js.map +1 -1
  83. package/dist/admin/user-roster-labels.d.ts +2 -0
  84. package/dist/admin/user-roster-labels.js +16 -0
  85. package/dist/admin/user-roster-labels.js.map +1 -0
  86. package/dist/admin/user-roster.d.ts +225 -101
  87. package/dist/admin/user-roster.js +4 -14
  88. package/dist/admin/user-roster.js.map +1 -1
  89. package/dist/auth/accept-invitation-labels.d.ts +2 -0
  90. package/dist/auth/accept-invitation-labels.js +20 -0
  91. package/dist/auth/accept-invitation-labels.js.map +1 -0
  92. package/dist/auth/accept-invitation.d.ts +11 -13
  93. package/dist/auth/accept-invitation.js +3 -16
  94. package/dist/auth/accept-invitation.js.map +1 -1
  95. package/dist/auth/auth-errors.d.ts +34 -23
  96. package/dist/auth/auth-errors.js +8 -4
  97. package/dist/auth/auth-errors.js.map +1 -1
  98. package/dist/auth/complete-name-dialog-labels.d.ts +2 -0
  99. package/dist/auth/complete-name-dialog-labels.js +14 -0
  100. package/dist/auth/complete-name-dialog-labels.js.map +1 -0
  101. package/dist/auth/complete-name-dialog.d.ts +9 -11
  102. package/dist/auth/complete-name-dialog.js +4 -11
  103. package/dist/auth/complete-name-dialog.js.map +1 -1
  104. package/dist/auth/email-tag.d.ts +2 -4
  105. package/dist/auth/forgot-password-form-labels.d.ts +3 -0
  106. package/dist/auth/forgot-password-form-labels.js +17 -0
  107. package/dist/auth/forgot-password-form-labels.js.map +1 -0
  108. package/dist/auth/forgot-password-form.d.ts +9 -12
  109. package/dist/auth/forgot-password-form.js +6 -15
  110. package/dist/auth/forgot-password-form.js.map +1 -1
  111. package/dist/auth/form-rules.d.ts +5 -7
  112. package/dist/auth/new-password-fields.d.ts +4 -8
  113. package/dist/auth/new-password-fields.js +1 -1
  114. package/dist/auth/new-password-fields.js.map +1 -1
  115. package/dist/auth/not-found-page-labels.d.ts +2 -0
  116. package/dist/auth/not-found-page-labels.js +11 -0
  117. package/dist/auth/not-found-page-labels.js.map +1 -0
  118. package/dist/auth/not-found-page.d.ts +9 -11
  119. package/dist/auth/not-found-page.js +5 -10
  120. package/dist/auth/not-found-page.js.map +1 -1
  121. package/dist/auth/register-form-labels.d.ts +3 -0
  122. package/dist/auth/register-form-labels.js +28 -0
  123. package/dist/auth/register-form-labels.js.map +1 -0
  124. package/dist/auth/register-form.d.ts +174 -101
  125. package/dist/auth/register-form.js +11 -31
  126. package/dist/auth/register-form.js.map +1 -1
  127. package/dist/auth/reset-password-form-labels.d.ts +3 -0
  128. package/dist/auth/reset-password-form-labels.js +24 -0
  129. package/dist/auth/reset-password-form-labels.js.map +1 -0
  130. package/dist/auth/reset-password-form.d.ts +12 -15
  131. package/dist/auth/reset-password-form.js +6 -22
  132. package/dist/auth/reset-password-form.js.map +1 -1
  133. package/dist/auth/sign-in-form-labels.d.ts +3 -0
  134. package/dist/auth/sign-in-form-labels.js +42 -0
  135. package/dist/auth/sign-in-form-labels.js.map +1 -0
  136. package/dist/auth/sign-in-form.d.ts +15 -19
  137. package/dist/auth/sign-in-form.js +12 -46
  138. package/dist/auth/sign-in-form.js.map +1 -1
  139. package/dist/auth/status-parts.d.ts +13 -17
  140. package/dist/auth/status-parts.js +3 -3
  141. package/dist/auth/status-parts.js.map +1 -1
  142. package/dist/auth/verify-email-labels.d.ts +2 -0
  143. package/dist/auth/verify-email-labels.js +25 -0
  144. package/dist/auth/verify-email-labels.js.map +1 -0
  145. package/dist/auth/verify-email.d.ts +15 -17
  146. package/dist/auth/verify-email.js +4 -21
  147. package/dist/auth/verify-email.js.map +1 -1
  148. package/dist/billing/billing-banner.d.ts +12 -110
  149. package/dist/billing/billing-banner.js +2 -1
  150. package/dist/billing/billing-banner.js.map +1 -1
  151. package/dist/billing/billing-labels.d.ts +168 -101
  152. package/dist/billing/billing-labels.js.map +1 -1
  153. package/dist/billing/billing-lock.d.ts +10 -110
  154. package/dist/billing/billing-overview.d.ts +7 -109
  155. package/dist/billing/billing-standing.d.ts +6 -108
  156. package/dist/billing/checkout-processing.d.ts +8 -111
  157. package/dist/billing/checkout-processing.js +2 -9
  158. package/dist/billing/checkout-processing.js.map +1 -1
  159. package/dist/billing/checkout-return.d.ts +5 -7
  160. package/dist/billing/pay-page.d.ts +15 -117
  161. package/dist/billing/plan-card.d.ts +14 -115
  162. package/dist/billing/plan-card.js +15 -13
  163. package/dist/billing/plan-card.js.map +1 -1
  164. package/dist/billing/plan-limit-notice.d.ts +12 -110
  165. package/dist/billing/plan-limit-notice.js +1 -1
  166. package/dist/billing/plan-limit-notice.js.map +1 -1
  167. package/dist/billing/plan-limit-toast.d.ts +6 -107
  168. package/dist/billing/plan-limit.d.ts +2 -4
  169. package/dist/billing/plan-price.d.ts +10 -12
  170. package/dist/billing/subscription-actions.d.ts +6 -109
  171. package/dist/billing/subscription-actions.js +2 -2
  172. package/dist/billing/subscription-actions.js.map +1 -1
  173. package/dist/billing/subscription-status-chip.d.ts +7 -108
  174. package/dist/chart.d.ts +12 -113
  175. package/dist/chart.js +4 -0
  176. package/dist/chart.js.map +1 -1
  177. package/dist/components/account-chips-labels.d.ts +10 -0
  178. package/dist/components/account-chips-labels.js +15 -0
  179. package/dist/components/account-chips-labels.js.map +1 -0
  180. package/dist/components/account-chips.d.ts +22 -44
  181. package/dist/components/account-chips.js +9 -14
  182. package/dist/components/account-chips.js.map +1 -1
  183. package/dist/components/alert-banner.d.ts +15 -11
  184. package/dist/components/alert-banner.js +28 -18
  185. package/dist/components/alert-banner.js.map +1 -1
  186. package/dist/components/amount-input.d.ts +64 -111
  187. package/dist/components/amount-input.js +259 -283
  188. package/dist/components/amount-input.js.map +1 -1
  189. package/dist/components/appearance-labels.d.ts +6 -10
  190. package/dist/components/appearance-labels.js.map +1 -1
  191. package/dist/components/authed-image-labels.d.ts +2 -0
  192. package/dist/components/authed-image-labels.js +10 -0
  193. package/dist/components/authed-image-labels.js.map +1 -0
  194. package/dist/components/authed-image.d.ts +11 -13
  195. package/dist/components/authed-image.js +2 -6
  196. package/dist/components/authed-image.js.map +1 -1
  197. package/dist/components/autocomplete.d.ts +13 -11
  198. package/dist/components/autocomplete.js +33 -28
  199. package/dist/components/autocomplete.js.map +1 -1
  200. package/dist/components/breadcrumbs-labels.d.ts +2 -0
  201. package/dist/components/breadcrumbs-labels.js +9 -0
  202. package/dist/components/breadcrumbs-labels.js.map +1 -0
  203. package/dist/components/breadcrumbs.d.ts +10 -12
  204. package/dist/components/breadcrumbs.js +7 -10
  205. package/dist/components/breadcrumbs.js.map +1 -1
  206. package/dist/components/bulk-action-bar-labels.d.ts +2 -0
  207. package/dist/components/bulk-action-bar-labels.js +10 -0
  208. package/dist/components/bulk-action-bar-labels.js.map +1 -0
  209. package/dist/components/bulk-action-bar.d.ts +10 -12
  210. package/dist/components/bulk-action-bar.js +9 -12
  211. package/dist/components/bulk-action-bar.js.map +1 -1
  212. package/dist/components/button-group.d.ts +7 -110
  213. package/dist/components/button-group.js +5 -5
  214. package/dist/components/button-group.js.map +1 -1
  215. package/dist/components/button-look.d.ts +88 -0
  216. package/dist/components/button-look.js +91 -0
  217. package/dist/components/button-look.js.map +1 -0
  218. package/dist/components/button.d.ts +153 -0
  219. package/dist/components/button.js +165 -0
  220. package/dist/components/button.js.map +1 -0
  221. package/dist/components/calculator.d.ts +6 -108
  222. package/dist/components/calculator.js +8 -7
  223. package/dist/components/calculator.js.map +1 -1
  224. package/dist/components/calendar-heatmap-labels.d.ts +3 -0
  225. package/dist/components/calendar-heatmap-labels.js +12 -0
  226. package/dist/components/calendar-heatmap-labels.js.map +1 -0
  227. package/dist/components/calendar-heatmap.d.ts +13 -16
  228. package/dist/components/calendar-heatmap.js +7 -13
  229. package/dist/components/calendar-heatmap.js.map +1 -1
  230. package/dist/components/card.d.ts +145 -0
  231. package/dist/components/card.js +186 -0
  232. package/dist/components/card.js.map +1 -0
  233. package/dist/components/chart-height.d.ts +4 -7
  234. package/dist/components/chart-zoom-core.d.ts +202 -0
  235. package/dist/components/chart-zoom-core.js +334 -0
  236. package/dist/components/chart-zoom-core.js.map +1 -0
  237. package/dist/components/chart-zoom.d.ts +2 -205
  238. package/dist/components/chart-zoom.js +2 -330
  239. package/dist/components/chart-zoom.js.map +1 -1
  240. package/dist/components/chart.d.ts +11 -15
  241. package/dist/components/chart.js +11 -11
  242. package/dist/components/chart.js.map +1 -1
  243. package/dist/components/checkbox-group.d.ts +6 -10
  244. package/dist/components/checkbox-group.js +10 -9
  245. package/dist/components/checkbox-group.js.map +1 -1
  246. package/dist/components/checkbox.d.ts +8 -12
  247. package/dist/components/checkbox.js +20 -18
  248. package/dist/components/checkbox.js.map +1 -1
  249. package/dist/components/chip-input.d.ts +69 -0
  250. package/dist/components/chip-input.js +220 -0
  251. package/dist/components/chip-input.js.map +1 -0
  252. package/dist/components/chip-labels.d.ts +2 -0
  253. package/dist/components/chip-labels.js +12 -0
  254. package/dist/components/chip-labels.js.map +1 -0
  255. package/dist/components/chip.d.ts +18 -93
  256. package/dist/components/chip.js +74 -426
  257. package/dist/components/chip.js.map +1 -1
  258. package/dist/components/choice-card.d.ts +21 -18
  259. package/dist/components/choice-card.js +45 -45
  260. package/dist/components/choice-card.js.map +1 -1
  261. package/dist/components/column-mapper-labels.d.ts +5 -0
  262. package/dist/components/column-mapper-labels.js +34 -0
  263. package/dist/components/column-mapper-labels.js.map +1 -0
  264. package/dist/components/column-mapper.d.ts +15 -17
  265. package/dist/components/column-mapper.js +11 -37
  266. package/dist/components/column-mapper.js.map +1 -1
  267. package/dist/components/combobox-core.d.ts +79 -40
  268. package/dist/components/combobox-core.js +187 -121
  269. package/dist/components/combobox-core.js.map +1 -1
  270. package/dist/components/combobox-suggestions.d.ts +76 -0
  271. package/dist/components/combobox-suggestions.js +56 -0
  272. package/dist/components/combobox-suggestions.js.map +1 -0
  273. package/dist/components/combobox.d.ts +15 -160
  274. package/dist/components/combobox.js +40 -507
  275. package/dist/components/combobox.js.map +1 -1
  276. package/dist/components/confirm-dialog-labels.d.ts +7 -0
  277. package/dist/components/confirm-dialog-labels.js +10 -0
  278. package/dist/components/confirm-dialog-labels.js.map +1 -0
  279. package/dist/components/confirm-dialog.d.ts +165 -101
  280. package/dist/components/confirm-dialog.js +2 -6
  281. package/dist/components/confirm-dialog.js.map +1 -1
  282. package/dist/components/copy-button-labels.d.ts +2 -0
  283. package/dist/components/copy-button-labels.js +12 -0
  284. package/dist/components/copy-button-labels.js.map +1 -0
  285. package/dist/components/copy-button.d.ts +74 -101
  286. package/dist/components/copy-button.js +4 -9
  287. package/dist/components/copy-button.js.map +1 -1
  288. package/dist/components/country-select-labels.d.ts +2 -0
  289. package/dist/components/country-select-labels.js +10 -0
  290. package/dist/components/country-select-labels.js.map +1 -0
  291. package/dist/components/country-select.d.ts +18 -15
  292. package/dist/components/country-select.js +49 -64
  293. package/dist/components/country-select.js.map +1 -1
  294. package/dist/components/currency-option-list.d.ts +58 -0
  295. package/dist/components/currency-option-list.js +107 -0
  296. package/dist/components/currency-option-list.js.map +1 -0
  297. package/dist/components/currency-select.d.ts +10 -30
  298. package/dist/components/currency-select.js +50 -133
  299. package/dist/components/currency-select.js.map +1 -1
  300. package/dist/components/danger-confirm-labels.d.ts +14 -0
  301. package/dist/components/danger-confirm-labels.js +17 -0
  302. package/dist/components/danger-confirm-labels.js.map +1 -0
  303. package/dist/components/danger-confirm.d.ts +448 -101
  304. package/dist/components/danger-confirm.js +40 -46
  305. package/dist/components/danger-confirm.js.map +1 -1
  306. package/dist/components/data-table-cells.d.ts +9 -24
  307. package/dist/components/data-table-cells.js +1 -1
  308. package/dist/components/data-table-cells.js.map +1 -1
  309. package/dist/components/data-table-column-settings.d.ts +16 -0
  310. package/dist/components/data-table-column-settings.js +119 -0
  311. package/dist/components/data-table-column-settings.js.map +1 -0
  312. package/dist/components/data-table-filter-popover.d.ts +5 -19
  313. package/dist/components/data-table-filter-popover.js +40 -23
  314. package/dist/components/data-table-filter-popover.js.map +1 -1
  315. package/dist/components/data-table-filters.d.ts +93 -15
  316. package/dist/components/data-table-filters.js +22 -7
  317. package/dist/components/data-table-filters.js.map +1 -1
  318. package/dist/components/data-table-head.d.ts +36 -0
  319. package/dist/components/data-table-head.js +190 -0
  320. package/dist/components/data-table-head.js.map +1 -0
  321. package/dist/components/data-table-labels.d.ts +83 -15
  322. package/dist/components/data-table-labels.js +4 -29
  323. package/dist/components/data-table-labels.js.map +1 -1
  324. package/dist/components/data-table-mobile.d.ts +106 -0
  325. package/dist/components/data-table-mobile.js +412 -0
  326. package/dist/components/data-table-mobile.js.map +1 -0
  327. package/dist/components/data-table-pagination.d.ts +6 -21
  328. package/dist/components/data-table-pagination.js +7 -7
  329. package/dist/components/data-table-pagination.js.map +1 -1
  330. package/dist/components/data-table-resize.d.ts +37 -0
  331. package/dist/components/data-table-resize.js +103 -0
  332. package/dist/components/data-table-resize.js.map +1 -0
  333. package/dist/components/data-table-row-actions.d.ts +24 -0
  334. package/dist/components/data-table-row-actions.js +95 -0
  335. package/dist/components/data-table-row-actions.js.map +1 -0
  336. package/dist/components/data-table-selection.d.ts +21 -0
  337. package/dist/components/data-table-selection.js +30 -0
  338. package/dist/components/data-table-selection.js.map +1 -0
  339. package/dist/components/data-table-shared.d.ts +93 -0
  340. package/dist/components/data-table-shared.js +99 -0
  341. package/dist/components/data-table-shared.js.map +1 -0
  342. package/dist/components/data-table-sort.d.ts +8 -10
  343. package/dist/components/data-table.d.ts +613 -15
  344. package/dist/components/data-table.js +150 -939
  345. package/dist/components/data-table.js.map +1 -1
  346. package/dist/components/date-field.d.ts +203 -0
  347. package/dist/components/date-field.js +270 -0
  348. package/dist/components/date-field.js.map +1 -0
  349. package/dist/components/date-picker.d.ts +6 -261
  350. package/dist/components/date-picker.js +24 -760
  351. package/dist/components/date-picker.js.map +1 -1
  352. package/dist/components/date-range-picker.d.ts +125 -0
  353. package/dist/components/date-range-picker.js +261 -0
  354. package/dist/components/date-range-picker.js.map +1 -0
  355. package/dist/components/description-list-labels.d.ts +2 -0
  356. package/dist/components/description-list-labels.js +8 -0
  357. package/dist/components/description-list-labels.js.map +1 -0
  358. package/dist/components/description-list.d.ts +13 -15
  359. package/dist/components/description-list.js +17 -11
  360. package/dist/components/description-list.js.map +1 -1
  361. package/dist/components/dialog-frame-labels.d.ts +2 -0
  362. package/dist/components/dialog-frame-labels.js +6 -0
  363. package/dist/components/dialog-frame-labels.js.map +1 -0
  364. package/dist/components/dialog-frame.d.ts +10 -12
  365. package/dist/components/dialog-frame.js +6 -6
  366. package/dist/components/dialog-frame.js.map +1 -1
  367. package/dist/components/disclosure.d.ts +6 -10
  368. package/dist/components/disclosure.js +11 -11
  369. package/dist/components/disclosure.js.map +1 -1
  370. package/dist/components/dropdown.d.ts +61 -30
  371. package/dist/components/dropdown.js +61 -30
  372. package/dist/components/dropdown.js.map +1 -1
  373. package/dist/components/empty-state.d.ts +64 -0
  374. package/dist/components/empty-state.js +70 -0
  375. package/dist/components/empty-state.js.map +1 -0
  376. package/dist/components/entity-combobox.d.ts +11 -12
  377. package/dist/components/entity-combobox.js +14 -13
  378. package/dist/components/entity-combobox.js.map +1 -1
  379. package/dist/components/error-boundary-labels.d.ts +2 -0
  380. package/dist/components/error-boundary-labels.js +19 -0
  381. package/dist/components/error-boundary-labels.js.map +1 -0
  382. package/dist/components/error-boundary.d.ts +17 -17
  383. package/dist/components/error-boundary.js +5 -17
  384. package/dist/components/error-boundary.js.map +1 -1
  385. package/dist/components/facing-pair.d.ts +7 -116
  386. package/dist/components/facing-pair.js +1 -1
  387. package/dist/components/facing-pair.js.map +1 -1
  388. package/dist/components/field-base.d.ts +255 -0
  389. package/dist/components/field-base.js +223 -0
  390. package/dist/components/field-base.js.map +1 -0
  391. package/dist/components/field-parts.d.ts +180 -87
  392. package/dist/components/field-parts.js +52 -33
  393. package/dist/components/field-parts.js.map +1 -1
  394. package/dist/components/field-strip.d.ts +12 -36
  395. package/dist/components/field-strip.js +24 -22
  396. package/dist/components/field-strip.js.map +1 -1
  397. package/dist/components/field-sync-labels.d.ts +2 -0
  398. package/dist/components/field-sync-labels.js +12 -0
  399. package/dist/components/field-sync-labels.js.map +1 -0
  400. package/dist/components/field-sync.d.ts +16 -19
  401. package/dist/components/field-sync.js +4 -14
  402. package/dist/components/field-sync.js.map +1 -1
  403. package/dist/components/field.d.ts +45 -10
  404. package/dist/components/field.js +10 -8
  405. package/dist/components/field.js.map +1 -1
  406. package/dist/components/file-button.d.ts +85 -101
  407. package/dist/components/file-button.js +23 -169
  408. package/dist/components/file-button.js.map +1 -1
  409. package/dist/components/file-dropzone.d.ts +9 -110
  410. package/dist/components/file-dropzone.js +22 -22
  411. package/dist/components/file-dropzone.js.map +1 -1
  412. package/dist/components/floating-panel-labels.d.ts +2 -0
  413. package/dist/components/floating-panel-labels.js +9 -0
  414. package/dist/components/floating-panel-labels.js.map +1 -0
  415. package/dist/components/floating-panel.d.ts +17 -21
  416. package/dist/components/floating-panel.js +19 -18
  417. package/dist/components/floating-panel.js.map +1 -1
  418. package/dist/components/focus-ring.d.ts +9 -6
  419. package/dist/components/focus-ring.js +2 -2
  420. package/dist/components/focus-ring.js.map +1 -1
  421. package/dist/components/form-actions-labels.d.ts +2 -0
  422. package/dist/components/form-actions-labels.js +10 -0
  423. package/dist/components/form-actions-labels.js.map +1 -0
  424. package/dist/components/form-actions.d.ts +280 -101
  425. package/dist/components/form-actions.js +10 -20
  426. package/dist/components/form-actions.js.map +1 -1
  427. package/dist/components/full-bleed-dialog.d.ts +7 -8
  428. package/dist/components/full-bleed-dialog.js +14 -13
  429. package/dist/components/full-bleed-dialog.js.map +1 -1
  430. package/dist/components/grouped-picker.d.ts +21 -16
  431. package/dist/components/grouped-picker.js +76 -62
  432. package/dist/components/grouped-picker.js.map +1 -1
  433. package/dist/components/help-popover.d.ts +60 -0
  434. package/dist/components/help-popover.js +48 -0
  435. package/dist/components/help-popover.js.map +1 -0
  436. package/dist/components/hover-menu.d.ts +12 -18
  437. package/dist/components/hover-menu.js +19 -18
  438. package/dist/components/hover-menu.js.map +1 -1
  439. package/dist/components/iban-input-labels.d.ts +2 -0
  440. package/dist/components/iban-input-labels.js +13 -0
  441. package/dist/components/iban-input-labels.js.map +1 -0
  442. package/dist/components/iban-input.d.ts +73 -101
  443. package/dist/components/iban-input.js +22 -15
  444. package/dist/components/iban-input.js.map +1 -1
  445. package/dist/components/icon-button.d.ts +300 -0
  446. package/dist/components/icon-button.js +465 -0
  447. package/dist/components/icon-button.js.map +1 -0
  448. package/dist/components/icon-picker-labels.d.ts +2 -0
  449. package/dist/components/icon-picker-labels.js +12 -0
  450. package/dist/components/icon-picker-labels.js.map +1 -0
  451. package/dist/components/icon-picker.d.ts +13 -13
  452. package/dist/components/icon-picker.js +18 -17
  453. package/dist/components/icon-picker.js.map +1 -1
  454. package/dist/components/image-grid-labels.d.ts +2 -0
  455. package/dist/components/image-grid-labels.js +10 -0
  456. package/dist/components/image-grid-labels.js.map +1 -0
  457. package/dist/components/image-grid.d.ts +12 -14
  458. package/dist/components/image-grid.js +6 -10
  459. package/dist/components/image-grid.js.map +1 -1
  460. package/dist/components/inline-edit-field-labels.d.ts +2 -0
  461. package/dist/components/inline-edit-field-labels.js +10 -0
  462. package/dist/components/inline-edit-field-labels.js.map +1 -0
  463. package/dist/components/inline-edit-field.d.ts +11 -13
  464. package/dist/components/inline-edit-field.js +11 -13
  465. package/dist/components/inline-edit-field.js.map +1 -1
  466. package/dist/components/inline-entity-combobox.d.ts +155 -0
  467. package/dist/components/inline-entity-combobox.js +467 -0
  468. package/dist/components/inline-entity-combobox.js.map +1 -0
  469. package/dist/components/input.d.ts +98 -0
  470. package/dist/components/input.js +219 -0
  471. package/dist/components/input.js.map +1 -0
  472. package/dist/components/jump-calendar.d.ts +8 -0
  473. package/dist/components/jump-calendar.js +274 -0
  474. package/dist/components/jump-calendar.js.map +1 -0
  475. package/dist/components/language-select.d.ts +20 -111
  476. package/dist/components/language-select.js +12 -7
  477. package/dist/components/language-select.js.map +1 -1
  478. package/dist/components/legal-labels.d.ts +2 -0
  479. package/dist/components/legal-labels.js +100 -0
  480. package/dist/components/legal-labels.js.map +1 -0
  481. package/dist/components/legal-page.d.ts +17 -118
  482. package/dist/components/legal-page.js +2 -2
  483. package/dist/components/legal-page.js.map +1 -1
  484. package/dist/components/legal.d.ts +353 -101
  485. package/dist/components/legal.js +7 -101
  486. package/dist/components/legal.js.map +1 -1
  487. package/dist/components/lightbox-labels.d.ts +2 -0
  488. package/dist/components/lightbox-labels.js +17 -0
  489. package/dist/components/lightbox-labels.js.map +1 -0
  490. package/dist/components/lightbox.d.ts +12 -14
  491. package/dist/components/lightbox.js +9 -19
  492. package/dist/components/lightbox.js.map +1 -1
  493. package/dist/components/line-items-labels.d.ts +2 -0
  494. package/dist/components/line-items-labels.js +13 -0
  495. package/dist/components/line-items-labels.js.map +1 -0
  496. package/dist/components/line-items.d.ts +16 -18
  497. package/dist/components/line-items.js +18 -26
  498. package/dist/components/line-items.js.map +1 -1
  499. package/dist/components/list-labels.d.ts +2 -0
  500. package/dist/components/list-labels.js +9 -0
  501. package/dist/components/list-labels.js.map +1 -0
  502. package/dist/components/list.d.ts +32 -21
  503. package/dist/components/list.js +41 -25
  504. package/dist/components/list.js.map +1 -1
  505. package/dist/components/loading-state.d.ts +5 -9
  506. package/dist/components/loading-state.js +2 -2
  507. package/dist/components/loading-state.js.map +1 -1
  508. package/dist/components/measured-grid-labels.d.ts +4 -0
  509. package/dist/components/measured-grid-labels.js +21 -0
  510. package/dist/components/measured-grid-labels.js.map +1 -0
  511. package/dist/components/measured-grid.d.ts +13 -17
  512. package/dist/components/measured-grid.js +31 -50
  513. package/dist/components/measured-grid.js.map +1 -1
  514. package/dist/components/menu-item.d.ts +10 -10
  515. package/dist/components/menu-item.js +9 -9
  516. package/dist/components/menu-item.js.map +1 -1
  517. package/dist/components/mini-calendar-labels.d.ts +4 -0
  518. package/dist/components/mini-calendar-labels.js +14 -0
  519. package/dist/components/mini-calendar-labels.js.map +1 -0
  520. package/dist/components/mini-calendar.d.ts +10 -14
  521. package/dist/components/mini-calendar.js +22 -29
  522. package/dist/components/mini-calendar.js.map +1 -1
  523. package/dist/components/modal.d.ts +34 -18
  524. package/dist/components/modal.js +13 -13
  525. package/dist/components/modal.js.map +1 -1
  526. package/dist/components/money-draft.d.ts +34 -0
  527. package/dist/components/money-draft.js +39 -0
  528. package/dist/components/money-draft.js.map +1 -0
  529. package/dist/components/money-field.d.ts +13 -111
  530. package/dist/components/money-field.js +6 -22
  531. package/dist/components/money-field.js.map +1 -1
  532. package/dist/components/month-picker-labels.d.ts +5 -0
  533. package/dist/components/month-picker-labels.js +18 -0
  534. package/dist/components/month-picker-labels.js.map +1 -0
  535. package/dist/components/month-picker.d.ts +15 -13
  536. package/dist/components/month-picker.js +34 -32
  537. package/dist/components/month-picker.js.map +1 -1
  538. package/dist/components/multi-entity-combobox.d.ts +9 -11
  539. package/dist/components/multi-entity-combobox.js +14 -13
  540. package/dist/components/multi-entity-combobox.js.map +1 -1
  541. package/dist/components/multi-select.d.ts +74 -25
  542. package/dist/components/multi-select.js +166 -197
  543. package/dist/components/multi-select.js.map +1 -1
  544. package/dist/components/nav-pills.d.ts +9 -10
  545. package/dist/components/nav-pills.js +53 -42
  546. package/dist/components/nav-pills.js.map +1 -1
  547. package/dist/components/number-field.d.ts +6 -111
  548. package/dist/components/number-field.js +2 -3
  549. package/dist/components/number-field.js.map +1 -1
  550. package/dist/components/number-input.d.ts +28 -112
  551. package/dist/components/number-input.js +33 -67
  552. package/dist/components/number-input.js.map +1 -1
  553. package/dist/components/numeric-field.d.ts +85 -0
  554. package/dist/components/numeric-field.js +71 -0
  555. package/dist/components/numeric-field.js.map +1 -0
  556. package/dist/components/numpad-sheet.d.ts +5 -108
  557. package/dist/components/numpad-sheet.js +7 -7
  558. package/dist/components/numpad-sheet.js.map +1 -1
  559. package/dist/components/one-time-code-input.d.ts +6 -7
  560. package/dist/components/one-time-code-input.js +119 -124
  561. package/dist/components/one-time-code-input.js.map +1 -1
  562. package/dist/components/page-contents-labels.d.ts +2 -0
  563. package/dist/components/page-contents-labels.js +8 -0
  564. package/dist/components/page-contents-labels.js.map +1 -0
  565. package/dist/components/page-contents.d.ts +13 -15
  566. package/dist/components/page-contents.js +7 -9
  567. package/dist/components/page-contents.js.map +1 -1
  568. package/dist/components/page-header.d.ts +10 -10
  569. package/dist/components/page-header.js +3 -3
  570. package/dist/components/page-header.js.map +1 -1
  571. package/dist/components/password-strength-labels.d.ts +2 -0
  572. package/dist/components/password-strength-labels.js +21 -0
  573. package/dist/components/password-strength-labels.js.map +1 -0
  574. package/dist/components/password-strength.d.ts +15 -17
  575. package/dist/components/password-strength.js +7 -22
  576. package/dist/components/password-strength.js.map +1 -1
  577. package/dist/components/phone-input-labels.d.ts +2 -0
  578. package/dist/components/phone-input-labels.js +9 -0
  579. package/dist/components/phone-input-labels.js.map +1 -0
  580. package/dist/components/phone-input.d.ts +75 -101
  581. package/dist/components/phone-input.js +20 -23
  582. package/dist/components/phone-input.js.map +1 -1
  583. package/dist/components/picker-sheet.d.ts +20 -8
  584. package/dist/components/picker-sheet.js +22 -11
  585. package/dist/components/picker-sheet.js.map +1 -1
  586. package/dist/components/pie-chart-labels.d.ts +2 -4
  587. package/dist/components/pie-chart.d.ts +8 -11
  588. package/dist/components/pie-chart.js +5 -5
  589. package/dist/components/pie-chart.js.map +1 -1
  590. package/dist/components/popover-labels.d.ts +2 -0
  591. package/dist/components/popover-labels.js +11 -0
  592. package/dist/components/popover-labels.js.map +1 -0
  593. package/dist/components/popover.d.ts +13 -11
  594. package/dist/components/popover.js +15 -21
  595. package/dist/components/popover.js.map +1 -1
  596. package/dist/components/progress-bar-labels.d.ts +2 -0
  597. package/dist/components/progress-bar-labels.js +9 -0
  598. package/dist/components/progress-bar-labels.js.map +1 -0
  599. package/dist/components/progress-bar.d.ts +16 -15
  600. package/dist/components/progress-bar.js +22 -20
  601. package/dist/components/progress-bar.js.map +1 -1
  602. package/dist/components/qr-code.d.ts +4 -8
  603. package/dist/components/qr-code.js.map +1 -1
  604. package/dist/components/reauth-dialog-labels.d.ts +2 -0
  605. package/dist/components/reauth-dialog-labels.js +12 -0
  606. package/dist/components/reauth-dialog-labels.js.map +1 -0
  607. package/dist/components/reauth-dialog.d.ts +8 -10
  608. package/dist/components/reauth-dialog.js +3 -8
  609. package/dist/components/reauth-dialog.js.map +1 -1
  610. package/dist/components/row-actions-labels.d.ts +2 -0
  611. package/dist/components/row-actions-labels.js +9 -0
  612. package/dist/components/row-actions-labels.js.map +1 -0
  613. package/dist/components/row-actions.d.ts +224 -101
  614. package/dist/components/row-actions.js +4 -5
  615. package/dist/components/row-actions.js.map +1 -1
  616. package/dist/components/scroll-area.d.ts +6 -9
  617. package/dist/components/scroll-area.js +4 -10
  618. package/dist/components/scroll-area.js.map +1 -1
  619. package/dist/components/search-field.d.ts +12 -27
  620. package/dist/components/search-field.js +13 -16
  621. package/dist/components/search-field.js.map +1 -1
  622. package/dist/components/select.d.ts +69 -0
  623. package/dist/components/select.js +164 -0
  624. package/dist/components/select.js.map +1 -0
  625. package/dist/components/separator.d.ts +3 -7
  626. package/dist/components/separator.js.map +1 -1
  627. package/dist/components/series-chart-axes.d.ts +120 -0
  628. package/dist/components/series-chart-axes.js +164 -0
  629. package/dist/components/series-chart-axes.js.map +1 -0
  630. package/dist/components/series-chart-budget.d.ts +8 -9
  631. package/dist/components/series-chart-labels.d.ts +2 -4
  632. package/dist/components/series-chart-thin.d.ts +34 -0
  633. package/dist/components/series-chart-thin.js +103 -0
  634. package/dist/components/series-chart-thin.js.map +1 -0
  635. package/dist/components/series-chart-ticks.d.ts +9 -11
  636. package/dist/components/series-chart.d.ts +64 -214
  637. package/dist/components/series-chart.js +69 -160
  638. package/dist/components/series-chart.js.map +1 -1
  639. package/dist/components/server-wake-labels.d.ts +2 -0
  640. package/dist/components/server-wake-labels.js +9 -0
  641. package/dist/components/server-wake-labels.js.map +1 -0
  642. package/dist/components/server-wake.d.ts +29 -26
  643. package/dist/components/server-wake.js +15 -35
  644. package/dist/components/server-wake.js.map +1 -1
  645. package/dist/components/settings-fields.d.ts +50 -120
  646. package/dist/components/settings-fields.js +13 -3
  647. package/dist/components/settings-fields.js.map +1 -1
  648. package/dist/components/share-card-labels.d.ts +2 -0
  649. package/dist/components/share-card-labels.js +29 -0
  650. package/dist/components/share-card-labels.js.map +1 -0
  651. package/dist/components/share-card.d.ts +188 -101
  652. package/dist/components/share-card.js +16 -34
  653. package/dist/components/share-card.js.map +1 -1
  654. package/dist/components/sign-chip-labels.d.ts +2 -0
  655. package/dist/components/sign-chip-labels.js +11 -0
  656. package/dist/components/sign-chip-labels.js.map +1 -0
  657. package/dist/components/sign-chip.d.ts +7 -9
  658. package/dist/components/sign-chip.js +2 -7
  659. package/dist/components/sign-chip.js.map +1 -1
  660. package/dist/components/signature-pad-labels.d.ts +2 -0
  661. package/dist/components/signature-pad-labels.js +23 -0
  662. package/dist/components/signature-pad-labels.js.map +1 -0
  663. package/dist/components/signature-pad.d.ts +16 -15
  664. package/dist/components/signature-pad.js +22 -35
  665. package/dist/components/signature-pad.js.map +1 -1
  666. package/dist/components/signed-amount-labels.d.ts +2 -0
  667. package/dist/components/signed-amount-labels.js +9 -0
  668. package/dist/components/signed-amount-labels.js.map +1 -0
  669. package/dist/components/signed-amount.d.ts +19 -21
  670. package/dist/components/signed-amount.js +12 -15
  671. package/dist/components/signed-amount.js.map +1 -1
  672. package/dist/components/skeleton.d.ts +5 -9
  673. package/dist/components/skeleton.js +1 -1
  674. package/dist/components/skeleton.js.map +1 -1
  675. package/dist/components/slider.d.ts +16 -11
  676. package/dist/components/slider.js +23 -18
  677. package/dist/components/slider.js.map +1 -1
  678. package/dist/components/sparkline-labels.d.ts +2 -0
  679. package/dist/components/sparkline-labels.js +13 -0
  680. package/dist/components/sparkline-labels.js.map +1 -0
  681. package/dist/components/sparkline.d.ts +10 -12
  682. package/dist/components/sparkline.js +10 -17
  683. package/dist/components/sparkline.js.map +1 -1
  684. package/dist/components/spinner.d.ts +45 -0
  685. package/dist/components/spinner.js +39 -0
  686. package/dist/components/spinner.js.map +1 -0
  687. package/dist/components/stat-tile-labels.d.ts +2 -0
  688. package/dist/components/stat-tile-labels.js +14 -0
  689. package/dist/components/stat-tile-labels.js.map +1 -0
  690. package/dist/components/stat-tile.d.ts +21 -17
  691. package/dist/components/stat-tile.js +25 -32
  692. package/dist/components/stat-tile.js.map +1 -1
  693. package/dist/components/status-dot.d.ts +8 -20
  694. package/dist/components/status-dot.js +13 -12
  695. package/dist/components/status-dot.js.map +1 -1
  696. package/dist/components/swatch-picker-labels.d.ts +2 -0
  697. package/dist/components/swatch-picker-labels.js +9 -0
  698. package/dist/components/swatch-picker-labels.js.map +1 -0
  699. package/dist/components/swatch-picker.d.ts +14 -14
  700. package/dist/components/swatch-picker.js +14 -8
  701. package/dist/components/swatch-picker.js.map +1 -1
  702. package/dist/components/swipeable-row.d.ts +11 -39
  703. package/dist/components/swipeable-row.js +13 -18
  704. package/dist/components/swipeable-row.js.map +1 -1
  705. package/dist/components/switch.d.ts +10 -13
  706. package/dist/components/switch.js +13 -12
  707. package/dist/components/switch.js.map +1 -1
  708. package/dist/components/table.d.ts +28 -31
  709. package/dist/components/table.js +15 -15
  710. package/dist/components/table.js.map +1 -1
  711. package/dist/components/tabs.d.ts +182 -0
  712. package/dist/components/tabs.js +312 -0
  713. package/dist/components/tabs.js.map +1 -0
  714. package/dist/components/text-link.d.ts +17 -119
  715. package/dist/components/text-link.js +7 -7
  716. package/dist/components/text-link.js.map +1 -1
  717. package/dist/components/text.d.ts +14 -13
  718. package/dist/components/text.js +5 -5
  719. package/dist/components/text.js.map +1 -1
  720. package/dist/components/textarea.d.ts +20 -0
  721. package/dist/components/textarea.js +161 -0
  722. package/dist/components/textarea.js.map +1 -0
  723. package/dist/components/tile-radio.d.ts +6 -10
  724. package/dist/components/tile-radio.js +6 -6
  725. package/dist/components/tile-radio.js.map +1 -1
  726. package/dist/components/time-input.d.ts +5 -108
  727. package/dist/components/time-input.js +14 -5
  728. package/dist/components/time-input.js.map +1 -1
  729. package/dist/components/toast-labels.d.ts +2 -0
  730. package/dist/components/toast-labels.js +11 -0
  731. package/dist/components/toast-labels.js.map +1 -0
  732. package/dist/components/toast.d.ts +21 -23
  733. package/dist/components/toast.js +1 -6
  734. package/dist/components/toast.js.map +1 -1
  735. package/dist/components/toggle-group.d.ts +26 -26
  736. package/dist/components/toggle-group.js +43 -19
  737. package/dist/components/toggle-group.js.map +1 -1
  738. package/dist/components/toggle-legend.d.ts +18 -21
  739. package/dist/components/toggle-legend.js +3 -3
  740. package/dist/components/toggle-legend.js.map +1 -1
  741. package/dist/components/tooltip.d.ts +24 -15
  742. package/dist/components/tooltip.js +24 -14
  743. package/dist/components/tooltip.js.map +1 -1
  744. package/dist/components/translation-review-editor.d.ts +8 -10
  745. package/dist/components/translation-review-editor.js +6 -5
  746. package/dist/components/translation-review-editor.js.map +1 -1
  747. package/dist/components/translation-review-labels.d.ts +4 -7
  748. package/dist/components/translation-review-parts.d.ts +19 -28
  749. package/dist/components/translation-review-parts.js +4 -7
  750. package/dist/components/translation-review-parts.js.map +1 -1
  751. package/dist/components/translation-review.d.ts +35 -25
  752. package/dist/components/translation-review.js +24 -13
  753. package/dist/components/translation-review.js.map +1 -1
  754. package/dist/components/tree-view.d.ts +7 -11
  755. package/dist/components/tree-view.js +8 -8
  756. package/dist/components/tree-view.js.map +1 -1
  757. package/dist/components/treemap.d.ts +9 -13
  758. package/dist/components/trigger-aria.d.ts +2 -4
  759. package/dist/components/ui-labels.d.ts +5 -0
  760. package/dist/components/ui-labels.js +20 -0
  761. package/dist/components/ui-labels.js.map +1 -0
  762. package/dist/components/use-anchor-dir.d.ts +3 -6
  763. package/dist/components/use-mobile-reveal.d.ts +2 -6
  764. package/dist/components/use-table-state.d.ts +182 -15
  765. package/dist/components/use-table-state.js +53 -8
  766. package/dist/components/use-table-state.js.map +1 -1
  767. package/dist/components/user-avatar.d.ts +10 -23
  768. package/dist/components/user-avatar.js +1 -1
  769. package/dist/components/user-avatar.js.map +1 -1
  770. package/dist/components/wizard-stepper.d.ts +4 -7
  771. package/dist/components/wizard-stepper.js +3 -3
  772. package/dist/components/wizard-stepper.js.map +1 -1
  773. package/dist/components/write-lock-labels.d.ts +2 -0
  774. package/dist/components/write-lock-labels.js +8 -0
  775. package/dist/components/write-lock-labels.js.map +1 -0
  776. package/dist/components/write-lock.d.ts +16 -18
  777. package/dist/components/write-lock.js +4 -8
  778. package/dist/components/write-lock.js.map +1 -1
  779. package/dist/currencies.d.ts +23 -0
  780. package/dist/currencies.js +38 -0
  781. package/dist/currencies.js.map +1 -0
  782. package/dist/data-table.d.ts +10 -18
  783. package/dist/data-table.js.map +1 -1
  784. package/dist/demo/demo-banner.d.ts +15 -13
  785. package/dist/demo/demo-banner.js +5 -19
  786. package/dist/demo/demo-banner.js.map +1 -1
  787. package/dist/demo/demo-countdown.d.ts +7 -9
  788. package/dist/demo/demo-ended.d.ts +10 -13
  789. package/dist/demo/demo-ended.js +4 -4
  790. package/dist/demo/demo-ended.js.map +1 -1
  791. package/dist/demo/demo-labels.d.ts +4 -5
  792. package/dist/demo/demo-labels.js.map +1 -1
  793. package/dist/demo/demo-session.d.ts +3 -5
  794. package/dist/demo/demo-start.d.ts +8 -11
  795. package/dist/demo/demo-start.js +3 -2
  796. package/dist/demo/demo-start.js.map +1 -1
  797. package/dist/eslint/index.d.ts +83 -0
  798. package/dist/eslint/index.js +69 -0
  799. package/dist/eslint/index.js.map +1 -0
  800. package/dist/eslint/role-utilities.d.ts +48 -0
  801. package/dist/eslint/role-utilities.generated.d.ts +2 -0
  802. package/dist/eslint/role-utilities.generated.js +143 -0
  803. package/dist/eslint/role-utilities.generated.js.map +1 -0
  804. package/dist/eslint/role-utilities.js +43 -0
  805. package/dist/eslint/role-utilities.js.map +1 -0
  806. package/dist/feedback/feedback-attachment-labels.d.ts +16 -0
  807. package/dist/feedback/feedback-attachment-labels.js +17 -0
  808. package/dist/feedback/feedback-attachment-labels.js.map +1 -0
  809. package/dist/feedback/feedback-attachment.d.ts +312 -101
  810. package/dist/feedback/feedback-attachment.js +31 -42
  811. package/dist/feedback/feedback-attachment.js.map +1 -1
  812. package/dist/feedback/feedback-capture.d.ts +2 -4
  813. package/dist/feedback/feedback-context-labels.d.ts +2 -0
  814. package/dist/feedback/feedback-context-labels.js +9 -0
  815. package/dist/feedback/feedback-context-labels.js.map +1 -0
  816. package/dist/feedback/feedback-context.d.ts +110 -101
  817. package/dist/feedback/feedback-context.js +4 -7
  818. package/dist/feedback/feedback-context.js.map +1 -1
  819. package/dist/feedback/feedback-crash.d.ts +8 -110
  820. package/dist/feedback/feedback-dialog-labels.d.ts +12 -0
  821. package/dist/feedback/feedback-dialog-labels.js +17 -0
  822. package/dist/feedback/feedback-dialog-labels.js.map +1 -0
  823. package/dist/feedback/feedback-dialog.d.ts +186 -101
  824. package/dist/feedback/feedback-dialog.js +8 -18
  825. package/dist/feedback/feedback-dialog.js.map +1 -1
  826. package/dist/feedback/feedback-inbox.d.ts +236 -101
  827. package/dist/feedback/feedback-inbox.js +31 -141
  828. package/dist/feedback/feedback-inbox.js.map +1 -1
  829. package/dist/feedback/feedback-labels.d.ts +83 -101
  830. package/dist/feedback/feedback-labels.js.map +1 -1
  831. package/dist/feedback/feedback-menu-labels.d.ts +2 -0
  832. package/dist/feedback/feedback-menu-labels.js +10 -0
  833. package/dist/feedback/feedback-menu-labels.js.map +1 -0
  834. package/dist/feedback/feedback-menu.d.ts +83 -101
  835. package/dist/feedback/feedback-menu.js +3 -7
  836. package/dist/feedback/feedback-menu.js.map +1 -1
  837. package/dist/feedback/feedback-record.d.ts +239 -101
  838. package/dist/feedback/feedback-record.js +7 -2
  839. package/dist/feedback/feedback-record.js.map +1 -1
  840. package/dist/feedback/feedback-row-detail-labels.d.ts +2 -0
  841. package/dist/feedback/feedback-row-detail-labels.js +31 -0
  842. package/dist/feedback/feedback-row-detail-labels.js.map +1 -0
  843. package/dist/feedback/feedback-row-detail.d.ts +315 -101
  844. package/dist/feedback/feedback-row-detail.js +11 -37
  845. package/dist/feedback/feedback-row-detail.js.map +1 -1
  846. package/dist/feedback/feedback-status-undo.d.ts +66 -101
  847. package/dist/feedback/feedback-status-undo.js.map +1 -1
  848. package/dist/feedback/feedback-submit-dialog.d.ts +39 -0
  849. package/dist/feedback/feedback-submit-dialog.js +66 -0
  850. package/dist/feedback/feedback-submit-dialog.js.map +1 -0
  851. package/dist/feedback/feedback-submit.d.ts +13 -109
  852. package/dist/feedback/feedback-submit.js +19 -40
  853. package/dist/feedback/feedback-submit.js.map +1 -1
  854. package/dist/feedback/feedback-swipe.d.ts +9 -110
  855. package/dist/feedback/feedback-swipe.js +1 -1
  856. package/dist/feedback/feedback-swipe.js.map +1 -1
  857. package/dist/feedback/feedback-table-labels.d.ts +2 -0
  858. package/dist/feedback/feedback-table-labels.js +26 -0
  859. package/dist/feedback/feedback-table-labels.js.map +1 -0
  860. package/dist/feedback/feedback-table.d.ts +314 -101
  861. package/dist/feedback/feedback-table.js +11 -31
  862. package/dist/feedback/feedback-table.js.map +1 -1
  863. package/dist/feedback/feedback-thread-labels.d.ts +3 -0
  864. package/dist/feedback/feedback-thread-labels.js +21 -0
  865. package/dist/feedback/feedback-thread-labels.js.map +1 -0
  866. package/dist/feedback/feedback-thread.d.ts +373 -101
  867. package/dist/feedback/feedback-thread.js +26 -35
  868. package/dist/feedback/feedback-thread.js.map +1 -1
  869. package/dist/feedback/feedback-vocabulary.d.ts +98 -0
  870. package/dist/feedback/feedback-vocabulary.js +143 -0
  871. package/dist/feedback/feedback-vocabulary.js.map +1 -0
  872. package/dist/feedback.d.ts +15 -105
  873. package/dist/hooks/use-anchored-panel.d.ts +77 -15
  874. package/dist/hooks/use-anchored-panel.js +34 -1
  875. package/dist/hooks/use-anchored-panel.js.map +1 -1
  876. package/dist/hooks/use-anchored-rect.d.ts +15 -6
  877. package/dist/hooks/use-anchored-rect.js +2 -1
  878. package/dist/hooks/use-anchored-rect.js.map +1 -1
  879. package/dist/hooks/use-announce.d.ts +4 -6
  880. package/dist/hooks/use-authed-src.d.ts +5 -7
  881. package/dist/hooks/use-authed-src.js +3 -7
  882. package/dist/hooks/use-authed-src.js.map +1 -1
  883. package/dist/hooks/use-body-scroll-lock.d.ts +1 -3
  884. package/dist/hooks/use-breakpoint.d.ts +16 -17
  885. package/dist/hooks/use-breakpoint.js.map +1 -1
  886. package/dist/hooks/use-close-transition.d.ts +8 -8
  887. package/dist/hooks/use-close-transition.js +5 -4
  888. package/dist/hooks/use-close-transition.js.map +1 -1
  889. package/dist/hooks/use-copy-to-clipboard.d.ts +5 -7
  890. package/dist/hooks/use-debounce.d.ts +13 -6
  891. package/dist/hooks/use-debounce.js.map +1 -1
  892. package/dist/hooks/use-dismiss.d.ts +50 -11
  893. package/dist/hooks/use-dismiss.js +80 -9
  894. package/dist/hooks/use-dismiss.js.map +1 -1
  895. package/dist/hooks/use-file-drop.d.ts +8 -110
  896. package/dist/hooks/use-file-drop.js +1 -1
  897. package/dist/hooks/use-file-drop.js.map +1 -1
  898. package/dist/hooks/use-file-picker-labels.d.ts +2 -0
  899. package/dist/hooks/use-file-picker-labels.js +25 -0
  900. package/dist/hooks/use-file-picker-labels.js.map +1 -0
  901. package/dist/hooks/use-file-picker.d.ts +164 -0
  902. package/dist/hooks/use-file-picker.js +149 -0
  903. package/dist/hooks/use-file-picker.js.map +1 -0
  904. package/dist/hooks/use-focus-trap.d.ts +2 -4
  905. package/dist/hooks/use-focus-trap.js +2 -1
  906. package/dist/hooks/use-focus-trap.js.map +1 -1
  907. package/dist/hooks/use-hotkey.d.ts +7 -9
  908. package/dist/hooks/use-large-text.d.ts +3 -5
  909. package/dist/hooks/use-media-query.d.ts +1 -3
  910. package/dist/hooks/use-noindex.d.ts +1 -3
  911. package/dist/hooks/use-overlay-history.d.ts +1 -3
  912. package/dist/hooks/use-row-swipe.d.ts +5 -8
  913. package/dist/hooks/use-search-param-state.d.ts +10 -11
  914. package/dist/hooks/use-windowed-rows.d.ts +4 -7
  915. package/dist/i18n/defaults.d.ts +11 -105
  916. package/dist/i18n/defaults.js +82 -82
  917. package/dist/i18n/defaults.js.map +1 -1
  918. package/dist/i18n/german.d.ts +2 -105
  919. package/dist/i18n/german.js +52 -8
  920. package/dist/i18n/german.js.map +1 -1
  921. package/dist/i18n/kit-labels.d.ts +619 -101
  922. package/dist/i18n/kit-labels.js +22 -97
  923. package/dist/i18n/kit-labels.js.map +1 -1
  924. package/dist/i18n/languages.d.ts +197 -101
  925. package/dist/i18n/locales/de-CH.d.ts +3 -106
  926. package/dist/i18n/locales/en.d.ts +4 -105
  927. package/dist/i18n/locales/en.js +1834 -4
  928. package/dist/i18n/locales/en.js.map +7 -1
  929. package/dist/i18n/locales/es.d.ts +3 -106
  930. package/dist/i18n/locales/es.js +52 -8
  931. package/dist/i18n/locales/es.js.map +1 -1
  932. package/dist/i18n/locales/fr.d.ts +3 -106
  933. package/dist/i18n/locales/fr.js +56 -10
  934. package/dist/i18n/locales/fr.js.map +1 -1
  935. package/dist/i18n/locales/hu.d.ts +3 -106
  936. package/dist/i18n/locales/hu.js +52 -8
  937. package/dist/i18n/locales/hu.js.map +1 -1
  938. package/dist/i18n/locales/it.d.ts +3 -106
  939. package/dist/i18n/locales/it.js +56 -10
  940. package/dist/i18n/locales/it.js.map +1 -1
  941. package/dist/i18n/locales/zh.d.ts +3 -106
  942. package/dist/i18n/locales/zh.js +51 -8
  943. package/dist/i18n/locales/zh.js.map +1 -1
  944. package/dist/i18n/plurals.d.ts +3 -5
  945. package/dist/i18n/review.d.ts +5 -107
  946. package/dist/i18n/review.js +4 -0
  947. package/dist/i18n/review.js.map +1 -1
  948. package/dist/i18n/shared-labels.d.ts +17 -0
  949. package/dist/i18n/shared-labels.js +111 -0
  950. package/dist/i18n/shared-labels.js.map +1 -0
  951. package/dist/i18n/swiss.d.ts +1 -3
  952. package/dist/index.d.ts +330 -249
  953. package/dist/index.js +188 -215
  954. package/dist/index.js.map +1 -1
  955. package/dist/landing/access.d.ts +7 -9
  956. package/dist/landing/access.js.map +1 -1
  957. package/dist/landing/landing-actions.d.ts +9 -109
  958. package/dist/landing/landing-actions.js +1 -1
  959. package/dist/landing/landing-actions.js.map +1 -1
  960. package/dist/landing/landing-labels.d.ts +4 -5
  961. package/dist/landing/landing-labels.js.map +1 -1
  962. package/dist/landing/landing-sections.d.ts +21 -121
  963. package/dist/landing/landing-sections.js +15 -15
  964. package/dist/landing/landing-sections.js.map +1 -1
  965. package/dist/landing/page-seo.d.ts +7 -9
  966. package/dist/landing/public-header.d.ts +10 -111
  967. package/dist/landing/public-header.js +1 -1
  968. package/dist/landing/public-header.js.map +1 -1
  969. package/dist/landing/routing.d.ts +16 -120
  970. package/dist/landing/routing.js +2 -6
  971. package/dist/landing/routing.js.map +1 -1
  972. package/dist/lib/calc.d.ts +7 -9
  973. package/dist/lib/chip-edge-snap.d.ts +10 -0
  974. package/dist/lib/chip-edge-snap.js +131 -0
  975. package/dist/lib/chip-edge-snap.js.map +1 -0
  976. package/dist/lib/clipping.d.ts +2 -4
  977. package/dist/lib/cn.d.ts +2 -5
  978. package/dist/lib/cn.js +6 -1
  979. package/dist/lib/cn.js.map +1 -1
  980. package/dist/lib/column-mapping.d.ts +10 -13
  981. package/dist/lib/countries.d.ts +3 -5
  982. package/dist/lib/dates.d.ts +25 -25
  983. package/dist/lib/dates.js.map +1 -1
  984. package/dist/lib/decimal-marks.d.ts +8 -8
  985. package/dist/lib/decimal-marks.js +1 -1
  986. package/dist/lib/decimal-marks.js.map +1 -1
  987. package/dist/lib/direction.d.ts +4 -6
  988. package/dist/lib/document-navigation.d.ts +1 -3
  989. package/dist/lib/download.d.ts +67 -0
  990. package/dist/lib/download.js +89 -0
  991. package/dist/lib/download.js.map +1 -0
  992. package/dist/lib/floating-stack.d.ts +8 -6
  993. package/dist/lib/floating-stack.js.map +1 -1
  994. package/dist/lib/format.d.ts +24 -23
  995. package/dist/lib/format.js +3 -3
  996. package/dist/lib/format.js.map +1 -1
  997. package/dist/lib/iban.d.ts +9 -11
  998. package/dist/lib/ime.d.ts +17 -0
  999. package/dist/lib/ime.js +13 -0
  1000. package/dist/lib/ime.js.map +1 -0
  1001. package/dist/lib/isin.d.ts +3 -5
  1002. package/dist/lib/logger.d.ts +4 -6
  1003. package/dist/lib/person-name.d.ts +3 -5
  1004. package/dist/lib/phone.d.ts +9 -11
  1005. package/dist/lib/presence.d.ts +15 -0
  1006. package/dist/lib/presence.js +8 -0
  1007. package/dist/lib/presence.js.map +1 -0
  1008. package/dist/lib/qr-encode.d.ts +4 -6
  1009. package/dist/lib/reduced-motion.d.ts +18 -0
  1010. package/dist/lib/reduced-motion.js +9 -0
  1011. package/dist/lib/reduced-motion.js.map +1 -0
  1012. package/dist/lib/refs.d.ts +13 -0
  1013. package/dist/lib/refs.js +15 -0
  1014. package/dist/lib/refs.js.map +1 -0
  1015. package/dist/lib/round.d.ts +22 -0
  1016. package/dist/lib/round.js +15 -0
  1017. package/dist/lib/round.js.map +1 -0
  1018. package/dist/lib/safe-storage.d.ts +14 -4
  1019. package/dist/lib/safe-storage.js +24 -9
  1020. package/dist/lib/safe-storage.js.map +1 -1
  1021. package/dist/lib/server-wake.d.ts +13 -14
  1022. package/dist/lib/session-refresh.d.ts +200 -0
  1023. package/dist/lib/session-refresh.js +187 -0
  1024. package/dist/lib/session-refresh.js.map +1 -0
  1025. package/dist/lib/strip-fade.d.ts +10 -13
  1026. package/dist/lib/table-text.d.ts +14 -16
  1027. package/dist/lib/text-match.d.ts +41 -0
  1028. package/dist/lib/text-match.js +23 -0
  1029. package/dist/lib/text-match.js.map +1 -0
  1030. package/dist/lib/translation-review.d.ts +44 -45
  1031. package/dist/lib/translation-review.js +3 -2
  1032. package/dist/lib/translation-review.js.map +1 -1
  1033. package/dist/rhf/fields.d.ts +131 -197
  1034. package/dist/rhf/fields.js +46 -54
  1035. package/dist/rhf/fields.js.map +1 -1
  1036. package/dist/rhf/form-core.d.ts +154 -0
  1037. package/dist/rhf/form-core.js +143 -0
  1038. package/dist/rhf/form-core.js.map +1 -0
  1039. package/dist/rhf/form.d.ts +9 -175
  1040. package/dist/rhf/form.js +2 -139
  1041. package/dist/rhf/form.js.map +1 -1
  1042. package/dist/rhf/line-items.d.ts +40 -11
  1043. package/dist/rhf/line-items.js.map +1 -1
  1044. package/dist/rhf/use-rhf-wizard-step.d.ts +3 -6
  1045. package/dist/rhf/use-rhf-wizard-step.js.map +1 -1
  1046. package/dist/rhf.d.ts +4 -118
  1047. package/dist/rhf.js.map +1 -1
  1048. package/dist/search/command-palette-labels.d.ts +2 -0
  1049. package/dist/search/command-palette-labels.js +15 -0
  1050. package/dist/search/command-palette-labels.js.map +1 -0
  1051. package/dist/search/command-palette.d.ts +10 -13
  1052. package/dist/search/command-palette.js +25 -44
  1053. package/dist/search/command-palette.js.map +1 -1
  1054. package/dist/search/global-search-labels.d.ts +2 -0
  1055. package/dist/search/global-search-labels.js +12 -0
  1056. package/dist/search/global-search-labels.js.map +1 -0
  1057. package/dist/search/global-search.d.ts +17 -16
  1058. package/dist/search/global-search.js +12 -12
  1059. package/dist/search/global-search.js.map +1 -1
  1060. package/dist/search/search-index.d.ts +13 -14
  1061. package/dist/search/search-index.js +2 -1
  1062. package/dist/search/search-index.js.map +1 -1
  1063. package/dist/search/use-command-key.d.ts +9 -0
  1064. package/dist/search/use-command-key.js +18 -0
  1065. package/dist/search/use-command-key.js.map +1 -0
  1066. package/dist/search.d.ts +3 -4
  1067. package/dist/settings/settings-card-classes.d.ts +11 -0
  1068. package/dist/settings/settings-card-classes.js +8 -0
  1069. package/dist/settings/settings-card-classes.js.map +1 -0
  1070. package/dist/settings/settings-catalogue.d.ts +11 -14
  1071. package/dist/settings/settings-context.d.ts +4 -8
  1072. package/dist/settings/settings-heading.d.ts +8 -12
  1073. package/dist/settings/settings-heading.js +1 -1
  1074. package/dist/settings/settings-heading.js.map +1 -1
  1075. package/dist/settings/settings-labels.d.ts +6 -7
  1076. package/dist/settings/settings-labels.js.map +1 -1
  1077. package/dist/settings/settings-layout.d.ts +36 -13
  1078. package/dist/settings/settings-layout.js +25 -4
  1079. package/dist/settings/settings-layout.js.map +1 -1
  1080. package/dist/settings/settings-section.d.ts +4 -107
  1081. package/dist/settings/settings-section.js +2 -2
  1082. package/dist/settings/settings-section.js.map +1 -1
  1083. package/dist/settings/use-account-appearance.d.ts +11 -16
  1084. package/dist/settings/use-account-language.d.ts +8 -10
  1085. package/dist/settings/use-settings-focus.d.ts +3 -5
  1086. package/dist/settings/use-settings-focus.js +2 -4
  1087. package/dist/settings/use-settings-focus.js.map +1 -1
  1088. package/dist/settings/use-settings-route.d.ts +11 -16
  1089. package/dist/shell/app-shell-labels.d.ts +2 -0
  1090. package/dist/shell/app-shell-labels.js +9 -0
  1091. package/dist/shell/app-shell-labels.js.map +1 -0
  1092. package/dist/shell/app-shell.d.ts +216 -101
  1093. package/dist/shell/app-shell.js +51 -35
  1094. package/dist/shell/app-shell.js.map +1 -1
  1095. package/dist/shell/auth-layout.d.ts +6 -9
  1096. package/dist/shell/auth-layout.js +6 -6
  1097. package/dist/shell/auth-layout.js.map +1 -1
  1098. package/dist/shell/company-switcher-labels.d.ts +2 -0
  1099. package/dist/shell/company-switcher-labels.js +12 -0
  1100. package/dist/shell/company-switcher-labels.js.map +1 -0
  1101. package/dist/shell/company-switcher.d.ts +9 -11
  1102. package/dist/shell/company-switcher.js +3 -9
  1103. package/dist/shell/company-switcher.js.map +1 -1
  1104. package/dist/shell/option-switcher-menu.d.ts +7 -9
  1105. package/dist/shell/option-switcher-menu.js +4 -4
  1106. package/dist/shell/option-switcher-menu.js.map +1 -1
  1107. package/dist/shell/role-switcher.d.ts +3 -7
  1108. package/dist/shell/role-switcher.js +3 -3
  1109. package/dist/shell/role-switcher.js.map +1 -1
  1110. package/dist/shell/top-bar-brand.d.ts +4 -107
  1111. package/dist/shell/top-bar-brand.js +1 -1
  1112. package/dist/shell/top-bar-brand.js.map +1 -1
  1113. package/dist/shell/top-bar.d.ts +6 -9
  1114. package/dist/shell/top-bar.js +1 -1
  1115. package/dist/shell/top-bar.js.map +1 -1
  1116. package/dist/shell/topbar-action-menu.d.ts +18 -31
  1117. package/dist/shell/topbar-action-menu.js +10 -10
  1118. package/dist/shell/topbar-action-menu.js.map +1 -1
  1119. package/dist/shell/topbar-controls.d.ts +14 -21
  1120. package/dist/shell/topbar-controls.js +11 -11
  1121. package/dist/shell/topbar-controls.js.map +1 -1
  1122. package/dist/shell.d.ts +25 -116
  1123. package/dist/shell.js.map +1 -1
  1124. package/dist/table-text.d.ts +1 -1
  1125. package/dist/testing/css-variables.d.ts +4 -6
  1126. package/dist/testing/css-variables.generated.d.ts +2 -4
  1127. package/dist/testing/index.d.ts +2 -1
  1128. package/dist/testing/index.js +11 -1
  1129. package/dist/testing/index.js.map +1 -1
  1130. package/dist/testing/viewport.d.ts +116 -0
  1131. package/dist/testing/viewport.js +203 -0
  1132. package/dist/testing/viewport.js.map +1 -0
  1133. package/dist/theme/chart-palette.d.ts +7 -9
  1134. package/dist/theme/color.d.ts +14 -16
  1135. package/dist/theme/color.js +2 -1
  1136. package/dist/theme/color.js.map +1 -1
  1137. package/dist/theme/contrast-tokens.d.ts +13 -17
  1138. package/dist/theme/contrast.d.ts +22 -27
  1139. package/dist/theme/palette-derive.d.ts +16 -22
  1140. package/dist/theme/palette-derive.js +15 -7
  1141. package/dist/theme/palette-derive.js.map +1 -1
  1142. package/dist/theme/palette-presets.d.ts +18 -15
  1143. package/dist/theme/palette-presets.js +66 -345
  1144. package/dist/theme/palette-presets.js.map +1 -1
  1145. package/dist/theme/palette-store.d.ts +10 -13
  1146. package/dist/theme/text-size.d.ts +21 -26
  1147. package/dist/theme/theme-store.d.ts +9 -14
  1148. package/dist/tour/tour-labels.d.ts +2 -0
  1149. package/dist/tour/tour-labels.js +13 -0
  1150. package/dist/tour/tour-labels.js.map +1 -0
  1151. package/dist/tour/tour.d.ts +12 -14
  1152. package/dist/tour/tour.js +9 -20
  1153. package/dist/tour/tour.js.map +1 -1
  1154. package/dist/tour.d.ts +1 -2
  1155. package/dist/wizard/stepper-nav.d.ts +8 -108
  1156. package/dist/wizard/stepper-nav.js +15 -14
  1157. package/dist/wizard/stepper-nav.js.map +1 -1
  1158. package/dist/wizard/types.d.ts +13 -18
  1159. package/dist/wizard/types.js +2 -24
  1160. package/dist/wizard/types.js.map +1 -1
  1161. package/dist/wizard/use-wizard.d.ts +2 -6
  1162. package/dist/wizard/validation.d.ts +3 -7
  1163. package/dist/wizard/wizard-context.d.ts +7 -11
  1164. package/dist/wizard/wizard-labels.d.ts +4 -0
  1165. package/dist/wizard/wizard-labels.js +24 -0
  1166. package/dist/wizard/wizard-labels.js.map +1 -0
  1167. package/dist/wizard/wizard-step.d.ts +3 -7
  1168. package/dist/wizard/wizard-step.js +2 -2
  1169. package/dist/wizard/wizard-step.js.map +1 -1
  1170. package/dist/wizard/wizard-summary.d.ts +5 -8
  1171. package/dist/wizard/wizard-summary.js +6 -5
  1172. package/dist/wizard/wizard-summary.js.map +1 -1
  1173. package/dist/wizard.d.ts +7 -107
  1174. package/package.json +17 -6
  1175. package/src/account/account-parts.ts +3 -3
  1176. package/src/{components → account}/account-settings-labels.ts +11 -1
  1177. package/src/{components → account}/account-settings.tsx +145 -34
  1178. package/src/account/data-export-setting-labels.ts +20 -0
  1179. package/src/account/data-export-setting.tsx +19 -38
  1180. package/src/account/delete-account-setting-labels.ts +33 -0
  1181. package/src/account/delete-account-setting.tsx +13 -35
  1182. package/src/account/email-change-setting-labels.ts +32 -0
  1183. package/src/account/email-change-setting.tsx +17 -36
  1184. package/src/{components → account}/passkeys-setting.tsx +14 -9
  1185. package/src/account/sessions-setting-labels.ts +24 -0
  1186. package/src/account/sessions-setting.tsx +14 -28
  1187. package/src/admin/admin-action-confirm-labels.ts +19 -0
  1188. package/src/admin/admin-action-confirm.tsx +59 -38
  1189. package/src/admin/admin-action-log-labels.ts +74 -0
  1190. package/src/admin/admin-action-log.tsx +180 -54
  1191. package/src/admin/admin-parts.ts +0 -7
  1192. package/src/admin/invitations-panel-labels.ts +31 -0
  1193. package/src/admin/invitations-panel.tsx +16 -34
  1194. package/src/admin/plan-change-labels.ts +23 -0
  1195. package/src/admin/plan-change.tsx +58 -34
  1196. package/src/admin/reviewer-scope-editor-labels.ts +16 -0
  1197. package/src/admin/reviewer-scope-editor.tsx +9 -15
  1198. package/src/admin/role-select-labels.ts +16 -0
  1199. package/src/admin/role-select.tsx +13 -23
  1200. package/src/admin/transfer-ownership-dialog-labels.ts +20 -0
  1201. package/src/admin/transfer-ownership-dialog.tsx +7 -17
  1202. package/src/admin/user-roster-labels.ts +17 -0
  1203. package/src/admin/user-roster.tsx +9 -17
  1204. package/src/auth/accept-invitation-labels.ts +21 -0
  1205. package/src/auth/accept-invitation.tsx +9 -20
  1206. package/src/auth/auth-errors.ts +29 -13
  1207. package/src/auth/complete-name-dialog-labels.ts +16 -0
  1208. package/src/auth/complete-name-dialog.tsx +8 -13
  1209. package/src/auth/forgot-password-form-labels.ts +20 -0
  1210. package/src/auth/forgot-password-form.tsx +12 -20
  1211. package/src/auth/new-password-fields.tsx +1 -1
  1212. package/src/auth/not-found-page-labels.ts +12 -0
  1213. package/src/auth/not-found-page.tsx +11 -14
  1214. package/src/auth/register-form-labels.ts +31 -0
  1215. package/src/auth/register-form.tsx +18 -38
  1216. package/src/auth/reset-password-form-labels.ts +27 -0
  1217. package/src/auth/reset-password-form.tsx +12 -27
  1218. package/src/auth/sign-in-form-labels.ts +44 -0
  1219. package/src/auth/sign-in-form.tsx +18 -51
  1220. package/src/auth/status-parts.tsx +3 -3
  1221. package/src/auth/verify-email-labels.ts +26 -0
  1222. package/src/auth/verify-email.tsx +15 -30
  1223. package/src/billing/billing-banner.tsx +7 -3
  1224. package/src/billing/billing-labels.ts +1 -1
  1225. package/src/billing/checkout-processing.ts +2 -12
  1226. package/src/billing/plan-card.tsx +26 -27
  1227. package/src/billing/plan-limit-notice.tsx +6 -3
  1228. package/src/billing/subscription-actions.tsx +4 -4
  1229. package/src/chart.ts +9 -1
  1230. package/src/components/account-chips-labels.ts +16 -0
  1231. package/src/components/account-chips.tsx +13 -16
  1232. package/src/components/alert-banner.tsx +39 -19
  1233. package/src/components/amount-input.tsx +458 -406
  1234. package/src/components/appearance-labels.ts +2 -2
  1235. package/src/components/authed-image-labels.ts +11 -0
  1236. package/src/components/authed-image.tsx +6 -8
  1237. package/src/components/autocomplete.tsx +79 -78
  1238. package/src/components/breadcrumbs-labels.ts +10 -0
  1239. package/src/components/breadcrumbs.tsx +17 -14
  1240. package/src/components/bulk-action-bar-labels.ts +11 -0
  1241. package/src/components/bulk-action-bar.tsx +15 -16
  1242. package/src/components/button-group.tsx +7 -7
  1243. package/src/components/button-look.ts +230 -0
  1244. package/src/components/button.tsx +339 -0
  1245. package/src/components/calculator.tsx +14 -12
  1246. package/src/components/calendar-heatmap-labels.ts +15 -0
  1247. package/src/components/calendar-heatmap.tsx +13 -19
  1248. package/src/components/card.tsx +381 -0
  1249. package/src/components/chart-zoom-core.tsx +653 -0
  1250. package/src/components/chart-zoom.tsx +21 -648
  1251. package/src/components/chart.tsx +11 -11
  1252. package/src/components/checkbox-group.tsx +13 -10
  1253. package/src/components/checkbox.tsx +43 -42
  1254. package/src/components/chip-input.tsx +370 -0
  1255. package/src/components/chip-labels.ts +13 -0
  1256. package/src/components/chip.tsx +115 -711
  1257. package/src/components/choice-card.tsx +83 -85
  1258. package/src/components/column-mapper-labels.ts +45 -0
  1259. package/src/components/column-mapper.tsx +16 -51
  1260. package/src/components/combobox-core.tsx +346 -194
  1261. package/src/components/combobox-suggestions.tsx +129 -0
  1262. package/src/components/combobox.tsx +70 -896
  1263. package/src/components/confirm-dialog-labels.ts +13 -0
  1264. package/src/components/confirm-dialog.tsx +4 -8
  1265. package/src/components/copy-button-labels.ts +13 -0
  1266. package/src/components/copy-button.tsx +15 -13
  1267. package/src/components/country-select-labels.ts +11 -0
  1268. package/src/components/country-select.tsx +74 -89
  1269. package/src/components/currency-option-list.tsx +182 -0
  1270. package/src/components/currency-select.tsx +85 -196
  1271. package/src/components/danger-confirm-labels.ts +20 -0
  1272. package/src/components/danger-confirm.tsx +72 -80
  1273. package/src/components/data-table-cells.tsx +5 -5
  1274. package/src/components/data-table-column-settings.tsx +137 -0
  1275. package/src/components/data-table-filter-popover.tsx +51 -25
  1276. package/src/components/data-table-filters.ts +48 -11
  1277. package/src/components/data-table-head.tsx +258 -0
  1278. package/src/components/data-table-labels.ts +21 -71
  1279. package/src/components/data-table-mobile.tsx +594 -0
  1280. package/src/components/data-table-pagination.tsx +11 -10
  1281. package/src/components/data-table-resize.ts +187 -0
  1282. package/src/components/data-table-row-actions.tsx +152 -0
  1283. package/src/components/data-table-selection.ts +55 -0
  1284. package/src/components/data-table-shared.tsx +225 -0
  1285. package/src/components/data-table.tsx +205 -1315
  1286. package/src/components/date-field.tsx +627 -0
  1287. package/src/components/date-picker.tsx +58 -1446
  1288. package/src/components/date-range-picker.tsx +490 -0
  1289. package/src/components/description-list-labels.ts +9 -0
  1290. package/src/components/description-list.tsx +31 -15
  1291. package/src/components/dialog-frame-labels.ts +7 -0
  1292. package/src/components/dialog-frame.tsx +11 -7
  1293. package/src/components/disclosure.tsx +14 -14
  1294. package/src/components/dropdown.tsx +129 -43
  1295. package/src/components/empty-state.tsx +137 -0
  1296. package/src/components/entity-combobox.tsx +18 -13
  1297. package/src/components/error-boundary-labels.ts +20 -0
  1298. package/src/components/error-boundary.tsx +10 -20
  1299. package/src/components/facing-pair.ts +1 -1
  1300. package/src/components/field-base.tsx +604 -0
  1301. package/src/components/field-parts.tsx +192 -77
  1302. package/src/components/field-strip.tsx +12 -33
  1303. package/src/components/field-sync-labels.ts +13 -0
  1304. package/src/components/field-sync.tsx +8 -16
  1305. package/src/components/field.tsx +17 -15
  1306. package/src/components/file-button.tsx +64 -412
  1307. package/src/components/file-dropzone.tsx +36 -33
  1308. package/src/components/floating-panel-labels.ts +10 -0
  1309. package/src/components/floating-panel.tsx +32 -21
  1310. package/src/components/focus-ring.ts +9 -4
  1311. package/src/components/form-actions-labels.ts +11 -0
  1312. package/src/components/form-actions.tsx +17 -21
  1313. package/src/components/full-bleed-dialog.tsx +28 -25
  1314. package/src/components/grouped-picker.tsx +118 -96
  1315. package/src/components/help-popover.tsx +115 -0
  1316. package/src/components/hover-menu.tsx +45 -38
  1317. package/src/components/iban-input-labels.ts +15 -0
  1318. package/src/components/iban-input.tsx +25 -20
  1319. package/src/components/icon-button.tsx +945 -0
  1320. package/src/components/icon-picker-labels.ts +13 -0
  1321. package/src/components/icon-picker.tsx +27 -24
  1322. package/src/components/image-grid-labels.ts +11 -0
  1323. package/src/components/image-grid.tsx +13 -15
  1324. package/src/components/inline-edit-field-labels.ts +11 -0
  1325. package/src/components/inline-edit-field.tsx +20 -19
  1326. package/src/components/inline-entity-combobox.tsx +789 -0
  1327. package/src/components/input.tsx +380 -0
  1328. package/src/components/jump-calendar.tsx +385 -0
  1329. package/src/components/language-select.tsx +31 -16
  1330. package/src/components/legal-labels.ts +99 -0
  1331. package/src/components/legal-page.tsx +2 -2
  1332. package/src/components/legal.tsx +11 -101
  1333. package/src/components/lightbox-labels.ts +18 -0
  1334. package/src/components/lightbox.tsx +13 -21
  1335. package/src/components/line-items-labels.ts +14 -0
  1336. package/src/components/line-items.tsx +24 -31
  1337. package/src/components/list-labels.ts +10 -0
  1338. package/src/components/list.tsx +98 -75
  1339. package/src/components/loading-state.tsx +4 -4
  1340. package/src/components/measured-grid-labels.ts +25 -0
  1341. package/src/components/measured-grid.tsx +39 -62
  1342. package/src/components/menu-item.tsx +29 -26
  1343. package/src/components/mini-calendar-labels.ts +17 -0
  1344. package/src/components/mini-calendar.tsx +38 -45
  1345. package/src/components/modal.tsx +49 -20
  1346. package/src/components/money-draft.ts +72 -0
  1347. package/src/components/money-field.tsx +16 -30
  1348. package/src/components/month-picker-labels.ts +22 -0
  1349. package/src/components/month-picker.tsx +59 -45
  1350. package/src/components/multi-entity-combobox.tsx +17 -12
  1351. package/src/components/multi-select.tsx +300 -292
  1352. package/src/components/nav-pills.tsx +19 -8
  1353. package/src/components/number-field.tsx +7 -5
  1354. package/src/components/number-input.tsx +69 -99
  1355. package/src/components/numeric-field.ts +161 -0
  1356. package/src/components/numpad-sheet.tsx +16 -16
  1357. package/src/components/one-time-code-input.tsx +154 -169
  1358. package/src/components/page-contents-labels.ts +9 -0
  1359. package/src/components/page-contents.tsx +15 -15
  1360. package/src/components/page-header.tsx +9 -5
  1361. package/src/components/password-strength-labels.ts +23 -0
  1362. package/src/components/password-strength.tsx +13 -27
  1363. package/src/components/phone-input-labels.ts +10 -0
  1364. package/src/components/phone-input.tsx +42 -57
  1365. package/src/components/picker-sheet.tsx +47 -18
  1366. package/src/components/pie-chart.tsx +11 -9
  1367. package/src/components/popover-labels.ts +14 -0
  1368. package/src/components/popover.tsx +36 -37
  1369. package/src/components/progress-bar-labels.ts +10 -0
  1370. package/src/components/progress-bar.tsx +32 -23
  1371. package/src/components/qr-code.tsx +2 -2
  1372. package/src/components/reauth-dialog-labels.ts +13 -0
  1373. package/src/components/reauth-dialog.tsx +7 -10
  1374. package/src/components/row-actions-labels.ts +10 -0
  1375. package/src/components/row-actions.tsx +11 -10
  1376. package/src/components/scroll-area.tsx +4 -10
  1377. package/src/components/search-field.tsx +35 -49
  1378. package/src/components/select.tsx +302 -0
  1379. package/src/components/separator.tsx +2 -2
  1380. package/src/components/series-chart-axes.ts +329 -0
  1381. package/src/components/series-chart-thin.ts +180 -0
  1382. package/src/components/series-chart.tsx +125 -326
  1383. package/src/components/server-wake-labels.ts +11 -0
  1384. package/src/components/server-wake.tsx +39 -66
  1385. package/src/components/settings-fields.tsx +50 -19
  1386. package/src/components/share-card-labels.ts +30 -0
  1387. package/src/components/share-card.tsx +22 -37
  1388. package/src/components/sign-chip-labels.ts +12 -0
  1389. package/src/components/sign-chip.tsx +6 -9
  1390. package/src/components/signature-pad-labels.ts +24 -0
  1391. package/src/components/signature-pad.tsx +48 -57
  1392. package/src/components/signed-amount-labels.ts +10 -0
  1393. package/src/components/signed-amount.tsx +21 -22
  1394. package/src/components/skeleton.tsx +1 -1
  1395. package/src/components/slider.tsx +51 -39
  1396. package/src/components/sparkline-labels.ts +14 -0
  1397. package/src/components/sparkline.tsx +16 -21
  1398. package/src/components/spinner.tsx +81 -0
  1399. package/src/components/stat-tile-labels.ts +15 -0
  1400. package/src/components/stat-tile.tsx +39 -38
  1401. package/src/components/status-dot.tsx +17 -14
  1402. package/src/components/swatch-picker-labels.ts +10 -0
  1403. package/src/components/swatch-picker.tsx +23 -16
  1404. package/src/components/swipeable-row.tsx +17 -39
  1405. package/src/components/switch.tsx +33 -32
  1406. package/src/components/table.tsx +15 -15
  1407. package/src/components/tabs.tsx +660 -0
  1408. package/src/components/text-link.tsx +7 -7
  1409. package/src/components/text.tsx +10 -6
  1410. package/src/components/textarea.tsx +323 -0
  1411. package/src/components/tile-radio.tsx +6 -6
  1412. package/src/components/time-input.tsx +15 -7
  1413. package/src/components/toast-labels.ts +12 -0
  1414. package/src/components/toast.tsx +5 -8
  1415. package/src/components/toggle-group.tsx +105 -42
  1416. package/src/components/toggle-legend.tsx +6 -6
  1417. package/src/components/tooltip.tsx +47 -27
  1418. package/src/components/translation-review-editor.tsx +8 -7
  1419. package/src/components/translation-review-parts.tsx +9 -13
  1420. package/src/components/translation-review.tsx +50 -20
  1421. package/src/components/tree-view.tsx +14 -12
  1422. package/src/components/ui-labels.ts +22 -0
  1423. package/src/components/use-table-state.ts +91 -10
  1424. package/src/components/user-avatar.tsx +5 -5
  1425. package/src/components/wizard-stepper.tsx +7 -6
  1426. package/src/components/write-lock-labels.ts +9 -0
  1427. package/src/components/write-lock.tsx +10 -12
  1428. package/src/currencies.ts +70 -0
  1429. package/src/data-table.ts +4 -0
  1430. package/src/demo/demo-banner.tsx +16 -27
  1431. package/src/demo/demo-ended.tsx +8 -7
  1432. package/src/demo/demo-labels.ts +2 -2
  1433. package/src/demo/demo-start.tsx +7 -5
  1434. package/src/eslint/index.ts +161 -0
  1435. package/src/eslint/role-utilities.generated.ts +146 -0
  1436. package/src/eslint/role-utilities.ts +99 -0
  1437. package/src/feedback/feedback-attachment-labels.ts +36 -0
  1438. package/src/feedback/feedback-attachment.tsx +42 -66
  1439. package/src/feedback/feedback-context-labels.ts +10 -0
  1440. package/src/feedback/feedback-context.tsx +8 -9
  1441. package/src/feedback/feedback-dialog-labels.ts +28 -0
  1442. package/src/feedback/feedback-dialog.tsx +14 -44
  1443. package/src/feedback/feedback-inbox.tsx +49 -252
  1444. package/src/feedback/feedback-labels.ts +5 -5
  1445. package/src/feedback/feedback-menu-labels.ts +11 -0
  1446. package/src/feedback/feedback-menu.tsx +13 -11
  1447. package/src/feedback/feedback-record.ts +21 -7
  1448. package/src/feedback/feedback-row-detail-labels.ts +33 -0
  1449. package/src/feedback/feedback-row-detail.tsx +30 -43
  1450. package/src/feedback/feedback-status-undo.ts +3 -2
  1451. package/src/feedback/feedback-submit-dialog.tsx +109 -0
  1452. package/src/feedback/feedback-submit.tsx +45 -55
  1453. package/src/feedback/feedback-swipe.tsx +1 -1
  1454. package/src/feedback/feedback-table-labels.ts +27 -0
  1455. package/src/feedback/feedback-table.tsx +24 -40
  1456. package/src/feedback/feedback-thread-labels.ts +22 -0
  1457. package/src/feedback/feedback-thread.tsx +46 -50
  1458. package/src/feedback/feedback-vocabulary.ts +252 -0
  1459. package/src/hooks/use-anchored-panel.ts +102 -2
  1460. package/src/hooks/use-anchored-rect.ts +11 -9
  1461. package/src/hooks/use-authed-src.ts +13 -7
  1462. package/src/hooks/use-breakpoint.ts +5 -4
  1463. package/src/hooks/use-close-transition.ts +9 -10
  1464. package/src/hooks/use-debounce.ts +9 -0
  1465. package/src/hooks/use-dismiss.ts +210 -18
  1466. package/src/hooks/use-file-drop.ts +2 -2
  1467. package/src/hooks/use-file-picker-labels.ts +29 -0
  1468. package/src/hooks/use-file-picker.tsx +363 -0
  1469. package/src/hooks/use-focus-trap.ts +19 -6
  1470. package/src/i18n/defaults.ts +91 -82
  1471. package/src/i18n/german.ts +50 -6
  1472. package/src/i18n/kit-labels.tsx +85 -124
  1473. package/src/i18n/locales/en.ts +46 -0
  1474. package/src/i18n/locales/es.ts +50 -6
  1475. package/src/i18n/locales/fr.ts +55 -9
  1476. package/src/i18n/locales/hu.ts +50 -6
  1477. package/src/i18n/locales/it.ts +54 -8
  1478. package/src/i18n/locales/zh.ts +49 -6
  1479. package/src/i18n/review.ts +4 -0
  1480. package/src/i18n/shared-labels.ts +135 -0
  1481. package/src/index.ts +414 -404
  1482. package/src/landing/access.ts +2 -1
  1483. package/src/landing/landing-actions.tsx +6 -5
  1484. package/src/landing/landing-labels.ts +2 -2
  1485. package/src/landing/landing-sections.tsx +24 -23
  1486. package/src/landing/public-header.tsx +3 -2
  1487. package/src/landing/routing.tsx +2 -7
  1488. package/src/lib/chip-edge-snap.ts +212 -0
  1489. package/src/lib/cn.ts +13 -1
  1490. package/src/lib/dates.ts +4 -2
  1491. package/src/lib/decimal-marks.ts +4 -2
  1492. package/src/lib/download.ts +172 -0
  1493. package/src/lib/floating-stack.ts +5 -1
  1494. package/src/lib/format.ts +5 -3
  1495. package/src/lib/ime.ts +24 -0
  1496. package/src/lib/presence.ts +18 -0
  1497. package/src/lib/reduced-motion.ts +21 -0
  1498. package/src/lib/refs.ts +22 -0
  1499. package/src/lib/round.ts +38 -0
  1500. package/src/lib/safe-storage.ts +35 -11
  1501. package/src/lib/session-refresh.ts +473 -0
  1502. package/src/lib/text-match.ts +60 -0
  1503. package/src/lib/translation-review.ts +7 -6
  1504. package/src/rhf/fields.tsx +141 -189
  1505. package/src/rhf/form-core.tsx +328 -0
  1506. package/src/rhf/form.tsx +9 -320
  1507. package/src/rhf/line-items.tsx +2 -2
  1508. package/src/rhf/use-rhf-wizard-step.ts +4 -4
  1509. package/src/rhf.ts +4 -2
  1510. package/src/search/command-palette-labels.ts +16 -0
  1511. package/src/search/command-palette.tsx +37 -50
  1512. package/src/search/global-search-labels.ts +13 -0
  1513. package/src/search/global-search.tsx +50 -24
  1514. package/src/search/search-index.ts +5 -6
  1515. package/src/search/use-command-key.ts +22 -0
  1516. package/src/settings/settings-card-classes.ts +11 -0
  1517. package/src/settings/settings-heading.tsx +1 -1
  1518. package/src/settings/settings-labels.ts +2 -2
  1519. package/src/settings/settings-layout.tsx +65 -6
  1520. package/src/settings/settings-section.tsx +5 -5
  1521. package/src/settings/use-settings-focus.ts +2 -5
  1522. package/src/shell/app-shell-labels.ts +10 -0
  1523. package/src/shell/app-shell.tsx +79 -53
  1524. package/src/shell/auth-layout.tsx +11 -10
  1525. package/src/shell/company-switcher-labels.ts +13 -0
  1526. package/src/shell/company-switcher.tsx +7 -11
  1527. package/src/shell/option-switcher-menu.tsx +7 -5
  1528. package/src/shell/role-switcher.tsx +4 -4
  1529. package/src/shell/top-bar-brand.tsx +1 -1
  1530. package/src/shell/top-bar.tsx +6 -5
  1531. package/src/shell/topbar-action-menu.tsx +21 -19
  1532. package/src/shell/topbar-controls.tsx +15 -15
  1533. package/src/shell.ts +1 -1
  1534. package/src/testing/index.ts +13 -0
  1535. package/src/testing/viewport.ts +357 -0
  1536. package/src/theme/color.ts +7 -1
  1537. package/src/theme/palette-derive.ts +29 -7
  1538. package/src/theme/palette-presets.ts +82 -355
  1539. package/src/tour/tour-labels.ts +14 -0
  1540. package/src/tour/tour.tsx +13 -23
  1541. package/src/wizard/stepper-nav.tsx +19 -18
  1542. package/src/wizard/types.ts +3 -26
  1543. package/src/wizard/wizard-labels.ts +29 -0
  1544. package/src/wizard/wizard-step.tsx +2 -2
  1545. package/src/wizard/wizard-summary.tsx +8 -7
  1546. package/tokens.css +45 -14
  1547. package/dist/components/account-settings-labels.js.map +0 -1
  1548. package/dist/components/account-settings.js.map +0 -1
  1549. package/dist/components/choice-parts.d.ts +0 -24
  1550. package/dist/components/choice-parts.js +0 -18
  1551. package/dist/components/choice-parts.js.map +0 -1
  1552. package/dist/components/passkeys-setting.js.map +0 -1
  1553. package/dist/components/ui.d.ts +0 -101
  1554. package/dist/components/ui.js +0 -1955
  1555. package/dist/components/ui.js.map +0 -1
  1556. package/dist/data-table-labels-BJ5JE_X2.d.ts +0 -923
  1557. package/dist/delete-account-setting-DAgGBrbn.d.ts +0 -8167
  1558. package/src/components/choice-parts.ts +0 -33
  1559. package/src/components/ui.tsx +0 -4233
@@ -1,4233 +0,0 @@
1
- import { createContext, forwardRef, useContext, useId, useLayoutEffect, useRef, useState } from "react";
2
- import { ChevronDown, Eye, EyeOff, Plus, X } from "lucide-react";
3
- import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ChangeEvent, ComponentPropsWithoutRef, CSSProperties, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
4
- import { cn } from "../lib/cn";
5
- import { FOCUS_RING, FOCUS_RING_WIDTH } from "./focus-ring";
6
- import { scrollIntoStrip, useStripFade } from "../lib/strip-fade";
7
- import { horizontalStep } from "../lib/direction";
8
- import { usePhoneLayout } from "../hooks/use-breakpoint";
9
- import { useLargeText } from "../hooks/use-large-text";
10
- import { Tooltip, type TooltipSide } from "./tooltip";
11
- import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-labels";
12
- import type { KitLinkComponent, KitLinkProps } from "../i18n/kit-labels";
13
- import { pickLinkRenderer, replacingClick, routerLinkNavigation } from "./text-link";
14
- import { useCommitReason } from "./write-lock";
15
- import type { CommitScope } from "./write-lock";
16
- import { mergeDescribedBy } from "./choice-parts";
17
- import {
18
- CompactControlsContext,
19
- DisabledReasonLine,
20
- EndHintRow,
21
- FieldCaption,
22
- LockedReason,
23
- useDisabledReasonLine,
24
- type DisabledReasonDisplay,
25
- useFieldHint,
26
- useLockReason,
27
- FLOATING_LABEL_STATIC,
28
- LABEL_ABOVE_AT_LARGE,
29
- LABEL_ABOVE_BOX,
30
- STATIC_LABEL_TYPE,
31
- type FieldHintParts,
32
- } from "./field-parts";
33
-
34
- // The static label lives in field-parts since 0.23 (it is what the field anatomy
35
- // there draws, and field-parts must not import this file); public from here as before.
36
- export { FieldLabel, FLOATING_LABEL_STATIC, FieldHint } from "./field-parts";
37
- export type { FieldLabelProps, FieldHintProps } from "./field-parts";
38
-
39
- // The kit's focus frame (§5) lives in its own module so the parts ui.tsx itself imports
40
- // (field-parts, the choice controls) can draw it too; public from here as before.
41
- export { FOCUS_RING, FOCUS_RING_WIDTH } from "./focus-ring";
42
-
43
- export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
44
-
45
- // Shared base ring for every button-styled element. Kept as a named const so the
46
- // <Button> component and the {@link buttonClasses} helper draw from one source and
47
- // can never drift apart.
48
- //
49
- // The ring is the kit's focus frame (§5): {@link FOCUS_RING_WIDTH} wide, on keyboard focus
50
- // (`focus-visible`) — a tap or a click no longer leaves a ring on the button — in each
51
- // variant's own colour below.
52
- //
53
- // `[&_svg]:shrink-0` (0.32.2, kastlan's 0.32.1 report): an icon beside a label keeps its
54
- // size when the label wraps at Large. Without it the svg was a flex item like the text,
55
- // and a two-line label squeezed it to a sliver ("New invoice" in a page header at 360 px
56
- // / Extra large).
57
- const BUTTON_BASE = cn(
58
- "inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:shrink-0",
59
- FOCUS_RING_WIDTH,
60
- );
61
-
62
- /**
63
- * The touch target at Large and Extra large (docs/text-size-harmonization.md §4): at
64
- * least 48 px, the size a finger needs whatever the type. In px on purpose, like the
65
- * field floor ({@link FIELD_TOUCH_TEXT}): it is a physical threshold, not a size that
66
- * follows the text. Normal keeps the kit's own heights; a `large:` variant, so it costs
67
- * no render and is right on the first paint.
68
- */
69
- export const TOUCH_TARGET_LARGE = "large:min-h-[48px] large:min-w-[48px]";
70
-
71
- export type ButtonSize = "sm" | "md";
72
-
73
- // Box geometry per size, split out of the base so the two cannot be merged into one
74
- // string that a size then has to fight. `md` is the pre-0.8.0 look, unchanged. `sm` is
75
- // the compact secondary action keksdose repeats by hand as `px-2 py-1 text-xs` (and
76
- // `px-2 py-0.5 text-xs`) on the buttons in a toolbar or a table header; one rung is
77
- // enough, so the two spellings meet at `py-1`. There is no `lg`: no app has asked for
78
- // a bigger text button (IconButton's `lg` is a touch target, not a text size). The
79
- // variant map comes AFTER the size, so `link`'s `p-0` still wins at either size.
80
- //
81
- // At Large and above (§4) `sm` grows to `md`'s height — the padding that makes its
82
- // 1rem line as tall as `md`'s 1.25rem one — and both reach the 48 px touch target. The
83
- // type stays `sm`'s: the compact button is still the quieter one. Not on `link`, which
84
- // is a word in a sentence and has no box to grow (see `buttonLook`).
85
- const BUTTON_SIZES: Record<ButtonSize, string> = {
86
- md: "gap-2 px-3 py-2 text-sm",
87
- sm: "gap-1.5 px-2 py-1 text-xs",
88
- };
89
- const BUTTON_SIZES_LARGE: Record<ButtonSize, string> = {
90
- md: "large:min-h-[48px]",
91
- sm: "large:min-h-[48px] large:py-2.5",
92
- };
93
-
94
- /**
95
- * The hover of a control that may sit on the page, a card or a well (0.33, B′ —
96
- * docs/colour-roles-harmonization.md §12.1, §12.10): the body ink at 7 %, TRANSLUCENT,
97
- * so it darkens (in dark, lightens) whatever is under it by the same step — 1.11:1 or
98
- * more on all three surfaces, both themes, with the label at 7:1 and up. It was
99
- * `--bg-surface-2`, which since 0.33 is the page itself in light: a ghost button on the
100
- * page would not have moved at all. Not `--bg-hover` either, which is mixed against the
101
- * CARD and stands only 1.04:1 from the page. IconButton's `custom` tone uses the same
102
- * recipe at 12 %.
103
- */
104
- const HOVER_INK = "hover:bg-[color-mix(in_srgb,var(--text-primary)_7%,transparent)]";
105
-
106
- // Warm, palette-token-driven so buttons blend with the fields + cards in every theme.
107
- // Actions default to a warm bordered look (primary = filled warm chip, secondary =
108
- // outline); `brand` stays the solid accent for the rare strong CTA; `danger` takes the
109
- // semantic `--danger` family (destructive semantics), so a consumer can re-point the
110
- // destructive hue instead of inheriting a hard-coded red. Focus rings follow each
111
- // variant's own accent: brand for the four neutral ones, danger for `danger`.
112
- const buttonVariantClasses: Record<ButtonVariant, string> = {
113
- primary:
114
- // Hovers onto `--bg-active`, not `--border` (0.33, §12.9): More contrast steps the
115
- // border until it is a 3:1 LINE, and as a fill under the label that left 2.7:1.
116
- "border border-[var(--border)] bg-[var(--bg-surface-2)] text-[var(--text-primary)] hover:bg-active focus-visible:ring-[var(--brand)]",
117
- secondary: `border border-[var(--border)] bg-transparent text-[var(--text-primary)] ${HOVER_INK} focus-visible:ring-[var(--brand)]`,
118
- ghost: `bg-transparent text-[var(--text-primary)] ${HOVER_INK} focus-visible:ring-[var(--brand)]`,
119
- danger:
120
- "bg-[var(--danger)] text-[var(--danger-contrast)] hover:bg-[var(--danger-hover)] focus-visible:ring-[var(--danger-border-strong)]",
121
- brand:
122
- "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)] focus-visible:ring-[var(--brand)]",
123
- // A text link that is still a `<button>` — keksdose's six hand-rolled
124
- // `<button className="text-brand underline">` sites ("Resend code", "Show all",
125
- // "Undo" in a toast…), which act rather than navigate and so must not be anchors.
126
- // The box padding goes (`p-0`) so it sits in a sentence at the text's own size, but
127
- // the base's focus ring stays: those copies had `outline-none` and no ring, so
128
- // a keyboard user tabbing onto them saw nothing at all. `rounded-sm` keeps that
129
- // ring hugging the word instead of drawing a pill round it.
130
- link:
131
- "rounded-sm p-0 bg-transparent text-[var(--brand)] underline-offset-4 hover:underline focus-visible:ring-[var(--brand)]",
132
- };
133
-
134
- /**
135
- * The text colour of a `link` or `ghost` button, over the variant's own.
136
- *
137
- * `muted` is the QUIET link keksdose hand-rolls in six places (tours-page:321's "Mark
138
- * undone", transaction-fields:176/215's "Add line" / "Fill total",
139
- * invoice-lines-table:376, invoice-review:464): secondary text that darkens to the
140
- * body colour under the pointer, for an action that must be findable but not compete
141
- * with the brand-coloured one beside it. `danger` is the same quiet look turning
142
- * `--danger` on hover — transaction-editor:401's "Remove split", which is a text
143
- * action that destroys something.
144
- *
145
- * A `tone` rather than a `link-muted` variant, because it is the axis IconButton
146
- * already has (`tone="muted"`, `tone="danger"`, same quiet-until-hover meaning), and
147
- * because the same two looks are wanted on `ghost`.
148
- *
149
- * `danger` also reaches the two NEUTRAL boxed variants, where the box carries it:
150
- * `secondary` becomes an outlined destructive button (`--danger` text and a
151
- * `--danger-border` outline, the quiet danger fill on hover) and `primary` a soft one
152
- * (the quiet fill at rest, solid `--danger` on hover). kastlan's meeting-invitations-
153
- * tab.tsx "Remove all" is `variant="secondary" className="text-destructive"` — a
154
- * destructive action that must sit in a row of secondary buttons without shouting
155
- * like the solid `variant="danger"` would, and whose red text alone left the border
156
- * and hover saying "neutral". `muted` stays a text look and does nothing on a box, and
157
- * `danger`, `brand` and `link`'s own colours are left alone: a tone that recoloured a
158
- * solid danger or brand button would be a second variant under another name.
159
- */
160
- export type ButtonTone = "default" | "muted" | "danger";
161
-
162
- const BUTTON_TONES: Record<Exclude<ButtonTone, "default">, string> = {
163
- muted:
164
- "text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:hover:text-[var(--text-secondary)]",
165
- danger:
166
- "text-[var(--text-secondary)] hover:text-[var(--danger)] focus-visible:ring-[var(--danger-border-strong)] disabled:hover:text-[var(--text-secondary)]",
167
- };
168
-
169
- /** Only the two transparent variants take every tone; see {@link ButtonTone}. */
170
- const TONED_VARIANTS = new Set<ButtonVariant>(["link", "ghost"]);
171
-
172
- // `tone="danger"` on the neutral boxed variants — see {@link ButtonTone}. Each string
173
- // restates every colour the variant sets (border, fill, text, hover, ring) so none of
174
- // the neutral ones survives the merge.
175
- const BUTTON_BOXED_DANGER: Partial<Record<ButtonVariant, string>> = {
176
- // Not boxed, but its hover is a fill: the danger wash under the danger text, as
177
- // IconButton's danger tone (0.33). On B′'s translucent ink `--danger` fell to 4.2:1
178
- // over a light page.
179
- ghost: "hover:bg-[var(--danger-bg)]",
180
- secondary:
181
- "border-[var(--danger-border)] bg-transparent text-[var(--danger)] hover:bg-[var(--danger-bg)] focus-visible:ring-[var(--danger-border-strong)]",
182
- primary:
183
- "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-visible:ring-[var(--danger-border-strong)] disabled:hover:border-[var(--danger-border)] disabled:hover:bg-[var(--danger-bg)] disabled:hover:text-[var(--danger)]",
184
- };
185
-
186
- // `pressed` on a `link`: the brand colour and a heavier weight, which is exactly what
187
- // lenkbank's "All speeds" toggle paints by hand (gear/hysteresis-charts.tsx:482). After
188
- // the tone, so a pressed muted link is brand, and an unpressed one is quiet grey.
189
- const BUTTON_LINK_PRESSED =
190
- "font-medium text-[var(--brand)] hover:text-[var(--brand)] disabled:hover:text-[var(--brand)]";
191
-
192
- /** The second argument of {@link buttonClasses} in its options form. */
193
- export interface ButtonClassesOptions {
194
- /** See {@link ButtonProps.size}. */
195
- size?: ButtonSize;
196
- className?: string;
197
- }
198
-
199
- /**
200
- * Button classes for the rare case where the styling must land on a non-`<button>`
201
- * element that {@link Button} can't render — e.g. a router `<Link>` or a Radix
202
- * AlertDialog Action/Cancel (which must stay the Radix element). Everywhere a real
203
- * button works, prefer `<Button>`. Draws from the same base, size and variant maps as
204
- * `<Button>`, so the two stay in lockstep.
205
- *
206
- * The second argument is either the extra classes (the pre-0.8.0 form) or
207
- * `{ size, className }` — `buttonClasses("secondary", { size: "sm" })` for keksdose's
208
- * compact toolbar links.
209
- */
210
- export function buttonClasses(
211
- variant: ButtonVariant = "primary",
212
- classNameOrOptions?: string | ButtonClassesOptions,
213
- ): string {
214
- const { size = "md", className } =
215
- typeof classNameOrOptions === "object" ? classNameOrOptions : { className: classNameOrOptions };
216
- return cn(
217
- BUTTON_BASE,
218
- BUTTON_SIZES[size],
219
- variant !== "link" && BUTTON_SIZES_LARGE[size],
220
- buttonVariantClasses[variant],
221
- className,
222
- );
223
- }
224
-
225
- /**
226
- * ⚠️ No default `type`: like a native `<button>`, a `Button` inside a `<form>` SUBMITS
227
- * it unless you pass `type="button"`. Kept native on purpose — a form's own submit
228
- * button relies on it, and changing the default would silently stop those forms from
229
- * submitting. Pass `type="button"` for every other action in a form (keksdose had six
230
- * link buttons submitting their form).
231
- */
232
- export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
233
- variant?: ButtonVariant;
234
- /** `md` (default) is the page's action button. `sm` is the compact one — 12px text
235
- * and `px-2 py-1` — for the secondary actions in a toolbar, a card header or a
236
- * table's header row (keksdose writes `px-2 py-1 text-xs` over `secondary` by hand
237
- * there). Every variant takes either size. */
238
- size?: ButtonSize;
239
- /** In a flex row next to a taller labelled field, fill the field's height so the
240
- * two line up. No effect outside a flex row. */
241
- stretch?: boolean;
242
- /** The `<button>` element. React 19 passes `ref` to a function component as an
243
- * ordinary prop, so it rides `...rest` onto the element with no `forwardRef` —
244
- * declared here only because `ButtonHTMLAttributes` does not carry it. */
245
- ref?: Ref<HTMLButtonElement>;
246
- /** Text colour for `link` and `ghost`: `muted` (quiet, body colour on hover) or
247
- * `danger` (quiet, `--danger` on hover). `danger` also makes `secondary` an
248
- * outlined destructive button and `primary` a soft one; ignored by the other
249
- * variants. See {@link ButtonTone}. */
250
- tone?: ButtonTone;
251
- /**
252
- * Make it a toggle button, as {@link IconButton}'s `pressed` does. `true` sets
253
- * `aria-pressed="true"` and draws the "on" look — on a `link`, the brand colour and a
254
- * medium weight; on the boxed variants, the quiet brand fill IconButton uses — and
255
- * `false` sets `aria-pressed="false"` with the ordinary look. Left out, no
256
- * `aria-pressed` (or the caller's own). For lenkbank's "All speeds" text toggle
257
- * (gear/hysteresis-charts.tsx:482), which is a hand-rolled `<button aria-pressed>`
258
- * swapping two class strings; with `variant="link" tone="muted"` it is this.
259
- */
260
- pressed?: boolean;
261
- /**
262
- * Why the action is not available — the button's half of DangerConfirm's
263
- * `lockedReason` (kastlan handover-detail-page.tsx:197 locks a signed handover).
264
- *
265
- * A disabled button that cannot say why is a dead end: the native `disabled` takes it
266
- * out of the tab order, so a keyboard user never lands on it, and a pointer gets a
267
- * `not-allowed` cursor and nothing else. With a reason the button is `aria-disabled`
268
- * instead — still focusable, still hoverable — clicks (and the Enter/Space and form
269
- * submission they stand for) do nothing, and the reason is shown in the kit
270
- * {@link Tooltip} and attached with `aria-describedby`, so a screen reader hears it
271
- * on focus. It wins over `disabled`: passing both keeps the button reachable, which is
272
- * the point of giving a reason.
273
- *
274
- * The description is a `hidden` copy of the reason rather than the bubble itself: the
275
- * bubble exists only while hovered or focused once it is portalled, and a description
276
- * that comes and goes is read inconsistently. The bubble is kept visual, the way
277
- * DangerConfirm keeps its own.
278
- */
279
- disabledReason?: ReactNode;
280
- /**
281
- * Where `disabledReason` shows (0.32, §4 "No fact only in a tooltip"): by default a
282
- * line under the button at Large and on a touch screen, the tooltip otherwise. See
283
- * {@link DisabledReasonDisplay}.
284
- */
285
- disabledReasonDisplay?: DisabledReasonDisplay;
286
- /**
287
- * This button COMMITS — it saves, creates, deletes. Under a locked
288
- * {@link WriteLockProvider} it is disabled the `disabledReason` way, with the lock's
289
- * reason (which wins over a `disabledReason` of its own). No provider, or an
290
- * unlocked one: no effect. See {@link WriteLockProvider}; a {@link CommitScope} since
291
- * 0.33 (`COMMIT_EXCEPT_BILLING`: every lock but a lapsed plan's).
292
- */
293
- commit?: CommitScope;
294
- /**
295
- * Busy — the save is in flight: a spinner, `aria-busy`, and no second submit.
296
- *
297
- * kastlan's FormActions (form-actions.tsx:50) disables its submit and swaps in a
298
- * `submittingLabel`, so the button jumps width mid-click and a screen reader hears
299
- * the name change instead of "busy". Here the label STAYS — it is still the
300
- * accessible name and still holds the button's width — and the spinner is drawn over
301
- * it in the text colour, so nothing beside the button moves.
302
- *
303
- * The same prop {@link FileButton} has (`pending`), with one difference: this one is
304
- * `aria-disabled`, not `disabled`. The button being pressed is the one that has focus,
305
- * and a native `disabled` drops that focus to `<body>` the moment the save starts;
306
- * clicks, and the Enter that submits a form through it, are swallowed instead.
307
- */
308
- pending?: boolean;
309
- /** Only on the link form — see {@link ButtonLinkProps}. */
310
- href?: never;
311
- renderLink?: never;
312
- external?: never;
313
- replace?: never;
314
- reloadDocument?: never;
315
- }
316
-
317
- /**
318
- * `<Button href>`: a link that looks like a button — for an action that NAVIGATES.
319
- *
320
- * kastlan paints `buttonClasses(…)` onto router Links by hand in six places (the
321
- * invoice / lease / unit preview dialogs' "Open" at invoice-preview-dialog.tsx:36,
322
- * platform-companies-page.tsx:111, verify-email-page.tsx:86's "Log in",
323
- * billing-history-page.tsx:85's PDF download), and has 26 `<Button onClick={() =>
324
- * navigate(…)}>` that are links in all but markup: no middle-click, no "open in new
325
- * tab", no URL on hover, and a screen reader announces a button that then changes the
326
- * page. With `href` it is an `<a>`: the provider's router link
327
- * ({@link UiKitProvider}'s `linkComponent`), or `renderLink` if given, else a plain one.
328
- *
329
- * `type` and `form` are typed out: they are a `<button>`'s, and a link never submits.
330
- */
331
- export interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "type" | "href"> {
332
- href: string;
333
- variant?: ButtonVariant;
334
- size?: ButtonSize;
335
- stretch?: boolean;
336
- tone?: ButtonTone;
337
- /** The in-app router link, over the provider's `linkComponent` (see
338
- * {@link KitLinkComponent}). Ignored when `external`. */
339
- renderLink?: KitLinkComponent;
340
- /** Leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`, never the
341
- * router link, and "(opens in a new tab)" read after the label — billing-history's
342
- * Stripe PDF. A `target="_blank"` of your own is announced the same way. */
343
- external?: boolean;
344
- /**
345
- * Navigate by replacing the current history entry, so Back does not return here —
346
- * keksdose F1: the "Continue" out of a one-shot page (an emailed confirmation link, a
347
- * payment return URL) that must not be landed on again. Handed to the router link as
348
- * `replace` (see `KitLinkProps.replace` for mapping it); on a plain `<a>` a plain click
349
- * becomes `location.replace(href)`. Ignored when `external`.
350
- */
351
- replace?: boolean;
352
- /**
353
- * A plain `<a>` for an in-app `href`, so the browser loads the whole document instead
354
- * of the router swapping the view — keksdose F1: a route the SPA does not own (a
355
- * server-rendered export, a logout that must drop every in-memory cache). Wins over
356
- * `renderLink` and the provider's `linkComponent`.
357
- */
358
- reloadDocument?: boolean;
359
- /** A link cannot be `disabled`, so this renders an `<a>` with NO `href` —
360
- * `role="link"` and `aria-disabled`, out of the tab order like a disabled button,
361
- * with the disabled look. The router link is not used: it needs somewhere to go. */
362
- disabled?: boolean;
363
- ref?: Ref<HTMLAnchorElement>;
364
- type?: never;
365
- form?: never;
366
- pending?: never;
367
- pressed?: never;
368
- disabledReason?: never;
369
- disabledReasonDisplay?: never;
370
- commit?: never;
371
- }
372
-
373
- function hasContent(node: ReactNode): boolean {
374
- return node !== undefined && node !== null && node !== false && node !== "";
375
- }
376
-
377
- // No fact only in a tooltip (§4): the reason line and the compact region live in
378
- // field-parts, where the self-saving controls (Switch, Checkbox) reach them too.
379
- export {
380
- CompactControls,
381
- DisabledReasonLine,
382
- DISABLED_REASON_LINE_CLASS,
383
- useDisabledReasonLine,
384
- } from "./field-parts";
385
- export type { DisabledReasonDisplay } from "./field-parts";
386
-
387
- /** The button look shared by the `<button>` and the `<a>` form. */
388
- function buttonLook(variant: ButtonVariant, size: ButtonSize, stretch: boolean | undefined, tone: ButtonTone) {
389
- return cn(
390
- BUTTON_BASE,
391
- BUTTON_SIZES[size],
392
- // §4: `sm` at `md`'s height and the 48 px target at Large — not on a `link`. In the
393
- // order `buttonClasses` has it, so the two strings stay identical.
394
- variant !== "link" && BUTTON_SIZES_LARGE[size],
395
- // In a flex row next to a taller labelled field, `stretch` makes the button
396
- // fill the field's height so the two line up (self-stretch overrides the row's
397
- // align-items). No effect outside a flex row / when it's already the tallest.
398
- stretch && "self-stretch",
399
- buttonVariantClasses[variant],
400
- tone !== "default" && TONED_VARIANTS.has(variant) && BUTTON_TONES[tone],
401
- tone === "danger" && BUTTON_BOXED_DANGER[variant],
402
- );
403
- }
404
-
405
- // Declared link-first so `<Button href>` picks the link props; the LAST signature is
406
- // what `ComponentProps<typeof Button>` reads, so wrappers (FileButton) still see the
407
- // `<button>` props they always did.
408
- export function Button(props: ButtonLinkProps): ReactElement;
409
- export function Button(props: ButtonProps): ReactElement;
410
- export function Button(props: ButtonProps | ButtonLinkProps): ReactElement {
411
- if (props.href !== undefined) return <ButtonLink {...(props as ButtonLinkProps)} />;
412
- return <ButtonElement {...(props as ButtonProps)} />;
413
- }
414
-
415
- /** Calls a `renderLink` / provider link as a function, as ListItem and Chip do — an
416
- * inline `renderLink` arrow would otherwise be a new component type every render. */
417
- function RenderedButtonLink({ render, ...props }: KitLinkProps & { render: KitLinkComponent }) {
418
- return render(props);
419
- }
420
-
421
- function ButtonLink({
422
- href,
423
- renderLink,
424
- external,
425
- replace,
426
- reloadDocument,
427
- disabled,
428
- variant = "primary",
429
- size = "md",
430
- stretch,
431
- tone = "default",
432
- className,
433
- children,
434
- target,
435
- rel,
436
- onClick,
437
- ...rest
438
- }: ButtonLinkProps) {
439
- const kitLink = useKitLink();
440
- const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
441
- // The same rule as every kit link (`pickLinkRenderer`): an external `href` or an
442
- // in-page `#anchor` is never handed to the provider's router link, even without
443
- // `external`.
444
- // `reloadDocument` is the caller saying "not the router" for this one link.
445
- const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);
446
- const newTab = !disabled && (external || target === "_blank");
447
- const cls = cn(
448
- buttonLook(variant, size, stretch, tone),
449
- // `relative` holds the sr-only note (see sr-only-containment.test).
450
- newTab && "relative",
451
- // BUTTON_BASE's `disabled:` classes never match an `<a>`; no pointer events also
452
- // keeps the hover fill from lighting up on something that refuses the click.
453
- disabled && "pointer-events-none cursor-not-allowed opacity-50",
454
- className,
455
- );
456
- const body = (
457
- <>
458
- {children}
459
- {/* The space OUTSIDE the sr-only span: a name computation trims each child's
460
- text, so one inside it was lost ("Download(opens…"). In a flex box a bare
461
- space draws nothing. */}
462
- {newTab && " "}
463
- {newTab && <span className="sr-only">({common.opensInNewTab})</span>}
464
- </>
465
- );
466
- if (disabled) {
467
- return (
468
- <a {...rest} role="link" aria-disabled="true" className={cls}>
469
- {body}
470
- </a>
471
- );
472
- }
473
- if (external || !Link) {
474
- return (
475
- <a
476
- {...rest}
477
- href={href}
478
- target={external ? "_blank" : target}
479
- rel={external ? (rel ?? "noopener noreferrer") : rel}
480
- onClick={external ? onClick : replacingClick(onClick, href, replace, target)}
481
- className={cls}
482
- >
483
- {body}
484
- </a>
485
- );
486
- }
487
- return (
488
- <RenderedButtonLink
489
- render={Link}
490
- {...rest}
491
- {...routerLinkNavigation(replace)}
492
- href={href}
493
- target={target}
494
- rel={rel}
495
- onClick={onClick}
496
- className={cls}
497
- >
498
- {body}
499
- </RenderedButtonLink>
500
- );
501
- }
502
-
503
- function ButtonElement({
504
- variant = "primary",
505
- size = "md",
506
- stretch,
507
- tone = "default",
508
- pressed,
509
- disabledReason: ownDisabledReason,
510
- disabledReasonDisplay,
511
- commit,
512
- pending,
513
- className,
514
- onClick,
515
- children,
516
- href: _href,
517
- renderLink: _renderLink,
518
- external: _external,
519
- replace: _replace,
520
- reloadDocument: _reloadDocument,
521
- ...rest
522
- }: ButtonProps) {
523
- const reasonId = useId();
524
- const disabledReason = useCommitReason(commit, ownDisabledReason);
525
- const locked = hasContent(disabledReason);
526
- const inert = locked || Boolean(pending);
527
- const ownDescribedBy = rest["aria-describedby"];
528
- const button = (
529
- <button
530
- {...rest}
531
- disabled={inert ? undefined : rest.disabled}
532
- aria-disabled={inert || rest["aria-disabled"]}
533
- aria-busy={pending || rest["aria-busy"]}
534
- aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
535
- aria-pressed={pressed ?? rest["aria-pressed"]}
536
- onClick={(e) => {
537
- // `preventDefault` as well as not calling through: a locked submit button must
538
- // not submit its form, and the browser does that after this handler returns.
539
- if (inert) {
540
- e.preventDefault();
541
- return;
542
- }
543
- onClick?.(e);
544
- }}
545
- className={cn(
546
- buttonLook(variant, size, stretch, tone),
547
- pressed && (variant === "link" ? BUTTON_LINK_PRESSED : ICON_BUTTON_PRESSED),
548
- // The disabled look for the focusable kind of disabled.
549
- locked && "cursor-not-allowed opacity-50",
550
- // No dimming while pending: the spinner IS the state, and at half opacity it
551
- // would be the faintest thing on the button. `relative` anchors it.
552
- pending && "relative cursor-progress",
553
- className,
554
- )}
555
- >
556
- {pending ? (
557
- <>
558
- {/* Transparent, not hidden: it keeps the width AND stays the accessible
559
- name (`visibility: hidden` would drop it from the name). `gap-[inherit]`
560
- keeps an icon + text spaced as the button spaces them. */}
561
- <span className="inline-flex items-center justify-center gap-[inherit] opacity-0">{children}</span>
562
- {/* Decorative: `aria-busy` says it, as on FileButton. */}
563
- <Spinner
564
- label={null}
565
- className="absolute inset-0 m-auto size-4 border-current/30 border-t-current"
566
- />
567
- </>
568
- ) : (
569
- children
570
- )}
571
- </button>
572
- );
573
- if (!locked) return button;
574
- return (
575
- <ReasonFrame
576
- reason={disabledReason}
577
- reasonId={reasonId}
578
- display={disabledReasonDisplay}
579
- className={stretch ? "self-stretch" : undefined}
580
- >
581
- {button}
582
- </ReasonFrame>
583
- );
584
- }
585
-
586
- /**
587
- * A locked control and its reason: the line under it, or the kit {@link Tooltip} with a
588
- * hidden copy for `aria-describedby` — see {@link DisabledReasonDisplay}. A component of
589
- * its own so only a LOCKED control asks the text size and the pointer; the hundreds of
590
- * unlocked buttons on a page subscribe to nothing.
591
- *
592
- * Tooltip form: the control in a FRAGMENT so Tooltip does not clone its bubble into the
593
- * description as well — the hidden copy already describes it, and the same sentence
594
- * twice would be read on every focus. Line form: the line is the description, and no
595
- * bubble repeats what is on screen. Either way `className` (the control's `stretch`)
596
- * moves to the wrapper, which is now the flex item.
597
- */
598
- function ReasonFrame({
599
- reason,
600
- reasonId,
601
- display,
602
- className,
603
- side,
604
- portal,
605
- children,
606
- }: {
607
- reason: ReactNode;
608
- reasonId: string;
609
- display: DisabledReasonDisplay | undefined;
610
- className?: string;
611
- side?: TooltipSide;
612
- portal?: boolean;
613
- children: ReactNode;
614
- }) {
615
- const line = useDisabledReasonLine(display);
616
- if (line) {
617
- return (
618
- <DisabledReasonLine id={reasonId} reason={reason} className={className}>
619
- {children}
620
- </DisabledReasonLine>
621
- );
622
- }
623
- return (
624
- <Tooltip label={reason} side={side} portal={portal} className={className}>
625
- <>
626
- {children}
627
- <span id={reasonId} hidden>
628
- {reason}
629
- </span>
630
- </>
631
- </Tooltip>
632
- );
633
- }
634
-
635
- // Canonical square icon-only button. <Button> carries TEXT geometry — px-3 py-2
636
- // gap-2 — so rendering a bare glyph through it gives a small icon in a wide,
637
- // text-shaped box. This fixes a square box instead and, crucially, forces the
638
- // child icon to a size set by the BOX (20px at md/sm) via `[&_svg]:size-*` so a
639
- // caller cannot under-size it. Use it wherever an action is a bare icon
640
- // (edit/delete/tools) so they all match the top-bar icon buttons and can never
641
- // drift apart again.
642
- //
643
- // Draws its colours from the same `buttonVariantClasses` map as <Button>, so the
644
- // two re-skin together with the palette.
645
- const ICON_BUTTON_BASE = cn(
646
- "inline-flex items-center justify-center rounded-md transition-colors focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed",
647
- FOCUS_RING_WIDTH,
648
- );
649
-
650
- export type IconButtonSize = "2xl" | "xl" | "lg" | "md" | "sm" | "xs" | "2xs";
651
-
652
- // Box and glyph together, so a 24px chip action cannot end up holding a 20px icon
653
- // that touches its edges. The two small steps are lenkbank's list-row (28px) and
654
- // chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.
655
- const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
656
- // 64px with a 28px glyph: a camera's shutter (keksdose invoices/camera-capture.tsx:248,
657
- // `size-16` by hand) — the one control of a full-screen capture view, thumb-sized.
658
- "2xl": "size-16 [&_svg]:size-7",
659
- // 48px with a 24px glyph: the one primary action of a phone screen or sheet, a step
660
- // past the 44px minimum rather than on it.
661
- xl: "size-12 [&_svg]:size-6",
662
- // The 44px touch target (WCAG 2.5.5's size) with the same 20px glyph — keksdose's
663
- // bulk-action bars write `size-11` by hand over an `md` button to get it on phones.
664
- lg: "size-11 [&_svg]:size-5",
665
- md: "size-9 [&_svg]:size-5",
666
- sm: "size-8 [&_svg]:size-5",
667
- xs: "size-7 rounded [&_svg]:size-4",
668
- "2xs": "size-6 rounded [&_svg]:size-3.5",
669
- };
670
-
671
- // `stretch`: the same WIDTH and glyph, and a height taken from the row instead of
672
- // stated — `size-*` sets a height that `self-stretch` cannot override, so the box is
673
- // written as a width plus a minimum height (the square it would otherwise be).
674
- const ICON_BUTTON_STRETCH_SIZES: Record<IconButtonSize, string> = {
675
- "2xl": "w-16 min-h-16 self-stretch [&_svg]:size-7",
676
- xl: "w-12 min-h-12 self-stretch [&_svg]:size-6",
677
- lg: "w-11 min-h-11 self-stretch [&_svg]:size-5",
678
- md: "w-9 min-h-9 self-stretch [&_svg]:size-5",
679
- sm: "w-8 min-h-8 self-stretch [&_svg]:size-5",
680
- xs: "w-7 min-h-7 self-stretch rounded [&_svg]:size-4",
681
- "2xs": "w-6 min-h-6 self-stretch rounded [&_svg]:size-3.5",
682
- };
683
-
684
- // The label as visible text beside the glyph (0.32, §4: at Large "IconButton shows its
685
- // label"): the same height and glyph as the square, a width taken from the words. A
686
- // map of its own rather than `w-auto` over `size-*`, which tailwind-merge cannot undo
687
- // in one direction.
688
- const ICON_BUTTON_TEXT_SIZES: Record<IconButtonSize, string> = {
689
- "2xl": "h-16 gap-2 px-4 text-base [&_svg]:size-7",
690
- xl: "h-12 gap-2 px-3.5 text-sm [&_svg]:size-6",
691
- lg: "h-11 gap-2 px-3 text-sm [&_svg]:size-5",
692
- md: "h-9 gap-1.5 px-2.5 text-sm [&_svg]:size-5",
693
- sm: "h-8 gap-1.5 px-2 text-sm [&_svg]:size-5",
694
- xs: "h-7 gap-1 rounded px-1.5 text-xs [&_svg]:size-4",
695
- "2xs": "h-6 gap-1 rounded px-1.5 text-xs [&_svg]:size-3.5",
696
- };
697
- const ICON_BUTTON_TEXT_STRETCH_SIZES: Record<IconButtonSize, string> = {
698
- "2xl": "min-h-16 self-stretch gap-2 px-4 text-base [&_svg]:size-7",
699
- xl: "min-h-12 self-stretch gap-2 px-3.5 text-sm [&_svg]:size-6",
700
- lg: "min-h-11 self-stretch gap-2 px-3 text-sm [&_svg]:size-5",
701
- md: "min-h-9 self-stretch gap-1.5 px-2.5 text-sm [&_svg]:size-5",
702
- sm: "min-h-8 self-stretch gap-1.5 px-2 text-sm [&_svg]:size-5",
703
- xs: "min-h-7 self-stretch gap-1 rounded px-1.5 text-xs [&_svg]:size-4",
704
- "2xs": "min-h-6 self-stretch gap-1 rounded px-1.5 text-xs [&_svg]:size-3.5",
705
- };
706
-
707
- // The 48 px touch target at Large (§4), per size. `lg` and up are past it already
708
- // (2.75rem is 55 px at Large). `md` … `xs` grow their box to it. `2xs` is the action ON
709
- // a chip or a tab, whose box cannot grow without growing the chip: it keeps its box and
710
- // gets a 48 px hit area instead — an invisible `::after` centred on it, which takes the
711
- // taps a finger lands round the glyph.
712
- const ICON_BUTTON_TOUCH_LARGE: Record<IconButtonSize, string> = {
713
- "2xl": "",
714
- xl: "",
715
- lg: "",
716
- md: TOUCH_TARGET_LARGE,
717
- sm: TOUCH_TARGET_LARGE,
718
- xs: TOUCH_TARGET_LARGE,
719
- "2xs":
720
- "relative large:after:absolute large:after:inset-[calc((100%-48px)/2)] large:after:content-['']",
721
- };
722
-
723
- // A tone re-colours the glyph without changing what the variant draws around it.
724
- // Each coloured tone comes in two resting looks, picked by `quiet` (see the prop):
725
- // QUIET is placeholder grey until the pointer or focus arrives, then the tone's
726
- // family; TONED wears the tone's colour at rest. The defaults are the looks each tone
727
- // had before `quiet` existed: `danger` is quiet — an action in every row of a list
728
- // must not shout from every row, so the red arrives only on the one row you are about
729
- // to act on — while `warning` and `info` are toned, because they mark the ONE thing
730
- // on the row to notice (keksdose's "needs review" flag, its reconcile action at
731
- // accounts-page:867), and quiet grey would hide the very thing they point out.
732
- // `muted` is quiet by definition and has no toned look; `default` has no tone.
733
- //
734
- // `success` is toned at rest too: the green is the news (keksdose's "in sync" chip).
735
- // `custom` paints from `--icon-button-tone`, which `toneColor` sets — for a glyph whose
736
- // colour FOLLOWS STATE across tones (keksdose's sync chip: rose, amber, sky, green as
737
- // the sync moves), where swapping `tone` would do but the colour is one variable.
738
- export type IconButtonTone = "default" | "muted" | "danger" | "warning" | "info" | "success" | "custom";
739
-
740
- type ColouredTone = "danger" | "warning" | "info" | "success" | "custom";
741
-
742
- const ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }> = {
743
- danger: {
744
- quiet:
745
- "text-[var(--text-placeholder)] hover:bg-[var(--danger-bg)] hover:text-[var(--danger)] focus-visible:ring-[var(--danger-border-strong)]",
746
- toned: "text-[var(--danger)] hover:bg-[var(--danger-bg)] focus-visible:ring-[var(--danger-border-strong)]",
747
- },
748
- warning: {
749
- quiet:
750
- "text-[var(--text-placeholder)] hover:bg-[var(--warning-bg)] hover:text-[var(--warning)] focus-visible:ring-warning-strong",
751
- // The ring is warning's loud line (0.33, k27): `--warning-border`, amber-300, was a
752
- // focus ring at 1.1:1 on the light surfaces.
753
- toned: "text-[var(--warning)] hover:bg-[var(--warning-bg)] focus-visible:ring-warning-strong",
754
- },
755
- info: {
756
- quiet:
757
- "text-[var(--text-placeholder)] hover:bg-[var(--info-bg)] hover:text-[var(--info)] focus-visible:ring-[var(--info-border)]",
758
- toned: "text-[var(--info)] hover:bg-[var(--info-bg)] focus-visible:ring-[var(--info-border)]",
759
- },
760
- success: {
761
- quiet:
762
- "text-[var(--text-placeholder)] hover:bg-[var(--success-bg)] hover:text-[var(--success)] focus-visible:ring-[var(--success-border)]",
763
- toned: "text-[var(--success)] hover:bg-[var(--success-bg)] focus-visible:ring-[var(--success-border)]",
764
- },
765
- // The hover fill and the ring are MIXED from the one colour, since a custom tone
766
- // brings no `-bg` / `-border` pair of its own. The fallback is the body colour, so a
767
- // `custom` tone with no colour set is the default look rather than invisible.
768
- custom: {
769
- quiet:
770
- "text-[var(--text-placeholder)] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] hover:text-[var(--icon-button-tone,var(--text-primary))] focus-visible:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]",
771
- toned:
772
- "text-[var(--icon-button-tone,var(--text-primary))] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] focus-visible:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]",
773
- },
774
- };
775
-
776
- // The translucent ink on hover, as Button's secondary/ghost (B′, see HOVER_INK).
777
- const ICON_BUTTON_MUTED = `text-[var(--text-placeholder)] ${HOVER_INK} hover:text-[var(--text-primary)]`;
778
-
779
- /** Which tones sit quiet at rest when `quiet` is left out. */
780
- const QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = {
781
- danger: true,
782
- warning: false,
783
- info: false,
784
- success: false,
785
- custom: false,
786
- };
787
-
788
- function iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined): string {
789
- if (tone === "default") return "";
790
- if (tone === "muted") return cn(ICON_BUTTON_MUTED, ICON_BUTTON_QUIET_DISABLED_REST);
791
- const isQuiet = quiet ?? QUIET_BY_DEFAULT[tone];
792
- const look = ICON_BUTTON_TONES[tone];
793
- return isQuiet ? cn(look.quiet, ICON_BUTTON_QUIET_DISABLED_REST) : look.toned;
794
- }
795
-
796
- // A disabled button must not answer the pointer. The hover classes above are plain
797
- // `hover:` (so a caller's `className="hover:…"` still replaces them through
798
- // tailwind-merge), which means they fire on a disabled button too — the old grey
799
- // icon lit up on hover while refusing the click. Rather than rewrite every hover as
800
- // `enabled:hover:` (which never matches the `<a>` that `buttonClasses` also styles,
801
- // and would out-rank a caller's plain `hover:` override), each variant pins its
802
- // RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity
803
- // and only ever matches a disabled button.
804
- const ICON_BUTTON_DISABLED_REST: Record<IconButtonVariant, string> = {
805
- primary: "disabled:hover:bg-[var(--bg-surface-2)]",
806
- secondary: "disabled:hover:bg-transparent",
807
- ghost: "disabled:hover:bg-transparent",
808
- danger: "disabled:hover:bg-[var(--danger)]",
809
- brand: "disabled:hover:bg-[var(--brand)]",
810
- link: "disabled:hover:bg-transparent disabled:hover:no-underline",
811
- overlay: "disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]",
812
- shutter: "disabled:hover:bg-[var(--media-scrim)]",
813
- };
814
-
815
- // The quiet looks change the glyph on hover, so they pin their resting glyph the
816
- // same way. A toned look keeps its glyph on hover and needs no pin.
817
- const ICON_BUTTON_QUIET_DISABLED_REST = "disabled:hover:text-[var(--text-placeholder)]";
818
-
819
- // `pressed`: a toggle that is on. The brand glyph on the quiet brand fill — the
820
- // "selected" look of a Chip or a SegmentedControl option, so an on toggle reads as on
821
- // beside them. After the tone, so a pressed `muted` button is brand, not grey. On hover
822
- // the glyph turns brand-muted: `--brand` on `--brand-bg-hover` is 2.69–2.80 in four dark
823
- // presets, under an icon's 3:1 (colour-roles §12.12).
824
- const ICON_BUTTON_PRESSED =
825
- "bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)] hover:text-brand-muted disabled:hover:bg-[var(--brand-bg)] disabled:hover:text-[var(--brand)]";
826
-
827
- // `variant="overlay"`: a round, translucent disc for a control that sits ON a photo
828
- // (keksdose's receipt-scan preview: close, rotate, retake over the camera image).
829
- // The inverse pair, not the surface one: what has to hold is the contrast between the
830
- // disc and its glyph, whatever the picture underneath is, and `--bg-inverse` /
831
- // `--text-inverse` are the one token pair defined as each other's opposite in both
832
- // themes. The disc is the inverse at 60% (`color-mix`, so it stays a token a palette
833
- // can re-point); the blur keeps a busy background from breaking the glyph's edge.
834
- const ICON_BUTTON_OVERLAY =
835
- "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-visible:ring-[var(--text-inverse)]";
836
-
837
- // `variant="shutter"`: the camera's release — keksdose's camera-capture.tsx:248 draws
838
- // it by hand as a 64px circle with a thick white ring round a translucent fill. It sits
839
- // on a live camera picture, which is dark whatever the theme, so it uses the MEDIA
840
- // tokens (tokens.css), white ink on a dark scrim in both themes — the inverse pair
841
- // `overlay` uses would give a dark ring on a dark viewfinder in dark mode. The focus ring stands off by 2px, or it would
842
- // merge into the ring that is part of the look.
843
- const ICON_BUTTON_SHUTTER =
844
- "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-visible:ring-[var(--media-ink)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--media-scrim-hover)]";
845
-
846
- export type IconButtonVariant = ButtonVariant | "overlay" | "shutter";
847
-
848
- /**
849
- * The glyph's size in px, over the one the box size implies — see
850
- * {@link IconButtonProps.glyphSize}.
851
- */
852
- export type IconButtonGlyphSize = 12 | 14 | 16 | 20 | 24 | 28;
853
-
854
- const ICON_BUTTON_GLYPH_SIZES: Record<IconButtonGlyphSize, string> = {
855
- 12: "[&_svg]:size-3",
856
- 14: "[&_svg]:size-3.5",
857
- 16: "[&_svg]:size-4",
858
- 20: "[&_svg]:size-5",
859
- 24: "[&_svg]:size-6",
860
- 28: "[&_svg]:size-7",
861
- };
862
-
863
- // `pending`'s spinner, the size of the glyph it replaces (the `[&_svg]` rules above
864
- // size only an svg, and the spinner is a bordered span).
865
- const ICON_BUTTON_SPINNER: Record<IconButtonSize, string> = {
866
- "2xl": "size-7",
867
- xl: "size-6",
868
- lg: "size-5",
869
- md: "size-5",
870
- sm: "size-5",
871
- xs: "size-4",
872
- "2xs": "size-3.5",
873
- };
874
- const ICON_BUTTON_SPINNER_GLYPH: Record<IconButtonGlyphSize, string> = {
875
- 12: "size-3",
876
- 14: "size-3.5",
877
- 16: "size-4",
878
- 20: "size-5",
879
- 24: "size-6",
880
- 28: "size-7",
881
- };
882
-
883
- export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
884
- /** Any {@link ButtonVariant}; `overlay` — a round translucent disc for use over
885
- * an image (see `ICON_BUTTON_OVERLAY`); or `shutter` — a camera's release, a ringed
886
- * disc over the viewfinder (see `ICON_BUTTON_SHUTTER`; pair it with `size="2xl"`). */
887
- variant?: IconButtonVariant;
888
- /** Box size: 2xl = 64px with a 28px icon (a camera shutter), xl = 48px with a 24px
889
- * icon, lg = 44px (a phone's touch target), md = 36px (matches the top bar),
890
- * sm = 32px — all three with a 20px icon;
891
- * xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px
892
- * icon (an action on a chip or a tab). `glyphSize` overrides the icon. */
893
- size?: IconButtonSize;
894
- /**
895
- * The icon's size in px, when the box's own is not the one wanted. keksdose's sync
896
- * chip (app/sync-status-indicator.tsx:142) is a 24px round chip round a 20px cloud —
897
- * `size="2xs" glyphSize={20} shape="round"` — where `2xs` alone draws 14px. A number
898
- * over a new size step because the box and the glyph are two independent choices; a
899
- * step per combination (`"sm-lg"`) would have to be named for every one.
900
- */
901
- glyphSize?: IconButtonGlyphSize;
902
- /**
903
- * A small mark on the button's corner — the sync chip's 12px check over its cloud
904
- * ("all good", feedback #67). Rendered OUTSIDE the `<button>`, in a wrapper beside
905
- * it, so the box's `[&_svg]:size-*` rule that sizes the glyph does not blow the badge
906
- * up to the glyph's size; a bare `<Check />` comes out 12px, on a
907
- * `--bg-surface` disc that keeps it legible over the glyph. Decorative
908
- * (`aria-hidden`): the name (`label` / `aria-label`) must already carry the state.
909
- * With a badge, `className` still lands on the button; the wrapper is the flex item.
910
- */
911
- badge?: ReactNode;
912
- /**
913
- * `keep`: a disabled button keeps its full colour (no 50% drop) and an ordinary
914
- * cursor. For a STATUS that is also an action — the sync chip shows "in sync" in
915
- * green and refreshes on click, and is disabled while offline or syncing, when the
916
- * state it shows matters most; dimmed it read as broken (keksdose wrote
917
- * `disabled:cursor-default` by hand and left out the dimming). Default `dim`.
918
- */
919
- disabledStyle?: "dim" | "keep";
920
- /**
921
- * Fill the height of the flex row it stands in, keeping its width — the delete at
922
- * the end of keksdose's split line (transaction-fields:162), level with the labelled
923
- * fields beside it rather than floating at half their height. {@link Button}'s
924
- * `stretch`; no effect outside a flex row. With a `label`, the tooltip's wrapper is
925
- * what stretches.
926
- */
927
- stretch?: boolean;
928
- /** `round`: a circle instead of the rounded square — a compact status chip in a top
929
- * bar (keksdose's sync indicator). `overlay` is always round. */
930
- shape?: "square" | "round";
931
- /**
932
- * The colour of `tone="custom"`, as any CSS colour — a token (`var(--warning)`),
933
- * ideally. Sets `--icon-button-tone` on the button, so passing a different one per
934
- * state re-colours the glyph, its hover fill and its focus ring together. Passing it
935
- * implies `tone="custom"`. Or leave it out and set the variable yourself from a class
936
- * (`className="[--icon-button-tone:var(--success)]"`).
937
- *
938
- * Or a `{ light, dark }` pair, one colour per theme — keksdose: a raw colour that reads
939
- * on the light surface (`#be123c`) is too dark on the dark one, and an inline style
940
- * cannot say "in dark mode". See {@link IconButtonToneColor}.
941
- */
942
- toneColor?: IconButtonToneColor;
943
- /** Glyph colour over the variant. `muted`: placeholder grey, full text colour on
944
- * hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
945
- * remove/delete that repeats down a list. `warning`: amber at rest — a flag that
946
- * wants attention. `info`: sky at rest — a notice-worthy but harmless action
947
- * (keksdose's reconcile). `success`: green at rest — a good state worth seeing.
948
- * `custom`: the colour of `toneColor` (see there). Default: the variant's own colours. See `quiet` for
949
- * turning a coloured tone's resting look the other way. */
950
- tone?: IconButtonTone;
951
- /**
952
- * Whether a coloured tone (`danger`, `warning`, `info`) waits for the pointer:
953
- * `true` is placeholder grey at rest and the tone's colour on hover and focus;
954
- * `false` wears the tone's colour at rest. Left out, each tone keeps its own
955
- * default — `danger` quiet, `warning` and `info` not. Ignored for `muted` (quiet by
956
- * definition) and `default` (no tone).
957
- *
958
- * `quiet={false}` on `danger` is for a destructive action that stands ALONE, where
959
- * hover-to-reveal hides it: keksdose's phone bulk bar (mobile-bulk-bar.tsx) has one
960
- * "delete all" and a touch screen that never hovers, so it painted rose by hand.
961
- * `quiet` on `warning`/`info` is the same switch the other way, for a flag repeated
962
- * down a list. A boolean over the tone rather than a new tone (`danger-solid`) or an
963
- * `emphasis` scale: there are exactly two resting looks, every coloured tone has
964
- * both, and which one fits is a question about the SITE (alone or repeated, touch or
965
- * pointer), not about the tone — so it is one switch the family shares.
966
- */
967
- quiet?: boolean;
968
- /**
969
- * Make it a toggle button. `true` sets `aria-pressed="true"` and draws the "on" look
970
- * (brand glyph on the quiet brand fill); `false` sets `aria-pressed="false"` with the
971
- * normal look, so a screen reader still hears a toggle that is off. Left out, it is
972
- * an ordinary button with no `aria-pressed` — or whatever `aria-pressed` the caller
973
- * passes. For keksdose's budget share toggle (budgets-page:291), which paints its own
974
- * brand colour over a ghost button today. Keep the `aria-label` the same in both
975
- * states ("Share budget"): the pressed state already says whether it is on.
976
- */
977
- pressed?: boolean;
978
- /** Keep the click (and the Enter/Space that produces it) from reaching an
979
- * ancestor's handler — for an action inside a clickable table row or card.
980
- *
981
- * **Not a licence to put this inside another `<button>`.** HTML forbids any
982
- * interactive content, and any element with a `tabindex`, inside a button — so
983
- * the `<span role="button" tabIndex={0}>` workaround is invalid too — and ARIA
984
- * makes a button's children presentational, so a screen reader flattens the
985
- * inner one into the outer one's name and it cannot be reached at all. Make the
986
- * row's main action a button that fills the row and put this one BESIDE it, on
987
- * top:
988
- *
989
- * ```tsx
990
- * <div className="relative">
991
- * <button className="w-full pe-16 …" onClick={open}>…row…</button>
992
- * <div className="absolute inset-y-0 end-2 flex items-center gap-0.5">
993
- * <IconButton size="xs" tone="danger" aria-label="Delete" onClick={remove}>
994
- * <Trash2 />
995
- * </IconButton>
996
- * </div>
997
- * </div>
998
- * ```
999
- *
1000
- * The pair look exactly like the nested version, click exactly like it, and
1001
- * are two tab stops a screen reader can tell apart. */
1002
- stopPropagation?: boolean;
1003
- /**
1004
- * The button's name, said once: it becomes the `aria-label` AND the text of a kit
1005
- * {@link Tooltip} round the button.
1006
- *
1007
- * Every app writes the pair by hand. lenkbank's nine icon actions all carry
1008
- * `aria-label={t(x)} title={t(x)}` (shared/lib/table-columns.tsx:103,
1009
- * projects-page.tsx:83, setpoint/segment-list.tsx:198/207, profile-bar.tsx:128/142,
1010
- * shortcut-dialog.tsx:815/857/1007) — and `title` is the browser's own tooltip,
1011
- * which shows late, never on focus and never on touch, and looks like no other
1012
- * label in the kit. kastlan's RowAction (shared/components/data-table/row-action.tsx)
1013
- * and keksdose's icon wrappers each exist to put a Tooltip round an IconButton.
1014
- *
1015
- * A caller's own `aria-label` still wins, for the rare name that should be longer
1016
- * than the bubble. The bubble is visual only: it would otherwise describe the button
1017
- * with its own name, and a screen reader would read the same word twice. Tooltip's
1018
- * default placement applies — in place, or portalled inside a scroll container —
1019
- * unless `tooltipPortal` says otherwise.
1020
- */
1021
- label?: string;
1022
- /**
1023
- * Show `label` as visible text beside the icon at Large and Extra large (0.32,
1024
- * docs/text-size-harmonization.md §4, §10.8). A reader who asked for bigger type is
1025
- * the one an unlabelled glyph fails, and on touch the tooltip never opens. Then there
1026
- * is no tooltip: the words are on the button.
1027
- *
1028
- * Default `true` — except for `variant="overlay"` and `"shutter"`, which sit ON a
1029
- * picture, and inside {@link CompactControls} (AppShell's top bar, `RowActions`'
1030
- * inline icons). `false`: the icon alone at every size, for an icon on content — a
1031
- * receipt overlay, a map's zoom, a viewfinder, a photo thumbnail. That must be rare;
1032
- * a dense row's actions belong in `RowActions`, which collapses them into a "⋯" menu
1033
- * at Large instead (§10.8). Normal never shows it. `tooltip={false}` (a universally
1034
- * read glyph, a dialog's ✕) keeps the icon alone too.
1035
- */
1036
- labelVisible?: boolean;
1037
- /** Show `label` as a tooltip. Default `true`; `false` keeps `label` as the
1038
- * accessible name only — for a button whose glyph is universally read (a close ✕ in
1039
- * a dialog header) or that already sits under a tooltip of its own. It also keeps the
1040
- * label from showing as text at Large (see `labelVisible`). */
1041
- tooltip?: boolean;
1042
- /** Where the `label` tooltip opens. See {@link Tooltip}'s `side`. */
1043
- tooltipSide?: TooltipSide;
1044
- /** Passed to the `label` tooltip's `portal`. Left out, Tooltip decides (see there). */
1045
- tooltipPortal?: boolean;
1046
- /**
1047
- * Mount the `label` bubble only while it is up — {@link Tooltip}'s `lazy`. Default
1048
- * `true` since 0.16.0; `false` is the always-mounted in-place bubble of before.
1049
- *
1050
- * Why the label bubble can be lazy when Tooltip's default is not: this bubble
1051
- * describes nothing. It is wrapped round the button in a fragment precisely so that
1052
- * it is NOT the button's `aria-describedby` (it would repeat the button's own name),
1053
- * so there is no description for a lazy mount to take away — the name is the
1054
- * `aria-label` either way, read in browse mode and on focus alike. What the
1055
- * always-mounted bubble did do was sit in the DOM as a `role="tooltip"` node full of
1056
- * text: every IconButton in a row added one to `getAllByRole("tooltip")` and its label
1057
- * to the row's `textContent`, and two keksdose tests tripped over that (0.15.5 P6).
1058
- * The cost is the one {@link Tooltip}'s "Lazy in place" note names — a test that
1059
- * looked the bubble up without hovering now has to hover (or focus) first; pass
1060
- * `false` while it is migrated. A `disabledReason` bubble is unaffected: that one
1061
- * stays mounted as it was.
1062
- */
1063
- tooltipLazy?: boolean;
1064
- /**
1065
- * Busy — the action is in flight: the glyph is swapped for a spinner of the same
1066
- * size, the button is `aria-busy`, and clicks (and the Enter/Space and form
1067
- * submission they stand for) are swallowed, so a second press cannot start the
1068
- * action twice. {@link Button}'s `pending`, with the same contract: `aria-disabled`
1069
- * rather than `disabled`, so the button that was pressed keeps its focus. The name
1070
- * (`label` / `aria-label`) stays — `aria-busy` is what says it is working. A SWAP
1071
- * rather than Button's overlay: the box is square and fixed, so there is no width
1072
- * for the glyph to hold, and a spinner drawn over a glyph would be two marks in one
1073
- * 20px square.
1074
- */
1075
- pending?: boolean;
1076
- /**
1077
- * Why the action is not available — {@link Button}'s `disabledReason`, on the icon
1078
- * button: keksdose's accounts page cannot hide an account with a balance or delete
1079
- * one with bookings (accounts-page:879/913 — "hide requires zero", "delete blocked"),
1080
- * nor its budgets page delete the only budget (budgets-page:330), and each wrapped a
1081
- * natively disabled IconButton in a Tooltip that swaps its label for the reason — a
1082
- * bubble a keyboard never opens, since `disabled` leaves the tab order, and a reason
1083
- * a screen reader never hears.
1084
- *
1085
- * The same contract as Button's: `aria-disabled` instead of `disabled`, so it stays
1086
- * focusable and hoverable; clicks (and the Enter/Space they stand for) are swallowed
1087
- * — `stopPropagation` still applies, so a locked action in a clickable row does not
1088
- * open the row either; the reason shows in the kit {@link Tooltip} and is attached
1089
- * through `aria-describedby` as a `hidden` copy. The NAME stays `aria-label` /
1090
- * `label` — "Delete", described by "The active budget cannot be deleted" — and the
1091
- * bubble shows the reason in place of the label, since the glyph already says what
1092
- * the button is and the reason is the news. Shown even with `tooltip={false}`: a
1093
- * reason nobody can see is not one. Wins over `disabled`, as on Button.
1094
- *
1095
- * 0.32: at Large and on a touch screen the reason is a line under the button rather
1096
- * than a bubble — see `disabledReasonDisplay`.
1097
- */
1098
- disabledReason?: ReactNode;
1099
- /** Where `disabledReason` shows — {@link Button}'s `disabledReasonDisplay`. */
1100
- disabledReasonDisplay?: DisabledReasonDisplay;
1101
- /** This action COMMITS — {@link Button}'s `commit`: under a locked
1102
- * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's
1103
- * reason. No effect without a lock. A {@link CommitScope} since 0.33. */
1104
- commit?: CommitScope;
1105
- /** The `<button>` element — a prop in React 19, as on {@link Button}. */
1106
- ref?: Ref<HTMLButtonElement>;
1107
- /** Only on the link form — see {@link IconButtonLinkProps}. */
1108
- href?: never;
1109
- renderLink?: never;
1110
- external?: never;
1111
- replace?: never;
1112
- reloadDocument?: never;
1113
- }
1114
-
1115
- /**
1116
- * `<IconButton href>`: an icon-only LINK — keksdose F2 and kastlan 45, whose icon
1117
- * actions that navigate (a row's "open", a card's "edit" that goes to the edit page, a
1118
- * top bar's settings cog) were `<IconButton onClick={() => navigate(…)}>`: a button that
1119
- * changes the page, with no middle click, no "open in new tab" and no URL on hover.
1120
- *
1121
- * The same rule as {@link Button}'s `href` (see {@link ButtonLinkProps}): an in-app
1122
- * `href` goes through `renderLink`, else the provider's `linkComponent`; an external one
1123
- * or an in-page `#anchor` stays a plain `<a>`; `external` opens a new tab and says so to
1124
- * a screen reader; `replace` and `reloadDocument` as on Button. The look, the `label` →
1125
- * `aria-label` + Tooltip, the badge, `toneColor` and `stopPropagation` are the button's.
1126
- *
1127
- * `disabled` renders an inert `<a>` with no `href` — `role="link"`, `aria-disabled`, out
1128
- * of the tab order, dimmed — like Button's disabled link. `pressed`, `disabledReason` and
1129
- * `disabledStyle` are a button's and are typed out: a link is not a toggle, and a link
1130
- * that explains why it cannot be followed is a job for a disabled BUTTON.
1131
- */
1132
- export interface IconButtonLinkProps
1133
- extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "type" | "href">,
1134
- Pick<
1135
- IconButtonProps,
1136
- | "variant"
1137
- | "size"
1138
- | "glyphSize"
1139
- | "badge"
1140
- | "stretch"
1141
- | "shape"
1142
- | "toneColor"
1143
- | "tone"
1144
- | "quiet"
1145
- | "stopPropagation"
1146
- | "label"
1147
- | "labelVisible"
1148
- | "tooltip"
1149
- | "tooltipSide"
1150
- | "tooltipPortal"
1151
- | "tooltipLazy"
1152
- > {
1153
- href: string;
1154
- /** See {@link ButtonLinkProps.renderLink}. */
1155
- renderLink?: KitLinkComponent;
1156
- /** See {@link ButtonLinkProps.external}. */
1157
- external?: boolean;
1158
- /** See {@link ButtonLinkProps.replace}. */
1159
- replace?: boolean;
1160
- /** See {@link ButtonLinkProps.reloadDocument}. */
1161
- reloadDocument?: boolean;
1162
- /** See {@link ButtonLinkProps.disabled}. */
1163
- disabled?: boolean;
1164
- ref?: Ref<HTMLAnchorElement>;
1165
- type?: never;
1166
- form?: never;
1167
- pressed?: never;
1168
- disabledReason?: never;
1169
- disabledReasonDisplay?: never;
1170
- commit?: never;
1171
- disabledStyle?: never;
1172
- pending?: never;
1173
- }
1174
-
1175
- /**
1176
- * Whether an IconButton draws its `label` as text (see `labelVisible`): at Large and
1177
- * above, with a label to draw, not switched off by the caller, the variant (`overlay`,
1178
- * `shutter`), `tooltip={false}` or a {@link CompactControls} region.
1179
- */
1180
- function useIconButtonText(
1181
- label: string | undefined,
1182
- labelVisible: boolean | undefined,
1183
- variant: IconButtonVariant,
1184
- tooltip: boolean,
1185
- ): boolean {
1186
- const large = useLargeText();
1187
- const compact = useContext(CompactControlsContext);
1188
- if (!large || label === undefined || label === "" || !tooltip) return false;
1189
- const visible = labelVisible ?? (!compact && variant !== "overlay" && variant !== "shutter");
1190
- return visible;
1191
- }
1192
-
1193
- /** The visible label (see `labelVisible`). */
1194
- function IconButtonText({ label }: { label: string }) {
1195
- return <span data-slot="icon-button-label">{label}</span>;
1196
- }
1197
-
1198
- /**
1199
- * `toneColor`: one CSS colour, or one per theme.
1200
- *
1201
- * The pair is two custom properties on the element — `--icon-button-tone-light` and
1202
- * `--icon-button-tone-dark` — and a class that points `--icon-button-tone` at one or the
1203
- * other by the kit's `dark` variant (`.dark` on an ancestor, tokens.css), so the colour
1204
- * follows a theme switch in CSS alone, with no re-render and no JS reading the theme.
1205
- * Both values are any CSS colour; a token that already flips (`var(--warning)`) needs no
1206
- * pair.
1207
- */
1208
- export type IconButtonToneColor = string | { light: string; dark: string };
1209
-
1210
- const ICON_BUTTON_TONE_PAIR =
1211
- "[--icon-button-tone:var(--icon-button-tone-light)] dark:[--icon-button-tone:var(--icon-button-tone-dark)]";
1212
-
1213
- /** The inline custom properties `toneColor` sets over the caller's `style`. */
1214
- function toneColorStyle(toneColor: IconButtonToneColor | undefined, style: CSSProperties | undefined) {
1215
- if (toneColor === undefined) return style;
1216
- const vars: Record<string, string> =
1217
- typeof toneColor === "string"
1218
- ? { "--icon-button-tone": toneColor }
1219
- : { "--icon-button-tone-light": toneColor.light, "--icon-button-tone-dark": toneColor.dark };
1220
- return { ...style, ...vars } as CSSProperties;
1221
- }
1222
-
1223
- /** Everything but the element: the look both forms of IconButton share. */
1224
- function iconButtonLook({
1225
- variant,
1226
- size,
1227
- stretch,
1228
- glyphSize,
1229
- shape,
1230
- tone,
1231
- quiet,
1232
- toneColor,
1233
- withText = false,
1234
- }: {
1235
- variant: IconButtonVariant;
1236
- size: IconButtonSize;
1237
- stretch: boolean | undefined;
1238
- glyphSize: IconButtonGlyphSize | undefined;
1239
- shape: "square" | "round";
1240
- tone: IconButtonTone;
1241
- quiet: boolean | undefined;
1242
- toneColor: IconButtonToneColor | undefined;
1243
- /** The label is drawn beside the glyph (see `labelVisible`). */
1244
- withText?: boolean;
1245
- }) {
1246
- return cn(
1247
- ICON_BUTTON_BASE,
1248
- withText
1249
- ? cn("font-medium whitespace-nowrap", stretch ? ICON_BUTTON_TEXT_STRETCH_SIZES[size] : ICON_BUTTON_TEXT_SIZES[size])
1250
- : stretch
1251
- ? ICON_BUTTON_STRETCH_SIZES[size]
1252
- : ICON_BUTTON_SIZES[size],
1253
- // With text the pill is a 48 px target at any size; the square follows its size.
1254
- withText ? TOUCH_TARGET_LARGE : ICON_BUTTON_TOUCH_LARGE[size],
1255
- glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],
1256
- // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
1257
- variant === "overlay"
1258
- ? ICON_BUTTON_OVERLAY
1259
- : variant === "shutter"
1260
- ? ICON_BUTTON_SHUTTER
1261
- : buttonVariantClasses[variant],
1262
- shape === "round" && "rounded-full",
1263
- ICON_BUTTON_DISABLED_REST[variant],
1264
- iconButtonToneClass(tone, quiet),
1265
- toneColor !== undefined && typeof toneColor !== "string" && ICON_BUTTON_TONE_PAIR,
1266
- );
1267
- }
1268
-
1269
- /** The badge beside the control, not in it — see `badge`. The wrapper is only a
1270
- * positioning box: the control inside it keeps its own focus ring and hit area. */
1271
- function withIconButtonBadge(control: ReactElement, badge: ReactNode, stretch: boolean | undefined) {
1272
- if (!hasContent(badge)) return control;
1273
- return (
1274
- <span className={cn("relative inline-flex shrink-0", stretch && "self-stretch")}>
1275
- {control}
1276
- <span
1277
- aria-hidden
1278
- data-slot="icon-button-badge"
1279
- className="pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-[var(--bg-surface)] [&_svg]:size-3"
1280
- >
1281
- {badge}
1282
- </span>
1283
- </span>
1284
- );
1285
- }
1286
-
1287
- // Declared link-first, as Button is, so `<IconButton href>` picks the link props and
1288
- // `ComponentProps<typeof IconButton>` (the LAST signature) is still the button's.
1289
- export function IconButton(props: IconButtonLinkProps): ReactElement;
1290
- export function IconButton(props: IconButtonProps): ReactElement;
1291
- export function IconButton(props: IconButtonProps | IconButtonLinkProps): ReactElement {
1292
- if (props.href !== undefined) return <IconButtonLink {...(props as IconButtonLinkProps)} />;
1293
- return <IconButtonElement {...(props as IconButtonProps)} />;
1294
- }
1295
- IconButton.displayName = "IconButton";
1296
-
1297
- function IconButtonLink({
1298
- href,
1299
- renderLink,
1300
- external,
1301
- replace,
1302
- reloadDocument,
1303
- disabled,
1304
- variant = "ghost",
1305
- size = "md",
1306
- tone: toneProp,
1307
- quiet,
1308
- stopPropagation,
1309
- stretch,
1310
- shape = "square",
1311
- toneColor,
1312
- label,
1313
- labelVisible,
1314
- tooltip = true,
1315
- tooltipSide,
1316
- tooltipPortal,
1317
- tooltipLazy = true,
1318
- glyphSize,
1319
- badge,
1320
- className,
1321
- style,
1322
- target,
1323
- rel,
1324
- onClick,
1325
- onKeyDown,
1326
- children: glyph,
1327
- ref,
1328
- ...rest
1329
- }: IconButtonLinkProps) {
1330
- const withText = useIconButtonText(label, labelVisible, variant, tooltip);
1331
- const children = withText ? (
1332
- <>
1333
- {glyph}
1334
- <IconButtonText label={label!} />
1335
- </>
1336
- ) : (
1337
- glyph
1338
- );
1339
- const kitLink = useKitLink();
1340
- const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
1341
- const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
1342
- // Button's rule, word for word: `reloadDocument` bypasses the router, an external
1343
- // href or an in-page `#anchor` never reaches it.
1344
- const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);
1345
- const newTab = !disabled && (external || target === "_blank");
1346
- const name = rest["aria-label"] ?? label;
1347
- // "(opens in a new tab)" joins the NAME here, not the body: an icon link has no text
1348
- // for an sr-only span to follow, and `aria-label` would override one anyway.
1349
- const ariaLabel = name !== undefined && newTab ? `${name} (${common.opensInNewTab})` : name;
1350
- const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
1351
- if (stopPropagation) e.stopPropagation();
1352
- onClick?.(e);
1353
- };
1354
- const handleKeyDown = (e: KeyboardEvent<HTMLAnchorElement>) => {
1355
- // A link activates on Enter only; that key still bubbles to a clickable row.
1356
- if (stopPropagation && e.key === "Enter") e.stopPropagation();
1357
- onKeyDown?.(e);
1358
- };
1359
- const cls = cn(
1360
- iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor, withText }),
1361
- // BASE's `disabled:` classes never match an `<a>` — Button's disabled link look.
1362
- disabled && "pointer-events-none cursor-not-allowed opacity-50",
1363
- className,
1364
- );
1365
- const look = { style: toneColorStyle(toneColor, style), className: cls };
1366
- let control: ReactElement;
1367
- if (disabled) {
1368
- control = (
1369
- <a {...rest} {...look} ref={ref} role="link" aria-disabled="true" aria-label={ariaLabel}>
1370
- {children}
1371
- </a>
1372
- );
1373
- } else if (external || !Link) {
1374
- control = (
1375
- <a
1376
- {...rest}
1377
- {...look}
1378
- ref={ref}
1379
- href={href}
1380
- aria-label={ariaLabel}
1381
- target={external ? "_blank" : target}
1382
- rel={external ? (rel ?? "noopener noreferrer") : rel}
1383
- onClick={external ? handleClick : replacingClick(handleClick, href, replace, target)}
1384
- onKeyDown={handleKeyDown}
1385
- >
1386
- {children}
1387
- </a>
1388
- );
1389
- } else {
1390
- control = (
1391
- <RenderedButtonLink
1392
- render={Link}
1393
- {...rest}
1394
- {...look}
1395
- {...routerLinkNavigation(replace)}
1396
- ref={ref}
1397
- href={href}
1398
- aria-label={ariaLabel}
1399
- target={target}
1400
- rel={rel}
1401
- onClick={handleClick}
1402
- onKeyDown={handleKeyDown}
1403
- >
1404
- {children}
1405
- </RenderedButtonLink>
1406
- );
1407
- }
1408
- control = withIconButtonBadge(control, badge, stretch);
1409
- // The words are on the link at Large: a bubble would only repeat them.
1410
- if (!tooltip || withText || label === undefined || label === "") return control;
1411
- return (
1412
- // A fragment, as on the button: the bubble says what `aria-label` already does.
1413
- <Tooltip
1414
- label={label}
1415
- side={tooltipSide}
1416
- portal={tooltipPortal}
1417
- lazy={tooltipLazy}
1418
- className={stretch ? "self-stretch" : undefined}
1419
- >
1420
- <>{control}</>
1421
- </Tooltip>
1422
- );
1423
- }
1424
-
1425
- function IconButtonElement(
1426
- {
1427
- variant = "ghost",
1428
- size = "md",
1429
- tone: toneProp,
1430
- quiet,
1431
- pressed,
1432
- stopPropagation,
1433
- stretch,
1434
- shape = "square",
1435
- toneColor,
1436
- label,
1437
- labelVisible,
1438
- tooltip = true,
1439
- tooltipSide,
1440
- tooltipPortal,
1441
- tooltipLazy = true,
1442
- disabledReason: ownDisabledReason,
1443
- disabledReasonDisplay,
1444
- commit,
1445
- pending,
1446
- glyphSize,
1447
- badge,
1448
- disabledStyle = "dim",
1449
- className,
1450
- style,
1451
- onClick,
1452
- onKeyDown,
1453
- children,
1454
- href: _href,
1455
- renderLink: _renderLink,
1456
- external: _external,
1457
- replace: _replace,
1458
- reloadDocument: _reloadDocument,
1459
- ref,
1460
- ...rest
1461
- }: IconButtonProps,
1462
- ) {
1463
- const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
1464
- const keep = disabledStyle === "keep";
1465
- const withText = useIconButtonText(label, labelVisible, variant, tooltip);
1466
- const reasonId = useId();
1467
- const disabledReason = useCommitReason(commit, ownDisabledReason);
1468
- const locked = hasContent(disabledReason);
1469
- // Pending is inert like a locked button — focusable, every activation swallowed —
1470
- // without the locked look: the spinner is the state.
1471
- const inert = locked || Boolean(pending);
1472
- const ownDescribedBy = rest["aria-describedby"];
1473
- const button = (
1474
- <button
1475
- ref={ref}
1476
- {...rest}
1477
- disabled={inert ? undefined : rest.disabled}
1478
- aria-disabled={inert || rest["aria-disabled"]}
1479
- aria-busy={pending || rest["aria-busy"]}
1480
- aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
1481
- aria-label={rest["aria-label"] ?? label}
1482
- aria-pressed={pressed ?? rest["aria-pressed"]}
1483
- style={toneColorStyle(toneColor, style)}
1484
- onClick={(e) => {
1485
- if (stopPropagation) e.stopPropagation();
1486
- // `preventDefault` too: a locked (or pending) submit must not submit its form.
1487
- if (inert) {
1488
- e.preventDefault();
1489
- return;
1490
- }
1491
- onClick?.(e);
1492
- }}
1493
- onKeyDown={(e) => {
1494
- // A button turns Enter/Space into a click of its own, but the KEY still
1495
- // bubbles — and a clickable row listening for Enter would act on it too.
1496
- if (stopPropagation && (e.key === "Enter" || e.key === " ")) e.stopPropagation();
1497
- onKeyDown?.(e);
1498
- }}
1499
- className={cn(
1500
- iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor, withText }),
1501
- pressed && ICON_BUTTON_PRESSED,
1502
- // The disabled look for the focusable kind of disabled, as on Button.
1503
- locked && (keep ? "cursor-default" : "cursor-not-allowed opacity-50"),
1504
- keep && "disabled:cursor-default disabled:opacity-100",
1505
- pending && "cursor-progress",
1506
- className,
1507
- )}
1508
- >
1509
- {pending ? (
1510
- // Decorative, as on Button: `aria-busy` says it. Sized to the glyph it stands
1511
- // in for, in the glyph's colour, so the swap does not change the button's look.
1512
- <Spinner
1513
- label={null}
1514
- className={cn(
1515
- "border-current/30 border-t-current",
1516
- glyphSize !== undefined ? ICON_BUTTON_SPINNER_GLYPH[glyphSize] : ICON_BUTTON_SPINNER[size],
1517
- )}
1518
- />
1519
- ) : (
1520
- children
1521
- )}
1522
- {withText && <IconButtonText label={label!} />}
1523
- </button>
1524
- );
1525
- const control = withIconButtonBadge(button, badge, stretch);
1526
- if (locked) {
1527
- return (
1528
- // Button's shape: the reason as a line under it, or the bubble with a hidden copy
1529
- // for the description (see ReasonFrame).
1530
- <ReasonFrame
1531
- reason={disabledReason}
1532
- reasonId={reasonId}
1533
- display={disabledReasonDisplay}
1534
- side={tooltipSide}
1535
- portal={tooltipPortal}
1536
- className={stretch ? "self-stretch" : undefined}
1537
- >
1538
- {control}
1539
- </ReasonFrame>
1540
- );
1541
- }
1542
- // The words are on the button at Large: a bubble would only repeat them.
1543
- if (!tooltip || withText || label === undefined || label === "") return control;
1544
- return (
1545
- // A fragment, so Tooltip leaves the button's description alone: the bubble says
1546
- // exactly what `aria-label` already does. Lazy by default — see `tooltipLazy`.
1547
- <Tooltip
1548
- label={label}
1549
- side={tooltipSide}
1550
- portal={tooltipPortal}
1551
- lazy={tooltipLazy}
1552
- className={stretch ? "self-stretch" : undefined}
1553
- >
1554
- <>{control}</>
1555
- </Tooltip>
1556
- );
1557
- }
1558
-
1559
- /**
1560
- * The 16 px floor on touch (docs/text-size-harmonization.md §10.1). iOS zooms the page
1561
- * when a field under 16 px takes focus, and keksdose's `maximum-scale=1` — which stopped
1562
- * it — goes so pinch zoom works again (WCAG 1.4.4). A zoomed page drops the keyboard
1563
- * inset to 0 (§10.2), so the zoom would put a pinned editor footer behind the keyboard
1564
- * (keksdose #154). So on a coarse pointer a field is at least 16 px, at every text size:
1565
- * `max()` leaves Large and Extra large alone, where `text-sm` is already 17.5 / 21 px.
1566
- *
1567
- * In px on purpose: the threshold is the browser's, in CSS px, whatever the root size.
1568
- * Font size only — the line height keeps `text-sm`'s ratio. Part of {@link FIELD_BASE};
1569
- * add it to any other text-entry control whose type is below 16 px.
1570
- */
1571
- export const FIELD_TOUCH_TEXT = "pointer-coarse:text-[length:max(0.875rem,16px)]";
1572
-
1573
- export const FIELD_BASE =
1574
- "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)] " +
1575
- // §10.1: at least 16 px on a touch screen, or iOS zooms the page on focus.
1576
- FIELD_TOUCH_TEXT +
1577
- " " +
1578
- // A field the user cannot change has to LOOK settled. Without this, `disabled`
1579
- // dimmed the floating label and nothing else — FIELD_BASE's own
1580
- // `text-[var(--text-primary)]` overrides the browser's grey — so a read-only value
1581
- // sat there at full body-text strength, indistinguishable from one you could retype
1582
- // (Keksdose dev#455/#474).
1583
- "disabled:cursor-default disabled:bg-[var(--bg-surface-2)] disabled:text-[var(--text-muted)] " +
1584
- // Same for a field that takes focus but refuses keys: `readOnly` is not
1585
- // `disabled`, and a value that cannot be edited should not claim it can be. Opt
1586
- // OUT with {@link FIELD_WRITABLE_LOOK} in the one case where `readOnly` does not
1587
- // mean that — see the two-factor code field, which uses it to block an autofill
1588
- // and drops it the moment you focus the field.
1589
- //
1590
- // ⚠️ The ATTRIBUTE, never the `:read-only` pseudo-class (Keksdose dev#477).
1591
- // `:read-only` does not mean "was marked readonly" — per Selectors 4 it matches
1592
- // everything that is not `:read-write`, and only editable inputs/textareas and
1593
- // contenteditable elements are `:read-write`. So `read-only:` matched every
1594
- // `<select>` in the app and every field-styled `<button>` built on this base —
1595
- // the native selects, the date trigger, the entity/currency/multi pickers — and
1596
- // painted them all in the "you may not edit this" grey. On one transaction form
1597
- // that made three different-looking families out of one field style: plain
1598
- // inputs, grey selects, grey trigger buttons.
1599
- "[&[readonly]]:bg-[var(--bg-surface-2)] [&[readonly]]:text-[var(--text-muted)]";
1600
-
1601
- /** Cancels FIELD_BASE's read-only treatment for a field that is `readOnly` for a
1602
- * reason other than "you may not edit this". Attribute-scoped for the same reason
1603
- * FIELD_BASE is — the two have to cancel on the identical selector or twMerge
1604
- * cannot make the later one win. */
1605
- export const FIELD_WRITABLE_LOOK =
1606
- "[&[readonly]]:bg-[var(--bg-surface)] [&[readonly]]:text-[var(--text-primary)]";
1607
-
1608
- // Extra top padding leaves room for a label that floats INSIDE the field (the
1609
- // "filled" pattern) — the label sits in the top strip, the value below it. Used by
1610
- // every labelled field (native + custom-dropdown triggers). twMerge lets pt/pb win
1611
- // over FIELD_BASE's py-2. At Large the label stands above the field (0.33, §10.17,
1612
- // LABEL_ABOVE_AT_LARGE), so the strip goes and the field is FIELD_BASE's `py-2` again.
1613
- export const FIELD_FLOATING_PAD = "pt-4 pb-1 large:py-2";
1614
-
1615
- // Error/required highlight for a field that is missing a value — a `--danger`
1616
- // border and matching focus ring so the control itself shows what's wrong, not just
1617
- // a note beside it (feedback #235). Layered after FIELD_BASE so twMerge wins.
1618
- //
1619
- // The `ring-1` is not decoration; it is what makes the highlight SURVIVE display
1620
- // scaling (Keksdose live #295 — *"Account select boundary. It is not highlighted on
1621
- // the sides."*). At 125%, the browser's usual setting on a 2560px screen, a 1px CSS
1622
- // border is 1.25 device pixels: the horizontal edges land on whole rows and paint
1623
- // solid, while one of the two VERTICAL edges lands across a pixel boundary and is
1624
- // spread over two columns at partial coverage. Measured on the receipt's required
1625
- // account field, dark theme, back when this was a hard-coded rose (full =
1626
- // rgb(208,30,78)): left/top/bottom all 208, right 160 then 115 — the side that is
1627
- // supposed to shout, at 55–77% of the others. A border plus a ring is 2 CSS px, so
1628
- // whatever the fraction there is always one fully covered device pixel on every side
1629
- // (re-measured: worst side 241). It is the GEOMETRY that carries that, not the hue,
1630
- // so the fix survives a consumer re-pointing `--danger-border`.
1631
- //
1632
- // A ring rather than `border-2`: a box-shadow adds no layout, so an invalid field
1633
- // stays exactly the size of a valid one and nothing beside it moves when the value
1634
- // arrives.
1635
- export const FIELD_INVALID =
1636
- "border-[var(--danger-border-strong)] ring-1 ring-[var(--danger-border-strong)] focus:border-[var(--danger)] focus:ring-[var(--danger)]";
1637
-
1638
- export const FLOATING_INPUT_CLASS = cn(FIELD_BASE, FIELD_FLOATING_PAD, "peer placeholder:text-transparent");
1639
-
1640
- /** The phone breakpoint at Normal text size, as a media query.
1641
- *
1642
- * @deprecated since 0.32 — it does not follow the text size (§3.3): at Large the CSS
1643
- * `max-md:` reaches 959 px and this still stops at 767. Use `usePhoneLayout()`, which
1644
- * agrees with `max-md:` at every size, or `breakpointQuery("max-md", scale)` where a
1645
- * query string is needed. Kept exported, unchanged, for the apps that import it. */
1646
- export const PHONE_QUERY = "(max-width: 767px)";
1647
-
1648
- /**
1649
- * The PHONE display treatment (Keksdose feedback #176, carried across the entry
1650
- * forms by #179): the field chrome removed so the ONE input a form is actually
1651
- * about reads as the thing itself, not as another boxed row in a stack.
1652
- *
1653
- * Every control that takes `variant="display"` — {@link Input}, `NumberInput`,
1654
- * `AmountInput` — means exactly the same thing by it: the treatment applies in the
1655
- * phone layout (`usePhoneLayout()`, `max-md:` at the text size in force) and the field
1656
- * is untouched above it, so a caller never has
1657
- * to ask the viewport, and a form can't end up half-treated across breakpoints.
1658
- *
1659
- * What stays, deliberately:
1660
- * - A hairline baseline. With the box gone something still has to say "you can
1661
- * type here"; it takes the brand colour on focus, where a bordered field would
1662
- * light its whole outline. Each edge is set exactly once (`border-x-0
1663
- * border-t-0 border-b`) so the rule can't hinge on utility order.
1664
- * - The muted placeholder colour, because a display field is usually empty at
1665
- * the moment it matters most and has nothing else to show.
1666
- * - The label, moved to `sr-only` rather than dropped: display type is legible
1667
- * to the eye, not to a screen reader.
1668
- *
1669
- * Size and weight are NOT here — an amount wants display type, a subject line
1670
- * wants a heading — so each control adds its own on top.
1671
- */
1672
- export const FIELD_DISPLAY =
1673
- "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";
1674
-
1675
- // A field-styled button trigger for the custom dropdown controls (MultiSelect,
1676
- // CurrencySelect) — the field look (border/bg) as a flex row for the value + chevron,
1677
- // so they don't hand-copy the field classes. Add FIELD_FLOATING_PAD only when the
1678
- // trigger carries a label (unlabelled triggers stay normal height to match buttons /
1679
- // adjacent controls). Pair the labelled case with a static FieldLabel.
1680
- // `relative` so the trigger can host an absolutely-centred FieldChevron / clear
1681
- // button the way the native Select does.
1682
- //
1683
- // `[&>:first-child]:min-h-[1lh]` holds one line box open whatever the value is. A
1684
- // trigger with nothing chosen and no placeholder renders an EMPTY value span, an
1685
- // empty span has no line box, and the trigger collapsed to its padding — 18px
1686
- // tall in a stack of 38px fields (an RhfCombobox without a placeholder; DatePicker
1687
- // and MonthPicker had met the same thing as Keksdose live #294 and patched their
1688
- // own span with a U+00A0). Here, on the shared class, it covers every trigger
1689
- // built on it, including a `renderTrigger` one. `1lh` rather than a fixed height
1690
- // so it tracks whatever type the trigger is set in; the first child is the value
1691
- // in every trigger here (the chevron and the clear button are absolute).
1692
- export const FIELD_TRIGGER = cn(
1693
- FIELD_BASE,
1694
- "relative flex items-center justify-between gap-2 text-start hover:bg-[var(--bg-hover)]",
1695
- "[&>:first-child]:min-h-[1lh]",
1696
- );
1697
-
1698
- /** An alias rather than an interface: the chevron adds nothing of its own to an
1699
- * `<svg>`'s props, and an interface declaring no members is the same type wearing a
1700
- * name that suggests otherwise. */
1701
- export type FieldChevronProps = Omit<ComponentPropsWithoutRef<"svg">, "children">;
1702
-
1703
- /** The dropdown chevron, shared by the native {@link Select} and every custom
1704
- * {@link FIELD_TRIGGER} control.
1705
- *
1706
- * Absolutely positioned and centred on the FIELD box. As an ordinary flex child
1707
- * it centres on the *content* box instead, and `FIELD_FLOATING_PAD` (pt-4 pb-1)
1708
- * pushes that box's midline down — so a labelled currency/multi-select chevron
1709
- * sat visibly lower than the native select's right beside it (feedback #400).
1710
- * Pair it with `pe-9` on the trigger so the value can't run underneath. At the
1711
- * inline END, not the right: a right-to-left form reads the value from the right,
1712
- * and a chevron parked on top of its first word hid exactly the part that says
1713
- * what was picked. */
1714
- export function FieldChevron({ className, ...rest }: FieldChevronProps) {
1715
- return (
1716
- <ChevronDown
1717
- {...rest}
1718
- // After the spread: the chevron is decoration beside a control that already has
1719
- // a name, and an `aria-hidden` a caller could switch off by accident is a second
1720
- // announcement of the same field.
1721
- aria-hidden
1722
- className={cn(
1723
- "pointer-events-none absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
1724
- className,
1725
- )}
1726
- />
1727
- );
1728
- }
1729
-
1730
- // Animated label that starts centred (as a placeholder) in an empty field and
1731
- // floats up INSIDE the top strip on focus or once the field has a value. Sits on
1732
- // the field's own surface, so no background chip and nothing to mismatch the card.
1733
- //
1734
- // At Large it is a static label ABOVE the field instead (0.33, §10.17): in flow, so it
1735
- // wraps; always in its floated look, since there is no strip to float into and no
1736
- // placeholder to stand in for; no transition, since it never moves.
1737
- const LABEL_FLOATED_AT_LARGE =
1738
- "large:text-caption large:leading-tight large:text-secondary large:transition-none";
1739
-
1740
- export const FLOATING_LABEL_CLASS = cn(
1741
- "pointer-events-none absolute start-3 top-2.5 text-sm text-[var(--text-placeholder)] transition-all",
1742
- "max-w-[calc(100%-1.5rem)] truncate-until-large",
1743
- "peer-focus:top-1 peer-focus:text-caption peer-focus:leading-tight peer-focus:text-[var(--text-secondary)]",
1744
- "peer-[:not(:placeholder-shown)]:top-1 peer-[:not(:placeholder-shown)]:text-caption peer-[:not(:placeholder-shown)]:leading-tight peer-[:not(:placeholder-shown)]:text-[var(--text-secondary)]",
1745
- "peer-disabled:opacity-50",
1746
- LABEL_ABOVE_AT_LARGE,
1747
- "large:block",
1748
- LABEL_FLOATED_AT_LARGE,
1749
- );
1750
-
1751
- // The animated label again, as a ROW that the label and its "?" share — same
1752
- // placement, same float, same type, but with the type on the row so the label
1753
- // INHERITS it. It has to be that way round: `peer-focus:` compiles to a sibling
1754
- // selector, so the classes have to sit on the element that is actually a sibling
1755
- // of the input, and a label nested inside a wrapper is not one.
1756
- //
1757
- // Wider end clearance than the label alone takes (`3rem` rather than `1.5rem`),
1758
- // because the controls that carry an animated label are the ones with something
1759
- // at the end edge of the field — NumberInput's calculator is the case this was
1760
- // written for. The label truncates a little sooner; the alternative was the "?"
1761
- // sitting on top of a button (steering-design feedback #48). At Large the row stands
1762
- // above the field, as the label alone does, and nothing at the end edge is under it.
1763
- const FLOATING_ROW_CLASS = cn(
1764
- "pointer-events-none absolute start-3 top-2.5 flex items-center gap-1 transition-all",
1765
- "max-w-[calc(100%-3rem)] text-sm text-[var(--text-placeholder)]",
1766
- "peer-focus:top-1 peer-focus:text-caption peer-focus:leading-tight peer-focus:text-[var(--text-secondary)]",
1767
- "peer-[:not(:placeholder-shown)]:top-1 peer-[:not(:placeholder-shown)]:text-caption peer-[:not(:placeholder-shown)]:leading-tight peer-[:not(:placeholder-shown)]:text-[var(--text-secondary)]",
1768
- "peer-disabled:opacity-50",
1769
- LABEL_ABOVE_AT_LARGE,
1770
- LABEL_FLOATED_AT_LARGE,
1771
- );
1772
-
1773
- /** The static label's row with its "?" at the field's end, in the top strip — and at
1774
- * Large above the field (LABEL_ABOVE_AT_LARGE). */
1775
- const STATIC_ROW_CLASS = cn("pointer-events-none absolute inset-x-3 top-1 flex items-center gap-1", LABEL_ABOVE_AT_LARGE);
1776
-
1777
- /**
1778
- * The one place that assembles a labelled field: a `relative` wrapper around the
1779
- * control (`children`) plus a floating label inside the top strip. Pass `staticLabel`
1780
- * for controls that always have a value (selects); omit it for free-text fields whose
1781
- * label animates from centred→up. Used by Input/Select/Textarea/NumberInput; the
1782
- * dropdown controls that need a ref + menu keep their own wrapper but the same label
1783
- * (via {@link FieldLabel}) and trigger ({@link FIELD_TRIGGER}) styles.
1784
- */
1785
- export interface FloatingFieldProps extends ComponentPropsWithoutRef<"div"> {
1786
- htmlFor?: string;
1787
- label?: ReactNode;
1788
- staticLabel?: boolean;
1789
- /** Keep the label in the accessibility tree but out of the layout — what
1790
- * {@link FIELD_DISPLAY} needs, since a floating label inside a field with no
1791
- * field left would have nothing to float in. */
1792
- srOnlyLabel?: boolean;
1793
- /** Something interactive that belongs to the LABEL rather than to the value —
1794
- * in practice a {@link FieldHint} "?" (dev#468). It is rendered in a flex row
1795
- * with the label, so it is centred on the label's line by the layout instead
1796
- * of by a hand-tuned `top-…`, and it can never drift when the type changes.
1797
- *
1798
- * It rides an ANIMATED label too, and the two cases place it differently on
1799
- * purpose. A static label has a field-wide strip to itself, so the hint sits
1800
- * at the far end of it and a long label truncates into the gap. An animated
1801
- * one belongs to a control with something at the end edge of the field — a
1802
- * calculator, a stepper — so the hint follows the label instead, and it is
1803
- * the label that gives way (steering-design feedback #48). */
1804
- hint?: ReactNode;
1805
- children: ReactNode;
1806
- }
1807
-
1808
- export function FloatingField({
1809
- className,
1810
- htmlFor,
1811
- label,
1812
- staticLabel,
1813
- srOnlyLabel,
1814
- hint,
1815
- children,
1816
- ...rest
1817
- }: FloatingFieldProps) {
1818
- const withHint = hint !== undefined && !srOnlyLabel;
1819
- const atEnd = withHint && staticLabel;
1820
- // A label drawn over the field: at Large it stands above it instead (0.33, §10.17),
1821
- // in the first row of this box (LABEL_ABOVE_BOX), though it FOLLOWS the control in
1822
- // the DOM — `peer-*` reads the input before it.
1823
- const above = label !== undefined && !srOnlyLabel;
1824
- const labelEl = label !== undefined && (
1825
- <label
1826
- htmlFor={htmlFor}
1827
- // In the first row at Large, out of LABEL_ABOVE_BOX's rule for the field's parts.
1828
- data-field-label={above && !withHint ? "" : undefined}
1829
- className={
1830
- srOnlyLabel
1831
- ? "sr-only"
1832
- : atEnd
1833
- ? cn("pointer-events-none min-w-0 truncate-until-large", STATIC_LABEL_TYPE)
1834
- : withHint
1835
- ? "pointer-events-none min-w-0 truncate-until-large"
1836
- : staticLabel
1837
- ? FLOATING_LABEL_STATIC
1838
- : FLOATING_LABEL_CLASS
1839
- }
1840
- >
1841
- {label}
1842
- </label>
1843
- );
1844
- return (
1845
- // `relative` is the whole contract of this wrapper — the floating label and every
1846
- // control that hangs off it (a reveal toggle, a chevron) are positioned against
1847
- // this box — so it is merged through `cn` after the spread rather than left where
1848
- // a caller's stray `className` or `style` could unset it. At Large, with a label
1849
- // above, a grid: the label's row over the field's, the field's parts in the latter.
1850
- <div {...rest} className={cn("relative", above && LABEL_ABOVE_BOX, className)}>
1851
- {children}
1852
- {withHint ? (
1853
- // Static: `inset-x-3` rather than `start-3`, so a long label truncates at
1854
- // the field's own end padding instead of running under the chevron.
1855
- // Animated: the row floats with the label and is only as wide as it needs
1856
- // to be. Either way the hint keeps its width (`shrink-0`) and the label
1857
- // is the one that gives way.
1858
- <div data-field-label="" className={atEnd ? STATIC_ROW_CLASS : FLOATING_ROW_CLASS}>
1859
- {labelEl}
1860
- <span className="pointer-events-auto flex shrink-0 items-center">{hint}</span>
1861
- </div>
1862
- ) : (
1863
- labelEl
1864
- )}
1865
- </div>
1866
- );
1867
- }
1868
-
1869
- export interface LabelProps extends ComponentPropsWithoutRef<"label"> {
1870
- /** Draw the required mark after the text. The mark is `aria-hidden`: what a
1871
- * screen reader hears is the control's own `required` / `aria-required`, which
1872
- * the caller still sets — a "star" read out as the last word of every name is
1873
- * noise, and a control that is not marked required is not required however its
1874
- * label looks. */
1875
- required?: boolean;
1876
- /** `sm` for a toolbar or a dense filter row (12px), `md` otherwise (14px). */
1877
- size?: "sm" | "md";
1878
- /** Dim the label with its control. A label ABOVE its field is not the field's
1879
- * `peer`, so `peer-disabled:` cannot reach it the way it reaches a floating one. */
1880
- disabled?: boolean;
1881
- }
1882
-
1883
- /**
1884
- * The label ABOVE a field, for the places a floating label does not fit: a control
1885
- * the kit did not render (a third-party picker, a range slider, a group of radios),
1886
- * a filter bar whose fields are unlabelled {@link Select}s, a form that sets labels
1887
- * above its fields throughout.
1888
- *
1889
- * A new component rather than a mode of {@link FieldLabel}, because the two share
1890
- * nothing but the word. `FieldLabel` is an absolutely positioned `<span>` inside a
1891
- * trigger's `relative` box and has to stay exactly that for every dropdown that
1892
- * already lines up with it; this is a real `<label>` in normal flow, with `htmlFor`,
1893
- * that a click focuses the control through. Spacing below it belongs to the caller's
1894
- * layout (`space-y-1.5` on the pair, typically), as it would for any block.
1895
- */
1896
- export function Label({ required, size = "md", disabled, className, children, ...rest }: LabelProps) {
1897
- return (
1898
- <label
1899
- {...rest}
1900
- className={cn(
1901
- "inline-flex items-center gap-0.5 font-medium leading-none text-[var(--text-primary)]",
1902
- size === "sm" ? "text-xs" : "text-sm",
1903
- disabled && "cursor-default opacity-50",
1904
- className,
1905
- )}
1906
- >
1907
- {children}
1908
- {required && (
1909
- <span aria-hidden className="text-[var(--danger)]">
1910
- *
1911
- </span>
1912
- )}
1913
- </label>
1914
- );
1915
- }
1916
-
1917
- /**
1918
- * The message under a field that is wrong, and the wiring that attaches it.
1919
- *
1920
- * `invalid` paints the field ({@link FIELD_INVALID}) and sets `aria-invalid`, and
1921
- * there it stopped: a field that announces "invalid" and nothing else has told a
1922
- * screen-reader user only that they are stuck. The message was always on screen —
1923
- * a sibling `<p>` beside the field — and never in the accessibility tree, because
1924
- * attaching it needs an id on the message and an `aria-describedby` on the control,
1925
- * and no caller was going to mint one by hand for every field on a form.
1926
- *
1927
- * Two rules the wiring has to keep:
1928
- *
1929
- * - **Merge, never replace.** A field may already point at a hint ("at least twelve
1930
- * characters"). Overwriting that reference to say the field is wrong trades one
1931
- * half of the answer for the other — and it keeps the half the user has already
1932
- * read. The hint stays first: it is the standing advice, the error is the news.
1933
- * - **`invalid` keeps working alone**, for the forms whose message lives somewhere
1934
- * else entirely (a summary at the top of a dialog). There is nothing to point at
1935
- * then, and a dangling id describes the field as nothing at all.
1936
- *
1937
- * Deliberately NOT `role="alert"`. `aria-describedby` is read when focus reaches the
1938
- * control, which is where a field's own error is wanted; an alert would also interrupt
1939
- * whatever is being read at the time, on every keystroke of a form that re-validates
1940
- * as you type.
1941
- */
1942
- const FIELD_ERROR_CLASS = "mt-1 text-caption leading-tight text-[var(--danger)]";
1943
-
1944
- function useFieldError(
1945
- error: ReactNode,
1946
- invalid: boolean | undefined,
1947
- describedBy: string | undefined,
1948
- /** The caller's own `aria-invalid`. A form library sets THIS (see `@eifi1/ui-kit/rhf`
1949
- * `FormControl`), not our `invalid` prop — and a field that announces invalid should
1950
- * look it too, as NumberField and the pickers already did. */
1951
- ariaInvalid?: unknown,
1952
- ) {
1953
- const errorId = useId();
1954
- // `null`, `false` and `""` are what a caller's `touched && errors.iban` evaluates to
1955
- // on the happy path. None of them is a message, and pointing the control at one
1956
- // would describe it with an empty node.
1957
- const hasError = error !== undefined && error !== null && error !== false && error !== "";
1958
- return {
1959
- /** A field carrying a message that says what is wrong with it IS wrong. */
1960
- isInvalid: Boolean(invalid) || hasError || ariaInvalid === true || ariaInvalid === "true",
1961
- describedBy: hasError ? (describedBy ? `${describedBy} ${errorId}` : errorId) : describedBy,
1962
- errorEl: hasError ? (
1963
- <p id={errorId} className={FIELD_ERROR_CLASS}>
1964
- {error}
1965
- </p>
1966
- ) : null,
1967
- };
1968
- }
1969
-
1970
- /**
1971
- * A field and its message as one box.
1972
- *
1973
- * The message cannot go INSIDE the field's own `relative` box. The password reveal
1974
- * toggle is `inset-y-0` and the select chevron is `top-1/2`, so both centre on
1975
- * whatever that box contains — put two lines of message in it and the chevron drifts
1976
- * down out of the field, between the value and the text. So the field keeps its box
1977
- * and this wraps the pair.
1978
- *
1979
- * WHEN IT WRAPS. A field that never takes `error` renders nothing of its own here,
1980
- * which is what keeps the prop additive: it is exactly the DOM it was before the prop
1981
- * existed, down to the bare `<input>` an unlabelled {@link Input} drops straight into a
1982
- * caller's flex row. A field that DOES take `error` — the key is passed, whatever its
1983
- * value — is wrapped for as long as it is mounted, message or not (`reserve`).
1984
- *
1985
- * It used to wrap only while a message showed, and that cost the control its
1986
- * identity: a fragment and a `<div>` are different elements at the same spot in the
1987
- * tree, so React threw the `<input>` away and mounted a new one every time the message
1988
- * came or went. Focus fell to `<body>`, the caret went with it, and an uncontrolled
1989
- * field lost what had been typed — Kurvenschmiede's confirm-password dialog clears its
1990
- * "Wrong password" on change, so the first keystroke of the retry dropped the user out
1991
- * of the field. Keeping the box stops that; the field is never reparented.
1992
- *
1993
- * The other ways to keep it stable were worse. A box on EVERY field changes what every
1994
- * flex row and grid in three apps lays out (`flex-1`, `w-56`, `col-span-2` on the
1995
- * field would land on a child of the item instead of the item). A `display: contents`
1996
- * box would keep the flex/grid item, but a contents box takes no margin, so a parent's
1997
- * `space-y-*` stops spacing the field — the shape of Kurvenschmiede's reset-password
1998
- * form and gear wizard, both `space-y-*` stacks of fields with `error` — and it moves
1999
- * `first:`/`last:`/`> *` matches. A field passing `error` already took this plain box
2000
- * whenever its message showed, so what changes is only that it keeps it while none
2001
- * does; every such call site in the three apps sits in a block or a `space-y`/grid
2002
- * stack where a full-width block box is the field's own size. The case it does change
2003
- * is a field passing `error` whose `className` sizes it as a flex item (`flex-1` in a
2004
- * row): that caller already lost the sizing while a message showed, and now sees the
2005
- * same box without one. Pass `error` only where a message can actually appear.
2006
- *
2007
- * `className` is NOT moved out here, for the same reason — it goes on the field, as it
2008
- * always has, so the box cannot silently change what that prop styles.
2009
- */
2010
- function FieldGroup({
2011
- errorEl,
2012
- reserve = false,
2013
- children,
2014
- }: {
2015
- errorEl: ReactNode;
2016
- /** The caller passed `error`: keep the box even while there is no message. */
2017
- reserve?: boolean;
2018
- children: ReactNode;
2019
- }) {
2020
- if (errorEl === null && !reserve) return <>{children}</>;
2021
- return (
2022
- <div>
2023
- {children}
2024
- {errorEl}
2025
- </div>
2026
- );
2027
- }
2028
-
2029
- /** The two names the password reveal toggle can wear. See {@link Input}. */
2030
- export interface PasswordRevealLabels {
2031
- /** While the value is hidden — activating the toggle will show it. */
2032
- show: string;
2033
- /** While the value is shown. */
2034
- hide: string;
2035
- }
2036
-
2037
- export const DEFAULT_PASSWORD_REVEAL_LABELS: PasswordRevealLabels = {
2038
- show: "Show password",
2039
- hide: "Hide password",
2040
- };
2041
-
2042
- /** Caller's labels over the English defaults — the same shape as
2043
- * `resolveDataTableLabels`, so a consumer translates every kit string one way. */
2044
- export function resolvePasswordRevealLabels(
2045
- partial?: Partial<PasswordRevealLabels>,
2046
- ): PasswordRevealLabels {
2047
- if (!partial) return DEFAULT_PASSWORD_REVEAL_LABELS;
2048
- return { ...DEFAULT_PASSWORD_REVEAL_LABELS, ...partial };
2049
- }
2050
-
2051
- /**
2052
- * What the character counter of {@link Input} and {@link Textarea} (`showCount`) says.
2053
- * The visible "12/80" is digits and a slash and is not translated; these are the words
2054
- * a screen reader gets instead of it.
2055
- */
2056
- export interface CharacterCountLabels {
2057
- /** The counter as part of the field's description, read when focus reaches the
2058
- * field: "12 of 80 characters". */
2059
- count: (used: number, max: number) => string;
2060
- /** Said once, politely, on entering the last stretch before the limit: "8
2061
- * characters left". */
2062
- remaining: (left: number) => string;
2063
- /** Said once, politely, on reaching the limit. */
2064
- limitReached: string;
2065
- }
2066
-
2067
- export const DEFAULT_CHARACTER_COUNT_LABELS: CharacterCountLabels = {
2068
- count: (used, max) => `${used} of ${max} characters`,
2069
- remaining: (left) => (left === 1 ? "1 character left" : `${left} characters left`),
2070
- limitReached: "Character limit reached",
2071
- };
2072
-
2073
- /** The length the browser's own `maxLength` counts — UTF-16 code units — so the counter
2074
- * and the limit can never disagree about whether a field is full. */
2075
- function textLength(value: unknown): number {
2076
- return value === undefined || value === null ? 0 : String(value).length;
2077
- }
2078
-
2079
- /** The counter's length: the controlled `value`'s, else what the field has been typed
2080
- * to (from `defaultValue` on). `track` is called from the field's change handler. */
2081
- function useTypedLength(value: unknown, defaultValue: unknown) {
2082
- const [typed, setTyped] = useState(() => textLength(defaultValue));
2083
- return { used: value !== undefined ? textLength(value) : typed, track: (text: string) => setTyped(text.length) };
2084
- }
2085
-
2086
- type CountZone = "ok" | "near" | "limit";
2087
-
2088
- /** The last stretch before the limit: a tenth of it, at least 1 and at most 20 — "8
2089
- * left" of 80, and 20 of 2,000 rather than 200, which is not "near" in any sense a
2090
- * person typing means. */
2091
- function nearLimitAt(max: number): number {
2092
- return Math.min(20, Math.max(1, Math.ceil(max * 0.1)));
2093
- }
2094
-
2095
- function countZone(used: number, max: number): CountZone {
2096
- if (used >= max) return "limit";
2097
- return max - used <= nearLimitAt(max) ? "near" : "ok";
2098
- }
2099
-
2100
- /**
2101
- * The live "12/80" under a field with `showCount` (keksdose K11: the admin broadcast,
2102
- * support replies, canned replies and the handoff note each counted by hand, and one
2103
- * announced every keystroke).
2104
- *
2105
- * Three channels, each saying only what it should:
2106
- * - the digits, `aria-hidden` — "12 slash 80" is not a sentence;
2107
- * - a `hidden` "12 of 80 characters" on the field's `aria-describedby`, so the count is
2108
- * read with the field when focus reaches it, and never while typing;
2109
- * - a polite live region that speaks ONCE per stretch: on entering the last tenth
2110
- * ("8 characters left") and on reaching the limit. A region that changed on every
2111
- * keystroke would talk over the user's own typing echo for the whole message.
2112
- * Nothing is said on mount: a stored note already near its limit is not news.
2113
- */
2114
- function CharacterCount({
2115
- id,
2116
- used,
2117
- max,
2118
- labels,
2119
- caption,
2120
- }: {
2121
- id: string;
2122
- used: number;
2123
- max: number;
2124
- labels: CharacterCountLabels;
2125
- caption: ReactNode;
2126
- }) {
2127
- const zone = countZone(used, max);
2128
- // What the live region holds, re-set only when the stretch changes — the previous
2129
- // render's zone kept in state, the pattern React documents for "adjusting state when
2130
- // a prop changes", so the region's text (and so the announcement) changes at most
2131
- // once per crossing.
2132
- const [said, setSaid] = useState<{ zone: CountZone; text: string }>({ zone, text: "" });
2133
- if (said.zone !== zone) {
2134
- setSaid({
2135
- zone,
2136
- text: zone === "near" ? labels.remaining(max - used) : zone === "limit" ? labels.limitReached : "",
2137
- });
2138
- }
2139
- return (
2140
- // `relative` holds the sr-only region (see sr-only-containment.test).
2141
- <div className="relative mt-1 flex items-start gap-2">
2142
- <div className="min-w-0 flex-1">{caption}</div>
2143
- <span
2144
- aria-hidden
2145
- className={cn(
2146
- "shrink-0 text-caption leading-tight tabular-nums",
2147
- zone === "limit"
2148
- ? "text-[var(--danger)]"
2149
- : zone === "near"
2150
- ? "text-[var(--warning)]"
2151
- : "text-[var(--text-muted)]",
2152
- )}
2153
- >
2154
- {used}/{max}
2155
- </span>
2156
- <span id={id} hidden>
2157
- {labels.count(used, max)}
2158
- </span>
2159
- <span role="status" className="sr-only">
2160
- {said.text}
2161
- </span>
2162
- </div>
2163
- );
2164
- }
2165
-
2166
- /** What goes under a text field: the caption (beside the counter, when there is one),
2167
- * then the error. `null` when there is nothing, so a field with none keeps exactly the
2168
- * DOM it had (see {@link FieldGroup}). */
2169
- function fieldBelow(hint: FieldHintParts, counter: ReactNode, errorEl: ReactNode): ReactNode {
2170
- if (counter === null && hint.captionText === undefined && errorEl === null) return null;
2171
- return (
2172
- <>
2173
- {counter ?? <FieldCaption parts={hint} />}
2174
- {errorEl}
2175
- </>
2176
- );
2177
- }
2178
-
2179
- /**
2180
- * kastlan 8: a caller's placeholder on a LABELLED field, shown once the label has
2181
- * floated out of its way — on focus — and transparent until then, while the label sits
2182
- * where the placeholder would be. Without it the field forced `placeholder=" "` for the
2183
- * float trick and dropped the caller's text without a word (kastlan's "note" field lost
2184
- * its hint). `:placeholder-shown` still drives the float: it matches an empty field
2185
- * whatever the placeholder's text is.
2186
- */
2187
- const FLOATING_PLACEHOLDER_ON_FOCUS = "focus:placeholder:text-[var(--text-placeholder)]";
2188
-
2189
- /** A placeholder with something to show. `""` and blanks are the float trick's own
2190
- * `" "`, never the caller's hint. */
2191
- const hasPlaceholderText = (placeholder: string | undefined): placeholder is string =>
2192
- placeholder !== undefined && placeholder.trim() !== "";
2193
-
2194
- // Native date/time inputs only reveal the calendar via the tiny trailing icon;
2195
- // open the picker on a click anywhere in the field instead (feedback #224).
2196
- const PICKER_TYPES = new Set(["date", "datetime-local", "month", "time", "week"]);
2197
-
2198
- export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
2199
- label?: ReactNode;
2200
- /** Classes for the `<input>` itself, as distinct from `className`, which
2201
- * styles the field WRAPPER once a `label` turns this into a FloatingField.
2202
- * Without it a labelled Input had no way to reach its own element — so
2203
- * `tabular-nums` on a numeric text field, which NumberInput has supported
2204
- * all along through the identically named prop, was simply unavailable. */
2205
- inputClassName?: string;
2206
- /** {@link FIELD_DISPLAY} — on a phone, drop the chrome and set the value as a
2207
- * heading. For the one field a form is about (a feedback subject, an account
2208
- * name), never for a stack of them. Labelled fields only: the label is what
2209
- * the placeholder falls back to once it goes `sr-only`. */
2210
- variant?: "field" | "display";
2211
- /** The field is required and unanswered, or holds something that cannot be
2212
- * saved — {@link FIELD_INVALID}, the same rose border/ring `Select` has worn
2213
- * since feedback #235.
2214
- *
2215
- * It exists here because writing `aria-invalid` by hand did NOT do this. The
2216
- * attribute spreads onto the element and nothing styles it — there is no
2217
- * `[aria-invalid]` rule in this package or in either consumer's stylesheet —
2218
- * so three Keksdose dialogs flagged a mismatched passphrase to a screen reader
2219
- * and painted the field exactly as if it were fine. Setting the prop sets the
2220
- * attribute too, so the two can no longer be spelled separately. */
2221
- invalid?: boolean;
2222
- /** What is wrong with the value, in the caller's own words ("That IBAN has 21
2223
- * digits"). Rendered under the field, pointed at by the control's
2224
- * `aria-describedby` — MERGED with any the caller already passed — and implies
2225
- * `invalid`, so the field paints as well as announces. `invalid` alone still
2226
- * covers the case where the message lives elsewhere. See {@link useFieldError}. */
2227
- error?: ReactNode;
2228
- /**
2229
- * Standing advice for the field (keksdose K4: Input had `error` but no `hint`, so the
2230
- * app built the caption by hand under each field, and half of those never reached a
2231
- * screen reader). The {@link Select} rule, so every field reads alike:
2232
- * - plain TEXT (a string or a number) is a caption UNDER the field, attached with
2233
- * `aria-describedby` — after the caller's own ids, before the error's;
2234
- * - anything else (a {@link FieldHint} "?") rides the label line beside the
2235
- * animated label, as NumberInput's does — or, on a field with no label, sits at the
2236
- * field's end edge outside the box, and `className` then styles that row.
2237
- */
2238
- hint?: ReactNode;
2239
- /**
2240
- * A live "12/80" under the field, with `maxLength` (keksdose K11 — the broadcast,
2241
- * support and handoff-note fields counted by hand). Read with the field as "12 of 80
2242
- * characters", and announced politely only on entering the last stretch and on
2243
- * reaching the limit — never on every keystroke. See {@link CharacterCount}. Ignored
2244
- * without a `maxLength`: a count with no limit is not what this answers. Counts
2245
- * UTF-16 code units, as `maxLength` itself does. Controlled, it counts `value`;
2246
- * uncontrolled, `defaultValue` and then every change event — a value written from
2247
- * outside without one (a form library's `reset`) shows from the next keystroke.
2248
- */
2249
- showCount?: boolean;
2250
- /** The counter's words. See {@link CharacterCountLabels}. */
2251
- countLabels?: Partial<CharacterCountLabels>;
2252
- /** Names for the password reveal toggle, English by default — it is the one
2253
- * string this component renders on its own behalf, and a German form was
2254
- * reading it out in English. See {@link PasswordRevealLabels}. */
2255
- passwordLabels?: Partial<PasswordRevealLabels>;
2256
- }
2257
-
2258
- /** The counter's words: the `characterCount` namespace — prop > `<UiKitProvider
2259
- * labels>` > English. One place, so the namespace is named once. */
2260
- function useCharacterCountLabels(prop: Partial<CharacterCountLabels> | undefined): CharacterCountLabels {
2261
- return useKitLabels("characterCount", DEFAULT_CHARACTER_COUNT_LABELS, prop);
2262
- }
2263
-
2264
- export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
2265
- const {
2266
- className,
2267
- inputClassName,
2268
- label,
2269
- id,
2270
- placeholder,
2271
- type,
2272
- variant = "field",
2273
- invalid,
2274
- error,
2275
- hint,
2276
- showCount,
2277
- countLabels,
2278
- passwordLabels,
2279
- ...rest
2280
- } = props;
2281
- // Passed at all — even as `undefined` — the field keeps its box; see FieldGroup. The
2282
- // same for `hint` and `showCount` (keksdose, 0.22): a caption coming and going under
2283
- // the control must not rebuild it, or focus and the caret are lost mid-edit.
2284
- const reserve = "error" in props || "hint" in props || "showCount" in props;
2285
- const generated = useId();
2286
- const fieldId = id ?? generated;
2287
- const countId = useId();
2288
- const hintParts = useFieldHint(hint, rest["aria-describedby"]);
2289
- const maxLength = rest.maxLength;
2290
- const counting = Boolean(showCount) && typeof maxLength === "number" && maxLength > 0;
2291
- const length = useTypedLength(rest.value, rest.defaultValue);
2292
- const countText = useCharacterCountLabels(countLabels);
2293
- const { isInvalid, describedBy, errorEl } = useFieldError(
2294
- error,
2295
- invalid,
2296
- // Caller's, caption, count — then the error, appended by the hook.
2297
- mergeDescribedBy(hintParts.describedBy, counting && countId),
2298
- rest["aria-invalid"],
2299
- );
2300
- const counter = counting ? (
2301
- <CharacterCount
2302
- id={countId}
2303
- used={length.used}
2304
- max={maxLength}
2305
- labels={countText}
2306
- caption={<FieldCaption parts={hintParts} className="mt-0" />}
2307
- />
2308
- ) : null;
2309
- const below = fieldBelow(hintParts, counter, errorEl);
2310
- const onChange = counting
2311
- ? (e: ChangeEvent<HTMLInputElement>) => {
2312
- length.track(e.target.value);
2313
- rest.onChange?.(e);
2314
- }
2315
- : rest.onChange;
2316
- const asDisplay = usePhoneLayout() && variant === "display";
2317
- // Password fields get a reveal toggle so users can check what they typed.
2318
- const isPassword = type === "password";
2319
- const [revealed, setRevealed] = useState(false);
2320
- const effectiveType = isPassword && revealed ? "text" : type;
2321
- const handleClick = PICKER_TYPES.has(type ?? "")
2322
- ? (e: MouseEvent<HTMLInputElement>) => {
2323
- rest.onClick?.(e);
2324
- try {
2325
- // showPicker throws if unsupported or not user-activated — a click is
2326
- // a valid activation, so this is safe; guard for older browsers.
2327
- (e.currentTarget as HTMLInputElement & { showPicker?: () => void }).showPicker?.();
2328
- } catch {
2329
- /* ignore */
2330
- }
2331
- }
2332
- : rest.onClick;
2333
- const passwordText = useKitLabels("passwordReveal", DEFAULT_PASSWORD_REVEAL_LABELS, passwordLabels);
2334
- const revealToggle = isPassword ? (
2335
- <button
2336
- type="button"
2337
- // `tabIndex={-1}` sat here, which made this a painted, clickable control that
2338
- // Tab stepped straight over — on the one field whose value cannot be checked by
2339
- // looking at it. There is no mouse-only case for a reveal toggle; the people who
2340
- // cannot see what they typed are exactly who it is for. It is a tab stop now,
2341
- // and it needs a focus ring of its own, because FIELD_BASE's ring belongs to the
2342
- // input underneath and stays put while focus moves onto the button on top of it.
2343
- onClick={() => setRevealed((v) => !v)}
2344
- // …and since it is a tab stop, a field the user may not edit must not hand the
2345
- // keyboard a control that shows what is in it.
2346
- disabled={rest.disabled}
2347
- aria-label={revealed ? passwordText.hide : passwordText.show}
2348
- aria-pressed={revealed}
2349
- className={cn(
2350
- "absolute inset-y-0 end-0 flex items-center rounded-e-md px-2.5 text-[var(--text-placeholder)] transition-colors hover:text-[var(--text-secondary)] focus:outline-none disabled:cursor-default disabled:opacity-50",
2351
- FOCUS_RING,
2352
- )}
2353
- >
2354
- {revealed ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
2355
- </button>
2356
- ) : null;
2357
- if (label === undefined) {
2358
- // A "?" with no label line to ride goes at the end edge, outside the box, and
2359
- // `className` moves to that row (see `hint`); without one nothing moves.
2360
- const endHint = hintParts.labelHint;
2361
- const ownClass = endHint === undefined ? className : undefined;
2362
- if (!isPassword) {
2363
- return (
2364
- <FieldGroup errorEl={below} reserve={reserve}>
2365
- <EndHintRow hint={endHint} className={className}>
2366
- <input
2367
- ref={ref}
2368
- id={id}
2369
- type={type}
2370
- placeholder={placeholder}
2371
- {...rest}
2372
- // AFTER the spread, so the prop wins — but OR-ed with whatever the spread
2373
- // carried, or setting `invalid` would have quietly deleted a caller's own
2374
- // `aria-invalid`. The prop is the one that also paints; a bare attribute
2375
- // still announces, which is all it ever did.
2376
- aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2377
- // Likewise merged rather than replaced — see {@link useFieldError}.
2378
- aria-describedby={describedBy}
2379
- onClick={handleClick}
2380
- onChange={onChange}
2381
- className={cn(FIELD_BASE, ownClass, inputClassName, isInvalid && FIELD_INVALID)}
2382
- />
2383
- </EndHintRow>
2384
- </FieldGroup>
2385
- );
2386
- }
2387
- return (
2388
- <FieldGroup errorEl={below} reserve={reserve}>
2389
- <EndHintRow hint={endHint} className={className}>
2390
- <div className={cn("relative", ownClass)}>
2391
- <input
2392
- ref={ref}
2393
- id={id}
2394
- type={effectiveType}
2395
- placeholder={placeholder}
2396
- {...rest}
2397
- aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2398
- aria-describedby={describedBy}
2399
- onChange={onChange}
2400
- className={cn(FIELD_BASE, "pe-9", inputClassName, isInvalid && FIELD_INVALID)}
2401
- />
2402
- {revealToggle}
2403
- </div>
2404
- </EndHintRow>
2405
- </FieldGroup>
2406
- );
2407
- }
2408
- const ownPlaceholder = hasPlaceholderText(placeholder);
2409
- return (
2410
- <FieldGroup errorEl={below} reserve={reserve}>
2411
- <FloatingField
2412
- className={className}
2413
- htmlFor={fieldId}
2414
- label={label}
2415
- srOnlyLabel={asDisplay}
2416
- hint={hintParts.labelHint}
2417
- >
2418
- <input
2419
- ref={ref}
2420
- id={fieldId}
2421
- type={effectiveType}
2422
- // A labelled field with no placeholder of its own gets a single space, which
2423
- // feeds the floating label's peer-placeholder-shown trick. A caller's own is
2424
- // kept and shown on focus, once the label has floated out of its way (kastlan
2425
- // 8) — see FLOATING_PLACEHOLDER_ON_FOCUS. With the label sr-only there is no
2426
- // float left to drive, and an empty borderless line would say nothing at all
2427
- // — so there the label text is the placeholder when the caller gave none.
2428
- placeholder={
2429
- asDisplay
2430
- ? (placeholder ?? (typeof label === "string" ? label : " "))
2431
- : ownPlaceholder
2432
- ? placeholder
2433
- : " "
2434
- }
2435
- {...rest}
2436
- aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2437
- aria-describedby={describedBy}
2438
- onClick={handleClick}
2439
- onChange={onChange}
2440
- className={cn(
2441
- asDisplay
2442
- ? cn(FIELD_DISPLAY, "text-xl font-semibold leading-snug")
2443
- : cn(FLOATING_INPUT_CLASS, ownPlaceholder && FLOATING_PLACEHOLDER_ON_FOCUS),
2444
- isPassword && "pe-9",
2445
- inputClassName,
2446
- isInvalid && FIELD_INVALID,
2447
- )}
2448
- />
2449
- {revealToggle}
2450
- </FloatingField>
2451
- </FieldGroup>
2452
- );
2453
- });
2454
- Input.displayName = "Input";
2455
-
2456
- export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
2457
- label?: ReactNode;
2458
- /** See {@link Input}'s `invalid`. */
2459
- invalid?: boolean;
2460
- /** See {@link Input}'s `error`. */
2461
- error?: ReactNode;
2462
- /** A {@link FieldHint} for the label line — see {@link FloatingField}. Plain
2463
- * TEXT (a string or a number) is a caption instead, and goes UNDER the field
2464
- * like every other field's hint, attached through `aria-describedby`: the label
2465
- * line is 11px of strip shared with the label, and a sentence placed there was
2466
- * set in the value's type on top of both the label and the value. On an
2467
- * UNLABELLED Select (no label line) a FieldHint sits at the field's end edge,
2468
- * outside the box. */
2469
- hint?: ReactNode;
2470
- /**
2471
- * `"sm"`: a 28px, 12px-type select for a toolbar or a table header, where the
2472
- * 36px field stands a head taller than the buttons beside it. `"md"` (default)
2473
- * is the field every form uses. Unlabelled selects only — a floating label needs
2474
- * the tall box to float in, so a labelled Select ignores `"sm"`.
2475
- *
2476
- * A NUMBER is still the native attribute (the rows of a list box) and is passed
2477
- * straight through, so the one HTML meaning of `size` keeps working. Above 1 (as
2478
- * with `multiple`) the browser draws a list box, and the Select drops its chevron
2479
- * and the end padding reserved for it.
2480
- */
2481
- size?: "sm" | "md" | number;
2482
- /** Classes for the `<select>` itself. `className` styles the WRAPPER — the box
2483
- * the chevron is positioned against — so it could set a width and nothing
2484
- * else; this is the way to the element. See {@link Input}'s `inputClassName`. */
2485
- selectClassName?: string;
2486
- /**
2487
- * Why the choice cannot be changed — {@link Button}'s `disabledReason`, for a select
2488
- * that SAVES on change (a role picker in a members table, a status in a row). keksdose
2489
- * K3: those sat inside a hand-rolled `SaveGuard` that forced a native `disabled`, which
2490
- * took the field out of the tab order so the reason never reached a keyboard.
2491
- *
2492
- * With a reason the select is `aria-disabled` instead — still focusable, still showing
2493
- * its value — its list does not open (pointer and keys are swallowed, Tab and Escape
2494
- * excepted), `onChange` is never called, and the reason is in the kit {@link Tooltip}
2495
- * and on `aria-describedby`. It wears the settled look of a disabled field and drops
2496
- * its chevron, as a disabled Select does (dev#474). It wins over `disabled`.
2497
- *
2498
- * Use it CONTROLLED (`value` + `onChange`): React puts a controlled value back after
2499
- * the swallowed change. A touch platform's own picker may still open; nothing it picks
2500
- * is kept or reported.
2501
- */
2502
- disabledReason?: ReactNode;
2503
- /**
2504
- * This select COMMITS — choosing saves. Under a locked {@link WriteLockProvider} it is
2505
- * locked the `disabledReason` way with the lock's reason (which wins over its own).
2506
- * No provider, or an unlocked one: no effect. Button's `commit`, for keksdose K3; a
2507
- * {@link CommitScope} since 0.33.
2508
- */
2509
- commit?: CommitScope;
2510
- }
2511
-
2512
- // The compact select: the field's colours, a toolbar button's height. `py-0` and a
2513
- // fixed height rather than a smaller padding, so the box is 28px whatever line
2514
- // height the caller's type brings with it.
2515
- const SELECT_SM = "h-7 py-0 ps-2 pe-7 text-xs";
2516
-
2517
- /** Keys a locked select still answers: leaving it, and dismissing its tooltip. Every
2518
- * other key would open the list or step the value. */
2519
- const LOCKED_SELECT_KEYS = new Set(["Tab", "Escape", "Shift"]);
2520
-
2521
- /** FIELD_BASE's `disabled:` look, for a select that is locked without being `disabled`
2522
- * (the `:disabled` variants cannot match it). */
2523
- const FIELD_LOCKED = "cursor-not-allowed bg-[var(--bg-surface-2)] text-[var(--text-muted)]";
2524
-
2525
- export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(props, ref) {
2526
- const {
2527
- className,
2528
- label,
2529
- id,
2530
- children,
2531
- invalid,
2532
- error,
2533
- hint,
2534
- size,
2535
- selectClassName,
2536
- disabledReason,
2537
- commit,
2538
- onMouseDown,
2539
- onKeyDown,
2540
- onChange,
2541
- ...rest
2542
- } = props;
2543
- // See Input's `reserve`.
2544
- const reserve = "error" in props || "hint" in props || "showCount" in props;
2545
- const generated = useId();
2546
- const lock = useLockReason(commit, disabledReason);
2547
- const locked = lock.locked;
2548
- // Only a number reaches the DOM; the two words are this component's own.
2549
- const nativeSize = typeof size === "number" ? size : undefined;
2550
- // A numeric size above 1 (or `multiple`) makes the browser draw a LIST BOX — all
2551
- // the rows in the field, no menu to drop. The dropdown dress did not fit it: the
2552
- // chevron was centred on a box several rows tall, sat on top of the rows' text
2553
- // at the end edge, and `pe-9` clipped every option nine units early. A list box
2554
- // gets the plain field instead — its own native scroll, no chevron, even padding.
2555
- const listBox = (nativeSize !== undefined && nativeSize > 1) || Boolean(rest.multiple);
2556
- const small = size === "sm" && label === undefined;
2557
- const fieldId = id ?? generated;
2558
- const hintId = useId();
2559
- // Text is a caption under the field; anything else (a FieldHint) rides the label
2560
- // line. See `hint` above. An empty string is no caption at all.
2561
- const textHint = (typeof hint === "string" && hint !== "") || typeof hint === "number";
2562
- const { isInvalid, describedBy, errorEl } = useFieldError(
2563
- error,
2564
- invalid,
2565
- // The caption goes in BEFORE the error — the standing advice first, the news
2566
- // second, the order useFieldError keeps for a caller's own description. A lock's
2567
- // reason sits between the two.
2568
- mergeDescribedBy(rest["aria-describedby"], textHint && hintId, locked && lock.reasonId),
2569
- rest["aria-invalid"],
2570
- );
2571
- const below =
2572
- textHint || errorEl !== null ? (
2573
- <>
2574
- {textHint && (
2575
- <p id={hintId} className="mt-1 text-caption leading-tight text-[var(--text-muted)]">
2576
- {hint}
2577
- </p>
2578
- )}
2579
- {errorEl}
2580
- </>
2581
- ) : null;
2582
- // Custom chevron (native arrow hidden via appearance-none) so it sits a touch
2583
- // in from the end border and matches both themes — feedback #223. A DISABLED
2584
- // select has no menu to drop, so it drops the chevron too: the arrow is the one
2585
- // thing on the control that promises a choice (Keksdose dev#474, where the
2586
- // account type became read-only and still looked exactly like a picker). A locked
2587
- // one has no menu either.
2588
- const chevron =
2589
- rest.disabled || listBox || locked ? null : (
2590
- <FieldChevron className={small ? "end-1.5 size-3.5" : undefined} />
2591
- );
2592
- // `pe-9` is the room the chevron takes; a list box has none to make room for.
2593
- //
2594
- // A list box's height is `size` rows plus its padding, but its rows are clipped at
2595
- // the PADDING edge, not the content edge — so FIELD_BASE's `pb-2` showed the next
2596
- // row through the bottom padding: `size={4}` drew four and a half. The vertical room
2597
- // moves onto the rows instead (option padding counts toward the row height the
2598
- // browser sizes the box by), and the box keeps no bottom padding: exactly `size`
2599
- // whole rows, at any width.
2600
- const dress = listBox ? "overflow-y-auto [&_option]:py-1" : "appearance-none pe-9";
2601
- // Locked: focusable, but nothing opens the list or moves the value, and nothing is
2602
- // reported. The no-op `onChange` keeps a controlled select's value where React put
2603
- // it (and keeps React from warning about a `value` with no handler).
2604
- const interaction = locked
2605
- ? {
2606
- disabled: undefined,
2607
- "aria-disabled": true as const,
2608
- onMouseDown: (e: MouseEvent<HTMLSelectElement>) => e.preventDefault(),
2609
- onKeyDown: (e: KeyboardEvent<HTMLSelectElement>) => {
2610
- if (!LOCKED_SELECT_KEYS.has(e.key) && !e.ctrlKey && !e.metaKey) e.preventDefault();
2611
- },
2612
- onChange: () => {},
2613
- }
2614
- : { onMouseDown, onKeyDown, onChange };
2615
- // While locked the field sits in the reason's Tooltip, which becomes the outermost
2616
- // box — so `className` (a width, a grid cell) moves onto it, and the form keeps its
2617
- // layout. `block` so a full-width field stays full width inside it.
2618
- const boxClass = locked ? undefined : className;
2619
- const withReason = (box: ReactNode) =>
2620
- locked ? (
2621
- <LockedReason lock={lock} className={cn("block", className)}>
2622
- {box}
2623
- </LockedReason>
2624
- ) : (
2625
- box
2626
- );
2627
- if (label === undefined) {
2628
- const select = (
2629
- <select
2630
- ref={ref}
2631
- // `id` is destructured out of the props to feed `fieldId`, and this branch
2632
- // never put it back — so an UNLABELLED Select swallowed it and the
2633
- // consumer's own `<label for="…">` pointed at nothing. The control stayed
2634
- // in the tab order with no accessible name at all: reachable, and silent
2635
- // when it was reached. Input and Textarea both forward it here; this is
2636
- // the third one doing the same thing.
2637
- id={id}
2638
- size={nativeSize}
2639
- {...rest}
2640
- {...interaction}
2641
- // OR-ed with the spread for the reason spelled out on Input's copy: this
2642
- // branch wrote `invalid || undefined`, so passing `aria-invalid` by hand
2643
- // to a Select — which is what a caller does when the validity is
2644
- // `"grammar"` or `"spelling"`, or when the paint is not wanted — had the
2645
- // attribute silently dropped. Input has never done that.
2646
- aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2647
- aria-describedby={describedBy}
2648
- className={cn(
2649
- FIELD_BASE,
2650
- dress,
2651
- listBox && "py-0",
2652
- small && SELECT_SM,
2653
- locked && FIELD_LOCKED,
2654
- selectClassName,
2655
- isInvalid && FIELD_INVALID,
2656
- )}
2657
- >
2658
- {children}
2659
- </select>
2660
- );
2661
- // A FieldHint with no label line to ride (0.15.5 P8): an unlabelled Select — named
2662
- // by `aria-label` or a `<label>` of the caller's — dropped a non-text `hint`
2663
- // without a trace, so the "?" a caller passed never rendered and a test looking
2664
- // for its button found none. It goes at the end of the field instead, outside the
2665
- // box, as the label line's own hint goes at the end of the label.
2666
- if (hasContent(hint) && !textHint) {
2667
- return (
2668
- <FieldGroup errorEl={below} reserve={reserve}>
2669
- {withReason(
2670
- <div className={cn("flex items-center gap-1.5", boxClass)}>
2671
- <div className="relative min-w-0 flex-1">
2672
- {select}
2673
- {chevron}
2674
- </div>
2675
- <span className="flex shrink-0 items-center">{hint}</span>
2676
- </div>,
2677
- )}
2678
- </FieldGroup>
2679
- );
2680
- }
2681
- return (
2682
- <FieldGroup errorEl={below} reserve={reserve}>
2683
- {withReason(
2684
- <div className={cn("relative", boxClass)}>
2685
- {select}
2686
- {chevron}
2687
- </div>,
2688
- )}
2689
- </FieldGroup>
2690
- );
2691
- }
2692
- return (
2693
- <FieldGroup errorEl={below} reserve={reserve}>
2694
- {withReason(
2695
- <FloatingField
2696
- className={boxClass}
2697
- htmlFor={fieldId}
2698
- label={label}
2699
- staticLabel
2700
- hint={textHint ? undefined : hint}
2701
- >
2702
- <select
2703
- ref={ref}
2704
- id={fieldId}
2705
- size={nativeSize}
2706
- {...rest}
2707
- {...interaction}
2708
- aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2709
- aria-describedby={describedBy}
2710
- className={cn(
2711
- FIELD_BASE,
2712
- // The floated label takes the top strip of a list box too, so its first
2713
- // row starts under the label rather than behind it. No bottom padding — see
2714
- // `dress`.
2715
- // At Large the label stands above the field (§10.17): no strip.
2716
- listBox ? "pt-5 pb-0 large:pt-2" : FIELD_FLOATING_PAD,
2717
- "peer",
2718
- dress,
2719
- locked && FIELD_LOCKED,
2720
- selectClassName,
2721
- isInvalid && FIELD_INVALID,
2722
- )}
2723
- >
2724
- {children}
2725
- </select>
2726
- {chevron}
2727
- </FloatingField>,
2728
- )}
2729
- </FieldGroup>
2730
- );
2731
- });
2732
- Select.displayName = "Select";
2733
-
2734
- /**
2735
- * The lower edge of {@link TEXTAREA_LABEL_STRIP} (0.25, Kurvenschmiede).
2736
- *
2737
- * The 0.24 strip ends on a hard line, and a line of text scrolled most of the way under
2738
- * it left its lower edge showing below that line: in a pasted table, a row of comma
2739
- * tails between the strip and the first whole line (`0,5;1,9` cut above its baseline is
2740
- * `, ,`). This layer, hung under the strip, closes that gap. What it draws depends on
2741
- * how much of the line on its way out is still below the strip ({@link labelEdge}):
2742
- *
2743
- * - **Half a line or less** — its descenders, the feet of its letters: covered, solid,
2744
- * down to exactly where that line's box ends. The strip's bottom snaps to the line
2745
- * box, so the row of tails is gone and the next line, which starts there, is not
2746
- * touched.
2747
- * - **More than half** — a line you can still read: a soft edge, the strip's surface
2748
- * fading into it over {@link LABEL_EDGE_SOFT_PX}, where 0.24 cut it hard. Never taller
2749
- * than the part of that line already gone, so it grows from nothing as a line starts
2750
- * to move and never reaches the next one.
2751
- * - **Nothing to cover** — at rest, or a line boundary right on the strip's edge:
2752
- * nothing drawn. An unscrolled first line is never covered (at rest the strip covers
2753
- * the top padding and nothing else, as in 0.24), and neither is any whole line.
2754
- *
2755
- * Why not one of the two alone. A plain fade under the strip, as tall as the tails,
2756
- * dims the top of the first WHOLE line whenever it sits right under the strip (its
2757
- * capitals start about 4px into a 20px line), and only half-hides the tails, which sit
2758
- * in its fading part. A plain snap to the line box would hide a whole line the moment
2759
- * one starts to scroll. The half-line threshold keeps what is shown legible: a line is
2760
- * either hidden or shown with most of its letters, never as a row of stray marks.
2761
- *
2762
- * Its height and solid part are `--label-strip-edge` / `--label-strip-edge-solid`,
2763
- * written by {@link TextareaLabelStrip} from the textarea's `scrollTop` and line height
2764
- * on every scroll; unset (no layout, no script yet) the height is 0 and nothing shows.
2765
- * A pseudo-element of the strip, so it inherits everything the strip already gets
2766
- * right: shown only while the label is floated, the same horizontal insets (stopped
2767
- * short of a classic scrollbar, on either side — RTL included), painted under the
2768
- * label. Its colour is INHERITED from the strip (`bg-inherit`): `--bg-surface`, and the
2769
- * disabled / `[readonly]` `--bg-surface-2`, in both themes, with no second copy of
2770
- * those selectors to keep in step. The soft part is a mask over that colour (alpha
2771
- * only — the `#000` is the mask's opacity, not a colour on screen).
2772
- */
2773
- const TEXTAREA_LABEL_EDGE = cn(
2774
- "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-[var(--label-strip-edge,0px)] after:content-['']",
2775
- "after:bg-inherit after:[mask-image:linear-gradient(#000_var(--label-strip-edge-solid,0px),transparent)]",
2776
- );
2777
-
2778
- /** The soft edge over a line still more than half in view, in px: "a few pixels" —
2779
- * enough to take the cut off its letters, little enough to leave them legible. */
2780
- const LABEL_EDGE_SOFT_PX = 3;
2781
-
2782
- /**
2783
- * {@link TEXTAREA_LABEL_EDGE}'s height and solid part, in px, for a field scrolled
2784
- * `scrollTop` with lines `lineHeight` tall.
2785
- *
2786
- * The strip covers the top padding exactly, so line boxes meet its lower edge whenever
2787
- * `scrollTop` is a whole number of lines; `gone` is how far the line now on its way out
2788
- * has passed under it, `left` how much of it is still below. A line height that is not
2789
- * a length (`normal`) gives no line boxes to snap to: only the soft edge then.
2790
- */
2791
- function labelEdge(scrollTop: number, lineHeight: number): { height: number; solid: number } {
2792
- if (!(scrollTop > 0)) return { height: 0, solid: 0 };
2793
- if (!(lineHeight > 0)) return { height: Math.min(scrollTop, LABEL_EDGE_SOFT_PX), solid: 0 };
2794
- const gone = scrollTop % lineHeight;
2795
- const left = gone === 0 ? 0 : lineHeight - gone;
2796
- if (left > 0 && left <= lineHeight / 2) return { height: left, solid: left };
2797
- return { height: Math.min(gone, LABEL_EDGE_SOFT_PX), solid: 0 };
2798
- }
2799
-
2800
- /**
2801
- * The backdrop of a labelled {@link Textarea}'s label strip (Kurvenschmiede, 0.24).
2802
- *
2803
- * The report: paste a long table into ColumnMapper, the field scrolls, and the floated
2804
- * "Paste a table" / "Tabelle einfügen" sits ON the first visible line of text — on
2805
- * desktop and on a phone. Not a ColumnMapper bug: every labelled textarea that scrolls
2806
- * did it. An `<input>` has one line that never moves, so the strip FIELD_FLOATING_PAD
2807
- * leaves for the label is always empty there; a textarea's top padding is part of its
2808
- * SCROLLING area, and once the content scrolls the lines travel up through the strip
2809
- * and under the label.
2810
- *
2811
- * The fix makes that padding stay put: this layer is a later sibling of the textarea
2812
- * (so `peer-*` reaches it, and as a positioned box it paints above the textarea's
2813
- * text), the label paints above it in turn, and it covers exactly the top padding —
2814
- * `top-px` / `inset-x-px` inside the 1px border, `h-4` = `pt-4`. At rest it covers
2815
- * padding and nothing else, so no pixel of an unscrolled line is ever hidden; scrolled
2816
- * text slides out of sight under it instead of through the label.
2817
- *
2818
- * Why a layer and not "start the scrolling area below the strip" (a wrapper that draws
2819
- * the border and background, the textarea transparent inside it): the box would stop
2820
- * being the textarea. Its focus outline, its brand border on focus, the invalid ring,
2821
- * the disabled and read-only greys and every caller's `[&_textarea]:…` would all have
2822
- * to be rebuilt on the wrapper with `focus-within:` / `has-[…]:`, and a caller's
2823
- * `style={{ height }}` would size the scroller rather than the field. The layer leaves
2824
- * all of that exactly where it was.
2825
- *
2826
- * What it has to match, it matches from the same tokens the field uses: the surface is
2827
- * FIELD_BASE's `--bg-surface`, and `--bg-surface-2` for `disabled` and `[readonly]` —
2828
- * on the same ATTRIBUTE selector FIELD_BASE uses, for the reason given there. Focus and
2829
- * `invalid` change only the border and the ring, which are outside the layer, so they
2830
- * need nothing. Both themes come with the variables. The inner corners are the field's
2831
- * radius (`rounded-md`, 0.375rem) less the 1px border, so the rounded border is never
2832
- * cut into — `calc(0.375rem - 1px)` rather than `var(--radius-md)`, which Tailwind emits
2833
- * only where a utility uses it and the kit's token check refuses (token-vars-declared).
2834
- * Since 0.32 in rem (§3.2): the field's own corner grows with the text, so a fixed 5px
2835
- * left the strip's corner square of the border's at 125 %.
2836
- *
2837
- * Hidden (`display: none`) until the label floats: an empty, unfocused field has
2838
- * nothing that could scroll, and it renders exactly as it did before 0.24. Pointer
2839
- * events pass through, so a click on the strip still lands in the field.
2840
- *
2841
- * The one thing CSS cannot know is a classic scrollbar's width, and a strip across the
2842
- * whole inner width would hide the scrollbar's top arrow — see {@link TextareaLabelStrip}.
2843
- *
2844
- * Its lower edge, where a line scrolled half under it used to leave its tails showing,
2845
- * is {@link TEXTAREA_LABEL_EDGE} (0.25).
2846
- */
2847
- const TEXTAREA_LABEL_STRIP = cn(
2848
- "pointer-events-none absolute inset-x-px top-px hidden h-4 rounded-t-[calc(0.375rem-1px)] bg-[var(--bg-surface)]",
2849
- // Never at Large (0.33, §10.17): the label stands above the field, so there is no
2850
- // strip to keep scrolled text out of, and a layer here would hide the first line.
2851
- "not-large:peer-focus:block not-large:peer-[:not(:placeholder-shown)]:block",
2852
- "peer-disabled:bg-[var(--bg-surface-2)] peer-[[readonly]]:bg-[var(--bg-surface-2)]",
2853
- TEXTAREA_LABEL_EDGE,
2854
- );
2855
-
2856
- /**
2857
- * {@link TEXTAREA_LABEL_STRIP}, stopped short of a classic scrollbar.
2858
- *
2859
- * A Windows / Linux desktop draws a textarea's vertical scrollbar INSIDE the border, in
2860
- * a gutter it takes from the content box (15px in Chromium); a strip across the full
2861
- * inner width would sit on top of the scrollbar's up arrow. So the gutter is measured —
2862
- * border box less client width less the borders — and the strip ends where it begins.
2863
- * On whichever side it is: `clientLeft` includes a gutter on the LEFT, which is where
2864
- * Chromium and Firefox put it in a right-to-left field. The side is applied as an
2865
- * inline-start / inline-end inset, so when `dir` flips afterwards and the scrollbar
2866
- * follows it to the other edge, the strip does too without a re-measure. That corner
2867
- * also loses its rounding, since it no longer meets the border.
2868
- *
2869
- * Overlay scrollbars (macOS, phones) take no gutter: the measurement is zero and the
2870
- * strip spans the field. A ResizeObserver on the textarea re-measures when the
2871
- * scrollbar comes or goes (it takes the content box with it) and when the field is
2872
- * resized by hand. Not laid out — hidden in a closed panel, or jsdom — the CSS insets
2873
- * stand until it is.
2874
- *
2875
- * It also writes the strip's lower edge (0.25, {@link TEXTAREA_LABEL_EDGE}) from the
2876
- * textarea's scroll position: a passive `scroll` listener, plus the same observer, since
2877
- * a resize can move the scroll without the user scrolling.
2878
- */
2879
- function TextareaLabelStrip() {
2880
- const ref = useRef<HTMLSpanElement>(null);
2881
- useLayoutEffect(() => {
2882
- const strip = ref.current;
2883
- const field = strip?.previousElementSibling;
2884
- if (!strip || !(field instanceof HTMLTextAreaElement)) return;
2885
- const fit = () => {
2886
- if (field.offsetWidth === 0) return;
2887
- const style = getComputedStyle(field);
2888
- const borderLeft = parseFloat(style.borderLeftWidth) || 0;
2889
- const borderRight = parseFloat(style.borderRightWidth) || 0;
2890
- const gutter = field.offsetWidth - field.clientWidth - borderLeft - borderRight;
2891
- // offsetWidth and clientWidth are whole pixels, so a field with no scrollbar can
2892
- // read ±1 here; no scrollbar is that thin.
2893
- const bar = gutter > 1.5;
2894
- const onLeft = field.clientLeft - borderLeft > 1.5;
2895
- // Kept as a LOGICAL side: a `dir` flipped later (a language switch) moves the
2896
- // scrollbar across without resizing anything, so nothing would re-measure —
2897
- // but it stays at the inline end, and so does an inline-end inset.
2898
- const rtl = style.direction === "rtl";
2899
- const atEnd = onLeft === rtl;
2900
- const border = (end: boolean) => (end === rtl ? borderLeft : borderRight);
2901
- strip.style.insetInlineEnd = bar && atEnd ? `${border(true) + gutter}px` : "";
2902
- strip.style.insetInlineStart = bar && !atEnd ? `${border(false) + gutter}px` : "";
2903
- strip.style.borderStartEndRadius = bar && atEnd ? "0" : "";
2904
- strip.style.borderStartStartRadius = bar && !atEnd ? "0" : "";
2905
- };
2906
- // The edge under the strip follows the scroll — see TEXTAREA_LABEL_EDGE. The line
2907
- // height is read each time, since a caller's class can change the type. Nothing to
2908
- // draw is no property at all (the CSS default is a 0px edge), so a field at rest
2909
- // carries exactly the strip it did in 0.24.
2910
- const follow = () => {
2911
- const edge = labelEdge(field.scrollTop, parseFloat(getComputedStyle(field).lineHeight));
2912
- if (edge.height > 0) {
2913
- strip.style.setProperty("--label-strip-edge", `${edge.height}px`);
2914
- strip.style.setProperty("--label-strip-edge-solid", `${edge.solid}px`);
2915
- } else {
2916
- strip.style.removeProperty("--label-strip-edge");
2917
- strip.style.removeProperty("--label-strip-edge-solid");
2918
- }
2919
- };
2920
- fit();
2921
- follow();
2922
- field.addEventListener("scroll", follow, { passive: true });
2923
- if (typeof ResizeObserver === "undefined") return () => field.removeEventListener("scroll", follow);
2924
- // A resize can move the scroll too (a taller field clamps it).
2925
- const observer = new ResizeObserver(() => {
2926
- fit();
2927
- follow();
2928
- });
2929
- observer.observe(field);
2930
- return () => {
2931
- field.removeEventListener("scroll", follow);
2932
- observer.disconnect();
2933
- };
2934
- }, []);
2935
- return <span ref={ref} aria-hidden data-slot="label-strip" className={TEXTAREA_LABEL_STRIP} />;
2936
- }
2937
-
2938
- export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
2939
- label?: ReactNode;
2940
- /** See {@link Input}'s `invalid`. */
2941
- invalid?: boolean;
2942
- /** See {@link Input}'s `error`. */
2943
- error?: ReactNode;
2944
- /** See {@link Input}'s `hint` (keksdose K4): text is a caption under the field, a
2945
- * {@link FieldHint} rides the label line (or, unlabelled, the end edge). */
2946
- hint?: ReactNode;
2947
- /** See {@link Input}'s `showCount` (keksdose K11): a live "12/80" under the field,
2948
- * with `maxLength`, announced only near and at the limit. */
2949
- showCount?: boolean;
2950
- /** The counter's words. See {@link CharacterCountLabels}. */
2951
- countLabels?: Partial<CharacterCountLabels>;
2952
- }
2953
-
2954
- export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(props, ref) {
2955
- const { className, label, id, placeholder, invalid, error, hint, showCount, countLabels, ...rest } = props;
2956
- // See Input's `reserve`.
2957
- const reserve = "error" in props || "hint" in props || "showCount" in props;
2958
- const generated = useId();
2959
- const fieldId = id ?? generated;
2960
- const countId = useId();
2961
- const hintParts = useFieldHint(hint, rest["aria-describedby"]);
2962
- const maxLength = rest.maxLength;
2963
- const counting = Boolean(showCount) && typeof maxLength === "number" && maxLength > 0;
2964
- const length = useTypedLength(rest.value, rest.defaultValue);
2965
- const countText = useCharacterCountLabels(countLabels);
2966
- const { isInvalid, describedBy, errorEl } = useFieldError(
2967
- error,
2968
- invalid,
2969
- mergeDescribedBy(hintParts.describedBy, counting && countId),
2970
- rest["aria-invalid"],
2971
- );
2972
- const counter = counting ? (
2973
- <CharacterCount
2974
- id={countId}
2975
- used={length.used}
2976
- max={maxLength}
2977
- labels={countText}
2978
- caption={<FieldCaption parts={hintParts} className="mt-0" />}
2979
- />
2980
- ) : null;
2981
- const below = fieldBelow(hintParts, counter, errorEl);
2982
- const onChange = counting
2983
- ? (e: ChangeEvent<HTMLTextAreaElement>) => {
2984
- length.track(e.target.value);
2985
- rest.onChange?.(e);
2986
- }
2987
- : rest.onChange;
2988
- if (label === undefined) {
2989
- const endHint = hintParts.labelHint;
2990
- return (
2991
- <FieldGroup errorEl={below} reserve={reserve}>
2992
- <EndHintRow hint={endHint} className={className}>
2993
- <textarea
2994
- ref={ref}
2995
- id={id}
2996
- placeholder={placeholder}
2997
- {...rest}
2998
- aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2999
- aria-describedby={describedBy}
3000
- onChange={onChange}
3001
- className={cn(FIELD_BASE, endHint === undefined && className, isInvalid && FIELD_INVALID)}
3002
- />
3003
- </EndHintRow>
3004
- </FieldGroup>
3005
- );
3006
- }
3007
- const ownPlaceholder = hasPlaceholderText(placeholder);
3008
- return (
3009
- <FieldGroup errorEl={below} reserve={reserve}>
3010
- <FloatingField className={className} htmlFor={fieldId} label={label} hint={hintParts.labelHint}>
3011
- <textarea
3012
- ref={ref}
3013
- id={fieldId}
3014
- // The float trick's single space, unless the caller has a placeholder of its
3015
- // own — then that, shown on focus once the label is out of its way (kastlan
3016
- // 8, see FLOATING_PLACEHOLDER_ON_FOCUS).
3017
- placeholder={ownPlaceholder ? placeholder : " "}
3018
- {...rest}
3019
- aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
3020
- aria-describedby={describedBy}
3021
- onChange={onChange}
3022
- className={cn(
3023
- FLOATING_INPUT_CLASS,
3024
- ownPlaceholder && FLOATING_PLACEHOLDER_ON_FOCUS,
3025
- isInvalid && FIELD_INVALID,
3026
- )}
3027
- />
3028
- {/* Between the textarea and the label, on purpose: after the textarea so it
3029
- is its `peer` and paints over its text, before the label so the label
3030
- paints over it. See TEXTAREA_LABEL_STRIP. */}
3031
- <TextareaLabelStrip />
3032
- </FloatingField>
3033
- </FieldGroup>
3034
- );
3035
- });
3036
- Textarea.displayName = "Textarea";
3037
-
3038
- export interface CardProps extends ComponentPropsWithoutRef<"div"> {
3039
- children: ReactNode;
3040
- /**
3041
- * When true, drop the card chrome (border, rounded corners, shadow) on mobile so the
3042
- * card spans edge-to-edge. Chrome reappears at the `md` breakpoint. Use for primary
3043
- * content cards on data pages; leave off for centered dialog/panel cards.
3044
- */
3045
- flush?: boolean;
3046
- /**
3047
- * `inset`: a panel INSIDE a card rather than a card on the page — the raised
3048
- * `--bg-surface-2`, a small radius, `p-3` of its own and no border or shadow, since
3049
- * it is already sitting on the card that has them. Lenkbank's results blocks and
3050
- * corner panels write this by hand (`rounded-md bg-surface-2 p-3`) under a Card of
3051
- * their own. Unlike the default card it carries its padding, because every one of
3052
- * those copies wanted the same one; a caller's `p-*` still wins. `flush` is ignored:
3053
- * an inset panel never runs edge to edge.
3054
- */
3055
- variant?: "default" | "inset" | "outline";
3056
- /**
3057
- * The card's own padding: `none`, `sm` (`p-3`) or `md` (`p-4`).
3058
- *
3059
- * `outline` is the bordered box with no shadow — a row or a block INSIDE a section
3060
- * rather than a card on the page: kastlan's rent breakdown rows
3061
- * (tenancy/components/breakdown-row.tsx, `rounded-lg border p-4`) and the meters
3062
- * list's inline add row (meters/components/meter-add-row.tsx). It takes `md` by
3063
- * default, as every one of those copies does; `sm` is the dense row.
3064
- *
3065
- * Left out, the default card keeps no padding (its CardHeader / CardContent own the
3066
- * rhythm) and `inset` keeps its `p-3`; passing it sets theirs too. A caller's `p-*`
3067
- * in `className` still wins.
3068
- *
3069
- * Whenever the card carries a padding — this prop, or the `inset` / `outline` one —
3070
- * CardHeader, CardContent and CardFooter drop their own `px-6` / `pt-6` / `pb-6`, so
3071
- * the card's padding is the only one. Before 0.16 they kept it regardless and a
3072
- * `padding="md"` card with parts was padded twice (40px a side); keksdose's settings
3073
- * cards zeroed all three parts by hand (`<CardHeader className="p-0">`). A padding set
3074
- * only through `className` cannot be seen from the parts: use the prop.
3075
- */
3076
- padding?: "none" | "sm" | "md";
3077
- /**
3078
- * A card that is a WARNING (or other status) as a whole: the border in the tone's
3079
- * `-border` colour and the {@link CardTitle} in the tone's text colour. kastlan's
3080
- * dunning summary (invoice-detail-page.tsx:231-235) writes both by hand —
3081
- * `border-[var(--warning-border)]` on the Card, `text-[var(--warning)]` on the title
3082
- * — and the next status card would copy the pair (`outline` takes it the same way).
3083
- * An `inset` panel has no border, so it takes the tone's quiet `-bg` fill instead.
3084
- * `data-tone` is set for a caller's own
3085
- * selectors.
3086
- */
3087
- tone?: CardTone;
3088
- /**
3089
- * How loud the `tone` frame is. `"strong"`: a 2px border in the tone's strong
3090
- * colour — `--danger-border-strong` for `danger`, `--warning-border-strong` for
3091
- * `warning` (0.33: amber-600 in dark, where `--warning` stood 13:1 off the card and
3092
- * shouted; docs/colour-roles-harmonization.md §5.2), the tone's own text colour
3093
- * (`--info`, `--success`) for the two with no `-border-strong` token of their own.
3094
- * For the one tile on a page that guards something destructive or security-relevant:
3095
- * keksdose's password / delete-account tile (shared/components/password-danger-card.tsx)
3096
- * wrote `toneFrameClass(tone)` over the Card by hand for the 2px rule, and `p-[15px]`
3097
- * under it so the content did not move. Here the extra pixel comes off the padding the
3098
- * same way (`padding` or the variant's own), so a strong card's content sits exactly
3099
- * where a soft one's does.
3100
- *
3101
- * Default `"soft"`: the 1px `-border` it has always been. No effect without `tone`,
3102
- * nor on `inset`, which has no border to make louder (its tone is a fill).
3103
- */
3104
- toneStrength?: "soft" | "strong";
3105
- /**
3106
- * Wash the card in the tone's quiet `-bg` surface as well as framing it. A toned
3107
- * card is a border and a title colour, at either strength — the wash belongs to
3108
- * `AlertBanner`, the message, not to a card that holds a form — so it stays
3109
- * off by default. Some tiles want both: keksdose's password / delete-account tile
3110
- * was `toneFrameClass(tone)` (border AND wash) before 0.16, and after moving to
3111
- * `toneStrength="strong"` it re-added `bg-[var(--danger-bg)]` by hand. The wash is
3112
- * layered OVER the card's own `--bg-surface` (as AlertBanner's `elevated` does), so
3113
- * the dark theme's translucent tints stay a card, not a hole to the page below.
3114
- *
3115
- * No effect without `tone`, nor on `inset`, whose tone is already the fill.
3116
- */
3117
- toneFill?: boolean;
3118
- /**
3119
- * The parts' type scale. `"compact"`: CardTitle `text-sm font-medium`,
3120
- * CardDescription `text-xs` and the CardHeader's title-to-description gap
3121
- * `gap-0.5` — the small settings / admin card. keksdose wrote those three classes by
3122
- * hand on ~25 cards. Set here, they reach the parts through context; a class on a
3123
- * part still wins, and a Card nested inside starts again from its own density.
3124
- *
3125
- * Default `"comfortable"`: the parts as they have always been.
3126
- */
3127
- density?: CardDensity;
3128
- }
3129
-
3130
- export type CardDensity = "comfortable" | "compact";
3131
-
3132
- export type CardTone = "warning" | "danger" | "info" | "success";
3133
-
3134
- // The title is reached through its `data-slot`, so the tone needs no context and a
3135
- // caller's `className` on CardTitle still wins (it is on the element itself).
3136
- const CARD_TONES: Record<CardTone, { border: string; fill: string; title: string }> = {
3137
- warning: {
3138
- border: "border-[var(--warning-border)]",
3139
- fill: "bg-[var(--warning-bg)]",
3140
- title: "[&_[data-slot=card-title]]:text-[var(--warning)]",
3141
- },
3142
- danger: {
3143
- border: "border-[var(--danger-border)]",
3144
- fill: "bg-[var(--danger-bg)]",
3145
- title: "[&_[data-slot=card-title]]:text-[var(--danger)]",
3146
- },
3147
- info: {
3148
- border: "border-[var(--info-border)]",
3149
- fill: "bg-[var(--info-bg)]",
3150
- title: "[&_[data-slot=card-title]]:text-[var(--info)]",
3151
- },
3152
- success: {
3153
- border: "border-[var(--success-border)]",
3154
- fill: "bg-[var(--success-bg)]",
3155
- title: "[&_[data-slot=card-title]]:text-[var(--success)]",
3156
- },
3157
- };
3158
-
3159
- const CARD_PADDING: Record<NonNullable<CardProps["padding"]>, string> = {
3160
- none: "p-0",
3161
- sm: "p-3",
3162
- md: "p-4",
3163
- };
3164
-
3165
- // The same padding one pixel short, for a 2px `toneStrength="strong"` frame: the
3166
- // border grows inwards by that pixel, so the content stays where the 1px card had it.
3167
- // `calc(rem − 1px)` (0.32, §3.2): the padding grows with the text like `p-3` / `p-4`,
3168
- // the border does not — a fixed 11 / 15 px stopped matching them at 125 %, and toggling
3169
- // the tone shifted the content.
3170
- const CARD_PADDING_STRONG: Record<NonNullable<CardProps["padding"]>, string> = {
3171
- none: "p-0",
3172
- sm: "p-[calc(0.75rem-1px)]",
3173
- md: "p-[calc(1rem-1px)]",
3174
- };
3175
-
3176
- // The wash as a background IMAGE over the card's opaque `--bg-surface` — see
3177
- // `toneFill`. A plain `bg-[var(--danger-bg)]` would REPLACE the surface, and the dark
3178
- // theme's washes are a translucent tint.
3179
- const CARD_TONE_WASH: Record<CardTone, string> = {
3180
- warning: "bg-[image:linear-gradient(var(--warning-bg),var(--warning-bg))]",
3181
- danger: "bg-[image:linear-gradient(var(--danger-bg),var(--danger-bg))]",
3182
- info: "bg-[image:linear-gradient(var(--info-bg),var(--info-bg))]",
3183
- success: "bg-[image:linear-gradient(var(--success-bg),var(--success-bg))]",
3184
- };
3185
-
3186
- const CARD_TONES_STRONG: Record<CardTone, string> = {
3187
- warning: "border-2 border-warning-strong",
3188
- danger: "border-2 border-[var(--danger-border-strong)]",
3189
- info: "border-2 border-[var(--info)]",
3190
- success: "border-2 border-[var(--success)]",
3191
- };
3192
-
3193
- /**
3194
- * Whether the {@link Card} round the parts carries the padding itself — so CardHeader,
3195
- * CardContent and CardFooter must not add theirs on top. `false` (no Card, or a default
3196
- * card with no `padding`) keeps the parts' own `px-6` rhythm, as it always was.
3197
- */
3198
- const CardPaddedContext = createContext(false);
3199
-
3200
- /** The {@link CardProps.density} of the nearest Card — the parts' type scale. */
3201
- const CardDensityContext = createContext<CardDensity>("comfortable");
3202
-
3203
- export function Card({
3204
- className,
3205
- children,
3206
- flush,
3207
- variant = "default",
3208
- tone,
3209
- toneStrength = "soft",
3210
- toneFill = false,
3211
- density = "comfortable",
3212
- padding,
3213
- ...rest
3214
- }: CardProps) {
3215
- const toned = tone ? CARD_TONES[tone] : undefined;
3216
- // An outline card is bordered like the default one, so a tone recolours its border.
3217
- const bordered = variant !== "inset";
3218
- const strong = toned !== undefined && bordered && toneStrength === "strong";
3219
- // The padding the card itself carries: the prop, else the variant's own (`inset`
3220
- // p-3, `outline` p-4). A default card with no prop carries none — its parts do.
3221
- const ownPadding = padding ?? (variant === "inset" ? "sm" : variant === "outline" ? "md" : undefined);
3222
- return (
3223
- <div
3224
- data-tone={tone}
3225
- data-tone-strength={strong ? "strong" : undefined}
3226
- data-density={density === "compact" ? "compact" : undefined}
3227
- {...rest}
3228
- className={cn(
3229
- variant === "inset"
3230
- ? "rounded-md bg-[var(--bg-surface-2)] p-3"
3231
- : variant === "outline"
3232
- ? // No shadow and no `flush`: it sits on a surface that already has both.
3233
- "rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] p-4"
3234
- : cn(
3235
- // Surface + border are theme tokens so the palette switcher (feedback
3236
- // #307) can re-skin every card; a caller's own bg-*/border-* override
3237
- // still wins via tailwind-merge.
3238
- "bg-[var(--bg-surface)]",
3239
- flush
3240
- ? "border-y border-[var(--border)] md:rounded-lg md:border md:shadow-sm"
3241
- : "rounded-lg border border-[var(--border)] shadow-sm",
3242
- ),
3243
- toned && (bordered ? toned.border : toned.fill),
3244
- strong && tone && CARD_TONES_STRONG[tone],
3245
- toneFill && bordered && tone && CARD_TONE_WASH[tone],
3246
- toned?.title,
3247
- ownPadding && (strong ? CARD_PADDING_STRONG : CARD_PADDING)[ownPadding],
3248
- className,
3249
- )}
3250
- >
3251
- <CardPaddedContext.Provider value={ownPadding !== undefined}>
3252
- <CardDensityContext.Provider value={density}>{children}</CardDensityContext.Provider>
3253
- </CardPaddedContext.Provider>
3254
- </div>
3255
- );
3256
- }
3257
-
3258
- // Composed shadcn-style Card sub-parts. A default `Card` with no `padding` stays
3259
- // padding-less, so these own the padding/rhythm (`px-6`, the header's `pt-6`, the
3260
- // last part's `pb-6`). A Card that carries a padding of its own — the `padding` prop,
3261
- // or `inset` / `outline`, which have one built in — tells them so through context and
3262
- // they add none on top (see CardPaddedContext). Token-driven so they re-skin with the
3263
- // palette. Use CardHeader → CardTitle/CardDescription (+ optional CardAction,
3264
- // top-right) → CardContent → CardFooter.
3265
- /** The sub-parts add nothing to a `<div>`'s props — they are the SAME element with a
3266
- * `data-slot` and a padding rhythm — so most names are aliases rather than an empty
3267
- * interface pretending to be more. They exist so a consumer's own wrapper can say
3268
- * `CardHeaderProps` instead of `ComponentProps<typeof CardHeader>`. */
3269
- export interface CardHeaderProps extends ComponentPropsWithoutRef<"div"> {
3270
- /**
3271
- * Below the `sm` breakpoint, put the {@link CardAction} UNDER the title and
3272
- * description instead of beside them. For an action that is wider than an icon —
3273
- * a "Set up two-factor" button, a checkbox and a filter — which, kept in the
3274
- * top-end column on a 390px phone, squeezed the title into a ribbon two words wide
3275
- * (keksdose's settings cards). Off by default at Normal: an icon action (the wizard
3276
- * summary's pencil) is narrow enough to stay beside the title at every width.
3277
- *
3278
- * Unset, it stacks at Large and Extra large only (0.32.1, the 360 px Extra-large
3279
- * sweep; docs/text-size-harmonization.md §4): there an IconButton shows its label as
3280
- * text, so the "icon" action is a word-wide pill, and "Refresh sessions" beside
3281
- * "Sessions" ran 47 px past a 360 px phone. `false` keeps the action beside the title
3282
- * at every size, for an action the caller knows stays narrow (`labelVisible={false}`).
3283
- */
3284
- stackAction?: boolean;
3285
- }
3286
- export type CardTitleLevel = "div" | "h2" | "h3" | "h4";
3287
- export interface CardTitleProps extends ComponentPropsWithoutRef<"div"> {
3288
- /**
3289
- * The element the title is: `"div"` (default) or a heading level. Five of keksdose's
3290
- * settings cards nested their own `<h2>` / `<h3>` INSIDE CardTitle to put the card in
3291
- * the page's outline — a heading in a div that looks like a heading. The heading
3292
- * takes the title's look unchanged (the reset leaves `h2`–`h4` at inherited size and
3293
- * weight), so only the outline changes.
3294
- */
3295
- as?: CardTitleLevel;
3296
- }
3297
- export type CardDescriptionProps = ComponentPropsWithoutRef<"div">;
3298
- export type CardActionProps = ComponentPropsWithoutRef<"div">;
3299
- export type CardContentProps = ComponentPropsWithoutRef<"div">;
3300
- export type CardFooterProps = ComponentPropsWithoutRef<"div">;
3301
-
3302
- /** {@link CardHeaderProps.stackAction}: one column under `sm`, and the action back into
3303
- * the flow (DOM order, so after the description when it is written after it), at the
3304
- * start edge. The descendant selector outranks CardAction's own placement classes. */
3305
- const CARD_STACK_ACTION =
3306
- "max-sm:grid-cols-1 max-sm:[&>[data-slot=card-action]]:col-start-1 max-sm:[&>[data-slot=card-action]]:row-span-1 max-sm:[&>[data-slot=card-action]]:row-start-auto max-sm:[&>[data-slot=card-action]]:justify-self-start";
3307
- /** The same at Large and Extra large only — `stackAction` unset (see its note). A
3308
- * `large:` class over the scaled `max-sm`, so it costs no render and is right on the
3309
- * first paint. */
3310
- const CARD_STACK_ACTION_AT_LARGE =
3311
- "large:max-sm:grid-cols-1 large:max-sm:[&>[data-slot=card-action]]:col-start-1 large:max-sm:[&>[data-slot=card-action]]:row-span-1 large:max-sm:[&>[data-slot=card-action]]:row-start-auto large:max-sm:[&>[data-slot=card-action]]:justify-self-start";
3312
-
3313
- export function CardHeader({ className, stackAction, ...props }: CardHeaderProps) {
3314
- const padded = useContext(CardPaddedContext);
3315
- const compact = useContext(CardDensityContext) === "compact";
3316
- return (
3317
- <div
3318
- data-slot="card-header"
3319
- className={cn(
3320
- // Grid (not flex) so CardAction can occupy a top-right column; with no
3321
- // action it collapses to one column and title/description stack.
3322
- //
3323
- // The two-column switch sets a VARIABLE, not `grid-template-columns`. As
3324
- // `has-data-[slot=card-action]:grid-cols-[1fr_auto]` it was two classes of
3325
- // specificity (the class plus the attribute inside `:has()`), and a caller's
3326
- // `max-sm:grid-cols-1` — one class inside a media query — lost to it at every
3327
- // width, so the action could never be stacked on a phone from outside. Now the
3328
- // property itself is set by a plain one-class utility that any `grid-cols-*`
3329
- // variant of the caller's outranks by coming later in the sheet; the `:has()`
3330
- // rule only picks the value. The base `1fr` resets the variable so a header
3331
- // never inherits an ancestor header's two columns.
3332
- "grid auto-rows-min items-start gap-1.5 [--card-header-cols:1fr] has-data-[slot=card-action]:[--card-header-cols:1fr_auto] grid-cols-[var(--card-header-cols,1fr)]",
3333
- padded ? "px-0 pt-0" : "px-6 pt-6",
3334
- compact && "gap-0.5",
3335
- stackAction === true && CARD_STACK_ACTION,
3336
- stackAction === undefined && CARD_STACK_ACTION_AT_LARGE,
3337
- className,
3338
- )}
3339
- {...props}
3340
- />
3341
- );
3342
- }
3343
-
3344
- export function CardTitle({ className, as: Tag = "div", ...props }: CardTitleProps) {
3345
- const compact = useContext(CardDensityContext) === "compact";
3346
- return (
3347
- <Tag
3348
- data-slot="card-title"
3349
- // Compact is `leading-snug`, not `leading-none`: there the title sits on a
3350
- // 12px description `gap-0.5` below, and a line box exactly the cap height left
3351
- // the hint hugging the title's descenders (every keksdose admin card added
3352
- // `leading-normal` by hand). Comfortable keeps `leading-none` — its 14px
3353
- // description is `gap-1.5` away and has always read right.
3354
- className={cn(compact ? "text-sm font-medium leading-snug" : "font-semibold leading-none", className)}
3355
- {...props}
3356
- />
3357
- );
3358
- }
3359
-
3360
- export function CardDescription({ className, ...props }: CardDescriptionProps) {
3361
- const compact = useContext(CardDensityContext) === "compact";
3362
- return (
3363
- <div
3364
- data-slot="card-description"
3365
- // `--text-muted`, the kit's secondary-line colour (StatTile's description, the
3366
- // field captions). It was `--money-neutral`, a MONEY token that happened to be
3367
- // a similar grey — and a palette that re-tunes the money colours moved every
3368
- // card's description with them.
3369
- className={cn(compact ? "text-xs" : "text-sm", "text-[var(--text-muted)]", className)}
3370
- {...props}
3371
- />
3372
- );
3373
- }
3374
-
3375
- export function CardAction({ className, ...props }: CardActionProps) {
3376
- return (
3377
- <div
3378
- data-slot="card-action"
3379
- className={cn("col-start-2 row-start-1 row-span-2 self-start justify-self-end", className)}
3380
- {...props}
3381
- />
3382
- );
3383
- }
3384
-
3385
- export function CardContent({ className, ...props }: CardContentProps) {
3386
- const padded = useContext(CardPaddedContext);
3387
- // `last:pb-6`, not a plain `pb-6`: CardHeader owns the top padding and CardFooter
3388
- // owns the bottom one, so a card WITHOUT a footer had nothing closing it off and
3389
- // its last field sat flush against the card edge (kastlan feedback: the language
3390
- // input touching the card bottom on /profile). Scoping to `:last-child` fixes that
3391
- // case and leaves a footered card's rhythm exactly as it was. Inside a padded Card
3392
- // the card closes itself off, so there is nothing to add.
3393
- return <div data-slot="card-content" className={cn(!padded && "px-6 last:pb-6", className)} {...props} />;
3394
- }
3395
-
3396
- export function CardFooter({ className, ...props }: CardFooterProps) {
3397
- const padded = useContext(CardPaddedContext);
3398
- return (
3399
- <div
3400
- data-slot="card-footer"
3401
- className={cn("flex items-center", !padded && "px-6 pb-6", className)}
3402
- {...props}
3403
- />
3404
- );
3405
- }
3406
-
3407
- /** A `<span>`'s props plus the words a reader hears — see {@link CardHeaderProps}.
3408
- * The ring is drawn with a border, so there is nothing inside it to put children in. */
3409
- export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "children"> {
3410
- /**
3411
- * What the spinner means, for a screen reader. Default: `common.loading` from the
3412
- * {@link UiKitProvider}, else "Loading…".
3413
- *
3414
- * `null` makes it DECORATIVE — no role, no text, hidden from assistive tech. Use it
3415
- * whenever words are already there: beside visible "Loading…" text (otherwise it is
3416
- * announced twice), inside a labelled button (otherwise its text joins the button's
3417
- * name), or inside a live region of the app's own (otherwise that region and this
3418
- * one both announce).
3419
- */
3420
- label?: string | null;
3421
- /**
3422
- * Show the label as TEXT beside the ring, not only to a screen reader. kastlan's
3423
- * LoadingState (feedback/loading-state.tsx:14) builds exactly this by hand — a
3424
- * `role="status"` wrapper, the spinner at `label={null}`, a `<p>` of text — so it
3425
- * has to get the three-part aria arrangement right itself. Here the visible text IS
3426
- * the live region's content (the label stays the accessible name, said once), and
3427
- * the ring is decorative. `className` still sizes the ring; the other props go on
3428
- * the wrapper. Ignored with `label={null}`: there are no words to show.
3429
- */
3430
- showLabel?: boolean;
3431
- /** Where the shown label sits: `end` (default) — beside the ring, for a row or a
3432
- * button; `below` — under it, centred, for a panel's loading state. */
3433
- labelPosition?: "end" | "below";
3434
- }
3435
-
3436
- /**
3437
- * A spinning ring that also SAYS it is loading.
3438
- *
3439
- * It used to be a bordered span and nothing else, so a reader met no element at all
3440
- * where a sighted user saw the page working — or, beside an emptied list, met the
3441
- * empty list and concluded there was nothing there. `role="status"` makes it a polite
3442
- * live region, and the text inside is what that region announces. Text rather than
3443
- * `aria-label`: a live region's announcement is its CONTENT, and several readers
3444
- * ignore a name on one.
3445
- *
3446
- * `relative` so the `sr-only` text has a local containing block (see
3447
- * sr-only-containment.test). Where the words are already on screen, pass
3448
- * `label={null}`: announcing is right for a spinner standing alone and wrong for one
3449
- * beside text, inside a button, or inside the app's own live region.
3450
- */
3451
- export function Spinner({ className, label, showLabel, labelPosition = "end", ...rest }: SpinnerProps) {
3452
- const common = useKitLabels("common", DEFAULT_COMMON_LABELS, {
3453
- loading: label ?? undefined,
3454
- });
3455
- const ring = cn(
3456
- "relative inline-block h-5 w-5 animate-spin rounded-full border-2 border-[var(--border)] border-t-[var(--text-primary)]",
3457
- className,
3458
- );
3459
- if (label === null) return <span aria-hidden {...rest} className={ring} />;
3460
- if (showLabel) {
3461
- return (
3462
- <span
3463
- role="status"
3464
- {...rest}
3465
- className={cn(
3466
- "inline-flex items-center text-sm text-[var(--text-secondary)]",
3467
- labelPosition === "below" ? "flex-col gap-2 text-center" : "gap-2",
3468
- )}
3469
- >
3470
- <span aria-hidden className={ring} />
3471
- <span>{common.loading}</span>
3472
- </span>
3473
- );
3474
- }
3475
- return (
3476
- <span role="status" {...rest} className={ring}>
3477
- {/* The trailing space is a separator for the case the caller forgot `label={null}`
3478
- inside a button: a name is the concatenation of its text, and without it the
3479
- spinner's word ran straight into the button's — "Loadingconfirm". */}
3480
- <span className="sr-only">{common.loading} </span>
3481
- </span>
3482
- );
3483
- }
3484
-
3485
- export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "title"> {
3486
- /** The box renders `title` and `hint` in its own two-line rhythm, which is what makes
3487
- * every empty state in three apps look like the same thing — so there is no
3488
- * `children` slot to put arbitrary content in. The two slots below are the only
3489
- * other things an empty state has turned out to need, and each has a fixed place. */
3490
- /** Nodes rather than strings, so a title can carry a code, a link or an emphasised
3491
- * word — keksdose's error boundary shows the error's name in `<code>` — while the
3492
- * box still sets the type. */
3493
- title: ReactNode;
3494
- hint?: ReactNode;
3495
- /**
3496
- * Render the title as a heading of this level. Left out it is a `<div>`, as before:
3497
- * most empty states are a message inside a section that already has its heading.
3498
- * keksdose's error boundary IS the page when it trips, and its `<h2>` went missing
3499
- * when it moved onto EmptyState — so the page's heading outline lost the one line
3500
- * that says what happened. Only the element changes; the look is the box's.
3501
- */
3502
- headingAs?: "h2" | "h3" | "h4" | "h5" | "h6";
3503
- /** A glyph ABOVE the title — kastlan's InboxEmptyState (an inbox), keksdose's
3504
- * offline card (a cloud with a slash). Sized by the box (`[&_svg]:size-8`) and
3505
- * muted, so five call sites cannot pick five sizes; hidden from assistive tech,
3506
- * because the title already says what it shows. */
3507
- icon?: ReactNode;
3508
- /** What to do about it, BELOW the hint: keksdose's query-state cards (Retry), its
3509
- * error boundary (Retry / Reload / Go home — pass all three in a fragment, they
3510
- * wrap and centre as one row), kastlan's "Create a rule". Buttons or links the
3511
- * caller renders; the box only places them. */
3512
- action?: ReactNode;
3513
- /**
3514
- * `box` (default): the dashed, padded card that stands in for a whole section.
3515
- * `inline`: one quiet line with no box — for the empty state INSIDE something that
3516
- * already has its own frame, where a dashed card would be a box in a box. keksdose
3517
- * writes that line by hand five times as a muted `<p className="py-6 text-center">`:
3518
- * the notification inbox (notification-inbox:140), a support thread
3519
- * (support-thread:144), the assistant (assistant-page:321), the admin support panel
3520
- * (support-panel:265) and the funding dialog (funding-dialog:89). The icon shrinks
3521
- * to the text's size and sits before the title, the hint follows on the same line,
3522
- * and the row wraps only when it must. Centred like the box; `className="justify-start"`
3523
- * for a list that reads from the start edge (funding-dialog).
3524
- */
3525
- variant?: "box" | "inline";
3526
- /**
3527
- * Colour the icon and the title: `danger` for an error standing where the content
3528
- * should be — kastlan's ErrorState (shared/components/feedback/error-state.tsx) and
3529
- * its error boundary's fallback (error-boundary.tsx:16), both a red triangle over a
3530
- * message — and `success` for an empty state that is GOOD news, kastlan's "No defects
3531
- * recorded for this room" (handover defects-step.tsx:142, a green check). Left out,
3532
- * both stay muted. The hint stays muted either way: it is the explanation, not the
3533
- * verdict.
3534
- */
3535
- tone?: "danger" | "success";
3536
- /**
3537
- * `inline` only (0.11.0). `sm`: 12px, start-aligned and tight (`py-1`) — the empty
3538
- * line INSIDE a detail panel, under a panel heading and among `text-xs` rows, where
3539
- * the default's 14px centred line with `py-4` read as a section of its own.
3540
- * keksdose's holdings panel (holdings-panel:563), asset-loan panel
3541
- * (asset-loan-panel:108) and loan-payment panel (loan-payment-panel:191) each wrote
3542
- * `className="justify-start py-1"` over it and still got body-size text. Default `md`,
3543
- * the look it had. Ignored by the box.
3544
- */
3545
- size?: "sm" | "md";
3546
- }
3547
-
3548
- const EMPTY_STATE_TONE: Record<"danger" | "success", string> = {
3549
- danger: "text-[var(--danger)]",
3550
- success: "text-[var(--success)]",
3551
- };
3552
-
3553
- export function EmptyState({
3554
- title,
3555
- hint,
3556
- icon,
3557
- action,
3558
- headingAs,
3559
- variant = "box",
3560
- tone,
3561
- size = "md",
3562
- className,
3563
- ...rest
3564
- }: EmptyStateProps) {
3565
- const Title = headingAs ?? "div";
3566
- const toneClass = tone ? EMPTY_STATE_TONE[tone] : undefined;
3567
- const hasHint = hint != null && hint !== false && hint !== "";
3568
- if (variant === "inline") {
3569
- const sm = size === "sm";
3570
- return (
3571
- <div
3572
- {...rest}
3573
- className={cn(
3574
- "flex flex-wrap items-center text-[var(--text-muted)]",
3575
- sm
3576
- ? "justify-start gap-x-1.5 gap-y-0.5 py-1 text-start text-xs"
3577
- : "justify-center gap-x-2 gap-y-1 py-4 text-center text-sm",
3578
- className,
3579
- )}
3580
- >
3581
- {icon != null && (
3582
- <span
3583
- aria-hidden
3584
- className={cn("flex text-[var(--text-placeholder)]", sm ? "[&_svg]:size-3.5" : "[&_svg]:size-4", toneClass)}
3585
- >
3586
- {icon}
3587
- </span>
3588
- )}
3589
- {/* No weight of its own: one muted line is what these sites were, and a
3590
- bold title would turn a quiet "nothing here" into a heading. */}
3591
- <Title className={cn(sm ? "text-xs" : "text-sm", toneClass)}>{title}</Title>
3592
- {hasHint && <span className="text-xs">{hint}</span>}
3593
- {action != null && <span className="flex flex-wrap items-center gap-2">{action}</span>}
3594
- </div>
3595
- );
3596
- }
3597
- return (
3598
- <div
3599
- {...rest}
3600
- className={cn(
3601
- "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)]",
3602
- className,
3603
- )}
3604
- >
3605
- {icon != null && (
3606
- <div aria-hidden className={cn("mb-3 text-[var(--text-placeholder)] [&_svg]:size-8", toneClass)}>
3607
- {icon}
3608
- </div>
3609
- )}
3610
- {/* `text-sm`: a heading keeps the box's size, not whatever a stylesheet gives h2. */}
3611
- <Title className={cn("text-sm font-medium text-[var(--text-secondary)]", toneClass)}>{title}</Title>
3612
- {hasHint && <div className="mt-1 text-xs">{hint}</div>}
3613
- {action != null && (
3614
- <div className="mt-4 flex flex-wrap items-center justify-center gap-2">{action}</div>
3615
- )}
3616
- </div>
3617
- );
3618
- }
3619
-
3620
- /** One tab of a {@link Tabs} strip. */
3621
- export interface TabItem<T extends string> {
3622
- id: T;
3623
- /** A ReactNode so a tab can pair an icon with text. */
3624
- label: ReactNode;
3625
- /** Optional trailing node (e.g. a count pill). */
3626
- badge?: ReactNode;
3627
- /** Marks a tab that IS a route — see {@link TabsProps}. */
3628
- href?: string;
3629
- /** Leading decoration: an icon, or the colour swatches of the lines this tab
3630
- * draws in a chart above the strip. Hidden from assistive tech — it repeats
3631
- * what the label says, or it says something the label must say instead. */
3632
- icon?: ReactNode;
3633
- /** A second, smaller line under the label — the one fact that tells two tabs
3634
- * with similar labels apart ("80 km/h", "Level 3"). Part of the tab's name. */
3635
- detail?: ReactNode;
3636
- /** A tab that exists as a slot with nothing in it yet, drawn with a dashed
3637
- * outline so a gap in a sequence looks like a gap. Visual only: say "empty" in
3638
- * the label or `detail` if a screen reader needs to know. */
3639
- empty?: boolean;
3640
- /** `false` keeps this one tab when the strip has `onRemove` — the one sheet a
3641
- * workbook cannot be without. Default: removable. */
3642
- removable?: boolean;
3643
- /** Plain-text name for the labels the strip composes about this tab ("Remove
3644
- * …"), for when `label` is not a string. Default: `label` if it is a string,
3645
- * else the id. */
3646
- name?: string;
3647
- }
3648
-
3649
- /** The strings the strip renders on its own behalf — only when it can add or
3650
- * remove tabs. */
3651
- export interface TabsLabels {
3652
- /** The "add" button's visible text when the caller gives no `addLabel`. */
3653
- add: string;
3654
- /** Accessible name of a tab's × — "Remove <tab>". */
3655
- remove: (tab: string) => string;
3656
- }
3657
-
3658
- export const DEFAULT_TABS_LABELS: TabsLabels = {
3659
- add: "Add tab",
3660
- remove: (tab) => `Remove ${tab}`,
3661
- };
3662
-
3663
- /**
3664
- * The strip's own props sit on a `<div>`: the tablist IS the root element, so anything
3665
- * a caller hangs on it — a `data-tour` anchor for the kit's guided tour, a test id, an
3666
- * `aria-describedby` — lands there. `onChange` is omitted from the div's props because
3667
- * this component's `onChange` hands over the chosen TAB ID, not a DOM event, and
3668
- * `children` because the strip renders `tabs` — a caller who wants a PANEL wires it up
3669
- * through `panelId`, which is the whole point of that prop.
3670
- */
3671
- export interface TabsProps<T extends string>
3672
- extends Omit<ComponentPropsWithoutRef<"div">, "onChange" | "children"> {
3673
- /** `label` is a ReactNode so tabs can pair an icon with text; `badge` is an
3674
- * optional trailing node (e.g. a count pill). `href` marks a tab that IS a route:
3675
- * it renders as an anchor so it can be middle-/⌘-clicked into a new tab (Keksdose
3676
- * feedback #451), while a plain click still goes through `onChange` and stays
3677
- * client-side. Tabs that only flip local state leave it unset — a link to a URL
3678
- * that does not select the tab would be worse than no link. */
3679
- tabs: TabItem<T>[];
3680
- active: T;
3681
- onChange: (id: T) => void;
3682
- className?: string;
3683
- /** Let the strip WRAP onto as many rows as it needs below `md`, instead of
3684
- * scrolling sideways — for a strip carrying more tabs than fit on a phone row.
3685
- * Keksdose live #262: ten reports on a 406px screen showed about three, and the
3686
- * round-2 answer (a <select> below `md`) cost a tap to open and a tap to choose.
3687
- * *"I would rather keep the tabs but have multirow tabs depending on the screen
3688
- * size, I will not loose the function to see all reports at once and navigate by
3689
- * single click rather by double click."*
3690
- *
3691
- * The active MARKER has to change with the layout, which is why this cannot be
3692
- * done from a call site with a `className`: the default marker is a `border-b-2`
3693
- * underline riding the container's own bottom rule, and a tab sitting in a row
3694
- * that does not touch that rule cannot wear it — every row but the last would
3695
- * show a stray line floating mid-strip, and `-mb-px` would pull each chip a
3696
- * pixel into the row beneath it. A wrapped strip marks the active tab with a
3697
- * filled brand chip instead, which is also the only thing findable at a glance
3698
- * among ten same-weight labels on three rows.
3699
- *
3700
- * From `md` up NOTHING changes: same single-line underline strip, same paddings,
3701
- * same colours as every other strip in the app.
3702
- *
3703
- * Opt-in on purpose. The three-tab strips (invoices, statements, bank imports)
3704
- * already fit a phone row, and turning those into chips would be an unrequested
3705
- * redesign of three other pages. */
3706
- wrap?: boolean;
3707
- /**
3708
- * Accessible name for the `role="tablist"` container. Every tab carries its own
3709
- * text, so this names the GROUP, not the tabs; leave it unset where a visible
3710
- * heading immediately above already does that job.
3711
- *
3712
- * @deprecated Pass `aria-label` instead. This kit had three spellings for one idea
3713
- * — `ariaLabel`, `aria-label` and this `label` — and settled on the DOM one, which
3714
- * is also the one that arrives for free now that the strip spreads its rest props.
3715
- * `label` still works and is unchanged; it names the strip only when `aria-label`
3716
- * is absent.
3717
- */
3718
- label?: string;
3719
- /**
3720
- * `id` of the element the caller renders the open tab's content into — the missing
3721
- * half of `role="tab"`. A tab that controls nothing is a tab in name only: a screen
3722
- * reader announces "tab, 2 of 5" and then has no way to take the user to what it
3723
- * opened, and no way back.
3724
- *
3725
- * **This component does not render the panel, on purpose.** `Tabs` is the STRIP; the
3726
- * content lives wherever the caller put it — three Keksdose pages render it in a
3727
- * sibling `<Card>`, one renders it through a router outlet, and a tab can BE a route
3728
- * (`href`), in which case the panel is a whole page this component never sees.
3729
- * Wrapping `children` here would mean either moving that content into the strip's
3730
- * subtree — a layout change on every page that uses tabs — or shipping a wrapper
3731
- * that only some callers could use. So the two halves are joined by an id instead.
3732
- *
3733
- * Wire the other end yourself:
3734
- *
3735
- * ```tsx
3736
- * <Tabs tabs={tabs} active={active} onChange={setActive} panelId="report-panel" />
3737
- * <div id="report-panel" role="tabpanel" aria-labelledby="report-panel-tab" tabIndex={-1}>
3738
- * ```
3739
- *
3740
- * `aria-controls` goes on the OPEN tab only, and `${panelId}-tab` is its id — the
3741
- * closed tabs' panels are not in the document, and a dangling `aria-controls`
3742
- * describes a tab as opening something that is not there.
3743
- */
3744
- panelId?: string;
3745
- /**
3746
- * Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab
3747
- * (only on the open one with `removeOn="active"`).
3748
- *
3749
- * The × is on the open tab only — a row of tabs each carrying one is a row of
3750
- * things to hit by accident, and the tab you can remove should be the one you
3751
- * are looking at — but its room is reserved on every removable tab, so opening a
3752
- * tab never widens it and never pushes the rest of the strip along (lenkbank
3753
- * feedback #72). It is named "Remove <tab>" and is not a tab stop of its own: the
3754
- * strip stays one stop, and the keyboard path is Delete (announced through
3755
- * `aria-keyshortcuts`), which removes whichever tab has focus (see `removeOn`).
3756
- *
3757
- * The strip only ASKS: it calls this, and the tab goes when the caller drops it
3758
- * from `tabs` — after a confirmation, a request, whatever it needs. When it does,
3759
- * focus that was on the removed tab (or its ×) moves to the open tab if the
3760
- * removed one was open, else to its neighbour, instead of falling to the page.
3761
- */
3762
- onRemove?: (id: T) => void;
3763
- /**
3764
- * Which tabs the Delete key removes.
3765
- *
3766
- * - `"every"` (default): any focused removable tab — the ARIA pattern's reading.
3767
- * - `"active"`: only the OPEN tab, the same one that wears the ×. For a strip whose
3768
- * remove is immediate and unconfirmed (lenkbank's sheet tabs delete a measurement
3769
- * sheet at once), so arrowing across the strip and pressing Delete cannot take
3770
- * out a sheet the user is not looking at. Delete on a closed tab does nothing,
3771
- * and only the open tab announces the shortcut (`aria-keyshortcuts`).
3772
- *
3773
- * Either way the × is drawn on the open tab only and its room stays reserved on
3774
- * every removable tab, so the strip never relayouts when the selection moves.
3775
- */
3776
- removeOn?: "every" | "active";
3777
- /** Renders an "add" button after the last tab, outside the tablist — it is an
3778
- * action, not a tab, and a tablist may own only tabs. */
3779
- onAdd?: () => void;
3780
- /** The add button's text. Say what is added — "Add loop", "Add report" says
3781
- * more than "Add". Default: `tabs.add` from the {@link UiKitProvider}. */
3782
- addLabel?: string;
3783
- /** Overrides for this strip's strings. See {@link TabsLabels}. */
3784
- labels?: Partial<TabsLabels>;
3785
- /** An add or a remove is in flight: the × and the add button are disabled and
3786
- * Delete is ignored, so a double click cannot remove two tabs. Nothing moves. */
3787
- busy?: boolean;
3788
- /**
3789
- * `"vertical"`: the same strip as a SIDE NAV — one item per row, the open one
3790
- * filled, ↑/↓ walking it (Home/End as before) and `aria-orientation="vertical"` on
3791
- * the tablist. For keksdose's admin page and settings page, which each hand-built
3792
- * the identical sidebar with none of this strip's keyboard, roving tab stop or
3793
- * routed-`href` handling. Everything else — `href` tabs, `badge`, `panelId`,
3794
- * `onRemove`, `onAdd` — means exactly what it means on the horizontal strip; the
3795
- * badge moves to the row's end.
3796
- *
3797
- * **On a phone (`usePhoneLayout()`) it becomes the horizontal strip**, `wrap` and
3798
- * all, rather than staying a column. A side nav only works beside its content; on
3799
- * a phone it has to go ABOVE it, and eight full-width rows there push the panel
3800
- * the user picked below the fold on every visit — the two keksdose pages both
3801
- * collapsed theirs into a scrolling row by hand for exactly that reason. The
3802
- * orientation is switched in JavaScript, not with `md:` classes, so the
3803
- * `aria-orientation` a screen reader hears and the arrow keys that work always
3804
- * match the layout on screen. The caller's own two-column layout has to stack at
3805
- * the same breakpoint (`md:grid-cols-[14rem_1fr]`).
3806
- */
3807
- orientation?: "horizontal" | "vertical";
3808
- }
3809
-
3810
- // The two shapes are written out as whole strings rather than as one base plus a
3811
- // pile of `md:` overrides. The unwrapped pair is character-for-character what the
3812
- // three strips that do not opt in get, so those cannot drift; the wrapped pair is
3813
- // authored from scratch, so no unprefixed utility has to be beaten by its own `md:`
3814
- // twin through tailwind-merge. The only ordering this relies on is Tailwind's own:
3815
- // unprefixed utilities are emitted first, then `md:` — so from 768px up the wrapped
3816
- // strip resolves to exactly the paint of the default one. Both themes come free now
3817
- // that the colours are tokens: there is no `md:dark:` tier left to keep in step.
3818
- const TABLIST_CLASSES =
3819
- "flex gap-1 overflow-x-auto overflow-y-hidden border-b border-[var(--border)]";
3820
- // The side nav. A column of full-width rows with the open one FILLED rather than
3821
- // underlined: an underline under one row of a list reads as a separator, not as a
3822
- // selection. `--bg-active` is the kit's own selected-row fill (the sidebar's), so the
3823
- // admin page's nav and the app's own sidebar mark "you are here" the same way.
3824
- const TABLIST_VERTICAL_CLASSES = "flex flex-col gap-0.5";
3825
- const TAB_VERTICAL_CLASSES =
3826
- FOCUS_RING_WIDTH +
3827
- " flex w-full items-center rounded-md px-3 py-2 text-start text-sm font-medium transition-colors focus:outline-none focus-visible:ring-[var(--border-strong)]";
3828
- const TAB_VERTICAL_ACTIVE_CLASSES = "bg-[var(--bg-active)] text-[var(--text-primary)]";
3829
- const TAB_VERTICAL_INACTIVE_CLASSES =
3830
- "text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]";
3831
-
3832
- const TABLIST_WRAP_CLASSES =
3833
- "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)]";
3834
-
3835
- // `max-w-full`: no tab outgrows its strip. A sheet's `detail` line ("Spurstangenkraft
3836
- // links · … · 31 Punkte") made one tab 448px wide in a 390px strip, and a tab wider
3837
- // than the strip cannot be scrolled into view — its end was simply off screen
3838
- // (lenkbank L3). The label and detail truncate inside it instead; see `inner`.
3839
- const TAB_CLASSES =
3840
- FOCUS_RING_WIDTH +
3841
- " max-w-full whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none focus-visible:ring-[var(--border-strong)]";
3842
- const TAB_ACTIVE_CLASSES = "border-[var(--text-primary)] text-[var(--text-primary)]";
3843
- const TAB_INACTIVE_CLASSES =
3844
- "border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-strong)]";
3845
-
3846
- // Below `md`, a chip: `py-2` on `text-xs` is exactly 32px tall, and ten German
3847
- // report labels then land in three rows on a 406px screen (four at `text-sm`). The
3848
- // active fill is the app's own selected pair, `--brand` / `--brand-contrast`: the
3849
- // palette store writes both halves of it together for every preset and both themes,
3850
- // so the contrast holds everywhere (5.1:1 at its worst preset) without a hard-coded
3851
- // colour. Inactive chips take the raised card surface with full-strength body text —
3852
- // all ten have to stay readable; it is the FILL, not the text weight, that says
3853
- // which one is open.
3854
- const TAB_WRAP_CLASSES =
3855
- FOCUS_RING_WIDTH +
3856
- " max-w-full whitespace-nowrap rounded-md px-2.5 py-2 text-xs font-medium transition-colors focus:outline-none focus-visible: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";
3857
- const TAB_WRAP_ACTIVE_CLASSES =
3858
- "bg-[var(--brand)] text-[var(--brand-contrast)] md:border-[var(--text-primary)] md:text-[var(--text-primary)]";
3859
- const TAB_WRAP_INACTIVE_CLASSES =
3860
- "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)]";
3861
-
3862
- // The add/remove chrome. The × reserves its gutter on every removable tab (see
3863
- // `onRemove`); `md:pe-8` is spelled out because the wrapped chip's `md:px-3` would
3864
- // otherwise hand the gutter back from 768px up.
3865
- const TAB_REMOVABLE_CLASSES = "pe-8 md:pe-8";
3866
- const TAB_EMPTY_CLASSES = "-mx-1.5 rounded border border-dashed border-[var(--border-strong)] px-1.5";
3867
- const TAB_ADD_CLASSES =
3868
- FOCUS_RING_WIDTH +
3869
- " inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)] focus:outline-none focus-visible:ring-[var(--border-strong)] disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4";
3870
- const TAB_ADD_WRAP_CLASSES = "px-2.5 text-xs md:px-3 md:text-sm";
3871
- // `relative`: the phone's visually hidden label needs a positioned ancestor (see the
3872
- // sr-only rule in CONTRIBUTING); `max-md:px-2` squares the lone "+".
3873
- const TAB_ADD_PHONE_CLASSES = "relative max-md:px-2";
3874
-
3875
-
3876
- export function Tabs<T extends string>({
3877
- tabs,
3878
- active,
3879
- onChange,
3880
- className,
3881
- wrap = false,
3882
- label,
3883
- panelId,
3884
- onRemove,
3885
- removeOn = "every",
3886
- onAdd,
3887
- addLabel,
3888
- labels,
3889
- busy = false,
3890
- orientation = "horizontal",
3891
- "aria-label": ariaLabel,
3892
- ...rest
3893
- }: TabsProps<T>) {
3894
- const text = useKitLabels("tabs", DEFAULT_TABS_LABELS, labels);
3895
- // See `orientation`: a vertical strip is the horizontal one on a phone.
3896
- const phone = usePhoneLayout();
3897
- const vertical = orientation === "vertical" && !phone;
3898
- const stripRef = useRef<HTMLDivElement>(null);
3899
- const fade = useStripFade(stripRef);
3900
- // The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a
3901
- // deep link to the seventh report. A selection behind the cut edge was the worst
3902
- // case of the overflow above: the one tab the panel below belongs to, sliced to
3903
- // "80 km," under the add button. Only the strip scrolls (`scrollBy` on it, not
3904
- // `scrollIntoView`, which would scroll the page as well), and only as far as needed
3905
- // to clear the fade.
3906
- useLayoutEffect(() => {
3907
- const strip = stripRef.current;
3908
- const tab = strip?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]');
3909
- if (strip && tab) scrollIntoStrip(strip, tab);
3910
- }, [active]);
3911
- const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;
3912
- // Whether Delete reaches this tab: every removable tab, or only the open one.
3913
- const deletesOnKey = (tab: TabItem<T>) =>
3914
- isRemovable(tab) && (removeOn === "every" || tab.id === active);
3915
- const nameOf = (tab: TabItem<T>) =>
3916
- tab.name ?? (typeof tab.label === "string" ? tab.label : tab.id);
3917
-
3918
- // Where focus goes once a removal the strip asked for has happened. The caller
3919
- // owns `tabs` and may take its time (a confirmation, a request), so this waits
3920
- // for the id to actually leave the list rather than guessing at the moment.
3921
- const pendingFocus = useRef<{ removed: T; wasActive: boolean; neighbour?: T } | null>(null);
3922
- const requestRemove = (id: T) => {
3923
- const index = tabs.findIndex((t) => t.id === id);
3924
- pendingFocus.current = {
3925
- removed: id,
3926
- wasActive: id === active,
3927
- neighbour: (tabs[index + 1] ?? tabs[index - 1])?.id,
3928
- };
3929
- onRemove?.(id);
3930
- };
3931
- useLayoutEffect(() => {
3932
- const pending = pendingFocus.current;
3933
- if (!pending || tabs.some((t) => t.id === pending.removed)) return;
3934
- pendingFocus.current = null;
3935
- const strip = stripRef.current;
3936
- if (!strip) return;
3937
- // Only focus that fell out of the strip with the removed tab is ours to place.
3938
- // If the user has moved on — into the panel, into a dialog — leave it there.
3939
- const focused = document.activeElement;
3940
- if (focused && focused !== document.body && !strip.contains(focused)) return;
3941
- // The removed tab was open: focus follows the caller's new selection, which is
3942
- // also the strip's tab stop. It was not: its neighbour, as the ARIA pattern has it.
3943
- const target =
3944
- !pending.wasActive && tabs.some((t) => t.id === pending.neighbour)
3945
- ? pending.neighbour
3946
- : active;
3947
- const index = tabs.findIndex((t) => t.id === target);
3948
- strip.querySelectorAll<HTMLElement>('[role="tab"]')[index]?.focus();
3949
- }, [tabs, active]);
3950
-
3951
- // Arrow keys walk the strip in DOM order (ARIA tabs pattern), which is what keeps
3952
- // a WRAPPED strip navigable: the rows flow in DOM order too, so Right off the end
3953
- // of row one lands on the first chip of row two rather than nowhere. Home/End jump
3954
- // to the ends. Focus only — activation stays on click/Enter/Space, because a tab
3955
- // here can be a real route and moving focus must not navigate.
3956
- //
3957
- // On the TABS, not on the tablist. The container carried it, which left a `<div>`
3958
- // wearing an interactive role and a key handler while being unfocusable —
3959
- // `jsx-a11y/interactive-supports-focus`, and the only instance of it left in `src/`.
3960
- // The fix that rule wants is `tabIndex` on the div; the fix the pattern wants is
3961
- // the handler on the elements that are natively focusable and actually receive the
3962
- // keystroke. Those are the tabs, so the warning goes away by being right rather
3963
- // than by being satisfied.
3964
- const onTabKeyDown = (e: KeyboardEvent<HTMLElement>, tab: TabItem<T>) => {
3965
- // Delete removes the FOCUSED tab (the ARIA pattern's optional key for deletable
3966
- // tabs) — the keyboard's way to the × that is not a tab stop. `busy` swallows it
3967
- // rather than letting it fall through to the page. With `removeOn="active"` only
3968
- // the open tab answers; a closed one lets the key pass untouched.
3969
- if (e.key === "Delete" && deletesOnKey(tab)) {
3970
- e.preventDefault();
3971
- if (!busy) requestRemove(tab.id);
3972
- return;
3973
- }
3974
- // Along the reading direction: the strip is a flex row, so in RTL the NEXT tab sits
3975
- // to the left, and ArrowLeft has to reach it. A vertical strip answers ↑/↓ instead
3976
- // — and ONLY those, as the pattern has it: ←/→ are left to the page, where a side
3977
- // nav's neighbour (the panel) may well want them.
3978
- const step: 1 | -1 | 0 = vertical
3979
- ? e.key === "ArrowDown"
3980
- ? 1
3981
- : e.key === "ArrowUp"
3982
- ? -1
3983
- : 0
3984
- : horizontalStep(e.key, e.currentTarget);
3985
- if (step === 0 && e.key !== "Home" && e.key !== "End") return;
3986
- const strip = e.currentTarget.closest('[role="tablist"]');
3987
- if (!strip) return;
3988
- const items = Array.from(strip.querySelectorAll<HTMLElement>('[role="tab"]'));
3989
- const from = items.indexOf(e.currentTarget);
3990
- if (from === -1) return;
3991
- e.preventDefault();
3992
- const to =
3993
- e.key === "Home"
3994
- ? 0
3995
- : e.key === "End"
3996
- ? items.length - 1
3997
- : (from + step + items.length) % items.length;
3998
- items[to]?.focus();
3999
- };
4000
- const tablist = (
4001
- <div
4002
- // Before the role and the name: a caller's arbitrary attribute is welcome on the
4003
- // strip, but a `role` or an `aria-label` arriving through a spread props object
4004
- // must not be able to unmake the tablist the tabs below are registered against.
4005
- {...rest}
4006
- ref={stripRef}
4007
- role="tablist"
4008
- // The DOM spelling wins over the deprecated `label`; see {@link TabsProps}.
4009
- aria-label={ariaLabel ?? label}
4010
- // Only when vertical: `horizontal` is the tablist's implicit value, and the
4011
- // attribute on every existing strip would be noise in every snapshot of them.
4012
- aria-orientation={vertical ? "vertical" : undefined}
4013
- // Merged over a caller's own style rather than replacing it; see useStripFade.
4014
- // `data-overflow` names the cut ends in reading-direction terms, for a caller's
4015
- // own affordance (a scroll button) and for tests, which cannot read the mask.
4016
- style={fade.mask ? { ...rest.style, maskImage: fade.mask, WebkitMaskImage: fade.mask } : rest.style}
4017
- data-overflow={fade.overflow}
4018
- // With an add button the strip gains an outer box, and `className` goes there
4019
- // — it is the box a caller's margin or width is meant for.
4020
- className={cn(
4021
- vertical ? TABLIST_VERTICAL_CLASSES : wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES,
4022
- onAdd ? "min-w-0" : className,
4023
- )}
4024
- >
4025
- {tabs.map((tab) => {
4026
- const isActive = tab.id === active;
4027
- const removable = isRemovable(tab);
4028
- // Written once and worn by either tag below, so a routed tab and a
4029
- // state-only tab stay indistinguishable to the eye and to a screen reader.
4030
- const shared = {
4031
- role: "tab" as const,
4032
- "aria-selected": isActive,
4033
- // Roving tabindex: the whole strip is ONE stop in the page's tab order, and
4034
- // it is the open tab. Ten report tabs otherwise cost ten Tab presses to step
4035
- // over on the way to the table below them (live #262's strip is the extreme
4036
- // case, but every strip in the app paid it).
4037
- //
4038
- // The stop follows the SELECTION, not the focus. Arrowing is a look around —
4039
- // activation here is manual, because a tab can be a real route — so re-
4040
- // pointing the stop at a report the user only arrowed past would hand the
4041
- // strip back on the next visit in a state they never chose. The cost is that
4042
- // Tab out and back returns to the open tab rather than to the last one
4043
- // looked at; the ARIA pattern allows either, and this one needs no second
4044
- // piece of state shadowing `active`.
4045
- tabIndex: isActive ? 0 : -1,
4046
- // Only the open tab: see `panelId`.
4047
- "aria-controls": isActive ? panelId : undefined,
4048
- id: isActive && panelId ? `${panelId}-tab` : undefined,
4049
- "aria-keyshortcuts": deletesOnKey(tab) ? "Delete" : undefined,
4050
- onKeyDown: (e: KeyboardEvent<HTMLElement>) => onTabKeyDown(e, tab),
4051
- className: vertical
4052
- ? cn(
4053
- TAB_VERTICAL_CLASSES,
4054
- isActive ? TAB_VERTICAL_ACTIVE_CLASSES : TAB_VERTICAL_INACTIVE_CLASSES,
4055
- removable && "pe-8",
4056
- )
4057
- : cn(
4058
- wrap ? TAB_WRAP_CLASSES : TAB_CLASSES,
4059
- wrap
4060
- ? isActive
4061
- ? TAB_WRAP_ACTIVE_CLASSES
4062
- : TAB_WRAP_INACTIVE_CLASSES
4063
- : isActive
4064
- ? TAB_ACTIVE_CLASSES
4065
- : TAB_INACTIVE_CLASSES,
4066
- removable && TAB_REMOVABLE_CLASSES,
4067
- ),
4068
- };
4069
- const inner = (
4070
- <span
4071
- className={cn(
4072
- // In a side nav the row is the full width, so the label takes it and a
4073
- // badge lands at the row's end, where a column of counts lines up.
4074
- vertical ? "flex min-w-0 flex-1 items-center gap-1.5" : "inline-flex max-w-full min-w-0 items-center gap-1.5",
4075
- tab.empty && TAB_EMPTY_CLASSES,
4076
- )}
4077
- >
4078
- {tab.icon != null && (
4079
- <span aria-hidden className="inline-flex shrink-0 items-center gap-0.5">
4080
- {tab.icon}
4081
- </span>
4082
- )}
4083
- {tab.detail != null ? (
4084
- // `min-w-0` + `truncate` on each line: capped by the tab's `max-w-full`, a
4085
- // long detail ends in an ellipsis. The whole text stays the tab's name.
4086
- <span className="flex min-w-0 flex-col items-start text-start">
4087
- <span className="max-w-full truncate">{tab.label}</span>
4088
- {/* A separator for the NAME only — whitespace between flex items is
4089
- not drawn, and without it the two lines ran together into one
4090
- word for a screen reader ("80 km/hloop 2"). */}{" "}
4091
- <span
4092
- className={cn(
4093
- "max-w-full truncate text-caption font-normal leading-tight text-[var(--text-muted)]",
4094
- // On a wrapped strip's filled chip (below `md`) the muted grey
4095
- // was dark text on the brand fill — "loop 2" all but vanished
4096
- // under the one label that most needed reading. It takes the
4097
- // chip's own ink instead, as the label and the × already do.
4098
- wrap && isActive && !vertical && "text-[var(--brand-contrast)] md:text-[var(--text-muted)]",
4099
- )}
4100
- >
4101
- {tab.detail}
4102
- </span>
4103
- </span>
4104
- ) : (
4105
- tab.label
4106
- )}
4107
- {tab.badge != null ? (
4108
- vertical ? <span className="ms-auto flex shrink-0 items-center">{tab.badge}</span> : tab.badge
4109
- ) : null}
4110
- </span>
4111
- );
4112
- const element = tab.href ? (
4113
- <a
4114
- key={tab.id}
4115
- {...shared}
4116
- href={tab.href}
4117
- draggable={false}
4118
- onClick={(e) => {
4119
- // Middle-, ⌘/Ctrl-, Shift- and Alt-click belong to the browser
4120
- // (feedback #451); only the plain left click is ours to cancel and
4121
- // route through `onChange`, which keeps the switch client-side.
4122
- if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
4123
- e.preventDefault();
4124
- onChange(tab.id);
4125
- }}
4126
- // A <button> tab activated on Space; an anchor does not, so becoming a
4127
- // link (feedback #451) would have quietly cost keyboard users the tab
4128
- // strip. Enter is deliberately untouched — it is the anchor's own
4129
- // default and already arrives at `onChange` through the click handler
4130
- // above, so handling it here too would switch tabs twice. Same three
4131
- // lines the mobile row card carries, for the same reason; the rejected
4132
- // alternative — keeping a button and faking the middle click with
4133
- // window.open — is what the whole item is moving away from.
4134
- //
4135
- // Composed with the strip's arrow-key walk rather than replacing it: this
4136
- // prop overrides the one in `shared`, and an anchor tab that lost the
4137
- // arrows would be a strip that is navigable only where it is not a link.
4138
- onKeyDown={(e) => {
4139
- onTabKeyDown(e, tab);
4140
- if (e.defaultPrevented || e.key !== " ") return;
4141
- e.preventDefault();
4142
- onChange(tab.id);
4143
- }}
4144
- >
4145
- {inner}
4146
- </a>
4147
- ) : (
4148
- <button key={tab.id} {...shared} onClick={() => onChange(tab.id)}>
4149
- {inner}
4150
- </button>
4151
- );
4152
- if (!removable) return element;
4153
- return (
4154
- // The × cannot go INSIDE the tab: a button in a button is invalid HTML, and
4155
- // a tab's children are presentational, so a reader would flatten it into the
4156
- // tab's name. It sits beside the tab, over the gutter the tab reserves.
4157
- <div key={tab.id} className={cn("relative flex max-w-full", !vertical && "shrink-0")}>
4158
- {element}
4159
- {isActive && (
4160
- <IconButton
4161
- size="2xs"
4162
- tone="danger"
4163
- // Not a tab stop: the strip is one, and Delete is the keyboard's path.
4164
- // Still a named button, so a screen reader's cursor and a touch
4165
- // reader's swipe both reach it.
4166
- tabIndex={-1}
4167
- type="button"
4168
- aria-label={text.remove(nameOf(tab))}
4169
- disabled={busy}
4170
- onClick={() => requestRemove(tab.id)}
4171
- className={cn(
4172
- "absolute inset-y-0 end-1 my-auto",
4173
- // On a wrapped strip's filled chip the quiet grey would vanish into
4174
- // the brand fill below `md`.
4175
- wrap && "text-[var(--brand-contrast)] md:text-[var(--text-placeholder)]",
4176
- )}
4177
- >
4178
- <X />
4179
- </IconButton>
4180
- )}
4181
- </div>
4182
- );
4183
- })}
4184
- </div>
4185
- );
4186
- if (!onAdd) return tablist;
4187
- if (vertical) {
4188
- // Under the column, not beside it, and with no rule to carry on: a side nav has none.
4189
- return (
4190
- <div className={cn("flex flex-col gap-0.5", className)}>
4191
- {tablist}
4192
- <button type="button" onClick={onAdd} disabled={busy} className={TAB_ADD_CLASSES}>
4193
- <Plus aria-hidden />
4194
- {addLabel ?? text.add}
4195
- </button>
4196
- </div>
4197
- );
4198
- }
4199
- return (
4200
- <div
4201
- className={cn(
4202
- wrap ? "flex flex-wrap items-end gap-1.5 md:flex-nowrap md:gap-0" : "flex items-end",
4203
- className,
4204
- )}
4205
- >
4206
- {tablist}
4207
- {/* Outside the tablist: an action, not a tab — and a tablist may own only
4208
- tabs. The rule under the strip carries on beneath it. */}
4209
- <div
4210
- className={cn(
4211
- "flex flex-1 items-center",
4212
- wrap
4213
- ? "md:self-stretch md:border-b md:border-[var(--border)]"
4214
- : "self-stretch border-b border-[var(--border)]",
4215
- )}
4216
- >
4217
- <button
4218
- type="button"
4219
- onClick={onAdd}
4220
- disabled={busy}
4221
- className={cn(TAB_ADD_CLASSES, wrap ? TAB_ADD_WRAP_CLASSES : TAB_ADD_PHONE_CLASSES)}
4222
- >
4223
- <Plus aria-hidden />
4224
- {/* Below md a scrolling strip keeps the room for tabs: the label is visually
4225
- hidden and the "+" carries it as its name. Beside "+ Add loop" a 390px strip
4226
- showed the open tab and a sliver of the next (showcase audit). A wrapping
4227
- strip has its own row there, so it keeps the label. */}
4228
- <span className={wrap ? undefined : "max-md:sr-only"}>{addLabel ?? text.add}</span>
4229
- </button>
4230
- </div>
4231
- </div>
4232
- );
4233
- }