@eifi1/ui-kit 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (397) hide show
  1. package/README.md +59 -34
  2. package/dist/chart.d.ts +3 -1
  3. package/dist/chart.js +2 -0
  4. package/dist/chart.js.map +1 -1
  5. package/dist/components/account-settings-labels.d.ts +105 -0
  6. package/dist/components/account-settings-labels.js +81 -0
  7. package/dist/components/account-settings-labels.js.map +1 -0
  8. package/dist/components/account-settings.d.ts +36 -52
  9. package/dist/components/account-settings.js +44 -8
  10. package/dist/components/account-settings.js.map +1 -1
  11. package/dist/components/amount-input.d.ts +40 -4
  12. package/dist/components/amount-input.js +50 -12
  13. package/dist/components/amount-input.js.map +1 -1
  14. package/dist/components/authed-image.d.ts +68 -0
  15. package/dist/components/authed-image.js +117 -0
  16. package/dist/components/authed-image.js.map +1 -0
  17. package/dist/components/breadcrumbs.d.ts +2 -1
  18. package/dist/components/breadcrumbs.js +5 -2
  19. package/dist/components/breadcrumbs.js.map +1 -1
  20. package/dist/components/bulk-action-bar.d.ts +10 -2
  21. package/dist/components/bulk-action-bar.js.map +1 -1
  22. package/dist/components/button-group.d.ts +51 -2
  23. package/dist/components/button-group.js +5 -1
  24. package/dist/components/button-group.js.map +1 -1
  25. package/dist/components/calculator.d.ts +15 -4
  26. package/dist/components/calendar-heatmap.d.ts +41 -3
  27. package/dist/components/calendar-heatmap.js +48 -7
  28. package/dist/components/calendar-heatmap.js.map +1 -1
  29. package/dist/components/chart.js +14 -8
  30. package/dist/components/chart.js.map +1 -1
  31. package/dist/components/chip.d.ts +2 -1
  32. package/dist/components/chip.js +26 -18
  33. package/dist/components/chip.js.map +1 -1
  34. package/dist/components/choice-card.d.ts +35 -3
  35. package/dist/components/choice-card.js +83 -25
  36. package/dist/components/choice-card.js.map +1 -1
  37. package/dist/components/combobox-core.d.ts +3 -1
  38. package/dist/components/combobox-core.js +18 -5
  39. package/dist/components/combobox-core.js.map +1 -1
  40. package/dist/components/combobox.d.ts +2 -2
  41. package/dist/components/combobox.js +31 -9
  42. package/dist/components/combobox.js.map +1 -1
  43. package/dist/components/copy-button.d.ts +51 -70
  44. package/dist/components/copy-button.js +1 -1
  45. package/dist/components/copy-button.js.map +1 -1
  46. package/dist/components/data-table-cells.d.ts +63 -0
  47. package/dist/components/data-table-cells.js +64 -0
  48. package/dist/components/data-table-cells.js.map +1 -0
  49. package/dist/components/data-table-filter-popover.d.ts +1 -1
  50. package/dist/components/data-table-filter-popover.js +8 -1
  51. package/dist/components/data-table-filter-popover.js.map +1 -1
  52. package/dist/components/data-table-filters.d.ts +1 -1
  53. package/dist/components/data-table-filters.js +31 -1
  54. package/dist/components/data-table-filters.js.map +1 -1
  55. package/dist/components/data-table-labels.d.ts +13 -0
  56. package/dist/components/data-table-labels.js +8 -1
  57. package/dist/components/data-table-labels.js.map +1 -1
  58. package/dist/components/data-table.d.ts +1 -1
  59. package/dist/components/data-table.js +125 -27
  60. package/dist/components/data-table.js.map +1 -1
  61. package/dist/components/date-picker.d.ts +3 -1
  62. package/dist/components/date-picker.js +15 -2
  63. package/dist/components/date-picker.js.map +1 -1
  64. package/dist/components/description-list.d.ts +23 -3
  65. package/dist/components/description-list.js +23 -3
  66. package/dist/components/description-list.js.map +1 -1
  67. package/dist/components/dialog-frame.d.ts +1 -0
  68. package/dist/components/entity-combobox.d.ts +1 -1
  69. package/dist/components/entity-combobox.js +40 -20
  70. package/dist/components/entity-combobox.js.map +1 -1
  71. package/dist/components/error-boundary.d.ts +281 -0
  72. package/dist/components/error-boundary.js +302 -0
  73. package/dist/components/error-boundary.js.map +1 -0
  74. package/dist/components/field-sync.js +90 -83
  75. package/dist/components/field-sync.js.map +1 -1
  76. package/dist/components/field.d.ts +30 -4
  77. package/dist/components/field.js +40 -17
  78. package/dist/components/field.js.map +1 -1
  79. package/dist/components/file-button.d.ts +50 -208
  80. package/dist/components/file-dropzone.d.ts +49 -2
  81. package/dist/components/floating-panel.d.ts +13 -4
  82. package/dist/components/floating-panel.js +7 -3
  83. package/dist/components/floating-panel.js.map +1 -1
  84. package/dist/components/form-actions.d.ts +53 -0
  85. package/dist/components/form-actions.js +98 -0
  86. package/dist/components/form-actions.js.map +1 -0
  87. package/dist/components/full-bleed-dialog.js +4 -0
  88. package/dist/components/full-bleed-dialog.js.map +1 -1
  89. package/dist/components/hover-menu.js +64 -56
  90. package/dist/components/hover-menu.js.map +1 -1
  91. package/dist/components/image-grid.d.ts +67 -0
  92. package/dist/components/image-grid.js +112 -0
  93. package/dist/components/image-grid.js.map +1 -0
  94. package/dist/components/lightbox.d.ts +104 -0
  95. package/dist/components/lightbox.js +229 -0
  96. package/dist/components/lightbox.js.map +1 -0
  97. package/dist/components/line-items.d.ts +114 -0
  98. package/dist/components/line-items.js +238 -0
  99. package/dist/components/line-items.js.map +1 -0
  100. package/dist/components/list.d.ts +37 -4
  101. package/dist/components/list.js +35 -14
  102. package/dist/components/list.js.map +1 -1
  103. package/dist/components/loading-state.d.ts +24 -0
  104. package/dist/components/loading-state.js +32 -0
  105. package/dist/components/loading-state.js.map +1 -0
  106. package/dist/components/menu-item.d.ts +2 -1
  107. package/dist/components/menu-item.js +5 -1
  108. package/dist/components/menu-item.js.map +1 -1
  109. package/dist/components/mini-calendar.d.ts +13 -1
  110. package/dist/components/mini-calendar.js +46 -2
  111. package/dist/components/mini-calendar.js.map +1 -1
  112. package/dist/components/modal.d.ts +20 -1
  113. package/dist/components/modal.js +26 -2
  114. package/dist/components/modal.js.map +1 -1
  115. package/dist/components/month-picker.d.ts +21 -1
  116. package/dist/components/month-picker.js +139 -26
  117. package/dist/components/month-picker.js.map +1 -1
  118. package/dist/components/multi-entity-combobox.d.ts +1 -1
  119. package/dist/components/multi-entity-combobox.js +42 -20
  120. package/dist/components/multi-entity-combobox.js.map +1 -1
  121. package/dist/components/nav-pills.d.ts +2 -1
  122. package/dist/components/nav-pills.js +5 -1
  123. package/dist/components/nav-pills.js.map +1 -1
  124. package/dist/components/number-field.d.ts +24 -5
  125. package/dist/components/number-field.js +5 -7
  126. package/dist/components/number-field.js.map +1 -1
  127. package/dist/components/number-input.d.ts +25 -5
  128. package/dist/components/number-input.js +7 -1
  129. package/dist/components/number-input.js.map +1 -1
  130. package/dist/components/numpad-sheet.d.ts +20 -5
  131. package/dist/components/numpad-sheet.js +3 -2
  132. package/dist/components/numpad-sheet.js.map +1 -1
  133. package/dist/components/page-header.d.ts +35 -6
  134. package/dist/components/page-header.js +7 -1
  135. package/dist/components/page-header.js.map +1 -1
  136. package/dist/components/passkeys-setting.d.ts +67 -0
  137. package/dist/components/passkeys-setting.js +214 -0
  138. package/dist/components/passkeys-setting.js.map +1 -0
  139. package/dist/components/picker-sheet.js +5 -1
  140. package/dist/components/picker-sheet.js.map +1 -1
  141. package/dist/components/pie-chart-labels.d.ts +20 -0
  142. package/dist/components/pie-chart-labels.js +12 -0
  143. package/dist/components/pie-chart-labels.js.map +1 -0
  144. package/dist/components/pie-chart.d.ts +101 -0
  145. package/dist/components/pie-chart.js +283 -0
  146. package/dist/components/pie-chart.js.map +1 -0
  147. package/dist/components/popover.js +3 -0
  148. package/dist/components/popover.js.map +1 -1
  149. package/dist/components/progress-bar.d.ts +44 -3
  150. package/dist/components/progress-bar.js +58 -22
  151. package/dist/components/progress-bar.js.map +1 -1
  152. package/dist/components/qr-code.d.ts +34 -0
  153. package/dist/components/qr-code.js +57 -0
  154. package/dist/components/qr-code.js.map +1 -0
  155. package/dist/components/search-field.d.ts +15 -3
  156. package/dist/components/search-field.js +5 -1
  157. package/dist/components/search-field.js.map +1 -1
  158. package/dist/components/settings-fields.d.ts +49 -1
  159. package/dist/components/signed-amount.d.ts +122 -0
  160. package/dist/components/signed-amount.js +152 -0
  161. package/dist/components/signed-amount.js.map +1 -0
  162. package/dist/components/skeleton.d.ts +10 -3
  163. package/dist/components/skeleton.js +12 -2
  164. package/dist/components/skeleton.js.map +1 -1
  165. package/dist/components/stat-tile.d.ts +2 -1
  166. package/dist/components/stat-tile.js +6 -3
  167. package/dist/components/stat-tile.js.map +1 -1
  168. package/dist/components/swipeable-row.js +4 -3
  169. package/dist/components/swipeable-row.js.map +1 -1
  170. package/dist/components/table.d.ts +39 -5
  171. package/dist/components/table.js +45 -11
  172. package/dist/components/table.js.map +1 -1
  173. package/dist/components/text-link.d.ts +238 -0
  174. package/dist/components/text-link.js +144 -0
  175. package/dist/components/text-link.js.map +1 -0
  176. package/dist/components/text.d.ts +26 -2
  177. package/dist/components/text.js +16 -2
  178. package/dist/components/text.js.map +1 -1
  179. package/dist/components/time-input.d.ts +49 -1
  180. package/dist/components/toggle-group.d.ts +35 -0
  181. package/dist/components/toggle-group.js +43 -14
  182. package/dist/components/toggle-group.js.map +1 -1
  183. package/dist/components/toggle-legend.d.ts +53 -3
  184. package/dist/components/toggle-legend.js +54 -1
  185. package/dist/components/toggle-legend.js.map +1 -1
  186. package/dist/components/tooltip.d.ts +51 -1
  187. package/dist/components/tooltip.js +59 -35
  188. package/dist/components/tooltip.js.map +1 -1
  189. package/dist/components/ui.d.ts +51 -779
  190. package/dist/components/ui.js +364 -34
  191. package/dist/components/ui.js.map +1 -1
  192. package/dist/components/use-table-state.d.ts +3 -52
  193. package/dist/components/use-table-state.js +128 -30
  194. package/dist/components/use-table-state.js.map +1 -1
  195. package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
  196. package/dist/data-table.d.ts +2 -1
  197. package/dist/data-table.js +5 -1
  198. package/dist/data-table.js.map +1 -1
  199. package/dist/feedback/feedback-attachment.js +4 -6
  200. package/dist/feedback/feedback-attachment.js.map +1 -1
  201. package/dist/feedback/feedback-dialog.d.ts +26 -3
  202. package/dist/feedback/feedback-dialog.js +21 -5
  203. package/dist/feedback/feedback-dialog.js.map +1 -1
  204. package/dist/feedback/feedback-inbox.js +86 -75
  205. package/dist/feedback/feedback-inbox.js.map +1 -1
  206. package/dist/feedback/feedback-thread.d.ts +173 -0
  207. package/dist/feedback/feedback-thread.js +264 -0
  208. package/dist/feedback/feedback-thread.js.map +1 -0
  209. package/dist/feedback.d.ts +2 -1
  210. package/dist/feedback.js +1 -0
  211. package/dist/feedback.js.map +1 -1
  212. package/dist/hooks/use-authed-src.d.ts +71 -0
  213. package/dist/hooks/use-authed-src.js +63 -0
  214. package/dist/hooks/use-authed-src.js.map +1 -0
  215. package/dist/hooks/use-file-drop.d.ts +49 -2
  216. package/dist/hooks/use-hotkey.d.ts +59 -0
  217. package/dist/hooks/use-hotkey.js +105 -0
  218. package/dist/hooks/use-hotkey.js.map +1 -0
  219. package/dist/hooks/use-search-param-state.d.ts +83 -0
  220. package/dist/hooks/use-search-param-state.js +94 -0
  221. package/dist/hooks/use-search-param-state.js.map +1 -0
  222. package/dist/i18n/defaults.d.ts +15 -4
  223. package/dist/i18n/defaults.js +31 -1
  224. package/dist/i18n/defaults.js.map +1 -1
  225. package/dist/i18n/kit-labels.d.ts +46 -312
  226. package/dist/i18n/kit-labels.js +20 -5
  227. package/dist/i18n/kit-labels.js.map +1 -1
  228. package/dist/i18n/locales/de-CH-informal.d.ts +15 -4
  229. package/dist/i18n/locales/de-CH.d.ts +15 -4
  230. package/dist/i18n/locales/de-informal.d.ts +15 -4
  231. package/dist/i18n/locales/de.d.ts +15 -4
  232. package/dist/i18n/locales/de.js +164 -3
  233. package/dist/i18n/locales/de.js.map +1 -1
  234. package/dist/i18n/locales/es.d.ts +15 -4
  235. package/dist/i18n/locales/es.js +161 -3
  236. package/dist/i18n/locales/es.js.map +1 -1
  237. package/dist/i18n/locales/fr.d.ts +15 -4
  238. package/dist/i18n/locales/fr.js +161 -3
  239. package/dist/i18n/locales/fr.js.map +1 -1
  240. package/dist/i18n/locales/hu.d.ts +15 -4
  241. package/dist/i18n/locales/hu.js +161 -3
  242. package/dist/i18n/locales/hu.js.map +1 -1
  243. package/dist/i18n/locales/it.d.ts +15 -4
  244. package/dist/i18n/locales/it.js +161 -3
  245. package/dist/i18n/locales/it.js.map +1 -1
  246. package/dist/i18n/locales/zh.d.ts +15 -4
  247. package/dist/i18n/locales/zh.js +161 -3
  248. package/dist/i18n/locales/zh.js.map +1 -1
  249. package/dist/index.d.ts +39 -19
  250. package/dist/index.js +40 -1
  251. package/dist/index.js.map +1 -1
  252. package/dist/kit-labels-Mz8zZHIF.d.ts +1744 -0
  253. package/dist/lib/format.d.ts +162 -0
  254. package/dist/lib/format.js +151 -0
  255. package/dist/lib/format.js.map +1 -0
  256. package/dist/lib/qr-encode.d.ts +44 -0
  257. package/dist/lib/qr-encode.js +338 -0
  258. package/dist/lib/qr-encode.js.map +1 -0
  259. package/dist/rhf/fields.d.ts +283 -0
  260. package/dist/rhf/fields.js +631 -0
  261. package/dist/rhf/fields.js.map +1 -0
  262. package/dist/rhf/form.d.ts +49 -1
  263. package/dist/rhf/line-items.d.ts +31 -0
  264. package/dist/rhf/line-items.js +59 -0
  265. package/dist/rhf/line-items.js.map +1 -0
  266. package/dist/rhf.d.ts +61 -1
  267. package/dist/rhf.js +2 -0
  268. package/dist/rhf.js.map +1 -1
  269. package/dist/search/command-palette.js +20 -14
  270. package/dist/search/command-palette.js.map +1 -1
  271. package/dist/shell/app-shell.d.ts +99 -2
  272. package/dist/shell/app-shell.js +120 -57
  273. package/dist/shell/app-shell.js.map +1 -1
  274. package/dist/shell/auth-layout.d.ts +74 -0
  275. package/dist/shell/auth-layout.js +97 -0
  276. package/dist/shell/auth-layout.js.map +1 -0
  277. package/dist/shell/top-bar-brand.d.ts +81 -0
  278. package/dist/shell/top-bar-brand.js +47 -0
  279. package/dist/shell/top-bar-brand.js.map +1 -0
  280. package/dist/shell/topbar-action-menu.d.ts +21 -4
  281. package/dist/shell/topbar-action-menu.js +18 -2
  282. package/dist/shell/topbar-action-menu.js.map +1 -1
  283. package/dist/shell.d.ts +56 -1
  284. package/dist/shell.js +2 -0
  285. package/dist/shell.js.map +1 -1
  286. package/dist/tour/tour.js +10 -4
  287. package/dist/tour/tour.js.map +1 -1
  288. package/dist/wizard/stepper-nav.d.ts +48 -1
  289. package/dist/wizard/use-wizard.js +4 -2
  290. package/dist/wizard/use-wizard.js.map +1 -1
  291. package/dist/wizard/wizard-context.d.ts +24 -1
  292. package/dist/wizard/wizard-context.js +19 -2
  293. package/dist/wizard/wizard-context.js.map +1 -1
  294. package/dist/wizard/wizard-step.d.ts +30 -5
  295. package/dist/wizard/wizard-step.js +34 -3
  296. package/dist/wizard/wizard-step.js.map +1 -1
  297. package/dist/wizard.d.ts +50 -3
  298. package/package.json +6 -6
  299. package/src/chart.ts +4 -0
  300. package/src/components/account-settings-labels.ts +190 -0
  301. package/src/components/account-settings.tsx +110 -63
  302. package/src/components/amount-input.tsx +109 -10
  303. package/src/components/authed-image.tsx +190 -0
  304. package/src/components/breadcrumbs.tsx +8 -2
  305. package/src/components/bulk-action-bar.tsx +16 -3
  306. package/src/components/button-group.tsx +7 -2
  307. package/src/components/calendar-heatmap.tsx +105 -10
  308. package/src/components/chart.tsx +19 -8
  309. package/src/components/chip.tsx +14 -4
  310. package/src/components/choice-card.tsx +136 -25
  311. package/src/components/combobox-core.tsx +33 -9
  312. package/src/components/combobox.tsx +33 -9
  313. package/src/components/copy-button.tsx +3 -2
  314. package/src/components/data-table-cells.tsx +119 -0
  315. package/src/components/data-table-filter-popover.tsx +13 -5
  316. package/src/components/data-table-filters.ts +77 -0
  317. package/src/components/data-table-labels.ts +23 -0
  318. package/src/components/data-table.tsx +230 -13
  319. package/src/components/date-picker.tsx +19 -3
  320. package/src/components/description-list.tsx +52 -3
  321. package/src/components/entity-combobox.tsx +25 -7
  322. package/src/components/error-boundary.tsx +640 -0
  323. package/src/components/field-sync.tsx +48 -40
  324. package/src/components/field.tsx +56 -5
  325. package/src/components/floating-panel.tsx +20 -7
  326. package/src/components/form-actions.tsx +191 -0
  327. package/src/components/full-bleed-dialog.tsx +4 -0
  328. package/src/components/hover-menu.tsx +26 -17
  329. package/src/components/image-grid.tsx +181 -0
  330. package/src/components/lightbox.tsx +374 -0
  331. package/src/components/line-items.tsx +385 -0
  332. package/src/components/list.tsx +82 -19
  333. package/src/components/loading-state.tsx +47 -0
  334. package/src/components/menu-item.tsx +7 -2
  335. package/src/components/mini-calendar.tsx +72 -3
  336. package/src/components/modal.tsx +65 -3
  337. package/src/components/month-picker.tsx +181 -26
  338. package/src/components/multi-entity-combobox.tsx +27 -7
  339. package/src/components/nav-pills.tsx +8 -1
  340. package/src/components/number-field.tsx +15 -10
  341. package/src/components/number-input.tsx +17 -1
  342. package/src/components/numpad-sheet.tsx +6 -1
  343. package/src/components/page-header.tsx +42 -5
  344. package/src/components/passkeys-setting.tsx +331 -0
  345. package/src/components/picker-sheet.tsx +10 -1
  346. package/src/components/pie-chart-labels.ts +32 -0
  347. package/src/components/pie-chart.tsx +479 -0
  348. package/src/components/popover.tsx +3 -0
  349. package/src/components/progress-bar.tsx +129 -19
  350. package/src/components/qr-code.tsx +83 -0
  351. package/src/components/search-field.tsx +20 -4
  352. package/src/components/signed-amount.tsx +295 -0
  353. package/src/components/skeleton.tsx +21 -3
  354. package/src/components/stat-tile.tsx +11 -3
  355. package/src/components/swipeable-row.tsx +7 -7
  356. package/src/components/table.tsx +102 -13
  357. package/src/components/text-link.tsx +356 -0
  358. package/src/components/text.tsx +46 -2
  359. package/src/components/toggle-group.tsx +93 -20
  360. package/src/components/toggle-legend.tsx +108 -2
  361. package/src/components/tooltip.tsx +79 -8
  362. package/src/components/ui.tsx +707 -49
  363. package/src/components/use-table-state.ts +265 -33
  364. package/src/data-table.ts +10 -0
  365. package/src/feedback/feedback-attachment.tsx +10 -7
  366. package/src/feedback/feedback-dialog.tsx +49 -7
  367. package/src/feedback/feedback-inbox.tsx +7 -0
  368. package/src/feedback/feedback-thread.tsx +510 -0
  369. package/src/feedback.ts +1 -0
  370. package/src/hooks/use-authed-src.ts +161 -0
  371. package/src/hooks/use-hotkey.ts +172 -0
  372. package/src/hooks/use-search-param-state.ts +187 -0
  373. package/src/i18n/defaults.ts +30 -0
  374. package/src/i18n/kit-labels.tsx +118 -8
  375. package/src/i18n/locales/de.ts +161 -0
  376. package/src/i18n/locales/es.ts +158 -0
  377. package/src/i18n/locales/fr.ts +161 -0
  378. package/src/i18n/locales/hu.ts +159 -0
  379. package/src/i18n/locales/it.ts +159 -0
  380. package/src/i18n/locales/zh.ts +158 -0
  381. package/src/index.ts +59 -0
  382. package/src/lib/format.ts +348 -0
  383. package/src/lib/qr-encode.ts +420 -0
  384. package/src/rhf/fields.tsx +1135 -0
  385. package/src/rhf/line-items.tsx +130 -0
  386. package/src/rhf.ts +6 -0
  387. package/src/search/command-palette.tsx +29 -19
  388. package/src/shell/app-shell.tsx +153 -3
  389. package/src/shell/auth-layout.tsx +190 -0
  390. package/src/shell/top-bar-brand.tsx +76 -0
  391. package/src/shell/topbar-action-menu.tsx +32 -4
  392. package/src/shell.ts +2 -0
  393. package/src/tour/tour.tsx +16 -5
  394. package/src/wizard/use-wizard.ts +8 -4
  395. package/src/wizard/wizard-context.tsx +41 -1
  396. package/src/wizard/wizard-step.tsx +75 -6
  397. package/tokens.css +12 -0
@@ -0,0 +1,356 @@
1
+ import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref } from "react";
2
+ import { ExternalLink } from "lucide-react";
3
+ import type { LucideIcon } from "lucide-react";
4
+ import { cn } from "../lib/cn";
5
+ import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-labels";
6
+ import type { KitLinkComponent } from "../i18n/kit-labels";
7
+
8
+ /* ── Choosing what draws a link ──────────────────────────────────────────── */
9
+
10
+ /**
11
+ * An `href` that leaves the app: a scheme (`https:`, `mailto:`, `tel:`) or a
12
+ * protocol-relative `//host`. A router's link is for paths inside the app, so the
13
+ * provider's `linkComponent` is never handed one of these — they stay a plain `<a>`.
14
+ *
15
+ * NOT external, and so handed to the router link: a path (`/x`, `x`, `?q=1`) and the
16
+ * hash-router form `#/x` — that IS a route in a `HashRouter` app, which maps it to
17
+ * `to="/x"`. A plain in-page anchor (`#section`) is not external either, but it is not
18
+ * a route: see {@link isInPageAnchor}.
19
+ */
20
+ export function isExternalHref(href: string): boolean {
21
+ return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(href);
22
+ }
23
+
24
+ /**
25
+ * An `href` that only scrolls the current page: `#section` (or a bare `#`). The
26
+ * hash-router form `#/x` is a ROUTE, not an anchor, and is not one of these. A router
27
+ * link must not get an anchor — under a `HashRouter` `#section` would navigate to the
28
+ * path `/section` — so every kit link draws it as a plain `<a>`.
29
+ */
30
+ export function isInPageAnchor(href: string): boolean {
31
+ return href.startsWith("#") && !href.startsWith("#/");
32
+ }
33
+
34
+ /**
35
+ * Whether the provider's `linkComponent` (or a kit component's router fallback) draws
36
+ * `href`: an in-app path, `#/x` included — not an external `href`
37
+ * ({@link isExternalHref}) and not an in-page `#anchor` ({@link isInPageAnchor}).
38
+ */
39
+ export function isRoutableHref(href: string): boolean {
40
+ return !isExternalHref(href) && !isInPageAnchor(href);
41
+ }
42
+
43
+ /**
44
+ * What draws a kit link to `href`: the component's own `renderLink` when it was given
45
+ * one (it always wins — the caller asked for it by name), else the
46
+ * `<UiKitProvider linkComponent>` for a routable `href` ({@link isRoutableHref}), else
47
+ * `undefined` — a plain `<a>`. The one rule every kit link follows: `https:` /
48
+ * `mailto:` / `//host` and `#section` stay plain anchors; `/x`, `x` and a hash-router
49
+ * `#/x` go to the router link. Every kit link props type is a subset of the anchor
50
+ * attributes `KitLinkProps` describes, which is what makes the cast sound.
51
+ * @internal Shared by the kit's link-bearing components; not part of the barrel.
52
+ */
53
+ export function pickLinkRenderer<P>(
54
+ own: ((props: P) => ReactElement) | undefined,
55
+ kitLink: KitLinkComponent | undefined,
56
+ href: string | undefined,
57
+ ): ((props: P) => ReactElement) | undefined {
58
+ if (own) return own;
59
+ if (!kitLink || href === undefined || !isRoutableHref(href)) return undefined;
60
+ return kitLink as unknown as (props: P) => ReactElement;
61
+ }
62
+
63
+ /** The plain primary click a page navigates on — not a middle click, not ⌘ / Ctrl /
64
+ * Shift / Alt (new tab, new window, download), and not one a handler already took. */
65
+ function isPlainNavigationClick(event: MouseEvent<HTMLAnchorElement>): boolean {
66
+ return (
67
+ !event.defaultPrevented &&
68
+ event.button === 0 &&
69
+ !event.metaKey &&
70
+ !event.ctrlKey &&
71
+ !event.shiftKey &&
72
+ !event.altKey
73
+ );
74
+ }
75
+
76
+ /**
77
+ * The `onClick` of a PLAIN `<a>` whose kit link was asked to `replace` (keksdose F1).
78
+ *
79
+ * A router link replaces the history entry itself (it is handed `replace`, see
80
+ * {@link routerLinkNavigation}); a plain `<a>` has no attribute that says so, and the
81
+ * browser's own navigation always pushes. So a plain click is taken over — the caller's
82
+ * `onClick` runs first and may still `preventDefault` — and becomes
83
+ * `location.replace(href)`: the same document load, with Back skipping the one-shot URL.
84
+ * That is the case of `replace` together with `reloadDocument`, or an app with no
85
+ * `linkComponent`. A modified click (new tab) and a `target` other than `_self` are left
86
+ * to the browser: they do not navigate THIS history. Without `replace`, `onClick` comes
87
+ * back untouched.
88
+ * @internal
89
+ */
90
+ export function replacingClick(
91
+ onClick: ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined,
92
+ href: string,
93
+ replace: boolean | undefined,
94
+ target?: string,
95
+ ): ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined {
96
+ if (!replace || (target !== undefined && target !== "" && target !== "_self")) return onClick;
97
+ return (event) => {
98
+ onClick?.(event);
99
+ if (!isPlainNavigationClick(event)) return;
100
+ event.preventDefault();
101
+ window.location.replace(href);
102
+ };
103
+ }
104
+
105
+ /**
106
+ * What a ROUTER link is handed for `replace`: `{ replace: true }` when asked, else
107
+ * nothing at all — so a provider link that spreads its props onto a DOM `<a>` never
108
+ * receives an unknown attribute from a link that did not ask (see `KitLinkProps.replace`).
109
+ * @internal
110
+ */
111
+ export function routerLinkNavigation(replace: boolean | undefined): { replace?: true } {
112
+ return replace ? { replace: true } : {};
113
+ }
114
+
115
+ /**
116
+ * Draws `props` through `render` as a component of its own, so a router link's hooks
117
+ * belong to it and not to the kit component calling it — for the kit components that
118
+ * call their own `renderLink` inline and reach the provider's link through here.
119
+ * @internal
120
+ */
121
+ export function RenderedKitLink<P>({ render, props }: { render: (props: P) => ReactElement; props: P }) {
122
+ return render(props);
123
+ }
124
+
125
+ /* ── TextLink ─────────────────────────────────────────────────────────────── */
126
+
127
+ /**
128
+ * `brand` (default) for a link in running text, `muted` for footer and meta links
129
+ * that should not compete with the content, `danger` for a destructive one ("Leave
130
+ * group"), `inherit` for a link that takes its surroundings' colour.
131
+ *
132
+ * `primary` and `secondary` are the body and the secondary text colour, UNDERLINED
133
+ * (keksdose F3): a link that must read as part of the text around it — a name in a
134
+ * list row, a reference in a card's meta line — where `brand` repaints it in the accent
135
+ * and `muted` is a step too light to read as content. `secondary` darkens to the body
136
+ * colour under the pointer, as `muted` does; `primary` has nowhere darker to go, so its
137
+ * underline thickens instead.
138
+ *
139
+ * `warning` is the kit's `--warning` text colour, underlined (kastlan 46): a link inside
140
+ * a `<Tone tone="warning">` sentence or a warning banner, which `brand` would paint in a
141
+ * second hue and `inherit` would leave indistinguishable from the sentence. Like
142
+ * `primary`, it keeps its colour on hover and thickens the underline.
143
+ *
144
+ * The three colours that sit IN the text default to `underline="always"`: with the
145
+ * colour of their surroundings, the underline is the only thing that says "link"
146
+ * (WCAG 1.4.1). An explicit `underline` still wins.
147
+ */
148
+ export type TextLinkTone = "brand" | "muted" | "danger" | "inherit" | "primary" | "secondary" | "warning";
149
+
150
+ /** `hover` (the default for `brand`, `muted`, `danger`, `inherit`): underlined under
151
+ * the pointer — the idiom all three apps draw. `always` (the default for `primary`,
152
+ * `secondary`, `warning`) for a link inside prose, where colour alone must not be the
153
+ * only mark (WCAG 1.4.1). `none` where the context already says "link" (a footer nav). */
154
+ export type TextLinkUnderline = "hover" | "always" | "none";
155
+
156
+ /** The `aria-current` token. `true` on {@link TextLinkProps.current} means `page`. */
157
+ export type TextLinkCurrent = "page" | "step" | "location" | "date" | "time" | "true";
158
+
159
+ /** What {@link TextLinkProps.renderLink} (and the provider's `linkComponent`) is
160
+ * handed. Spread it onto your router's link — `({ href, ...p }) => <Link to={href} {...p} />`. */
161
+ export interface TextLinkRenderProps {
162
+ href: string;
163
+ /** The link's look — keep it, or the tone, underline and focus ring go. */
164
+ className: string;
165
+ children: ReactNode;
166
+ ref?: Ref<HTMLAnchorElement>;
167
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
168
+ onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;
169
+ "aria-current"?: TextLinkCurrent;
170
+ id?: string;
171
+ title?: string;
172
+ /** Present, as `true`, only when the link was given `replace` — see
173
+ * `KitLinkProps.replace`. Map it to your router's replace; never onto a DOM `<a>`. */
174
+ replace?: boolean;
175
+ [key: `aria-${string}`]: string | boolean | number | undefined;
176
+ [key: `data-${string}`]: unknown;
177
+ }
178
+
179
+ export interface TextLinkProps
180
+ extends Omit<ComponentPropsWithoutRef<"a">, "href" | "children" | "aria-current" | "onClick" | "onKeyDown"> {
181
+ href: string;
182
+ children: ReactNode;
183
+ /** Default `brand`. */
184
+ tone?: TextLinkTone;
185
+ /** Default `always` for `primary`, `secondary` and `warning`, else `hover`. */
186
+ underline?: TextLinkUnderline;
187
+ /**
188
+ * Navigate by replacing the current history entry, so Back does not return to this
189
+ * URL — keksdose F1, a one-shot URL (a confirmation landing, a `?done=1` hop). Handed
190
+ * to the router link as `replace` (see `KitLinkProps.replace` for mapping it); on a
191
+ * plain `<a>` (no `linkComponent`, or `reloadDocument`) a plain click becomes
192
+ * `location.replace(href)`. Ignored on an `external` link, which opens a new tab.
193
+ */
194
+ replace?: boolean;
195
+ /**
196
+ * A plain `<a>` for an in-app `href`: the browser loads the whole document instead of
197
+ * the router swapping the view — keksdose F1, a route served outside the SPA (a file
198
+ * download, a server-rendered page, a logout that must drop every in-memory cache).
199
+ * Wins over `renderLink` and the provider's `linkComponent`: it is the caller saying
200
+ * "not the router" for this one link.
201
+ */
202
+ reloadDocument?: boolean;
203
+ /**
204
+ * The link leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`
205
+ * (never the router's), an external-link mark after the text (mirrored in RTL), and
206
+ * "opens in a new tab" (`common.opensInNewTab`) read after it — the mark says it
207
+ * only to the eye. kastlan's BWO / BFS source links (rent-increase-calculator-page).
208
+ */
209
+ external?: boolean;
210
+ /**
211
+ * Keep the click (and the Enter that activates the link) from reaching an ancestor —
212
+ * a link in a DataTable row with `onRowClick` goes to its own target instead of
213
+ * running the row's action. kastlan's `CellLink` did this by hand.
214
+ */
215
+ stopPropagation?: boolean;
216
+ /** Mark the link as the current one of a set — `true` is `aria-current="page"`, the
217
+ * legal footer's link to the page you are on. Drawn in semibold. */
218
+ current?: boolean | TextLinkCurrent;
219
+ /** A Lucide icon before the text, at the text's size — kastlan's "← Back" link. */
220
+ icon?: LucideIcon;
221
+ /** Your router's link for an in-app `href`. Default: the `<UiKitProvider
222
+ * linkComponent>`, then a plain `<a>`. Ignored on an `external` link. */
223
+ renderLink?: (props: TextLinkRenderProps) => ReactElement;
224
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
225
+ onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;
226
+ ref?: Ref<HTMLAnchorElement>;
227
+ }
228
+
229
+ const TONE: Record<TextLinkTone, string> = {
230
+ brand: "text-[var(--brand)] hover:text-[var(--brand-hover)]",
231
+ muted: "text-[var(--text-muted)] hover:text-[var(--text-primary)]",
232
+ danger: "text-[var(--danger)] hover:text-[var(--danger-hover)]",
233
+ inherit: "",
234
+ primary: "text-[var(--text-primary)] hover:decoration-2",
235
+ secondary: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
236
+ warning: "text-[var(--warning)] hover:decoration-2",
237
+ };
238
+
239
+ /** The tones whose colour is their surroundings' — underlined by default; see
240
+ * {@link TextLinkTone}. */
241
+ const UNDERLINED_BY_DEFAULT = new Set<TextLinkTone>(["primary", "secondary", "warning"]);
242
+
243
+ const UNDERLINE: Record<TextLinkUnderline, string> = {
244
+ hover: "no-underline hover:underline",
245
+ always: "underline",
246
+ none: "no-underline",
247
+ };
248
+
249
+ /**
250
+ * The one inline link: a real `<a href>` (middle click, "open in new tab", link
251
+ * semantics) through the app's router for an in-app path, in one of four tones.
252
+ *
253
+ * kastlan draws this link a dozen ways by hand — `EntityLink` and `CellLink` (text-primary,
254
+ * hover underline, one stopping the row click), the legal pages' back link and footer,
255
+ * the feedback page's URL links, the calculator's external sources with a hand-placed
256
+ * ↗ and no word for a screen reader — and the lease wizard's unit step (lease-unit-step)
257
+ * used a raw `<a>` to an in-app route, which reloads the whole app. Here the router
258
+ * link comes from `<UiKitProvider linkComponent>`, set once.
259
+ *
260
+ * Inline, not `inline-flex`: a link inside a sentence must wrap with it. The icons sit
261
+ * on the text's baseline at the text's size, so they scale with whatever type the link
262
+ * is set in. The focus ring is an outline, which follows every line of a wrapped link.
263
+ */
264
+ export function TextLink({
265
+ href,
266
+ children,
267
+ tone = "brand",
268
+ underline: underlineProp,
269
+ external = false,
270
+ replace,
271
+ reloadDocument = false,
272
+ stopPropagation = false,
273
+ current,
274
+ icon: Icon,
275
+ renderLink,
276
+ className,
277
+ onClick,
278
+ onKeyDown,
279
+ ref,
280
+ ...rest
281
+ }: TextLinkProps) {
282
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
283
+ const kitLink = useKitLink();
284
+ const underline = underlineProp ?? (UNDERLINED_BY_DEFAULT.has(tone) ? "always" : "hover");
285
+ const ariaCurrent: TextLinkCurrent | undefined =
286
+ current === true ? "page" : current === false ? undefined : current;
287
+
288
+ const look = cn(
289
+ "rounded-sm underline-offset-2 transition-colors",
290
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--brand)]",
291
+ TONE[tone],
292
+ UNDERLINE[underline],
293
+ ariaCurrent && "font-semibold",
294
+ className,
295
+ );
296
+
297
+ const body = (
298
+ <>
299
+ {Icon && <Icon aria-hidden className="me-1 inline size-[1em] shrink-0 align-[-0.125em]" />}
300
+ {children}
301
+ {external && (
302
+ <>
303
+ <ExternalLink aria-hidden className="ms-1 inline size-[0.85em] align-[-0.1em] rtl:-scale-x-100" />
304
+ {" "}
305
+ <span className="sr-only">({common.opensInNewTab})</span>
306
+ </>
307
+ )}
308
+ </>
309
+ );
310
+
311
+ const handleClick: TextLinkRenderProps["onClick"] = stopPropagation
312
+ ? (event) => {
313
+ event.stopPropagation();
314
+ onClick?.(event);
315
+ }
316
+ : onClick;
317
+ const props: TextLinkRenderProps = {
318
+ ...(rest as Partial<TextLinkRenderProps>),
319
+ ref,
320
+ href,
321
+ className: look,
322
+ "aria-current": ariaCurrent,
323
+ onClick: handleClick,
324
+ onKeyDown: stopPropagation
325
+ ? (event) => {
326
+ if (event.key === "Enter") event.stopPropagation();
327
+ onKeyDown?.(event);
328
+ }
329
+ : onKeyDown,
330
+ children: body,
331
+ };
332
+
333
+ const render = external || reloadDocument ? undefined : pickLinkRenderer(renderLink, kitLink, href);
334
+ if (render) return <RenderedTextLink render={render} {...props} {...routerLinkNavigation(replace)} />;
335
+ // A plain `<a>` never gets `replace` as an attribute; asked for, it takes the click.
336
+ const plainClick = external ? handleClick : replacingClick(handleClick, href, replace, rest.target);
337
+ const { children: content, ...anchor } = props;
338
+ return (
339
+ <a
340
+ {...anchor}
341
+ href={href}
342
+ onClick={plainClick}
343
+ {...(external ? { target: "_blank", rel: "noopener noreferrer" } : null)}
344
+ >
345
+ {content}
346
+ </a>
347
+ );
348
+ }
349
+
350
+ /** Calls the link renderer as a component, so a router link's hooks are its own. */
351
+ function RenderedTextLink({
352
+ render,
353
+ ...props
354
+ }: TextLinkRenderProps & { render: (props: TextLinkRenderProps) => ReactElement }) {
355
+ return render(props);
356
+ }
@@ -16,6 +16,14 @@ import { cn } from "../lib/cn";
16
16
  * (support-thread:153/223) at `text-[11px]` by hand — 10px read as a footnote under a
17
17
  * figure that size, 12px competed with it. Named `md` rather than slotted in order
18
18
  * because `sm` was already the default and renaming it would move every caller.
19
+ *
20
+ * `md` is also the heading over a CHART COLUMN (lenkbank P9): the small label centred
21
+ * over each of a pair of facing charts, or over each column of a small-multiples grid.
22
+ * There the label names a plot, not a section of the page, so `sm` (12px, the page's
23
+ * section heading) out-ranks the card title above it, and `xs` (10px, a legend's
24
+ * title) sinks below the axis ticks it sits beside. Rule of thumb: `xs` inside a
25
+ * control (legend, menu), `md` over a figure or a plot, `sm` over a block of page
26
+ * content.
19
27
  */
20
28
  export type SectionLabelSize = "xs" | "md" | "sm";
21
29
 
@@ -34,6 +42,31 @@ export const SECTION_LABEL_CLASS: Record<SectionLabelSize, string> = {
34
42
  sm: "text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]",
35
43
  };
36
44
 
45
+ /**
46
+ * `plain` (default): the label alone, as above.
47
+ *
48
+ * `band` (0.13, keksdose F5): the label on a full-width `surface-2` bar with a bottom
49
+ * border — the day-group header of keksdose's mobile transaction list
50
+ * (mobile-transaction-list.tsx:171), which separates one day's rows from the next.
51
+ * Its type is kept as keksdose drew it — `font-medium` in the SECONDARY text colour,
52
+ * not the plain label's semibold muted — so adopting the kit does not restyle a live
53
+ * list: the bar already sets the label apart, and needs no extra weight. The padding (`px-4 py-2`) lines the text up with a
54
+ * `ListItem density="comfortable"` row below it; override it with `className` for a
55
+ * list at another density. A sticky header is the caller's: add `sticky top-0`.
56
+ */
57
+ export type SectionLabelVariant = "plain" | "band";
58
+
59
+ /** The band's own type and box, per size — spelled out rather than merged over
60
+ * {@link SECTION_LABEL_CLASS}, so the result does not hang on tailwind-merge telling
61
+ * a font-size `text-[…]` from a colour `text-[var(…)]`. */
62
+ const SECTION_LABEL_BAND_CLASS: Record<SectionLabelSize, string> = {
63
+ xs: "text-[10px]",
64
+ md: "text-[11px]",
65
+ sm: "text-xs",
66
+ };
67
+ const BAND =
68
+ "block border-b border-[var(--border)] bg-[var(--bg-surface-2)] px-4 py-2 font-medium uppercase tracking-wide text-[var(--text-secondary)]";
69
+
37
70
  type SectionLabelElement = "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "div" | "legend";
38
71
 
39
72
  export interface SectionLabelProps extends ComponentPropsWithoutRef<"h3"> {
@@ -45,6 +78,9 @@ export interface SectionLabelProps extends ComponentPropsWithoutRef<"h3"> {
45
78
  */
46
79
  as?: SectionLabelElement;
47
80
  size?: SectionLabelSize;
81
+ /** `band` draws the label on a `surface-2` bar with a bottom border — a list's group
82
+ * header. See {@link SectionLabelVariant}. */
83
+ variant?: SectionLabelVariant;
48
84
  children: ReactNode;
49
85
  }
50
86
 
@@ -55,10 +91,18 @@ export interface SectionLabelProps extends ComponentPropsWithoutRef<"h3"> {
55
91
  * 10px version on a menu row. One component, the token colour, and the level chosen
56
92
  * where the outline is known.
57
93
  */
58
- export function SectionLabel({ as = "h3", size = "sm", className, children, ...rest }: SectionLabelProps) {
94
+ export function SectionLabel({
95
+ as = "h3",
96
+ size = "sm",
97
+ variant = "plain",
98
+ className,
99
+ children,
100
+ ...rest
101
+ }: SectionLabelProps) {
59
102
  const Tag = as as ElementType;
103
+ const type = variant === "band" ? cn(SECTION_LABEL_BAND_CLASS[size], BAND) : SECTION_LABEL_CLASS[size];
60
104
  return (
61
- <Tag {...rest} className={cn(SECTION_LABEL_CLASS[size], className)}>
105
+ <Tag {...rest} className={cn(type, className)}>
62
106
  {children}
63
107
  </Tag>
64
108
  );
@@ -2,7 +2,7 @@ import { useId } from "react";
2
2
  import type { ComponentPropsWithoutRef, KeyboardEvent, ReactElement, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { horizontalStep } from "../lib/direction";
5
- import { FIELD_INVALID, FloatingField } from "./ui";
5
+ import { FIELD_INVALID, FloatingField, Label } from "./ui";
6
6
 
7
7
  export interface ToggleOption<T extends string> {
8
8
  value: T;
@@ -67,6 +67,19 @@ export interface ToggleGroupBaseProps<T extends string>
67
67
  * group's own box is dropped: two nested borders read as a control in a control.
68
68
  */
69
69
  label?: ReactNode;
70
+ /**
71
+ * Where `label` goes. `"field"` (default): the field chrome described under `label`.
72
+ * `"above"`: the kit's {@link Label} over the bare group — the shape of a {@link Field}
73
+ * — with `hint` beside the label and `error` under the group, for a form that sets
74
+ * its labels above its fields (kastlan's international-rent-calculator.tsx, whose DE
75
+ * cap pair sits in a `Field` column between two labelled-above inputs, where the
76
+ * chrome's inner label would be the only one of its kind).
77
+ *
78
+ * Inside a `Field`, pass no `label` at all and spread the render-prop instead —
79
+ * `{(ids, { labelId }) => <ToggleGroup {...ids} aria-labelledby={labelId} … />}` —
80
+ * so the Field's label names the group and its hint and error describe it.
81
+ */
82
+ labelPlacement?: "field" | "above";
70
83
  /** A {@link FieldHint} on the label line, as on a labelled {@link Select}. Only with
71
84
  * `label`. */
72
85
  hint?: ReactNode;
@@ -91,6 +104,25 @@ type ToggleGroupCaption<V> = ReactNode | ((value: V) => ReactNode);
91
104
  /** The group as it has always been: one option is always the answer. */
92
105
  export interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupBaseProps<T> {
93
106
  allowEmpty?: false;
107
+ /**
108
+ * What the options ARE to a screen reader, separately from whether the group can be
109
+ * emptied (kastlan feedback #47).
110
+ *
111
+ * - `"radio"` (default) — a `radiogroup` of radios: one Tab stop, arrow keys MOVE the
112
+ * choice. Right for a choice between answers ("Monthly / Yearly").
113
+ * - `"pressed"` — a `group` of toggle buttons (`aria-pressed`), each its own Tab stop,
114
+ * Space/Enter to press; still one option always pressed. Right for a row that reads
115
+ * as a set of switches — a view mode, a toolbar-like filter — where arrows moving the
116
+ * selection on focus would be a surprise.
117
+ *
118
+ * Before this the only way to `aria-pressed` was `allowEmpty`, which also let a second
119
+ * press clear the choice — so kastlan took the clearable shape and threw the `null`
120
+ * away in `onChange` (`(v) => v && setMode(v)`), a group announcing that a press would
121
+ * unpress what it would not. Here a press on the pressed option re-sends its value, as
122
+ * the radio shape does, and nothing is ever unpressed. A clearable group
123
+ * (`allowEmpty`) is always `"pressed"`: a radio cannot be unchecked by activating it.
124
+ */
125
+ semantics?: "radio" | "pressed";
94
126
  value: T;
95
127
  onChange: (value: T) => void;
96
128
  /** See {@link ToggleGroupCaption}. */
@@ -109,6 +141,9 @@ export interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupB
109
141
  */
110
142
  export interface ToggleGroupClearableProps<T extends string> extends ToggleGroupBaseProps<T> {
111
143
  allowEmpty: true;
144
+ /** Always toggle buttons — see {@link ToggleGroupRequiredProps.semantics}. Accepted so
145
+ * a wrapper can forward one `semantics` to either shape. */
146
+ semantics?: "pressed";
112
147
  value: T | null;
113
148
  onChange: (value: T | null) => void;
114
149
  /** See {@link ToggleGroupCaption}. `null` while nothing is chosen. */
@@ -143,6 +178,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
143
178
  disabled = false,
144
179
  size = "md",
145
180
  label,
181
+ labelPlacement = "field",
146
182
  hint,
147
183
  error,
148
184
  "aria-label": ariaLabelAttr,
@@ -150,11 +186,17 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
150
186
  } = props;
151
187
  const labelId = useId();
152
188
  const errorId = useId();
153
- const field = label !== undefined && label !== null && label !== false && label !== "";
154
- const hasError = field && error !== undefined && error !== null && error !== false && error !== "";
189
+ const labelled = label !== undefined && label !== null && label !== false && label !== "";
190
+ // `field` is the chrome; a label placed above keeps the bare group's own box.
191
+ const field = labelled && labelPlacement === "field";
192
+ const above = labelled && labelPlacement === "above";
193
+ const hasError = labelled && error !== undefined && error !== null && error !== false && error !== "";
155
194
  // Taken off the rest so neither reaches the DOM; `props` keeps them paired, which is
156
195
  // what lets the `onChange` below be called with `null` only in the mode that allows it.
157
- const { allowEmpty: _allowEmpty, onChange: _onChange, caption, ...rest } = restWithMode;
196
+ const { allowEmpty: _allowEmpty, onChange: _onChange, caption, semantics: _semantics, ...rest } = restWithMode;
197
+ // Invalid from outside too: a `Field` hands the bare group `aria-invalid`, and the
198
+ // border has to say what the attribute says.
199
+ const outsideInvalid = rest["aria-invalid"] === true || rest["aria-invalid"] === "true";
158
200
  const captionId = useId();
159
201
  const captionIsLive = typeof caption === "function";
160
202
  const captionNode = captionIsLive ? (caption as (v: T | null) => ReactNode)(value) : caption;
@@ -163,11 +205,14 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
163
205
  if (props.allowEmpty) props.onChange(next === value ? null : next);
164
206
  else props.onChange(next);
165
207
  };
166
- const clearable = props.allowEmpty === true;
208
+ // `pressed`: toggle buttons. Every clearable group is; a required one is when it asks
209
+ // (kastlan #47) — the role, and not whether a second press clears, is what this flag
210
+ // decides from here on, so `choose` above still keys off `allowEmpty` alone.
211
+ const pressed = props.allowEmpty === true || props.semantics === "pressed";
167
212
  // A radio group is ONE tab stop (the checked radio, else the first) and arrows move
168
213
  // the choice — the pattern `role="radiogroup"` promises a screen-reader user. Before
169
- // 0.7.0 each segment was its own tab stop with no arrow keys. The clearable mode is a
170
- // row of toggle buttons, where separate tab stops are the pattern.
214
+ // 0.7.0 each segment was its own tab stop with no arrow keys. The toggle-button shape
215
+ // is a row of buttons, where separate tab stops are the pattern.
171
216
  const tabStop = options.some((o) => o.value === value) ? value : options[0]?.value;
172
217
  const onRadioKey = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
173
218
  const last = options.length - 1;
@@ -198,11 +243,11 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
198
243
  // radiogroup role or the disabled state by accident. `className` is destructured
199
244
  // out entirely and merged through `cn`, so it is never in here.
200
245
  {...rest}
201
- role={clearable ? "group" : "radiogroup"}
246
+ role={pressed ? "group" : "radiogroup"}
202
247
  // The DOM spelling wins; `ariaLabel` is the fallback for the call sites that
203
248
  // have not moved yet.
204
249
  aria-label={ariaLabelAttr ?? ariaLabel}
205
- aria-labelledby={field && ariaLabelAttr === undefined && ariaLabel === undefined ? labelId : rest["aria-labelledby"]}
250
+ aria-labelledby={labelled && ariaLabelAttr === undefined && ariaLabel === undefined ? labelId : rest["aria-labelledby"]}
206
251
  aria-invalid={hasError || rest["aria-invalid"] || undefined}
207
252
  aria-describedby={
208
253
  [rest["aria-describedby"], hasCaption && captionId, hasError && errorId].filter(Boolean).join(" ") || undefined
@@ -229,10 +274,12 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
229
274
  // package does; `cursor-not-allowed` is on the buttons, which is what a
230
275
  // pointer is actually over.
231
276
  disabled && "opacity-60",
277
+ // The bare group (no chrome to paint) wears the invalid border itself.
278
+ !field && (outsideInvalid || (above && hasError)) && FIELD_INVALID,
232
279
  // Inside the field's chrome the group is only a row of segments: no border, no
233
280
  // surface, no padding of its own, and the field (not the group) is what dims.
234
281
  field && "border-0 bg-transparent p-0 shadow-none opacity-100",
235
- !field && className,
282
+ !labelled && className,
236
283
  )}
237
284
  >
238
285
  {options.map((opt, index) => {
@@ -241,12 +288,12 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
241
288
  <button
242
289
  key={opt.value}
243
290
  type="button"
244
- role={clearable ? undefined : "radio"}
245
- aria-checked={clearable ? undefined : active}
246
- aria-pressed={clearable ? active : undefined}
291
+ role={pressed ? undefined : "radio"}
292
+ aria-checked={pressed ? undefined : active}
293
+ aria-pressed={pressed ? active : undefined}
247
294
  disabled={disabled}
248
- tabIndex={clearable ? undefined : opt.value === tabStop ? 0 : -1}
249
- onKeyDown={clearable ? undefined : (e) => onRadioKey(e, index)}
295
+ tabIndex={pressed ? undefined : opt.value === tabStop ? 0 : -1}
296
+ onKeyDown={pressed ? undefined : (e) => onRadioKey(e, index)}
250
297
  onClick={() => choose(opt.value)}
251
298
  className={cn(
252
299
  // `truncate` (which carries whitespace-nowrap) rather than letting a
@@ -308,6 +355,36 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
308
355
  {hasCaption ? captionNode : null}
309
356
  </p>
310
357
  ) : null;
358
+ const errorEl = hasError ? (
359
+ <p id={errorId} className="mt-1 text-[11px] leading-tight text-[var(--danger)]">
360
+ {error}
361
+ </p>
362
+ ) : null;
363
+ if (above) {
364
+ // `relative` so a caller's `sr-only` label cannot escape (sr-only-containment).
365
+ return (
366
+ <div className={cn("relative grid min-w-0 gap-1.5", className)}>
367
+ <div className="flex items-center gap-1">
368
+ {/* A `<label>` with no `htmlFor`: a group is not labelable, so it is named
369
+ by `aria-labelledby` on the group; the element keeps the Field look. */}
370
+ <Label
371
+ id={labelId}
372
+ disabled={disabled}
373
+ data-error={hasError || undefined}
374
+ className="data-[error=true]:text-[var(--danger)]"
375
+ >
376
+ {label}
377
+ </Label>
378
+ {hint}
379
+ </div>
380
+ <div className="min-w-0">
381
+ {group}
382
+ {captionEl}
383
+ {errorEl}
384
+ </div>
385
+ </div>
386
+ );
387
+ }
311
388
  if (!field) {
312
389
  if (!captionEl) return group;
313
390
  // The group keeps its `className`, as without a caption; the wrapper only stacks.
@@ -339,11 +416,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
339
416
  </div>
340
417
  </FloatingField>
341
418
  {captionEl}
342
- {hasError && (
343
- <p id={errorId} className="mt-1 text-[11px] leading-tight text-[var(--danger)]">
344
- {error}
345
- </p>
346
- )}
419
+ {errorEl}
347
420
  </div>
348
421
  );
349
422
  }