@eifi1/ui-kit 0.10.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 (382) hide show
  1. package/README.md +66 -37
  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/alert-banner.d.ts +26 -1
  12. package/dist/components/alert-banner.js +10 -7
  13. package/dist/components/alert-banner.js.map +1 -1
  14. package/dist/components/amount-input.d.ts +26 -4
  15. package/dist/components/amount-input.js +5 -2
  16. package/dist/components/amount-input.js.map +1 -1
  17. package/dist/components/authed-image.d.ts +68 -0
  18. package/dist/components/authed-image.js +117 -0
  19. package/dist/components/authed-image.js.map +1 -0
  20. package/dist/components/breadcrumbs.d.ts +2 -1
  21. package/dist/components/breadcrumbs.js +5 -2
  22. package/dist/components/breadcrumbs.js.map +1 -1
  23. package/dist/components/bulk-action-bar.d.ts +51 -5
  24. package/dist/components/bulk-action-bar.js +88 -41
  25. package/dist/components/bulk-action-bar.js.map +1 -1
  26. package/dist/components/button-group.d.ts +107 -3
  27. package/dist/components/button-group.js +60 -5
  28. package/dist/components/button-group.js.map +1 -1
  29. package/dist/components/calculator.d.ts +17 -4
  30. package/dist/components/calendar-heatmap.d.ts +162 -0
  31. package/dist/components/calendar-heatmap.js +336 -0
  32. package/dist/components/calendar-heatmap.js.map +1 -0
  33. package/dist/components/chip.d.ts +2 -1
  34. package/dist/components/chip.js +6 -3
  35. package/dist/components/chip.js.map +1 -1
  36. package/dist/components/choice-card.d.ts +66 -2
  37. package/dist/components/choice-card.js +108 -1
  38. package/dist/components/choice-card.js.map +1 -1
  39. package/dist/components/combobox-core.js +2 -0
  40. package/dist/components/combobox-core.js.map +1 -1
  41. package/dist/components/combobox.d.ts +2 -2
  42. package/dist/components/combobox.js +31 -9
  43. package/dist/components/combobox.js.map +1 -1
  44. package/dist/components/copy-button.d.ts +52 -70
  45. package/dist/components/copy-button.js +1 -1
  46. package/dist/components/copy-button.js.map +1 -1
  47. package/dist/components/data-table-cells.d.ts +63 -0
  48. package/dist/components/data-table-cells.js +64 -0
  49. package/dist/components/data-table-cells.js.map +1 -0
  50. package/dist/components/data-table-filter-popover.d.ts +1 -1
  51. package/dist/components/data-table-filters.d.ts +1 -1
  52. package/dist/components/data-table-filters.js +31 -1
  53. package/dist/components/data-table-filters.js.map +1 -1
  54. package/dist/components/data-table-labels.d.ts +13 -0
  55. package/dist/components/data-table-labels.js +8 -1
  56. package/dist/components/data-table-labels.js.map +1 -1
  57. package/dist/components/data-table.d.ts +1 -1
  58. package/dist/components/data-table.js +104 -12
  59. package/dist/components/data-table.js.map +1 -1
  60. package/dist/components/date-picker.d.ts +3 -1
  61. package/dist/components/date-picker.js +15 -2
  62. package/dist/components/date-picker.js.map +1 -1
  63. package/dist/components/description-list.d.ts +23 -3
  64. package/dist/components/description-list.js +23 -3
  65. package/dist/components/description-list.js.map +1 -1
  66. package/dist/components/dialog-frame.d.ts +1 -0
  67. package/dist/components/entity-combobox.d.ts +1 -1
  68. package/dist/components/entity-combobox.js +18 -3
  69. package/dist/components/entity-combobox.js.map +1 -1
  70. package/dist/components/error-boundary.d.ts +92 -0
  71. package/dist/components/error-boundary.js +111 -0
  72. package/dist/components/error-boundary.js.map +1 -0
  73. package/dist/components/field.d.ts +69 -0
  74. package/dist/components/field.js +75 -0
  75. package/dist/components/field.js.map +1 -0
  76. package/dist/components/file-button.d.ts +51 -208
  77. package/dist/components/file-dropzone.d.ts +50 -2
  78. package/dist/components/floating-panel.d.ts +156 -4
  79. package/dist/components/floating-panel.js +193 -29
  80. package/dist/components/floating-panel.js.map +1 -1
  81. package/dist/components/form-actions.d.ts +53 -0
  82. package/dist/components/form-actions.js +98 -0
  83. package/dist/components/form-actions.js.map +1 -0
  84. package/dist/components/image-grid.d.ts +67 -0
  85. package/dist/components/image-grid.js +112 -0
  86. package/dist/components/image-grid.js.map +1 -0
  87. package/dist/components/lightbox.d.ts +104 -0
  88. package/dist/components/lightbox.js +229 -0
  89. package/dist/components/lightbox.js.map +1 -0
  90. package/dist/components/line-items.d.ts +114 -0
  91. package/dist/components/line-items.js +238 -0
  92. package/dist/components/line-items.js.map +1 -0
  93. package/dist/components/list.d.ts +37 -3
  94. package/dist/components/list.js +39 -14
  95. package/dist/components/list.js.map +1 -1
  96. package/dist/components/loading-state.d.ts +24 -0
  97. package/dist/components/loading-state.js +32 -0
  98. package/dist/components/loading-state.js.map +1 -0
  99. package/dist/components/menu-item.d.ts +13 -2
  100. package/dist/components/menu-item.js +11 -2
  101. package/dist/components/menu-item.js.map +1 -1
  102. package/dist/components/mini-calendar.d.ts +82 -5
  103. package/dist/components/mini-calendar.js +212 -64
  104. package/dist/components/mini-calendar.js.map +1 -1
  105. package/dist/components/modal.d.ts +20 -1
  106. package/dist/components/modal.js +26 -2
  107. package/dist/components/modal.js.map +1 -1
  108. package/dist/components/month-picker.d.ts +21 -1
  109. package/dist/components/month-picker.js +139 -26
  110. package/dist/components/month-picker.js.map +1 -1
  111. package/dist/components/multi-entity-combobox.d.ts +1 -1
  112. package/dist/components/multi-entity-combobox.js +19 -3
  113. package/dist/components/multi-entity-combobox.js.map +1 -1
  114. package/dist/components/nav-pills.d.ts +67 -0
  115. package/dist/components/nav-pills.js +69 -0
  116. package/dist/components/nav-pills.js.map +1 -0
  117. package/dist/components/number-field.d.ts +26 -5
  118. package/dist/components/number-field.js +5 -7
  119. package/dist/components/number-field.js.map +1 -1
  120. package/dist/components/number-input.d.ts +27 -5
  121. package/dist/components/number-input.js +7 -1
  122. package/dist/components/number-input.js.map +1 -1
  123. package/dist/components/numpad-sheet.d.ts +17 -4
  124. package/dist/components/passkeys-setting.d.ts +67 -0
  125. package/dist/components/passkeys-setting.js +214 -0
  126. package/dist/components/passkeys-setting.js.map +1 -0
  127. package/dist/components/picker-sheet.js +2 -1
  128. package/dist/components/picker-sheet.js.map +1 -1
  129. package/dist/components/pie-chart-labels.d.ts +20 -0
  130. package/dist/components/pie-chart-labels.js +12 -0
  131. package/dist/components/pie-chart-labels.js.map +1 -0
  132. package/dist/components/pie-chart.d.ts +101 -0
  133. package/dist/components/pie-chart.js +283 -0
  134. package/dist/components/pie-chart.js.map +1 -0
  135. package/dist/components/progress-bar.d.ts +73 -3
  136. package/dist/components/progress-bar.js +110 -8
  137. package/dist/components/progress-bar.js.map +1 -1
  138. package/dist/components/qr-code.d.ts +34 -0
  139. package/dist/components/qr-code.js +57 -0
  140. package/dist/components/qr-code.js.map +1 -0
  141. package/dist/components/search-field.d.ts +15 -3
  142. package/dist/components/search-field.js +5 -1
  143. package/dist/components/search-field.js.map +1 -1
  144. package/dist/components/series-chart.d.ts +16 -0
  145. package/dist/components/series-chart.js +4 -1
  146. package/dist/components/series-chart.js.map +1 -1
  147. package/dist/components/settings-fields.d.ts +50 -1
  148. package/dist/components/signed-amount.d.ts +121 -0
  149. package/dist/components/signed-amount.js +152 -0
  150. package/dist/components/signed-amount.js.map +1 -0
  151. package/dist/components/skeleton.d.ts +10 -3
  152. package/dist/components/skeleton.js +12 -2
  153. package/dist/components/skeleton.js.map +1 -1
  154. package/dist/components/stat-tile.d.ts +2 -1
  155. package/dist/components/stat-tile.js +5 -2
  156. package/dist/components/stat-tile.js.map +1 -1
  157. package/dist/components/status-dot.d.ts +6 -2
  158. package/dist/components/status-dot.js +6 -1
  159. package/dist/components/status-dot.js.map +1 -1
  160. package/dist/components/table.d.ts +77 -11
  161. package/dist/components/table.js +85 -17
  162. package/dist/components/table.js.map +1 -1
  163. package/dist/components/text-link.d.ts +176 -0
  164. package/dist/components/text-link.js +109 -0
  165. package/dist/components/text-link.js.map +1 -0
  166. package/dist/components/text.d.ts +8 -1
  167. package/dist/components/text.js +1 -0
  168. package/dist/components/text.js.map +1 -1
  169. package/dist/components/time-input.d.ts +50 -1
  170. package/dist/components/toggle-group.d.ts +28 -0
  171. package/dist/components/toggle-group.js +58 -9
  172. package/dist/components/toggle-group.js.map +1 -1
  173. package/dist/components/toggle-legend.d.ts +36 -3
  174. package/dist/components/toggle-legend.js +49 -0
  175. package/dist/components/toggle-legend.js.map +1 -1
  176. package/dist/components/tooltip.d.ts +11 -3
  177. package/dist/components/tooltip.js +2 -0
  178. package/dist/components/tooltip.js.map +1 -1
  179. package/dist/components/ui.d.ts +52 -729
  180. package/dist/components/ui.js +296 -27
  181. package/dist/components/ui.js.map +1 -1
  182. package/dist/components/use-table-state.d.ts +3 -52
  183. package/dist/components/use-table-state.js +128 -30
  184. package/dist/components/use-table-state.js.map +1 -1
  185. package/dist/components/user-avatar.d.ts +25 -3
  186. package/dist/components/user-avatar.js +32 -3
  187. package/dist/components/user-avatar.js.map +1 -1
  188. package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
  189. package/dist/data-table.d.ts +2 -1
  190. package/dist/data-table.js +5 -1
  191. package/dist/data-table.js.map +1 -1
  192. package/dist/feedback/feedback-dialog.d.ts +26 -3
  193. package/dist/feedback/feedback-dialog.js +16 -2
  194. package/dist/feedback/feedback-dialog.js.map +1 -1
  195. package/dist/feedback/feedback-thread.d.ts +133 -0
  196. package/dist/feedback/feedback-thread.js +237 -0
  197. package/dist/feedback/feedback-thread.js.map +1 -0
  198. package/dist/feedback.d.ts +2 -1
  199. package/dist/feedback.js +1 -0
  200. package/dist/feedback.js.map +1 -1
  201. package/dist/hooks/use-authed-src.d.ts +71 -0
  202. package/dist/hooks/use-authed-src.js +63 -0
  203. package/dist/hooks/use-authed-src.js.map +1 -0
  204. package/dist/hooks/use-file-drop.d.ts +50 -2
  205. package/dist/hooks/use-hotkey.d.ts +59 -0
  206. package/dist/hooks/use-hotkey.js +105 -0
  207. package/dist/hooks/use-hotkey.js.map +1 -0
  208. package/dist/hooks/use-search-param-state.d.ts +83 -0
  209. package/dist/hooks/use-search-param-state.js +94 -0
  210. package/dist/hooks/use-search-param-state.js.map +1 -0
  211. package/dist/i18n/defaults.d.ts +17 -4
  212. package/dist/i18n/defaults.js +33 -1
  213. package/dist/i18n/defaults.js.map +1 -1
  214. package/dist/i18n/kit-labels.d.ts +47 -310
  215. package/dist/i18n/kit-labels.js +20 -5
  216. package/dist/i18n/kit-labels.js.map +1 -1
  217. package/dist/i18n/locales/de-CH-informal.d.ts +17 -4
  218. package/dist/i18n/locales/de-CH.d.ts +17 -4
  219. package/dist/i18n/locales/de-informal.d.ts +17 -4
  220. package/dist/i18n/locales/de.d.ts +17 -4
  221. package/dist/i18n/locales/de.js +165 -4
  222. package/dist/i18n/locales/de.js.map +1 -1
  223. package/dist/i18n/locales/es.d.ts +17 -4
  224. package/dist/i18n/locales/es.js +162 -4
  225. package/dist/i18n/locales/es.js.map +1 -1
  226. package/dist/i18n/locales/fr.d.ts +17 -4
  227. package/dist/i18n/locales/fr.js +162 -4
  228. package/dist/i18n/locales/fr.js.map +1 -1
  229. package/dist/i18n/locales/hu.d.ts +17 -4
  230. package/dist/i18n/locales/hu.js +162 -4
  231. package/dist/i18n/locales/hu.js.map +1 -1
  232. package/dist/i18n/locales/it.d.ts +17 -4
  233. package/dist/i18n/locales/it.js +162 -4
  234. package/dist/i18n/locales/it.js.map +1 -1
  235. package/dist/i18n/locales/zh.d.ts +17 -4
  236. package/dist/i18n/locales/zh.js +162 -4
  237. package/dist/i18n/locales/zh.js.map +1 -1
  238. package/dist/index.d.ts +45 -21
  239. package/dist/index.js +43 -1
  240. package/dist/index.js.map +1 -1
  241. package/dist/kit-labels-v3biUF1L.d.ts +1645 -0
  242. package/dist/lib/clipping.d.ts +21 -5
  243. package/dist/lib/clipping.js +3 -0
  244. package/dist/lib/clipping.js.map +1 -1
  245. package/dist/lib/dates.d.ts +17 -1
  246. package/dist/lib/dates.js +19 -0
  247. package/dist/lib/dates.js.map +1 -1
  248. package/dist/lib/format.d.ts +144 -0
  249. package/dist/lib/format.js +149 -0
  250. package/dist/lib/format.js.map +1 -0
  251. package/dist/lib/qr-encode.d.ts +44 -0
  252. package/dist/lib/qr-encode.js +338 -0
  253. package/dist/lib/qr-encode.js.map +1 -0
  254. package/dist/rhf/fields.d.ts +241 -0
  255. package/dist/rhf/fields.js +601 -0
  256. package/dist/rhf/fields.js.map +1 -0
  257. package/dist/rhf/form.d.ts +50 -1
  258. package/dist/rhf/line-items.d.ts +31 -0
  259. package/dist/rhf/line-items.js +59 -0
  260. package/dist/rhf/line-items.js.map +1 -0
  261. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  262. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  263. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  264. package/dist/rhf.d.ts +63 -1
  265. package/dist/rhf.js +3 -0
  266. package/dist/rhf.js.map +1 -1
  267. package/dist/shell/app-shell.d.ts +99 -2
  268. package/dist/shell/app-shell.js +58 -3
  269. package/dist/shell/app-shell.js.map +1 -1
  270. package/dist/shell/auth-layout.d.ts +74 -0
  271. package/dist/shell/auth-layout.js +97 -0
  272. package/dist/shell/auth-layout.js.map +1 -0
  273. package/dist/shell/top-bar-brand.d.ts +81 -0
  274. package/dist/shell/top-bar-brand.js +47 -0
  275. package/dist/shell/top-bar-brand.js.map +1 -0
  276. package/dist/shell/topbar-action-menu.d.ts +84 -3
  277. package/dist/shell/topbar-action-menu.js +115 -27
  278. package/dist/shell/topbar-action-menu.js.map +1 -1
  279. package/dist/shell.d.ts +58 -2
  280. package/dist/shell.js +2 -0
  281. package/dist/shell.js.map +1 -1
  282. package/dist/wizard/stepper-nav.d.ts +49 -1
  283. package/dist/wizard/wizard-context.d.ts +30 -1
  284. package/dist/wizard/wizard-context.js +23 -2
  285. package/dist/wizard/wizard-context.js.map +1 -1
  286. package/dist/wizard/wizard-step.d.ts +20 -5
  287. package/dist/wizard/wizard-step.js +16 -3
  288. package/dist/wizard/wizard-step.js.map +1 -1
  289. package/dist/wizard.d.ts +51 -3
  290. package/package.json +1 -1
  291. package/src/chart.ts +4 -0
  292. package/src/components/account-settings-labels.ts +190 -0
  293. package/src/components/account-settings.tsx +110 -63
  294. package/src/components/alert-banner.tsx +36 -7
  295. package/src/components/amount-input.tsx +13 -1
  296. package/src/components/authed-image.tsx +190 -0
  297. package/src/components/breadcrumbs.tsx +8 -2
  298. package/src/components/bulk-action-bar.tsx +163 -57
  299. package/src/components/button-group.tsx +129 -4
  300. package/src/components/calendar-heatmap.tsx +599 -0
  301. package/src/components/chip.tsx +8 -4
  302. package/src/components/choice-card.tsx +208 -1
  303. package/src/components/combobox-core.tsx +5 -0
  304. package/src/components/combobox.tsx +31 -9
  305. package/src/components/copy-button.tsx +3 -2
  306. package/src/components/data-table-cells.tsx +119 -0
  307. package/src/components/data-table-filters.ts +77 -0
  308. package/src/components/data-table-labels.ts +23 -0
  309. package/src/components/data-table.tsx +231 -13
  310. package/src/components/date-picker.tsx +19 -3
  311. package/src/components/description-list.tsx +52 -3
  312. package/src/components/entity-combobox.tsx +18 -3
  313. package/src/components/error-boundary.tsx +216 -0
  314. package/src/components/field.tsx +188 -0
  315. package/src/components/floating-panel.tsx +380 -16
  316. package/src/components/form-actions.tsx +191 -0
  317. package/src/components/image-grid.tsx +181 -0
  318. package/src/components/lightbox.tsx +374 -0
  319. package/src/components/line-items.tsx +385 -0
  320. package/src/components/list.tsx +93 -18
  321. package/src/components/loading-state.tsx +47 -0
  322. package/src/components/menu-item.tsx +29 -3
  323. package/src/components/mini-calendar.tsx +317 -57
  324. package/src/components/modal.tsx +61 -3
  325. package/src/components/month-picker.tsx +181 -26
  326. package/src/components/multi-entity-combobox.tsx +19 -3
  327. package/src/components/nav-pills.tsx +152 -0
  328. package/src/components/number-field.tsx +15 -10
  329. package/src/components/number-input.tsx +16 -1
  330. package/src/components/passkeys-setting.tsx +331 -0
  331. package/src/components/picker-sheet.tsx +7 -1
  332. package/src/components/pie-chart-labels.ts +32 -0
  333. package/src/components/pie-chart.tsx +479 -0
  334. package/src/components/progress-bar.tsx +241 -13
  335. package/src/components/qr-code.tsx +83 -0
  336. package/src/components/search-field.tsx +20 -4
  337. package/src/components/series-chart.tsx +21 -0
  338. package/src/components/signed-amount.tsx +286 -0
  339. package/src/components/skeleton.tsx +21 -3
  340. package/src/components/stat-tile.tsx +8 -2
  341. package/src/components/status-dot.tsx +10 -2
  342. package/src/components/table.tsx +186 -23
  343. package/src/components/text-link.tsx +248 -0
  344. package/src/components/text.tsx +9 -1
  345. package/src/components/toggle-group.tsx +106 -13
  346. package/src/components/toggle-legend.tsx +87 -2
  347. package/src/components/tooltip.tsx +17 -3
  348. package/src/components/ui.tsx +572 -44
  349. package/src/components/use-table-state.ts +265 -33
  350. package/src/components/user-avatar.tsx +57 -3
  351. package/src/data-table.ts +10 -0
  352. package/src/feedback/feedback-dialog.tsx +42 -3
  353. package/src/feedback/feedback-thread.tsx +434 -0
  354. package/src/feedback.ts +1 -0
  355. package/src/hooks/use-authed-src.ts +161 -0
  356. package/src/hooks/use-hotkey.ts +172 -0
  357. package/src/hooks/use-search-param-state.ts +187 -0
  358. package/src/i18n/defaults.ts +32 -0
  359. package/src/i18n/kit-labels.tsx +104 -8
  360. package/src/i18n/locales/de.ts +162 -0
  361. package/src/i18n/locales/es.ts +159 -0
  362. package/src/i18n/locales/fr.ts +162 -0
  363. package/src/i18n/locales/hu.ts +160 -0
  364. package/src/i18n/locales/it.ts +160 -0
  365. package/src/i18n/locales/zh.ts +159 -0
  366. package/src/index.ts +64 -0
  367. package/src/lib/clipping.ts +22 -4
  368. package/src/lib/dates.ts +37 -0
  369. package/src/lib/format.ts +325 -0
  370. package/src/lib/qr-encode.ts +420 -0
  371. package/src/rhf/fields.tsx +1054 -0
  372. package/src/rhf/line-items.tsx +130 -0
  373. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  374. package/src/rhf.ts +8 -0
  375. package/src/shell/app-shell.tsx +147 -2
  376. package/src/shell/auth-layout.tsx +190 -0
  377. package/src/shell/top-bar-brand.tsx +76 -0
  378. package/src/shell/topbar-action-menu.tsx +260 -49
  379. package/src/shell.ts +2 -0
  380. package/src/wizard/wizard-context.tsx +50 -1
  381. package/src/wizard/wizard-step.tsx +43 -6
  382. package/tokens.css +12 -0
@@ -0,0 +1,385 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState } from "react";
2
+ import type { ComponentPropsWithoutRef, CSSProperties, KeyboardEvent, Key, ReactNode } from "react";
3
+ import { Plus, Trash2 } from "lucide-react";
4
+ import { cn } from "../lib/cn";
5
+ import { useKitLabels } from "../i18n/kit-labels";
6
+ import { Button, IconButton } from "./ui";
7
+
8
+ /** The words {@link LineItems} renders on its own behalf. Row numbers are 1-based. */
9
+ export interface LineItemsLabels {
10
+ /** The dashed add button. */
11
+ add: string;
12
+ /** The remove button's name for one row. */
13
+ remove: (row: number) => string;
14
+ /** Its name while it waits for the second press (`confirmRemove`). */
15
+ confirmRemove: (row: number) => string;
16
+ /** A row's group name, when the caller gives no `rowLabel`. */
17
+ row: (row: number) => string;
18
+ /** The name a cell's field should carry: the column, and which row. */
19
+ cell: (column: string, row: number) => string;
20
+ /** The totals row's label, and its group name. */
21
+ totals: string;
22
+ }
23
+
24
+ export const DEFAULT_LINE_ITEMS_LABELS: LineItemsLabels = {
25
+ add: "Add row",
26
+ remove: (row) => `Remove row ${row}`,
27
+ confirmRemove: (row) => `Remove row ${row}? Press again to confirm`,
28
+ row: (row) => `Row ${row}`,
29
+ cell: (column, row) => `${column}, row ${row}`,
30
+ totals: "Total",
31
+ };
32
+
33
+ /** What a column's `render` receives for one row. */
34
+ export interface LineItemCellContext<T> {
35
+ item: T;
36
+ /** 0-based. */
37
+ index: number;
38
+ /** The accessible name for the cell's field — "Debit, row 2". The header is
39
+ * hidden from assistive tech (each row repeats it), so the field has to carry it:
40
+ * `<Input aria-label={label} … />`. */
41
+ label: string;
42
+ }
43
+
44
+ export interface LineItemsColumn<T> {
45
+ /** Stable id: the React key, and the key into `totals`. */
46
+ key: string;
47
+ /** The column header (and the small label over the cell once the rows stack). */
48
+ header: ReactNode;
49
+ /** The column's name for {@link LineItemCellContext.label}, when `header` is not a
50
+ * string. */
51
+ label?: string;
52
+ /** The grid track once the rows are wide enough to be a table. Default
53
+ * `minmax(0,1fr)`; `"7rem"` for an amount. */
54
+ width?: string;
55
+ /** `end` for figures: the header and the totals cell end-align with the field. */
56
+ align?: "start" | "end";
57
+ render: (ctx: LineItemCellContext<T>) => ReactNode;
58
+ }
59
+
60
+ export interface LineItemsProps<T> extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
61
+ items: readonly T[];
62
+ columns: readonly LineItemsColumn<T>[];
63
+ /** The row's React key. Default: the item's `id` (react-hook-form's `useFieldArray`
64
+ * `fields` carry one), else its index. */
65
+ getKey?: (item: T, index: number) => Key;
66
+ /** Append a row. Omitted: no add button. The first field of the new row is focused. */
67
+ onAdd?: () => void;
68
+ /** Remove the row at `index`. Omitted: no remove buttons. */
69
+ onRemove?: (index: number) => void;
70
+ /** Remove is disabled at or below this many rows (a journal entry needs two).
71
+ * Default 0. */
72
+ minItems?: number;
73
+ /** Add is disabled at this many rows. */
74
+ maxItems?: number;
75
+ /**
76
+ * Ask before a row goes:
77
+ * - `true`: the remove button asks for a second press — it fills red and its name
78
+ * becomes `lineItems.confirmRemove`; Escape, or leaving it, calls it off.
79
+ * - a function: called with the row, and the row goes only if it answers `true`
80
+ * (`(item, i) => confirm({ title: … , tone: "danger" })` with `useConfirm`).
81
+ */
82
+ confirmRemove?: boolean | ((item: T, index: number) => boolean | Promise<boolean>);
83
+ /** The add button's text. Default `lineItems.add`. */
84
+ addLabel?: ReactNode;
85
+ /** A row's group name ("Line 2"). Default `lineItems.row`. */
86
+ rowLabel?: (index: number, item: T) => string;
87
+ /** A totals row under the lines, one entry per column `key`. The first column
88
+ * without an entry carries {@link totalsLabel}. Omitted: no totals row. */
89
+ totals?: Partial<Record<string, ReactNode>>;
90
+ /** Default `lineItems.totals`, "Total". */
91
+ totalsLabel?: ReactNode;
92
+ /** Shown in place of the rows when there are none. */
93
+ empty?: ReactNode;
94
+ /** A message about the list as a whole ("The entry does not balance"). */
95
+ error?: ReactNode;
96
+ /** Disables add and remove (the fields are the caller's to disable). */
97
+ disabled?: boolean;
98
+ labels?: Partial<LineItemsLabels>;
99
+ }
100
+
101
+ const REMOVE_TRACK = "2.25rem";
102
+ const FOCUSABLE =
103
+ "input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])";
104
+
105
+ function defaultKey(item: unknown, index: number): Key {
106
+ if (typeof item === "object" && item !== null && "id" in item) {
107
+ const id = (item as { id: unknown }).id;
108
+ if (typeof id === "string" || typeof id === "number") return id;
109
+ }
110
+ return index;
111
+ }
112
+
113
+ function hasContent(node: ReactNode): boolean {
114
+ return node !== undefined && node !== null && node !== false && node !== "";
115
+ }
116
+
117
+ type PendingFocus = { kind: "added" } | { kind: "removed"; index: number };
118
+
119
+ /**
120
+ * An editable list of lines — journal entry lines, lease components, invoice
121
+ * positions, budget rows: column headers, a row of fields per item, a remove button
122
+ * per row, a dashed "add" button under them, and an optional totals row.
123
+ *
124
+ * Presentation only: the rows are the caller's `items` and the fields are the
125
+ * caller's, rendered per column. So it works over plain state —
126
+ *
127
+ * ```tsx
128
+ * <LineItems items={lines} columns={cols} onAdd={() => setLines([...lines, blank()])}
129
+ * onRemove={(i) => setLines(lines.filter((_, j) => j !== i))} minItems={2} />
130
+ * ```
131
+ *
132
+ * — and over react-hook-form's `useFieldArray` alike (`items={fields}`, `onAdd={() =>
133
+ * append(blank())}`, `onRemove={remove}`), for which `@eifi1/ui-kit/rhf` has
134
+ * `RhfLineItems`.
135
+ *
136
+ * On a wide pane the rows are a grid under the headers; on a narrow one (a container
137
+ * query, so a dialog stacks where it is cramped) each row stacks its fields, each
138
+ * under a small copy of its header. The headers are `aria-hidden`: every row is a
139
+ * named group and every field is named by `render`'s `label` ("Debit, row 2"), which
140
+ * is what a screen reader needs — a header row it would read once, out of context.
141
+ *
142
+ * Keyboard: after Add, focus lands in the new row's first field; after a remove, on
143
+ * the row that took its place (else the add button). Ctrl/⌘+Enter in any row adds a
144
+ * row.
145
+ */
146
+ export function LineItems<T>({
147
+ items,
148
+ columns,
149
+ getKey = defaultKey,
150
+ onAdd,
151
+ onRemove,
152
+ minItems = 0,
153
+ maxItems,
154
+ confirmRemove,
155
+ addLabel,
156
+ rowLabel,
157
+ totals,
158
+ totalsLabel,
159
+ empty,
160
+ error,
161
+ disabled = false,
162
+ labels: labelsProp,
163
+ className,
164
+ style,
165
+ ...rest
166
+ }: LineItemsProps<T>) {
167
+ const labels = useKitLabels("lineItems", DEFAULT_LINE_ITEMS_LABELS, labelsProp);
168
+ const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
169
+ const addRef = useRef<HTMLButtonElement>(null);
170
+ const pendingFocus = useRef<PendingFocus | null>(null);
171
+ const [confirmingAt, setConfirming] = useState<number | null>(null);
172
+ // A confirm waiting on a row that has since gone is void.
173
+ const confirming = confirmingAt !== null && confirmingAt < items.length ? confirmingAt : null;
174
+
175
+ const tracks = [...columns.map((c) => c.width ?? "minmax(0,1fr)"), ...(onRemove ? [REMOVE_TRACK] : [])];
176
+ const gridStyle = { "--line-items-cols": tracks.join(" ") } as CSSProperties;
177
+ const canAdd = Boolean(onAdd) && !disabled && (maxItems === undefined || items.length < maxItems);
178
+ const canRemove = Boolean(onRemove) && !disabled && items.length > minItems;
179
+ const hasError = hasContent(error);
180
+
181
+
182
+ // Focus after the list has re-rendered with the new rows — a layout effect, so the
183
+ // caret is where it belongs before paint.
184
+ useLayoutEffect(() => {
185
+ const pending = pendingFocus.current;
186
+ if (!pending) return;
187
+ pendingFocus.current = null;
188
+ if (pending.kind === "added") {
189
+ const row = rowRefs.current[items.length - 1];
190
+ row?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
191
+ return;
192
+ }
193
+ const row = rowRefs.current[Math.min(pending.index, items.length - 1)];
194
+ const target =
195
+ row?.querySelector<HTMLElement>("[data-line-items-remove]:not([disabled])") ??
196
+ row?.querySelector<HTMLElement>(FOCUSABLE) ??
197
+ addRef.current;
198
+ target?.focus();
199
+ }, [items.length]);
200
+
201
+ const add = useCallback(() => {
202
+ if (!canAdd || !onAdd) return;
203
+ pendingFocus.current = { kind: "added" };
204
+ onAdd();
205
+ }, [canAdd, onAdd]);
206
+
207
+ const removeAt = async (index: number) => {
208
+ if (!canRemove || !onRemove) return;
209
+ if (confirmRemove === true && confirming !== index) {
210
+ setConfirming(index);
211
+ return;
212
+ }
213
+ if (typeof confirmRemove === "function") {
214
+ const ok = await confirmRemove(items[index], index);
215
+ if (!ok) return;
216
+ }
217
+ setConfirming(null);
218
+ pendingFocus.current = { kind: "removed", index };
219
+ onRemove(index);
220
+ };
221
+
222
+ const onRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
223
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && canAdd) {
224
+ e.preventDefault();
225
+ add();
226
+ }
227
+ };
228
+
229
+ const columnName = (column: LineItemsColumn<T>) =>
230
+ column.label ?? (typeof column.header === "string" ? column.header : column.key);
231
+
232
+ const headerCell = (column: LineItemsColumn<T>) => (
233
+ <span
234
+ key={column.key}
235
+ className={cn(
236
+ "min-w-0 truncate text-xs font-medium text-[var(--text-muted)]",
237
+ column.align === "end" && "text-end",
238
+ )}
239
+ >
240
+ {column.header}
241
+ </span>
242
+ );
243
+
244
+ const showTotals = totals !== undefined;
245
+ const labelColumn = showTotals ? columns.find((c) => !hasContent(totals[c.key]))?.key : undefined;
246
+
247
+ return (
248
+ <div
249
+ data-slot="line-items"
250
+ {...rest}
251
+ style={{ ...gridStyle, ...style }}
252
+ className={cn("@container space-y-2", className)}
253
+ >
254
+ <div aria-hidden className="hidden gap-2 @lg:grid @lg:grid-cols-[var(--line-items-cols)]">
255
+ {columns.map(headerCell)}
256
+ {onRemove && <span />}
257
+ </div>
258
+
259
+ {items.length === 0 && hasContent(empty) && (
260
+ <div className="rounded-md border border-dashed border-[var(--border)] px-3 py-4 text-center text-sm text-[var(--text-muted)]">
261
+ {empty}
262
+ </div>
263
+ )}
264
+
265
+ {items.map((item, index) => {
266
+ const row = index + 1;
267
+ const isConfirming = confirming === index;
268
+ return (
269
+ // The row's Ctrl/⌘+Enter is a shortcut for the add button, which is itself
270
+ // reachable by Tab; the group only listens for it bubbling from its fields.
271
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
272
+ <div
273
+ key={getKey(item, index)}
274
+ ref={(el) => {
275
+ rowRefs.current[index] = el;
276
+ }}
277
+ role="group"
278
+ aria-label={rowLabel ? rowLabel(index, item) : labels.row(row)}
279
+ data-line-items-row=""
280
+ onKeyDown={onRowKeyDown}
281
+ className={cn(
282
+ "grid grid-cols-1 gap-2 @lg:grid-cols-[var(--line-items-cols)] @lg:items-start",
283
+ // Stacked, each row is a card of its own so the rows stay apart.
284
+ "@max-lg:rounded-md @max-lg:border @max-lg:border-[var(--border)] @max-lg:p-3",
285
+ )}
286
+ >
287
+ {columns.map((column) => (
288
+ <div key={column.key} className="min-w-0 space-y-1 @lg:space-y-0">
289
+ <span aria-hidden className="block text-xs font-medium text-[var(--text-muted)] @lg:hidden">
290
+ {column.header}
291
+ </span>
292
+ {column.render({ item, index, label: labels.cell(columnName(column), row) })}
293
+ </div>
294
+ ))}
295
+ {onRemove && (
296
+ <div className="flex justify-end @lg:justify-center">
297
+ <IconButton
298
+ type="button"
299
+ data-line-items-remove=""
300
+ variant={isConfirming ? "danger" : "ghost"}
301
+ tone={isConfirming ? undefined : "danger"}
302
+ label={isConfirming ? labels.confirmRemove(row) : labels.remove(row)}
303
+ disabled={!canRemove}
304
+ onClick={() => void removeAt(index)}
305
+ onBlur={() => {
306
+ if (isConfirming) setConfirming(null);
307
+ }}
308
+ onKeyDown={(e) => {
309
+ if (e.key === "Escape" && isConfirming) {
310
+ e.stopPropagation();
311
+ setConfirming(null);
312
+ }
313
+ }}
314
+ >
315
+ <Trash2 />
316
+ </IconButton>
317
+ </div>
318
+ )}
319
+ </div>
320
+ );
321
+ })}
322
+
323
+ {showTotals && (
324
+ <div
325
+ role="group"
326
+ aria-label={typeof totalsLabel === "string" ? totalsLabel : labels.totals}
327
+ data-line-items-totals=""
328
+ className="grid grid-cols-1 gap-2 border-t border-[var(--border)] pt-2 text-sm font-semibold tabular-nums text-[var(--text-primary)] @lg:grid-cols-[var(--line-items-cols)]"
329
+ >
330
+ {columns.map((column) => {
331
+ const value = totals[column.key];
332
+ if (column.key === labelColumn) {
333
+ return (
334
+ <span key={column.key} className="min-w-0">
335
+ {totalsLabel ?? labels.totals}
336
+ </span>
337
+ );
338
+ }
339
+ return (
340
+ <span
341
+ key={column.key}
342
+ className={cn(
343
+ "min-w-0",
344
+ column.align === "end" && "@lg:text-end",
345
+ // Stacked, a column with no total takes no line.
346
+ !hasContent(value) && "@max-lg:hidden",
347
+ )}
348
+ >
349
+ {hasContent(value) && (
350
+ <span className="flex justify-between gap-2 @lg:block">
351
+ <span aria-hidden className="font-normal text-[var(--text-muted)] @lg:hidden">
352
+ {column.header}
353
+ </span>
354
+ {value}
355
+ </span>
356
+ )}
357
+ </span>
358
+ );
359
+ })}
360
+ {onRemove && <span className="@max-lg:hidden" />}
361
+ </div>
362
+ )}
363
+
364
+ {hasError && (
365
+ <p className="text-[11px] leading-tight text-[var(--danger)]">
366
+ {error}
367
+ </p>
368
+ )}
369
+
370
+ {onAdd && (
371
+ <Button
372
+ ref={addRef}
373
+ type="button"
374
+ variant="secondary"
375
+ className="w-full border-dashed"
376
+ disabled={!canAdd}
377
+ onClick={add}
378
+ >
379
+ <Plus aria-hidden className="size-4" />
380
+ {addLabel ?? labels.add}
381
+ </Button>
382
+ )}
383
+ </div>
384
+ );
385
+ }
@@ -1,11 +1,12 @@
1
- import { createContext, forwardRef, useContext } from "react";
2
- import type { ComponentPropsWithoutRef, MouseEvent, ReactElement, ReactNode, Ref } from "react";
1
+ import { createContext, forwardRef, useContext, useId } from "react";
2
+ import type { ComponentPropsWithoutRef, HTMLAttributes, MouseEvent, ReactElement, ReactNode, Ref } from "react";
3
3
  import { ExternalLink } from "lucide-react";
4
4
  import type { LucideIcon } from "lucide-react";
5
5
  import { cn } from "../lib/cn";
6
6
  import { Spinner } from "./ui";
7
7
  import { StatusDot } from "./status-dot";
8
- import { useKitLabels } from "../i18n/kit-labels";
8
+ import { useKitLabels, useKitLink } from "../i18n/kit-labels";
9
+ import { pickLinkRenderer } from "./text-link";
9
10
 
10
11
  /* ── Labels ───────────────────────────────────────────────────────────────── */
11
12
 
@@ -95,10 +96,10 @@ export interface ListItemLinkProps {
95
96
  [key: `data-${string}`]: unknown;
96
97
  }
97
98
 
98
- const PAD: Record<ListDensity, { target: string; actions: string; gap: string }> = {
99
- compact: { target: "gap-2 px-2 py-1.5", actions: "pe-1.5", gap: "gap-0.5" },
100
- default: { target: "gap-3 px-3 py-2.5", actions: "pe-2", gap: "gap-1" },
101
- comfortable: { target: "gap-3 px-4 py-3", actions: "pe-3", gap: "gap-1" },
99
+ const PAD: Record<ListDensity, { target: string; actions: string; gap: string; body: string }> = {
100
+ compact: { target: "gap-2 px-2 py-1.5", actions: "pe-1.5", gap: "gap-0.5", body: "px-2 pb-1.5" },
101
+ default: { target: "gap-3 px-3 py-2.5", actions: "pe-2", gap: "gap-1", body: "px-3 pb-2.5" },
102
+ comfortable: { target: "gap-3 px-4 py-3", actions: "pe-3", gap: "gap-1", body: "px-4 pb-3" },
102
103
  };
103
104
 
104
105
  const TARGET_RING =
@@ -134,6 +135,26 @@ interface ListItemBaseProps {
134
135
  * on "Copy" from selecting the row.
135
136
  */
136
137
  actions?: ReactNode;
138
+ /**
139
+ * Rich content UNDER the title row and outside its target: photos, per-row buttons,
140
+ * form fields — kastlan's handover room inspector (room-inspector.tsx), the handover
141
+ * detail page and the wizard's defects step (wizard/defects-step.tsx), which draw
142
+ * each room or defect as a `Card variant="inset"` because a row could not hold a
143
+ * photo strip with its own buttons.
144
+ *
145
+ * The same rule as {@link actions}, one row down: the title area stays ONE named
146
+ * target (the button or link, with the title and subtitle as its name), and the body
147
+ * is its sibling, never nested in it — a thumbnail's "Remove" inside the row's button
148
+ * would be invalid HTML and unreachable by a screen reader. The body is a
149
+ * `role="group"` named by the row's title (`aria-labelledby`), so tabbing into the
150
+ * third photo's button of the second room is heard as "Kitchen, group" first rather
151
+ * than as a button floating free of any row. Inside the row's box, so a `selected`
152
+ * border and a `divider` hold both; the hover fill stays on the target alone, since
153
+ * the body is not what a click on the row does.
154
+ */
155
+ children?: ReactNode;
156
+ /** Classes on the {@link children} body. */
157
+ bodyClassName?: string;
137
158
  /**
138
159
  * The unread mark: a brand dot at the row's end, the title in semibold, and "Unread"
139
160
  * read after the title (keksdose notification-inbox.tsx:215). For another status,
@@ -165,6 +186,19 @@ interface ListItemBaseProps {
165
186
  className?: string;
166
187
  /** Classes on the main target (the button, link or static block). */
167
188
  targetClassName?: string;
189
+ /**
190
+ * Extra attributes and handlers for the main target — e.g. HTML5 drag-and-drop
191
+ * (`draggable`, `onDragStart`, `onDragEnd`): lenkbank's segment list drags the WHOLE
192
+ * 44px row, and a grip inside the button cannot start a drag in Firefox. Spread first,
193
+ * so the row's own role, name, state and click handling always win.
194
+ */
195
+ targetProps?: HTMLAttributes<HTMLElement>;
196
+ /**
197
+ * A visible border while unselected, instead of the transparent one. For rows laid out
198
+ * as a wrapping strip (lenkbank's profile bar), where borderless rows read as loose
199
+ * text. The selected row's brand border is unchanged.
200
+ */
201
+ bordered?: boolean;
168
202
  /** `div` for a single row outside a {@link List}; an `<li>` must sit in a list. */
169
203
  as?: "li" | "div";
170
204
  /** Reaches the main target — the element with the row's name and action. */
@@ -194,8 +228,9 @@ export type ListItemProps = ListItemBaseProps &
194
228
  * cannot do.
195
229
  */
196
230
  href: string;
197
- /** Your router's link for an in-app `href`. Default `<a>`. The API `Chip`,
198
- * `StatTile` and `MenuItem` share. Ignored for an `external` row. */
231
+ /** Your router's link for an in-app `href`. Default: the `<UiKitProvider
232
+ * linkComponent>`, then `<a>`. The API `Chip`, `StatTile` and `MenuItem`
233
+ * share. Ignored for an `external` row. */
199
234
  renderLink?: (props: ListItemLinkProps) => ReactElement;
200
235
  /**
201
236
  * The link leaves the app: a plain `<a target="_blank" rel="noopener
@@ -250,7 +285,11 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
250
285
  align = "center",
251
286
  className,
252
287
  targetClassName,
288
+ targetProps,
289
+ bordered = false,
253
290
  as = "li",
291
+ children,
292
+ bodyClassName,
254
293
  href,
255
294
  renderLink,
256
295
  external = false,
@@ -262,12 +301,15 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
262
301
  ) {
263
302
  const list = useContext(ListContext);
264
303
  const labels = useKitLabels("list", DEFAULT_LIST_LABELS);
304
+ const kitLink = useKitLink();
265
305
  const density = densityProp ?? list.density;
266
306
  const pad = PAD[density];
267
307
  const isLink = href !== undefined && !disabled;
268
308
  const isButton = !isLink && onClick !== undefined && href === undefined;
269
309
  const interactive = isLink || isButton;
270
310
  const inert = disabled || loading;
311
+ const hasBody = children !== undefined && children !== null && children !== false;
312
+ const titleId = useId();
271
313
 
272
314
  const mark =
273
315
  status ??
@@ -284,6 +326,8 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
284
326
  {leading}
285
327
  <span className="flex min-w-0 flex-1 flex-col">
286
328
  <span
329
+ // Names the body's group; harmless without one.
330
+ id={hasBody ? titleId : undefined}
287
331
  className={cn(
288
332
  "block truncate text-sm text-[var(--text-primary)]",
289
333
  unread ? "font-semibold" : "font-medium",
@@ -321,16 +365,24 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
321
365
  "flex min-w-0 flex-1 text-start",
322
366
  align === "start" ? "items-start" : "items-center",
323
367
  pad.target,
324
- "rounded-[inherit]",
368
+ // With a body the target is the top of the box, not all of it, so it takes its
369
+ // own corners and its own hover fill (see `children`).
370
+ hasBody ? "rounded-md" : "rounded-[inherit]",
371
+ hasBody && interactive && !inert && !selected && "transition-colors hover:bg-[var(--bg-hover)]",
325
372
  interactive && TARGET_RING,
326
373
  interactive && !inert && "cursor-pointer",
327
374
  loading && "cursor-progress",
328
375
  targetClassName,
329
376
  );
330
377
 
378
+ // `role` is the row's own (a button, a link, or nothing on a static block): a
379
+ // targetProps role would silently turn the row into something else.
380
+ const { role: _ignoredRole, ...targetExtras } = targetProps ?? {};
381
+
331
382
  let main: ReactNode;
332
383
  if (isLink) {
333
384
  const linkProps: ListItemLinkProps = {
385
+ ...(targetExtras as Partial<ListItemLinkProps>),
334
386
  ...rest,
335
387
  ref: ref as Ref<HTMLAnchorElement>,
336
388
  href,
@@ -344,8 +396,9 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
344
396
  children: body,
345
397
  };
346
398
  const { children: linkBody, ...anchorProps } = linkProps;
347
- main = renderLink && !external ? (
348
- <RenderedLink render={renderLink} {...linkProps} />
399
+ const render = external ? undefined : pickLinkRenderer(renderLink, kitLink, href);
400
+ main = render ? (
401
+ <RenderedLink render={render} {...linkProps} />
349
402
  ) : (
350
403
  <a {...anchorProps} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : null)}>
351
404
  {linkBody}
@@ -354,6 +407,7 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
354
407
  } else if (isButton) {
355
408
  main = (
356
409
  <button
410
+ {...(targetExtras as HTMLAttributes<HTMLButtonElement>)}
357
411
  {...rest}
358
412
  ref={ref as Ref<HTMLButtonElement>}
359
413
  type="button"
@@ -372,6 +426,7 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
372
426
  } else {
373
427
  main = (
374
428
  <div
429
+ {...(targetExtras as HTMLAttributes<HTMLDivElement>)}
375
430
  {...rest}
376
431
  ref={ref as Ref<HTMLDivElement>}
377
432
  aria-disabled={disabled || undefined}
@@ -383,6 +438,9 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
383
438
  );
384
439
  }
385
440
 
441
+ const actionsEl =
442
+ actions != null ? <div className={cn("flex shrink-0 items-center", pad.gap, pad.actions)}>{actions}</div> : null;
443
+
386
444
  const Tag = as;
387
445
  return (
388
446
  <Tag
@@ -393,16 +451,33 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
393
451
  >
394
452
  <div
395
453
  className={cn(
396
- "flex min-w-0 items-center rounded-md border transition-colors",
397
- selected ? "border-[var(--brand)] bg-[var(--bg-surface-2)]" : "border-transparent",
398
- interactive && !inert && !selected && "hover:bg-[var(--bg-hover)]",
454
+ "flex min-w-0 rounded-md border transition-colors",
455
+ hasBody ? "flex-col" : "items-center",
456
+ selected
457
+ ? "border-[var(--brand)] bg-[var(--bg-surface-2)]"
458
+ : bordered
459
+ ? "border-[var(--border)]"
460
+ : "border-transparent",
461
+ !hasBody && interactive && !inert && !selected && "hover:bg-[var(--bg-hover)]",
399
462
  disabled && "opacity-50",
400
463
  className,
401
464
  )}
402
465
  >
403
- {main}
404
- {actions != null && (
405
- <div className={cn("flex shrink-0 items-center", pad.gap, pad.actions)}>{actions}</div>
466
+ {hasBody ? (
467
+ <>
468
+ <div className="flex min-w-0 items-center">
469
+ {main}
470
+ {actionsEl}
471
+ </div>
472
+ <div role="group" aria-labelledby={titleId} className={cn("min-w-0", pad.body, bodyClassName)}>
473
+ {children}
474
+ </div>
475
+ </>
476
+ ) : (
477
+ <>
478
+ {main}
479
+ {actionsEl}
480
+ </>
406
481
  )}
407
482
  </div>
408
483
  </Tag>
@@ -0,0 +1,47 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+ import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
4
+ import { Spinner } from "./ui";
5
+
6
+ export type LoadingStateSize = "sm" | "md" | "lg";
7
+
8
+ export interface LoadingStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> {
9
+ /** The visible words under the spinner, and what the status region announces.
10
+ * Default: `common.loading` ("Loading…"). */
11
+ label?: ReactNode;
12
+ /** `sm` for a panel or a table body, `md` (default) for a section, `lg` for a page. */
13
+ size?: LoadingStateSize;
14
+ /** Spinner beside the text on one line, rather than above it. */
15
+ inline?: boolean;
16
+ }
17
+
18
+ const SPINNER: Record<LoadingStateSize, string> = { sm: "h-4 w-4", md: "h-8 w-8", lg: "h-10 w-10" };
19
+ const TEXT: Record<LoadingStateSize, string> = { sm: "text-xs", md: "text-sm", lg: "text-base" };
20
+ const PAD: Record<LoadingStateSize, string> = { sm: "py-4", md: "py-12", lg: "py-20" };
21
+
22
+ /**
23
+ * A spinner and the words "Loading…", centred where the content will be.
24
+ *
25
+ * kastlan's `LoadingState` (feedback/loading-state.tsx:14), which got the reading
26
+ * right the second time: the WRAPPER is the `role="status"` region and the words are
27
+ * visible text inside it, so the spinner is decorative (`label={null}`) — otherwise
28
+ * the reader hears "Loading… Loading…", one from each.
29
+ */
30
+ export function LoadingState({ label, size = "md", inline = false, className, ...rest }: LoadingStateProps) {
31
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
32
+ return (
33
+ <div
34
+ {...rest}
35
+ role="status"
36
+ className={cn(
37
+ "flex items-center justify-center text-center text-[var(--text-muted)]",
38
+ inline ? "gap-2" : "flex-col gap-2",
39
+ PAD[size],
40
+ className,
41
+ )}
42
+ >
43
+ <Spinner label={null} className={SPINNER[size]} />
44
+ <span className={TEXT[size]}>{label ?? common.loading}</span>
45
+ </div>
46
+ );
47
+ }