@eifi1/ui-kit 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (342) hide show
  1. package/README.md +57 -32
  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 +24 -4
  12. package/dist/components/amount-input.js +5 -2
  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/button-group.d.ts +51 -2
  21. package/dist/components/button-group.js +5 -1
  22. package/dist/components/button-group.js.map +1 -1
  23. package/dist/components/calculator.d.ts +15 -4
  24. package/dist/components/calendar-heatmap.d.ts +41 -3
  25. package/dist/components/calendar-heatmap.js +48 -7
  26. package/dist/components/calendar-heatmap.js.map +1 -1
  27. package/dist/components/chip.d.ts +2 -1
  28. package/dist/components/chip.js +6 -3
  29. package/dist/components/chip.js.map +1 -1
  30. package/dist/components/choice-card.d.ts +35 -3
  31. package/dist/components/choice-card.js +83 -25
  32. package/dist/components/choice-card.js.map +1 -1
  33. package/dist/components/combobox-core.js +2 -0
  34. package/dist/components/combobox-core.js.map +1 -1
  35. package/dist/components/combobox.d.ts +2 -2
  36. package/dist/components/combobox.js +31 -9
  37. package/dist/components/combobox.js.map +1 -1
  38. package/dist/components/copy-button.d.ts +51 -70
  39. package/dist/components/copy-button.js +1 -1
  40. package/dist/components/copy-button.js.map +1 -1
  41. package/dist/components/data-table-cells.d.ts +63 -0
  42. package/dist/components/data-table-cells.js +64 -0
  43. package/dist/components/data-table-cells.js.map +1 -0
  44. package/dist/components/data-table-filter-popover.d.ts +1 -1
  45. package/dist/components/data-table-filters.d.ts +1 -1
  46. package/dist/components/data-table-filters.js +31 -1
  47. package/dist/components/data-table-filters.js.map +1 -1
  48. package/dist/components/data-table-labels.d.ts +13 -0
  49. package/dist/components/data-table-labels.js +8 -1
  50. package/dist/components/data-table-labels.js.map +1 -1
  51. package/dist/components/data-table.d.ts +1 -1
  52. package/dist/components/data-table.js +102 -12
  53. package/dist/components/data-table.js.map +1 -1
  54. package/dist/components/date-picker.d.ts +3 -1
  55. package/dist/components/date-picker.js +15 -2
  56. package/dist/components/date-picker.js.map +1 -1
  57. package/dist/components/description-list.d.ts +23 -3
  58. package/dist/components/description-list.js +23 -3
  59. package/dist/components/description-list.js.map +1 -1
  60. package/dist/components/dialog-frame.d.ts +1 -0
  61. package/dist/components/entity-combobox.d.ts +1 -1
  62. package/dist/components/entity-combobox.js +18 -3
  63. package/dist/components/entity-combobox.js.map +1 -1
  64. package/dist/components/error-boundary.d.ts +92 -0
  65. package/dist/components/error-boundary.js +111 -0
  66. package/dist/components/error-boundary.js.map +1 -0
  67. package/dist/components/field.d.ts +30 -4
  68. package/dist/components/field.js +40 -17
  69. package/dist/components/field.js.map +1 -1
  70. package/dist/components/file-button.d.ts +50 -208
  71. package/dist/components/file-dropzone.d.ts +49 -2
  72. package/dist/components/floating-panel.d.ts +13 -4
  73. package/dist/components/floating-panel.js +7 -3
  74. package/dist/components/floating-panel.js.map +1 -1
  75. package/dist/components/form-actions.d.ts +53 -0
  76. package/dist/components/form-actions.js +98 -0
  77. package/dist/components/form-actions.js.map +1 -0
  78. package/dist/components/image-grid.d.ts +67 -0
  79. package/dist/components/image-grid.js +112 -0
  80. package/dist/components/image-grid.js.map +1 -0
  81. package/dist/components/lightbox.d.ts +104 -0
  82. package/dist/components/lightbox.js +229 -0
  83. package/dist/components/lightbox.js.map +1 -0
  84. package/dist/components/line-items.d.ts +114 -0
  85. package/dist/components/line-items.js +238 -0
  86. package/dist/components/line-items.js.map +1 -0
  87. package/dist/components/list.d.ts +23 -2
  88. package/dist/components/list.js +32 -13
  89. package/dist/components/list.js.map +1 -1
  90. package/dist/components/loading-state.d.ts +24 -0
  91. package/dist/components/loading-state.js +32 -0
  92. package/dist/components/loading-state.js.map +1 -0
  93. package/dist/components/menu-item.d.ts +2 -1
  94. package/dist/components/menu-item.js +5 -1
  95. package/dist/components/menu-item.js.map +1 -1
  96. package/dist/components/mini-calendar.d.ts +13 -1
  97. package/dist/components/mini-calendar.js +46 -2
  98. package/dist/components/mini-calendar.js.map +1 -1
  99. package/dist/components/modal.d.ts +20 -1
  100. package/dist/components/modal.js +26 -2
  101. package/dist/components/modal.js.map +1 -1
  102. package/dist/components/month-picker.d.ts +21 -1
  103. package/dist/components/month-picker.js +139 -26
  104. package/dist/components/month-picker.js.map +1 -1
  105. package/dist/components/multi-entity-combobox.d.ts +1 -1
  106. package/dist/components/multi-entity-combobox.js +19 -3
  107. package/dist/components/multi-entity-combobox.js.map +1 -1
  108. package/dist/components/nav-pills.d.ts +2 -1
  109. package/dist/components/nav-pills.js +5 -1
  110. package/dist/components/nav-pills.js.map +1 -1
  111. package/dist/components/number-field.d.ts +24 -5
  112. package/dist/components/number-field.js +5 -7
  113. package/dist/components/number-field.js.map +1 -1
  114. package/dist/components/number-input.d.ts +25 -5
  115. package/dist/components/number-input.js +7 -1
  116. package/dist/components/number-input.js.map +1 -1
  117. package/dist/components/numpad-sheet.d.ts +15 -4
  118. package/dist/components/passkeys-setting.d.ts +67 -0
  119. package/dist/components/passkeys-setting.js +214 -0
  120. package/dist/components/passkeys-setting.js.map +1 -0
  121. package/dist/components/picker-sheet.js +2 -1
  122. package/dist/components/picker-sheet.js.map +1 -1
  123. package/dist/components/pie-chart-labels.d.ts +20 -0
  124. package/dist/components/pie-chart-labels.js +12 -0
  125. package/dist/components/pie-chart-labels.js.map +1 -0
  126. package/dist/components/pie-chart.d.ts +101 -0
  127. package/dist/components/pie-chart.js +283 -0
  128. package/dist/components/pie-chart.js.map +1 -0
  129. package/dist/components/progress-bar.d.ts +44 -3
  130. package/dist/components/progress-bar.js +58 -22
  131. package/dist/components/progress-bar.js.map +1 -1
  132. package/dist/components/qr-code.d.ts +34 -0
  133. package/dist/components/qr-code.js +57 -0
  134. package/dist/components/qr-code.js.map +1 -0
  135. package/dist/components/search-field.d.ts +15 -3
  136. package/dist/components/search-field.js +5 -1
  137. package/dist/components/search-field.js.map +1 -1
  138. package/dist/components/settings-fields.d.ts +49 -1
  139. package/dist/components/signed-amount.d.ts +121 -0
  140. package/dist/components/signed-amount.js +152 -0
  141. package/dist/components/signed-amount.js.map +1 -0
  142. package/dist/components/skeleton.d.ts +10 -3
  143. package/dist/components/skeleton.js +12 -2
  144. package/dist/components/skeleton.js.map +1 -1
  145. package/dist/components/stat-tile.d.ts +2 -1
  146. package/dist/components/stat-tile.js +5 -2
  147. package/dist/components/stat-tile.js.map +1 -1
  148. package/dist/components/table.d.ts +39 -5
  149. package/dist/components/table.js +45 -11
  150. package/dist/components/table.js.map +1 -1
  151. package/dist/components/text-link.d.ts +176 -0
  152. package/dist/components/text-link.js +109 -0
  153. package/dist/components/text-link.js.map +1 -0
  154. package/dist/components/time-input.d.ts +49 -1
  155. package/dist/components/toggle-group.d.ts +13 -0
  156. package/dist/components/toggle-group.js +35 -6
  157. package/dist/components/toggle-group.js.map +1 -1
  158. package/dist/components/toggle-legend.d.ts +36 -3
  159. package/dist/components/toggle-legend.js +49 -0
  160. package/dist/components/toggle-legend.js.map +1 -1
  161. package/dist/components/ui.d.ts +51 -779
  162. package/dist/components/ui.js +215 -23
  163. package/dist/components/ui.js.map +1 -1
  164. package/dist/components/use-table-state.d.ts +3 -52
  165. package/dist/components/use-table-state.js +128 -30
  166. package/dist/components/use-table-state.js.map +1 -1
  167. package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
  168. package/dist/data-table.d.ts +2 -1
  169. package/dist/data-table.js +5 -1
  170. package/dist/data-table.js.map +1 -1
  171. package/dist/feedback/feedback-dialog.d.ts +26 -3
  172. package/dist/feedback/feedback-dialog.js +16 -2
  173. package/dist/feedback/feedback-dialog.js.map +1 -1
  174. package/dist/feedback/feedback-thread.d.ts +133 -0
  175. package/dist/feedback/feedback-thread.js +237 -0
  176. package/dist/feedback/feedback-thread.js.map +1 -0
  177. package/dist/feedback.d.ts +2 -1
  178. package/dist/feedback.js +1 -0
  179. package/dist/feedback.js.map +1 -1
  180. package/dist/hooks/use-authed-src.d.ts +71 -0
  181. package/dist/hooks/use-authed-src.js +63 -0
  182. package/dist/hooks/use-authed-src.js.map +1 -0
  183. package/dist/hooks/use-file-drop.d.ts +49 -2
  184. package/dist/hooks/use-hotkey.d.ts +59 -0
  185. package/dist/hooks/use-hotkey.js +105 -0
  186. package/dist/hooks/use-hotkey.js.map +1 -0
  187. package/dist/hooks/use-search-param-state.d.ts +83 -0
  188. package/dist/hooks/use-search-param-state.js +94 -0
  189. package/dist/hooks/use-search-param-state.js.map +1 -0
  190. package/dist/i18n/defaults.d.ts +15 -4
  191. package/dist/i18n/defaults.js +31 -1
  192. package/dist/i18n/defaults.js.map +1 -1
  193. package/dist/i18n/kit-labels.d.ts +46 -312
  194. package/dist/i18n/kit-labels.js +20 -5
  195. package/dist/i18n/kit-labels.js.map +1 -1
  196. package/dist/i18n/locales/de-CH-informal.d.ts +15 -4
  197. package/dist/i18n/locales/de-CH.d.ts +15 -4
  198. package/dist/i18n/locales/de-informal.d.ts +15 -4
  199. package/dist/i18n/locales/de.d.ts +15 -4
  200. package/dist/i18n/locales/de.js +156 -3
  201. package/dist/i18n/locales/de.js.map +1 -1
  202. package/dist/i18n/locales/es.d.ts +15 -4
  203. package/dist/i18n/locales/es.js +153 -3
  204. package/dist/i18n/locales/es.js.map +1 -1
  205. package/dist/i18n/locales/fr.d.ts +15 -4
  206. package/dist/i18n/locales/fr.js +153 -3
  207. package/dist/i18n/locales/fr.js.map +1 -1
  208. package/dist/i18n/locales/hu.d.ts +15 -4
  209. package/dist/i18n/locales/hu.js +153 -3
  210. package/dist/i18n/locales/hu.js.map +1 -1
  211. package/dist/i18n/locales/it.d.ts +15 -4
  212. package/dist/i18n/locales/it.js +153 -3
  213. package/dist/i18n/locales/it.js.map +1 -1
  214. package/dist/i18n/locales/zh.d.ts +15 -4
  215. package/dist/i18n/locales/zh.js +153 -3
  216. package/dist/i18n/locales/zh.js.map +1 -1
  217. package/dist/index.d.ts +37 -17
  218. package/dist/index.js +40 -1
  219. package/dist/index.js.map +1 -1
  220. package/dist/kit-labels-v3biUF1L.d.ts +1645 -0
  221. package/dist/lib/format.d.ts +144 -0
  222. package/dist/lib/format.js +149 -0
  223. package/dist/lib/format.js.map +1 -0
  224. package/dist/lib/qr-encode.d.ts +44 -0
  225. package/dist/lib/qr-encode.js +338 -0
  226. package/dist/lib/qr-encode.js.map +1 -0
  227. package/dist/rhf/fields.d.ts +241 -0
  228. package/dist/rhf/fields.js +601 -0
  229. package/dist/rhf/fields.js.map +1 -0
  230. package/dist/rhf/form.d.ts +49 -1
  231. package/dist/rhf/line-items.d.ts +31 -0
  232. package/dist/rhf/line-items.js +59 -0
  233. package/dist/rhf/line-items.js.map +1 -0
  234. package/dist/rhf.d.ts +61 -1
  235. package/dist/rhf.js +2 -0
  236. package/dist/rhf.js.map +1 -1
  237. package/dist/shell/app-shell.d.ts +99 -2
  238. package/dist/shell/app-shell.js +58 -3
  239. package/dist/shell/app-shell.js.map +1 -1
  240. package/dist/shell/auth-layout.d.ts +74 -0
  241. package/dist/shell/auth-layout.js +97 -0
  242. package/dist/shell/auth-layout.js.map +1 -0
  243. package/dist/shell/top-bar-brand.d.ts +81 -0
  244. package/dist/shell/top-bar-brand.js +47 -0
  245. package/dist/shell/top-bar-brand.js.map +1 -0
  246. package/dist/shell/topbar-action-menu.d.ts +12 -1
  247. package/dist/shell/topbar-action-menu.js +16 -1
  248. package/dist/shell/topbar-action-menu.js.map +1 -1
  249. package/dist/shell.d.ts +56 -1
  250. package/dist/shell.js +2 -0
  251. package/dist/shell.js.map +1 -1
  252. package/dist/wizard/stepper-nav.d.ts +48 -1
  253. package/dist/wizard/wizard-context.d.ts +24 -1
  254. package/dist/wizard/wizard-context.js +19 -2
  255. package/dist/wizard/wizard-context.js.map +1 -1
  256. package/dist/wizard/wizard-step.d.ts +20 -5
  257. package/dist/wizard/wizard-step.js +16 -3
  258. package/dist/wizard/wizard-step.js.map +1 -1
  259. package/dist/wizard.d.ts +50 -3
  260. package/package.json +1 -1
  261. package/src/chart.ts +4 -0
  262. package/src/components/account-settings-labels.ts +190 -0
  263. package/src/components/account-settings.tsx +110 -63
  264. package/src/components/amount-input.tsx +13 -1
  265. package/src/components/authed-image.tsx +190 -0
  266. package/src/components/breadcrumbs.tsx +8 -2
  267. package/src/components/button-group.tsx +7 -2
  268. package/src/components/calendar-heatmap.tsx +105 -10
  269. package/src/components/chip.tsx +8 -4
  270. package/src/components/choice-card.tsx +136 -25
  271. package/src/components/combobox-core.tsx +5 -0
  272. package/src/components/combobox.tsx +31 -9
  273. package/src/components/copy-button.tsx +3 -2
  274. package/src/components/data-table-cells.tsx +119 -0
  275. package/src/components/data-table-filters.ts +77 -0
  276. package/src/components/data-table-labels.ts +23 -0
  277. package/src/components/data-table.tsx +224 -13
  278. package/src/components/date-picker.tsx +19 -3
  279. package/src/components/description-list.tsx +52 -3
  280. package/src/components/entity-combobox.tsx +18 -3
  281. package/src/components/error-boundary.tsx +216 -0
  282. package/src/components/field.tsx +56 -5
  283. package/src/components/floating-panel.tsx +20 -7
  284. package/src/components/form-actions.tsx +191 -0
  285. package/src/components/image-grid.tsx +181 -0
  286. package/src/components/lightbox.tsx +374 -0
  287. package/src/components/line-items.tsx +385 -0
  288. package/src/components/list.tsx +65 -16
  289. package/src/components/loading-state.tsx +47 -0
  290. package/src/components/menu-item.tsx +7 -2
  291. package/src/components/mini-calendar.tsx +72 -3
  292. package/src/components/modal.tsx +61 -3
  293. package/src/components/month-picker.tsx +181 -26
  294. package/src/components/multi-entity-combobox.tsx +19 -3
  295. package/src/components/nav-pills.tsx +8 -1
  296. package/src/components/number-field.tsx +15 -10
  297. package/src/components/number-input.tsx +16 -1
  298. package/src/components/passkeys-setting.tsx +331 -0
  299. package/src/components/picker-sheet.tsx +7 -1
  300. package/src/components/pie-chart-labels.ts +32 -0
  301. package/src/components/pie-chart.tsx +479 -0
  302. package/src/components/progress-bar.tsx +129 -19
  303. package/src/components/qr-code.tsx +83 -0
  304. package/src/components/search-field.tsx +20 -4
  305. package/src/components/signed-amount.tsx +286 -0
  306. package/src/components/skeleton.tsx +21 -3
  307. package/src/components/stat-tile.tsx +8 -2
  308. package/src/components/table.tsx +102 -13
  309. package/src/components/text-link.tsx +248 -0
  310. package/src/components/toggle-group.tsx +58 -10
  311. package/src/components/toggle-legend.tsx +87 -2
  312. package/src/components/ui.tsx +426 -36
  313. package/src/components/use-table-state.ts +265 -33
  314. package/src/data-table.ts +10 -0
  315. package/src/feedback/feedback-dialog.tsx +42 -3
  316. package/src/feedback/feedback-thread.tsx +434 -0
  317. package/src/feedback.ts +1 -0
  318. package/src/hooks/use-authed-src.ts +161 -0
  319. package/src/hooks/use-hotkey.ts +172 -0
  320. package/src/hooks/use-search-param-state.ts +187 -0
  321. package/src/i18n/defaults.ts +30 -0
  322. package/src/i18n/kit-labels.tsx +102 -8
  323. package/src/i18n/locales/de.ts +153 -0
  324. package/src/i18n/locales/es.ts +150 -0
  325. package/src/i18n/locales/fr.ts +153 -0
  326. package/src/i18n/locales/hu.ts +151 -0
  327. package/src/i18n/locales/it.ts +151 -0
  328. package/src/i18n/locales/zh.ts +150 -0
  329. package/src/index.ts +59 -0
  330. package/src/lib/format.ts +325 -0
  331. package/src/lib/qr-encode.ts +420 -0
  332. package/src/rhf/fields.tsx +1054 -0
  333. package/src/rhf/line-items.tsx +130 -0
  334. package/src/rhf.ts +6 -0
  335. package/src/shell/app-shell.tsx +147 -2
  336. package/src/shell/auth-layout.tsx +190 -0
  337. package/src/shell/top-bar-brand.tsx +76 -0
  338. package/src/shell/topbar-action-menu.tsx +21 -0
  339. package/src/shell.ts +2 -0
  340. package/src/wizard/wizard-context.tsx +41 -1
  341. package/src/wizard/wizard-step.tsx +43 -6
  342. package/tokens.css +12 -0
@@ -21,6 +21,13 @@ export interface SkeletonProps extends Omit<ComponentPropsWithoutRef<"div">, "ch
21
21
  shape?: SkeletonShape;
22
22
  /** For `line`: this many lines, the last one shorter — a paragraph's outline. */
23
23
  lines?: number;
24
+ /**
25
+ * Say what is loading, once: wraps the placeholder in a `role="status"` region with
26
+ * this as its (visually hidden) text — kastlan's activity feed (activity-feed:52)
27
+ * built exactly that by hand around `<Skeleton lines={5} />`. The placeholder itself
28
+ * stays hidden. Left out, nothing is announced, as before.
29
+ */
30
+ label?: string;
24
31
  }
25
32
 
26
33
  const SHAPE: Record<SkeletonShape, string> = {
@@ -37,10 +44,21 @@ const SHAPE: Record<SkeletonShape, string> = {
37
44
  *
38
45
  * ALWAYS `aria-hidden`. A placeholder is not content, and a reader that met twelve
39
46
  * unnamed grey boxes would have learned nothing. Say "loading" once, where it
40
- * belongs: `aria-busy` on the region being filled, or a `Spinner` / sr-only line
41
- * beside the skeletons.
47
+ * belongs: `aria-busy` on the region being filled, a `Spinner` / sr-only line
48
+ * beside the skeletons, or this component's own `label`.
42
49
  */
43
- export function Skeleton({ shape = "line", lines, className, ...rest }: SkeletonProps) {
50
+ export function Skeleton({ label, ...props }: SkeletonProps) {
51
+ if (!label) return <SkeletonFill {...props} />;
52
+ return (
53
+ // `relative`: the containing block for the sr-only text (sr-only-containment.test).
54
+ <div role="status" className="relative" data-skeleton-status="">
55
+ <span className="sr-only">{label}</span>
56
+ <SkeletonFill {...props} />
57
+ </div>
58
+ );
59
+ }
60
+
61
+ function SkeletonFill({ shape = "line", lines, className, ...rest }: Omit<SkeletonProps, "label">) {
44
62
  if (shape === "line" && lines !== undefined && lines > 1) {
45
63
  return (
46
64
  <div {...rest} aria-hidden className={cn("flex flex-col gap-2", className)} data-skeleton="lines">
@@ -2,7 +2,8 @@ import { isValidElement, useId, useLayoutEffect, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, CSSProperties, ReactElement, ReactNode } from "react";
3
3
  import { ArrowDown, ArrowUp, Minus } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
- import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
5
+ import { useKitLabels, useKitLink, useKitLocale } from "../i18n/kit-labels";
6
+ import { pickLinkRenderer, RenderedKitLink } from "./text-link";
6
7
  import { Card, FieldHint } from "./ui";
7
8
  import { Sparkline } from "./sparkline";
8
9
  import { Tooltip } from "./tooltip";
@@ -219,7 +220,8 @@ export interface StatTileProps
219
220
  /** Makes the tile a link. See {@link renderLink} for a router. */
220
221
  href?: string;
221
222
  /** Renders the link for `href` — pass your router's `<Link>` here, since a plain
222
- * `<a>` reloads a single-page app. Default: `<a>`. */
223
+ * `<a>` reloads a single-page app. Default: the `<UiKitProvider linkComponent>`
224
+ * (for an in-app `href`), then `<a>`. */
223
225
  renderLink?: (props: StatTileLinkProps) => ReactElement;
224
226
  /** Makes the tile a button (ignored when `href` is given). */
225
227
  onClick?: () => void;
@@ -297,6 +299,7 @@ export function StatTile({
297
299
  ...rest
298
300
  }: StatTileProps) {
299
301
  const text = useKitLabels("statTile", DEFAULT_STAT_TILE_LABELS, labels);
302
+ const kitLink = useKitLink();
300
303
  const [labelEl, setLabelEl] = useState<HTMLElement | null>(null);
301
304
  const labelCut = useIsTruncated(truncateLabel ? labelEl : null);
302
305
  const kitLocale = useKitLocale(locale);
@@ -334,8 +337,11 @@ export function StatTile({
334
337
  "aria-describedby": describedBy,
335
338
  children: label,
336
339
  };
340
+ const kit = pickLinkRenderer<StatTileLinkProps>(undefined, kitLink, href);
337
341
  labelNode = renderLink ? (
338
342
  renderLink(linkProps)
343
+ ) : kit ? (
344
+ <RenderedKitLink render={kit} props={linkProps} />
339
345
  ) : (
340
346
  <a href={href} className={stretched} aria-describedby={describedBy}>
341
347
  {label}
@@ -29,10 +29,21 @@ export type TableHeaderCellSize = "xs" | "sm";
29
29
  /** The header cell's weight. Default `medium`. */
30
30
  export type TableHeaderCellWeight = "normal" | "medium" | "semibold";
31
31
 
32
+ /**
33
+ * What a {@link TableRow} is in a statement-style table (0.12.0): an ordinary `row`, a
34
+ * `group` heading ("Current assets", a tinted full-width label row), a `subtotal` under
35
+ * a group, or the `total` of the whole table. kastlan's balance sheet, income statement
36
+ * and journal entry each spelled these as `className="font-medium"`, `"font-bold
37
+ * border-t-2"` and `bg-muted/50` on the cell — three pages, three sets of classes for
38
+ * the same four kinds of line.
39
+ */
40
+ export type TableRowVariant = "row" | "group" | "subtotal" | "total";
41
+
32
42
  interface TableContextValue {
33
43
  density: TableDensity;
34
44
  zebra: boolean;
35
45
  hover: boolean;
46
+ rowDividers: boolean;
36
47
  captionId: string;
37
48
  registerCaption: () => () => void;
38
49
  }
@@ -41,12 +52,44 @@ const TableContext = createContext<TableContextValue>({
41
52
  density: "comfortable",
42
53
  zebra: false,
43
54
  hover: false,
55
+ rowDividers: true,
44
56
  captionId: "",
45
57
  registerCaption: () => () => {},
46
58
  });
47
59
 
48
- /** Which section a row sits in — zebra and hover apply to body rows only. */
49
- const SectionContext = createContext<"head" | "body" | "foot">("body");
60
+ type TableSection = "head" | "body" | "foot";
61
+
62
+ /** Which section a row sits in — zebra and hover apply to body rows only. `undefined`
63
+ * outside the kit's section parts: a raw `<thead>` / `<tbody>`, which
64
+ * {@link useSection} then reads off the DOM. */
65
+ const SectionContext = createContext<TableSection | undefined>(undefined);
66
+
67
+ const SECTION_OF_TAG: Record<string, TableSection> = { THEAD: "head", TBODY: "body", TFOOT: "foot" };
68
+
69
+ /**
70
+ * The section a row or cell sits in: the kit part's context where there is one, else the
71
+ * nearest `<thead>` / `<tbody>` / `<tfoot>` in the DOM.
72
+ *
73
+ * keksdose's VAT summary writes a raw `<thead>` round kit cells in places, and a
74
+ * `TableHeaderCell` there took itself for a BODY cell — `scope="row"`, body type — with
75
+ * nothing on the page to say why. React cannot see an ancestor's tag, so the fallback
76
+ * reads it after mount; a layout effect, so the corrected cell is what gets painted.
77
+ */
78
+ function useSection<E extends HTMLElement>(): [TableSection, ((el: E | null) => void) | undefined] {
79
+ const fromContext = useContext(SectionContext);
80
+ const [node, setNode] = useState<E | null>(null);
81
+ const [detected, setDetected] = useState<TableSection | undefined>(undefined);
82
+ useLayoutEffect(() => {
83
+ if (fromContext !== undefined || !node) return;
84
+ const section = node.closest("thead, tbody, tfoot");
85
+ const found = section ? SECTION_OF_TAG[section.tagName] : undefined;
86
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- read off the DOM, which only exists after mount
87
+ if (found !== detected) setDetected(found);
88
+ }, [fromContext, node, detected]);
89
+ // No ref at all under a kit section: holding the node would cost every row and cell
90
+ // a second render on mount for an answer the context already gave.
91
+ return [fromContext ?? detected ?? "body", fromContext === undefined ? setNode : undefined];
92
+ }
50
93
 
51
94
  /** A {@link TableRow}'s `valign`, which its cells take unless they set their own. It
52
95
  * cannot be left to CSS inheritance: every cell states its own default alignment,
@@ -102,6 +145,22 @@ export interface TableProps extends ComponentPropsWithoutRef<"table"> {
102
145
  wrapperClassName?: string;
103
146
  /** See {@link TableLayout}. Unset leaves the browser's `auto` and adds no class. */
104
147
  layout?: TableLayout;
148
+ /**
149
+ * A rounded border round the whole table (0.12.0) — for a table standing on the page
150
+ * or in a dialog rather than inside a card. kastlan wraps six of them in
151
+ * `<div className="rounded-md border">` by hand (payment allocation, the new-budget
152
+ * lines, the meter readings, the deposit transactions, the unit values editor, the
153
+ * maintenance detail), and a hand-made frame outside the scroll wrapper does not clip
154
+ * the rows' tint to its corners.
155
+ */
156
+ framed?: boolean;
157
+ /**
158
+ * The rule between body rows. Default true. `false` for a small table whose rows are
159
+ * spaced by their own content — keksdose's VAT summary, a block of three quiet
160
+ * columns inside a card — which otherwise had to drop to raw `<tr>`s to lose it. One
161
+ * row can still say otherwise with {@link TableRowProps.bordered}.
162
+ */
163
+ rowDividers?: boolean;
105
164
  }
106
165
 
107
166
  /**
@@ -127,6 +186,8 @@ export function Table({
127
186
  hover = false,
128
187
  wrapperClassName,
129
188
  layout,
189
+ framed = false,
190
+ rowDividers = true,
130
191
  className,
131
192
  "aria-label": ariaLabel,
132
193
  ...rest
@@ -141,13 +202,14 @@ export function Table({
141
202
  density,
142
203
  zebra,
143
204
  hover,
205
+ rowDividers,
144
206
  captionId,
145
207
  registerCaption: () => {
146
208
  setCaptions((n) => n + 1);
147
209
  return () => setCaptions((n) => n - 1);
148
210
  },
149
211
  }),
150
- [density, zebra, hover, captionId],
212
+ [density, zebra, hover, rowDividers, captionId],
151
213
  );
152
214
 
153
215
  const regionName = captions > 0 ? { "aria-labelledby": captionId } : ariaLabel ? { "aria-label": ariaLabel } : null;
@@ -163,9 +225,13 @@ export function Table({
163
225
  tabIndex={overflowing ? 0 : undefined}
164
226
  data-overflowing={overflowing || undefined}
165
227
  data-clips=""
228
+ data-framed={framed || undefined}
166
229
  className={cn(
167
230
  // `relative`: the containing block for an `sr-only` caption.
168
231
  "relative w-full overflow-x-auto",
232
+ // On the scroll wrapper itself, so its clipping keeps the rows inside the
233
+ // rounded corners.
234
+ framed && "rounded-md border border-[var(--border)]",
169
235
  THIN_SCROLLBAR_CLASS,
170
236
  "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--brand)]",
171
237
  wrapperClassName,
@@ -187,12 +253,16 @@ export function Table({
187
253
  );
188
254
  }
189
255
 
190
- export type TableHeadProps = ComponentPropsWithoutRef<"thead">;
256
+ export interface TableHeadProps extends ComponentPropsWithoutRef<"thead"> {
257
+ /** The rule under the head. Default true; `false` for a quiet header over a small
258
+ * table (keksdose's VAT summary wrote `className="border-b-0"` to lose it). */
259
+ bordered?: boolean;
260
+ }
191
261
 
192
- export function TableHead({ className, ...rest }: TableHeadProps) {
262
+ export function TableHead({ bordered = true, className, ...rest }: TableHeadProps) {
193
263
  return (
194
264
  <SectionContext.Provider value="head">
195
- <thead {...rest} className={cn("border-b border-[var(--border)]", className)} />
265
+ <thead {...rest} className={cn(bordered && "border-b border-[var(--border)]", className)} />
196
266
  </SectionContext.Provider>
197
267
  );
198
268
  }
@@ -290,24 +360,42 @@ export function TableFoot({ className, ...rest }: TableFootProps) {
290
360
  );
291
361
  }
292
362
 
363
+ const ROW_VARIANT: Record<TableRowVariant, string | false> = {
364
+ row: false,
365
+ group: "bg-[var(--bg-surface-2)] font-semibold",
366
+ subtotal: "font-medium",
367
+ // A 2px rule over the 1px divider the row above draws on its bottom: collapsed
368
+ // borders meet on one line, the wider wins, and the heavier rule is what says "sum".
369
+ total: "border-t-2 border-t-[var(--border-strong)] font-semibold",
370
+ };
371
+
293
372
  /** The deprecated HTML `valign` attribute is replaced by a class-backed one. */
294
373
  export interface TableRowProps extends ComponentPropsWithoutRef<"tr"> {
295
374
  /** Vertical alignment for every cell of the row that does not set its own. See
296
375
  * {@link TableVAlign}. Left out, each cell keeps its default. */
297
376
  valign?: TableVAlign;
377
+ /** See {@link TableRowVariant}. Default `row`. Zebra and hover tint ordinary rows
378
+ * only: a group heading or a total is not one more record. */
379
+ variant?: TableRowVariant;
380
+ /** The rule under this body row. Default: the table's `rowDividers`. */
381
+ bordered?: boolean;
298
382
  }
299
383
 
300
- export function TableRow({ valign, className, ...rest }: TableRowProps) {
301
- const { zebra, hover } = useContext(TableContext);
302
- const section = useContext(SectionContext);
384
+ export function TableRow({ valign, variant = "row", bordered, className, ...rest }: TableRowProps) {
385
+ const { zebra, hover, rowDividers } = useContext(TableContext);
386
+ const [section, ref] = useSection<HTMLTableRowElement>();
303
387
  const body = section === "body";
388
+ const record = body && variant === "row";
304
389
  const row = (
305
390
  <tr
391
+ ref={ref}
306
392
  {...rest}
393
+ data-variant={variant === "row" ? undefined : variant}
307
394
  className={cn(
308
- body && "border-b border-[var(--border)]",
309
- body && zebra && "even:bg-[var(--bg-surface-2)]",
310
- body && hover && "transition-colors hover:bg-[var(--bg-hover)]",
395
+ body && (bordered ?? rowDividers) && "border-b border-[var(--border)]",
396
+ record && zebra && "even:bg-[var(--bg-surface-2)]",
397
+ record && hover && "transition-colors hover:bg-[var(--bg-hover)]",
398
+ ROW_VARIANT[variant],
311
399
  // On the row as well, for a raw `<td>` of the caller's, which inherits it.
312
400
  valign && VALIGN[valign],
313
401
  className,
@@ -356,12 +444,13 @@ export function TableHeaderCell({
356
444
  ...rest
357
445
  }: TableHeaderCellProps) {
358
446
  const { density } = useContext(TableContext);
359
- const section = useContext(SectionContext);
447
+ const [section, ref] = useSection<HTMLTableCellElement>();
360
448
  const rowVAlign = useContext(RowVAlignContext);
361
449
  const head = section === "head";
362
450
  const textSize = size ?? (head ? "xs" : undefined);
363
451
  return (
364
452
  <th
453
+ ref={ref}
365
454
  {...rest}
366
455
  scope={scope ?? (section === "head" ? "col" : "row")}
367
456
  className={cn(
@@ -0,0 +1,248 @@
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
+ /**
64
+ * Draws `props` through `render` as a component of its own, so a router link's hooks
65
+ * belong to it and not to the kit component calling it — for the kit components that
66
+ * call their own `renderLink` inline and reach the provider's link through here.
67
+ * @internal
68
+ */
69
+ export function RenderedKitLink<P>({ render, props }: { render: (props: P) => ReactElement; props: P }) {
70
+ return render(props);
71
+ }
72
+
73
+ /* ── TextLink ─────────────────────────────────────────────────────────────── */
74
+
75
+ /** `brand` (default) for a link in running text, `muted` for footer and meta links
76
+ * that should not compete with the content, `danger` for a destructive one ("Leave
77
+ * group"), `inherit` for a link that takes its surroundings' colour. */
78
+ export type TextLinkTone = "brand" | "muted" | "danger" | "inherit";
79
+
80
+ /** `hover` (default): underlined under the pointer — the idiom all three apps draw.
81
+ * `always` for a link inside prose, where colour alone must not be the only mark
82
+ * (WCAG 1.4.1). `none` where the context already says "link" (a footer nav). */
83
+ export type TextLinkUnderline = "hover" | "always" | "none";
84
+
85
+ /** The `aria-current` token. `true` on {@link TextLinkProps.current} means `page`. */
86
+ export type TextLinkCurrent = "page" | "step" | "location" | "date" | "time" | "true";
87
+
88
+ /** What {@link TextLinkProps.renderLink} (and the provider's `linkComponent`) is
89
+ * handed. Spread it onto your router's link — `({ href, ...p }) => <Link to={href} {...p} />`. */
90
+ export interface TextLinkRenderProps {
91
+ href: string;
92
+ /** The link's look — keep it, or the tone, underline and focus ring go. */
93
+ className: string;
94
+ children: ReactNode;
95
+ ref?: Ref<HTMLAnchorElement>;
96
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
97
+ onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;
98
+ "aria-current"?: TextLinkCurrent;
99
+ id?: string;
100
+ title?: string;
101
+ [key: `aria-${string}`]: string | boolean | number | undefined;
102
+ [key: `data-${string}`]: unknown;
103
+ }
104
+
105
+ export interface TextLinkProps
106
+ extends Omit<ComponentPropsWithoutRef<"a">, "href" | "children" | "aria-current" | "onClick" | "onKeyDown"> {
107
+ href: string;
108
+ children: ReactNode;
109
+ /** Default `brand`. */
110
+ tone?: TextLinkTone;
111
+ /** Default `hover`. */
112
+ underline?: TextLinkUnderline;
113
+ /**
114
+ * The link leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`
115
+ * (never the router's), an external-link mark after the text (mirrored in RTL), and
116
+ * "opens in a new tab" (`common.opensInNewTab`) read after it — the mark says it
117
+ * only to the eye. kastlan's BWO / BFS source links (rent-increase-calculator-page).
118
+ */
119
+ external?: boolean;
120
+ /**
121
+ * Keep the click (and the Enter that activates the link) from reaching an ancestor —
122
+ * a link in a DataTable row with `onRowClick` goes to its own target instead of
123
+ * running the row's action. kastlan's `CellLink` did this by hand.
124
+ */
125
+ stopPropagation?: boolean;
126
+ /** Mark the link as the current one of a set — `true` is `aria-current="page"`, the
127
+ * legal footer's link to the page you are on. Drawn in semibold. */
128
+ current?: boolean | TextLinkCurrent;
129
+ /** A Lucide icon before the text, at the text's size — kastlan's "← Back" link. */
130
+ icon?: LucideIcon;
131
+ /** Your router's link for an in-app `href`. Default: the `<UiKitProvider
132
+ * linkComponent>`, then a plain `<a>`. Ignored on an `external` link. */
133
+ renderLink?: (props: TextLinkRenderProps) => ReactElement;
134
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
135
+ onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;
136
+ ref?: Ref<HTMLAnchorElement>;
137
+ }
138
+
139
+ const TONE: Record<TextLinkTone, string> = {
140
+ brand: "text-[var(--brand)] hover:text-[var(--brand-hover)]",
141
+ muted: "text-[var(--text-muted)] hover:text-[var(--text-primary)]",
142
+ danger: "text-[var(--danger)] hover:text-[var(--danger-hover)]",
143
+ inherit: "",
144
+ };
145
+
146
+ const UNDERLINE: Record<TextLinkUnderline, string> = {
147
+ hover: "no-underline hover:underline",
148
+ always: "underline",
149
+ none: "no-underline",
150
+ };
151
+
152
+ /**
153
+ * The one inline link: a real `<a href>` (middle click, "open in new tab", link
154
+ * semantics) through the app's router for an in-app path, in one of four tones.
155
+ *
156
+ * kastlan draws this link a dozen ways by hand — `EntityLink` and `CellLink` (text-primary,
157
+ * hover underline, one stopping the row click), the legal pages' back link and footer,
158
+ * the feedback page's URL links, the calculator's external sources with a hand-placed
159
+ * ↗ and no word for a screen reader — and the lease wizard's unit step (lease-unit-step)
160
+ * used a raw `<a>` to an in-app route, which reloads the whole app. Here the router
161
+ * link comes from `<UiKitProvider linkComponent>`, set once.
162
+ *
163
+ * Inline, not `inline-flex`: a link inside a sentence must wrap with it. The icons sit
164
+ * on the text's baseline at the text's size, so they scale with whatever type the link
165
+ * is set in. The focus ring is an outline, which follows every line of a wrapped link.
166
+ */
167
+ export function TextLink({
168
+ href,
169
+ children,
170
+ tone = "brand",
171
+ underline = "hover",
172
+ external = false,
173
+ stopPropagation = false,
174
+ current,
175
+ icon: Icon,
176
+ renderLink,
177
+ className,
178
+ onClick,
179
+ onKeyDown,
180
+ ref,
181
+ ...rest
182
+ }: TextLinkProps) {
183
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
184
+ const kitLink = useKitLink();
185
+ const ariaCurrent: TextLinkCurrent | undefined =
186
+ current === true ? "page" : current === false ? undefined : current;
187
+
188
+ const look = cn(
189
+ "rounded-sm underline-offset-2 transition-colors",
190
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--brand)]",
191
+ TONE[tone],
192
+ UNDERLINE[underline],
193
+ ariaCurrent && "font-semibold",
194
+ className,
195
+ );
196
+
197
+ const body = (
198
+ <>
199
+ {Icon && <Icon aria-hidden className="me-1 inline size-[1em] shrink-0 align-[-0.125em]" />}
200
+ {children}
201
+ {external && (
202
+ <>
203
+ <ExternalLink aria-hidden className="ms-1 inline size-[0.85em] align-[-0.1em] rtl:-scale-x-100" />
204
+ {" "}
205
+ <span className="sr-only">({common.opensInNewTab})</span>
206
+ </>
207
+ )}
208
+ </>
209
+ );
210
+
211
+ const props: TextLinkRenderProps = {
212
+ ...(rest as Partial<TextLinkRenderProps>),
213
+ ref,
214
+ href,
215
+ className: look,
216
+ "aria-current": ariaCurrent,
217
+ onClick: stopPropagation
218
+ ? (event) => {
219
+ event.stopPropagation();
220
+ onClick?.(event);
221
+ }
222
+ : onClick,
223
+ onKeyDown: stopPropagation
224
+ ? (event) => {
225
+ if (event.key === "Enter") event.stopPropagation();
226
+ onKeyDown?.(event);
227
+ }
228
+ : onKeyDown,
229
+ children: body,
230
+ };
231
+
232
+ const render = external ? undefined : pickLinkRenderer(renderLink, kitLink, href);
233
+ if (render) return <RenderedTextLink render={render} {...props} />;
234
+ const { children: content, ...anchor } = props;
235
+ return (
236
+ <a {...anchor} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : null)}>
237
+ {content}
238
+ </a>
239
+ );
240
+ }
241
+
242
+ /** Calls the link renderer as a component, so a router link's hooks are its own. */
243
+ function RenderedTextLink({
244
+ render,
245
+ ...props
246
+ }: TextLinkRenderProps & { render: (props: TextLinkRenderProps) => ReactElement }) {
247
+ return render(props);
248
+ }
@@ -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;
@@ -143,6 +156,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
143
156
  disabled = false,
144
157
  size = "md",
145
158
  label,
159
+ labelPlacement = "field",
146
160
  hint,
147
161
  error,
148
162
  "aria-label": ariaLabelAttr,
@@ -150,11 +164,17 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
150
164
  } = props;
151
165
  const labelId = useId();
152
166
  const errorId = useId();
153
- const field = label !== undefined && label !== null && label !== false && label !== "";
154
- const hasError = field && error !== undefined && error !== null && error !== false && error !== "";
167
+ const labelled = label !== undefined && label !== null && label !== false && label !== "";
168
+ // `field` is the chrome; a label placed above keeps the bare group's own box.
169
+ const field = labelled && labelPlacement === "field";
170
+ const above = labelled && labelPlacement === "above";
171
+ const hasError = labelled && error !== undefined && error !== null && error !== false && error !== "";
155
172
  // Taken off the rest so neither reaches the DOM; `props` keeps them paired, which is
156
173
  // what lets the `onChange` below be called with `null` only in the mode that allows it.
157
174
  const { allowEmpty: _allowEmpty, onChange: _onChange, caption, ...rest } = restWithMode;
175
+ // Invalid from outside too: a `Field` hands the bare group `aria-invalid`, and the
176
+ // border has to say what the attribute says.
177
+ const outsideInvalid = rest["aria-invalid"] === true || rest["aria-invalid"] === "true";
158
178
  const captionId = useId();
159
179
  const captionIsLive = typeof caption === "function";
160
180
  const captionNode = captionIsLive ? (caption as (v: T | null) => ReactNode)(value) : caption;
@@ -202,7 +222,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
202
222
  // The DOM spelling wins; `ariaLabel` is the fallback for the call sites that
203
223
  // have not moved yet.
204
224
  aria-label={ariaLabelAttr ?? ariaLabel}
205
- aria-labelledby={field && ariaLabelAttr === undefined && ariaLabel === undefined ? labelId : rest["aria-labelledby"]}
225
+ aria-labelledby={labelled && ariaLabelAttr === undefined && ariaLabel === undefined ? labelId : rest["aria-labelledby"]}
206
226
  aria-invalid={hasError || rest["aria-invalid"] || undefined}
207
227
  aria-describedby={
208
228
  [rest["aria-describedby"], hasCaption && captionId, hasError && errorId].filter(Boolean).join(" ") || undefined
@@ -229,10 +249,12 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
229
249
  // package does; `cursor-not-allowed` is on the buttons, which is what a
230
250
  // pointer is actually over.
231
251
  disabled && "opacity-60",
252
+ // The bare group (no chrome to paint) wears the invalid border itself.
253
+ !field && (outsideInvalid || (above && hasError)) && FIELD_INVALID,
232
254
  // Inside the field's chrome the group is only a row of segments: no border, no
233
255
  // surface, no padding of its own, and the field (not the group) is what dims.
234
256
  field && "border-0 bg-transparent p-0 shadow-none opacity-100",
235
- !field && className,
257
+ !labelled && className,
236
258
  )}
237
259
  >
238
260
  {options.map((opt, index) => {
@@ -308,6 +330,36 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
308
330
  {hasCaption ? captionNode : null}
309
331
  </p>
310
332
  ) : null;
333
+ const errorEl = hasError ? (
334
+ <p id={errorId} className="mt-1 text-[11px] leading-tight text-[var(--danger)]">
335
+ {error}
336
+ </p>
337
+ ) : null;
338
+ if (above) {
339
+ // `relative` so a caller's `sr-only` label cannot escape (sr-only-containment).
340
+ return (
341
+ <div className={cn("relative grid min-w-0 gap-1.5", className)}>
342
+ <div className="flex items-center gap-1">
343
+ {/* A `<label>` with no `htmlFor`: a group is not labelable, so it is named
344
+ by `aria-labelledby` on the group; the element keeps the Field look. */}
345
+ <Label
346
+ id={labelId}
347
+ disabled={disabled}
348
+ data-error={hasError || undefined}
349
+ className="data-[error=true]:text-[var(--danger)]"
350
+ >
351
+ {label}
352
+ </Label>
353
+ {hint}
354
+ </div>
355
+ <div className="min-w-0">
356
+ {group}
357
+ {captionEl}
358
+ {errorEl}
359
+ </div>
360
+ </div>
361
+ );
362
+ }
311
363
  if (!field) {
312
364
  if (!captionEl) return group;
313
365
  // The group keeps its `className`, as without a caption; the wrapper only stacks.
@@ -339,11 +391,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
339
391
  </div>
340
392
  </FloatingField>
341
393
  {captionEl}
342
- {hasError && (
343
- <p id={errorId} className="mt-1 text-[11px] leading-tight text-[var(--danger)]">
344
- {error}
345
- </p>
346
- )}
394
+ {errorEl}
347
395
  </div>
348
396
  );
349
397
  }