@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
@@ -1,4 +1,4 @@
1
- import { Fragment, useEffect, useMemo, useRef, useState } from "react";
1
+ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import type { CSSProperties, ReactNode, TdHTMLAttributes, ThHTMLAttributes } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
  import {
@@ -8,9 +8,11 @@ import {
8
8
  ChevronDown,
9
9
  ChevronRight,
10
10
  Filter,
11
+ Pencil,
12
+ Trash2,
11
13
  X,
12
14
  } from "lucide-react";
13
- import { Card, Spinner } from "./ui";
15
+ import { Card, IconButton, Spinner } from "./ui";
14
16
  import { cn } from "../lib/cn";
15
17
  import {
16
18
  defaultFilterState,
@@ -239,8 +241,11 @@ export interface DataTableProps<T> {
239
241
  * shareable and bookmarkable. URL params used: `f.<column>`, `sort`, `p`, `ps`.
240
242
  * URL state takes precedence over localStorage on initial load. Requires the
241
243
  * consuming app to render inside a react-router Router.
244
+ *
245
+ * `{ prefix }` namespaces them, for two URL-synced tables on one page:
246
+ * `urlSync={{ prefix: "inv." }}` uses `inv.f.<column>`, `inv.sort`, `inv.p`, `inv.ps`.
242
247
  */
243
- urlSync?: boolean;
248
+ urlSync?: TableUrlSync;
244
249
  /**
245
250
  * When true (desktop only), the table fills its parent's height and scrolls
246
251
  * INTERNALLY, with the header pinned — so the page itself doesn't add a second,
@@ -431,6 +436,98 @@ export interface DataTableProps<T> {
431
436
  /** Extra classes for the table's root element (the card, or with `frame={false}`
432
437
  * the plain wrapper). Merged last, so a caller's margin or width wins. */
433
438
  className?: string;
439
+ /**
440
+ * Per-row actions: an actions column of icon buttons at the row's end on desktop,
441
+ * and the same actions as swipe actions on a phone card (where the column is not
442
+ * shown). kastlan's `ResourceListPage` appended this column by hand to every CRUD
443
+ * list — the edit pencil, the delete bin, a `useConfirm` before the delete, and a
444
+ * `stopPropagation` on each so a press did not also open the row.
445
+ *
446
+ * `kind: "edit"` / `"delete"` bring the icon, the label (`labels.edit` /
447
+ * `labels.delete`), the tone and the swipe side; any other action states its own
448
+ * `label` and `icon`. `confirm` is the hook-in for a confirmation: pass the kit's
449
+ * `useConfirm()` (or anything answering a boolean) and the action runs only on a yes.
450
+ *
451
+ * const confirm = useConfirm();
452
+ * rowActions={[
453
+ * { kind: "edit", onAction: openEditor },
454
+ * { kind: "delete", onAction: (r) => remove(r.id),
455
+ * confirm: (r) => confirm({ title: t("delete", { name: r.name }), tone: "danger" }) },
456
+ * ]}
457
+ *
458
+ * A function receives the row, for actions that differ per row. An explicit
459
+ * `mobileSwipeActions` wins over the swipes derived from here.
460
+ */
461
+ rowActions?: DataTableRowAction<T>[] | ((row: T) => DataTableRowAction<T>[]);
462
+ /**
463
+ * The bar above the table — a search field, filter chips, a create button. Inside
464
+ * the table's frame, above the header, on desktop and phone alike (resource-list
465
+ * pages set a search box in a `div` above the card, outside the frame it filters).
466
+ */
467
+ toolbar?: ReactNode;
468
+ }
469
+
470
+ /** One entry of {@link DataTableProps.rowActions}. */
471
+ export interface DataTableRowAction<T> {
472
+ /** A preset: `"edit"` (pencil, `labels.edit`, swipes toward the END) or `"delete"`
473
+ * (bin, `labels.delete`, danger tone, swipes toward the START). */
474
+ kind?: "edit" | "delete";
475
+ /** The action's name — its tooltip, accessible name and swipe label. Required
476
+ * without a `kind`. */
477
+ label?: string;
478
+ /** The glyph. Required without a `kind`. */
479
+ icon?: ReactNode;
480
+ /** Runs after `confirm` (when given) has said yes. */
481
+ onAction: (row: T) => void;
482
+ /** Asked before `onAction`; a `false` (or a promise of one) cancels. */
483
+ confirm?: (row: T) => boolean | Promise<boolean>;
484
+ /** Default: `"danger"` for `kind: "delete"`, else `"default"`. */
485
+ tone?: "default" | "danger";
486
+ /** Leave the action out for this row. */
487
+ hidden?: (row: T) => boolean;
488
+ /** Why the action is locked for this row — the button stays focusable and says so,
489
+ * and the row gets no swipe for it. See `IconButton`'s `disabledReason`. */
490
+ disabledReason?: (row: T) => ReactNode;
491
+ /** Which way the phone card is dragged to commit it, or `false` for no swipe.
492
+ * Default: `"start"` for a danger action, `"end"` otherwise. */
493
+ swipe?: "start" | "end" | false;
494
+ }
495
+
496
+ /** The actions column's key. Not a column a caller declares, so it is kept out of the
497
+ * column-settings panel. */
498
+ const ROW_ACTIONS_KEY = "__rowActions";
499
+
500
+ interface ResolvedRowAction<T> {
501
+ key: string;
502
+ label: string;
503
+ icon: ReactNode;
504
+ tone: "default" | "danger";
505
+ swipe: "start" | "end" | false;
506
+ action: DataTableRowAction<T>;
507
+ }
508
+
509
+ function resolveRowAction<T>(
510
+ action: DataTableRowAction<T>,
511
+ index: number,
512
+ labels: DataTableLabels,
513
+ ): ResolvedRowAction<T> {
514
+ const tone = action.tone ?? (action.kind === "delete" ? "danger" : "default");
515
+ return {
516
+ key: action.kind ?? `${index}:${action.label ?? ""}`,
517
+ label: action.label ?? (action.kind === "edit" ? labels.edit : action.kind === "delete" ? labels.delete : ""),
518
+ icon:
519
+ action.icon ??
520
+ (action.kind === "edit" ? <Pencil /> : action.kind === "delete" ? <Trash2 /> : null),
521
+ tone,
522
+ swipe: action.swipe ?? (tone === "danger" ? "start" : "end"),
523
+ action,
524
+ };
525
+ }
526
+
527
+ /** Ask, then act. */
528
+ async function runRowAction<T>(action: DataTableRowAction<T>, row: T): Promise<void> {
529
+ if (action.confirm && !(await action.confirm(row))) return;
530
+ action.onAction(row);
434
531
  }
435
532
 
436
533
  /** See {@link DataTableProps.chrome}. */
@@ -516,7 +613,8 @@ function cleanAttrs(
516
613
  * header is not a string and whose key is not human-readable is a call site that
517
614
  * should pass a string header, and that is visible in what gets announced.
518
615
  */
519
- function columnLabel<T>(col: DataTableColumn<T>): string {
616
+ function columnLabel<T>(col: DataTableColumn<T>, labels?: DataTableLabels): string {
617
+ if (col.key === ROW_ACTIONS_KEY && labels) return labels.actions;
520
618
  return typeof col.header === "string" ? col.header : col.key;
521
619
  }
522
620
 
@@ -528,6 +626,20 @@ function columnLabel<T>(col: DataTableColumn<T>): string {
528
626
  const OWN_CONTROL =
529
627
  'a[href],button,input,select,textarea,label,summary,[role="button"],[role="link"],[role="checkbox"],[role="switch"],[role="menuitem"],[role="option"],[contenteditable=""],[contenteditable="true"],[tabindex]:not([tabindex="-1"])';
530
628
 
629
+ /**
630
+ * Whether a click that reached `row`'s handler did not start in the row's own DOM.
631
+ *
632
+ * React bubbles an event through the COMPONENT tree, portals included: a popover, a
633
+ * menu or a dialog a cell (or the expansion under it) opens renders at `<body>` but
634
+ * still reports its clicks to every React ancestor — the row among them. So a click
635
+ * in a cell's portalled date picker opened the row it came from, and a page wrapped
636
+ * such content in `<div onClick={(e) => e.stopPropagation()}>` to stop it (kastlan's
637
+ * feedback page did, round its comments). The row owns the clicks on the row.
638
+ */
639
+ function fromOutside(target: EventTarget | null, row: HTMLElement): boolean {
640
+ return !(target instanceof Node) || !row.contains(target);
641
+ }
642
+
531
643
  /** Whether an event on `row` started on a control nested inside it. */
532
644
  function fromOwnControl(target: EventTarget | null, row: HTMLElement): boolean {
533
645
  if (!(target instanceof Element)) return false;
@@ -617,9 +729,13 @@ function RowLink({
617
729
  // here happens before any descendant can silence the event; `stopPropagation`
618
730
  // does not undo a `preventDefault`.
619
731
  onClickCapture={(e) => {
732
+ // A click in a portal a descendant opened is not a click on the link: leave
733
+ // it (and a link inside it) alone. See `fromOutside`.
734
+ if (fromOutside(e.target, e.currentTarget)) return;
620
735
  if (isPlainLeftClick(e)) e.preventDefault();
621
736
  }}
622
737
  onClick={(e) => {
738
+ if (fromOutside(e.target, e.currentTarget)) return;
623
739
  if (!isPlainLeftClick(e)) {
624
740
  e.stopPropagation();
625
741
  return;
@@ -639,12 +755,12 @@ function RowLink({
639
755
 
640
756
 
641
757
 
642
- import { DEFAULT_PERSIST_PREFIX, useTableState } from "./use-table-state";
758
+ import { DEFAULT_PERSIST_PREFIX, useTableState, type TableUrlSync } from "./use-table-state";
643
759
  import { useMobileReveal } from "./use-mobile-reveal";
644
760
 
645
761
  export function DataTable<T>({
646
762
  rows,
647
- columns,
763
+ columns: columnsProp,
648
764
  rowKey,
649
765
  defaultPageSize = 25,
650
766
  onRowClick,
@@ -682,6 +798,8 @@ export function DataTable<T>({
682
798
  multiSort: multiSortProp,
683
799
  frame = true,
684
800
  className,
801
+ rowActions,
802
+ toolbar,
685
803
  }: DataTableProps<T>) {
686
804
  const compact = density === "compact";
687
805
  const minimal = chrome === "minimal";
@@ -711,6 +829,74 @@ export function DataTable<T>({
711
829
  [labelOverrides, labelsProp],
712
830
  );
713
831
  const locale = useKitLocale(localeProp);
832
+
833
+ // ---- Row actions ----
834
+ // The actions column is appended to the caller's columns rather than drawn as a
835
+ // second special case beside the selection column: it then gets the cell padding,
836
+ // the widths and the phone rules (`mobileHidden`) every other column gets.
837
+ const rowActionsFor = useCallback(
838
+ (row: T): ResolvedRowAction<T>[] =>
839
+ rowActions
840
+ ? (typeof rowActions === "function" ? rowActions(row) : rowActions)
841
+ .filter((a) => !a.hidden?.(row))
842
+ .map((a, i) => resolveRowAction(a, i, labels))
843
+ : [],
844
+ [rowActions, labels],
845
+ );
846
+ const columns = useMemo<DataTableColumn<T>[]>(
847
+ () =>
848
+ rowActions
849
+ ? [
850
+ ...columnsProp,
851
+ {
852
+ key: ROW_ACTIONS_KEY,
853
+ header: <span className="sr-only">{labels.actions}</span>,
854
+ cell: (row) => (
855
+ <div className="flex items-center justify-end gap-0.5">
856
+ {rowActionsFor(row).map((a) => (
857
+ <IconButton
858
+ key={a.key}
859
+ size={density === "compact" ? "2xs" : "xs"}
860
+ label={a.label}
861
+ tone={a.tone === "danger" ? "danger" : undefined}
862
+ disabledReason={a.action.disabledReason?.(row)}
863
+ // The row's click handler already skips its own controls; this is for
864
+ // the caller who wraps the table in a clickable element of their own.
865
+ stopPropagation
866
+ onClick={() => void runRowAction(a.action, row)}
867
+ >
868
+ {a.icon}
869
+ </IconButton>
870
+ ))}
871
+ </div>
872
+ ),
873
+ className: "w-px whitespace-nowrap text-end",
874
+ headClassName: "w-px",
875
+ // On a phone the same actions are the card's swipes.
876
+ mobileHidden: true,
877
+ noRowLink: true,
878
+ },
879
+ ]
880
+ : columnsProp,
881
+ [columnsProp, rowActions, rowActionsFor, labels.actions, density],
882
+ );
883
+ const swipesFromActions = (row: T): MobileSwipeActions | null => {
884
+ const swipes = rowActionsFor(row).filter((a) => a.swipe && !a.action.disabledReason?.(row));
885
+ if (!swipes.length) return null;
886
+ const toSwipe = (a: ResolvedRowAction<T>): SwipeAction => ({
887
+ label: a.label,
888
+ icon: a.icon,
889
+ onCommit: () => void runRowAction(a.action, row),
890
+ className: a.tone === "danger" ? "bg-[var(--danger)]" : "bg-[var(--brand)]",
891
+ armedClassName: a.tone === "danger" ? "bg-[var(--danger-hover)]" : "bg-[var(--brand-hover)]",
892
+ });
893
+ return {
894
+ start: swipes.filter((a) => a.swipe === "start").map(toSwipe),
895
+ end: swipes.filter((a) => a.swipe === "end").map(toSwipe),
896
+ };
897
+ };
898
+ const swipeActionsFor = mobileSwipeActions ?? (rowActions ? swipesFromActions : undefined);
899
+
714
900
  // Sorting, filtering and paging all change WHICH rows are on screen without
715
901
  // moving focus — the header button the user pressed is still the header button
716
902
  // they are on — so there is no other channel to say it on. See use-announce.ts.
@@ -875,7 +1061,7 @@ export function DataTable<T>({
875
1061
  // criterion BEHIND the existing one, and answering "Sorted by Date" to a click on
876
1062
  // Name misreports what the click did. The third click drops the column entirely,
877
1063
  // which is the state a user is most likely to have reached by accident.
878
- const name = col ? columnLabel(col) : key;
1064
+ const name = col ? columnLabel(col, labels) : key;
879
1065
  const entry = next.find((s) => s.key === key);
880
1066
  announce(
881
1067
  !entry
@@ -1122,7 +1308,10 @@ export function DataTable<T>({
1122
1308
  const totalCount = columns.length;
1123
1309
  // Spans the full row width including the optional leading selection column.
1124
1310
  const totalColSpan = visibleCount + (selection ? 1 : 0);
1125
- const columnsCountLabel = labels.columnsCount(visibleCount, totalCount);
1311
+ // The actions column is the table's own, not one the user can hide: out of the count.
1312
+ const columnsCountLabel = rowActions
1313
+ ? labels.columnsCount(visibleCount - 1, totalCount - 1)
1314
+ : labels.columnsCount(visibleCount, totalCount);
1126
1315
  // Match Tailwind's `md` breakpoint: we render either the table or the card
1127
1316
  // list — never both — so we don't double up DOM nodes that screen readers and
1128
1317
  // integration tests would have to disambiguate.
@@ -1252,7 +1441,7 @@ export function DataTable<T>({
1252
1441
  // uses for exactly this, and it points the way the row actually opens: right into
1253
1442
  // a sheet, or down into an inline panel that flips it when expanded.
1254
1443
  const opensDetail = interactive && !!expandedRow;
1255
- const swipeActions = mobileSwipeActions?.(row);
1444
+ const swipeActions = swipeActionsFor?.(row);
1256
1445
  const swipe = swipeActions ? physicalSwipe(swipeActions, mobileDir) : null;
1257
1446
  // An expanded row never swipes: the editor below it owns the horizontal space,
1258
1447
  // and dragging the header away from its own form reads as a glitch.
@@ -1362,7 +1551,14 @@ export function DataTable<T>({
1362
1551
  <div
1363
1552
  role={interactive ? "button" : undefined}
1364
1553
  tabIndex={interactive ? 0 : undefined}
1365
- onClick={interactive ? () => onRowClick!(row) : undefined}
1554
+ onClick={
1555
+ interactive
1556
+ ? (e) => {
1557
+ if (fromOutside(e.target, e.currentTarget)) return;
1558
+ onRowClick!(row);
1559
+ }
1560
+ : undefined
1561
+ }
1366
1562
  onKeyDown={
1367
1563
  interactive
1368
1564
  ? (e) => {
@@ -1427,6 +1623,11 @@ export function DataTable<T>({
1427
1623
  // density (`group-data-[density=compact]/table:…`) without being told twice.
1428
1624
  data-density={density}
1429
1625
  data-frame={frame ? undefined : "none"}
1626
+ // The frame clips (`overflow-clip`), so it is a clipping container for a
1627
+ // Tooltip's auto-portal — said as an attribute as well, because jsdom computes no
1628
+ // classes and a table-cell tooltip must portal under test just as it does in the
1629
+ // browser (see `CLIPS_ATTRIBUTE`). The bare root clips nothing and is not marked.
1630
+ data-clips={frame ? "" : undefined}
1430
1631
  className={cn(
1431
1632
  "group/table",
1432
1633
  frame ? "overflow-clip" : "min-w-0",
@@ -1440,6 +1641,19 @@ export function DataTable<T>({
1440
1641
  card list for the table wholesale, which would remount a region living
1441
1642
  inside either one. */}
1442
1643
  <span {...regionProps} />
1644
+ {toolbar != null && toolbar !== false && (
1645
+ <div
1646
+ data-table-toolbar=""
1647
+ className={cn(
1648
+ "flex flex-wrap items-center gap-2",
1649
+ frame
1650
+ ? cn("border-b border-[var(--border)]", compact ? "px-2 py-1.5" : "px-3 py-2")
1651
+ : "pb-3",
1652
+ )}
1653
+ >
1654
+ {toolbar}
1655
+ </div>
1656
+ )}
1443
1657
  {!isMdUp && (
1444
1658
  <div ref={mobileRootRef}>
1445
1659
  {/* Mobile filter access (feedback #299): the per-column filter popovers
@@ -1484,7 +1698,7 @@ export function DataTable<T>({
1484
1698
  compact ? "px-3 py-3 text-xs" : "px-4 py-6 text-sm",
1485
1699
  )}
1486
1700
  >
1487
- {isLoading ? <LoadingText label={labels.loading} /> : (empty ?? "—")}
1701
+ {isLoading ? <LoadingText label={labels.loading} /> : (empty ?? labels.empty)}
1488
1702
  </li>
1489
1703
  )}
1490
1704
  {/* Endless-scroll sentinel: observed by IntersectionObserver to pull in
@@ -1535,6 +1749,8 @@ export function DataTable<T>({
1535
1749
  other half of the `w-0 min-w-full` fix on the expansion cell below
1536
1750
  (feedback #104: "expanding an item resizes the columns"). */}
1537
1751
  <div
1752
+ // The scroller: marked for the Tooltip's auto-portal (see the root above).
1753
+ data-clips=""
1538
1754
  className={cn("overflow-auto [scrollbar-gutter:stable]", fillHeight && "flex-1 min-h-0")}
1539
1755
  style={fillHeight ? undefined : { maxHeight: maxBodyHeight }}
1540
1756
  >
@@ -1701,7 +1917,7 @@ export function DataTable<T>({
1701
1917
  </Popover>
1702
1918
  )}
1703
1919
  </div>
1704
- {resizable && (
1920
+ {resizable && col.key !== ROW_ACTIONS_KEY && (
1705
1921
  <span
1706
1922
  role="separator"
1707
1923
  aria-orientation="vertical"
@@ -1793,6 +2009,7 @@ export function DataTable<T>({
1793
2009
  // not also open the row. Cells no longer have to
1794
2010
  // `stopPropagation` to get that.
1795
2011
  if (fromOwnControl(e.target, e.currentTarget)) return;
2012
+ if (fromOutside(e.target, e.currentTarget)) return;
1796
2013
  // Modifier-clicks anywhere in the row drive
1797
2014
  // selection instead of expanding it (feedback #289):
1798
2015
  // Ctrl/Cmd toggles a single row, Shift selects the
@@ -1928,7 +2145,7 @@ export function DataTable<T>({
1928
2145
  >
1929
2146
  {/* Nothing is not the same as not-yet: "no results" while the first
1930
2147
  page is still in flight is a claim the table cannot make. */}
1931
- {isLoading ? <LoadingText label={labels.loading} /> : (empty ?? "—")}
2148
+ {isLoading ? <LoadingText label={labels.loading} /> : (empty ?? labels.empty)}
1932
2149
  </td>
1933
2150
  </tr>
1934
2151
  )}
@@ -2011,6 +2228,7 @@ export function DataTable<T>({
2011
2228
  </button>
2012
2229
  <div className="flex flex-col gap-0.5">
2013
2230
  {columns.map((col) => {
2231
+ if (col.key === ROW_ACTIONS_KEY) return null;
2014
2232
  const checked = !hiddenCols.has(col.key);
2015
2233
  const headerLabel = columnLabel(col);
2016
2234
  return (
@@ -91,6 +91,8 @@ export interface DateTriggerAttributes {
91
91
  "aria-label"?: string;
92
92
  "aria-describedby"?: string;
93
93
  "aria-invalid"?: true;
94
+ /** The field's required state — `Field required` hands it down with the id pair. */
95
+ "aria-required"?: boolean | "true" | "false";
94
96
  disabled?: boolean;
95
97
  onClick: () => void;
96
98
  className: string;
@@ -132,6 +134,7 @@ function DateFieldTrigger({
132
134
  padded,
133
135
  disabled,
134
136
  invalid,
137
+ required,
135
138
  panelId,
136
139
  aria,
137
140
  renderTrigger,
@@ -153,6 +156,8 @@ function DateFieldTrigger({
153
156
  padded: boolean;
154
157
  disabled?: boolean;
155
158
  invalid?: boolean;
159
+ /** `aria-required`, on the trigger with the rest of the field's ARIA. */
160
+ required?: boolean | "true" | "false";
156
161
  }) {
157
162
  const wasOpen = useRef(false);
158
163
  useEffect(() => {
@@ -177,6 +182,7 @@ function DateFieldTrigger({
177
182
  // One attribute object, worn either by the kit's own button below or by a caller's
178
183
  // (`renderTrigger`) — so a custom trigger cannot quietly fall out of step with the
179
184
  // naming and state this one announces.
185
+ const isInvalid = Boolean(invalid) || aria["aria-invalid"] === true || aria["aria-invalid"] === "true";
180
186
  const attrs: DateTriggerAttributes = {
181
187
  ref: triggerRef,
182
188
  type: "button",
@@ -209,14 +215,15 @@ function DateFieldTrigger({
209
215
  "aria-haspopup": "dialog",
210
216
  "aria-controls": panelId,
211
217
  "aria-expanded": open,
212
- "aria-invalid":
213
- invalid || aria["aria-invalid"] === true || aria["aria-invalid"] === "true" || undefined,
218
+ "aria-invalid": isInvalid || undefined,
219
+ "aria-required": required,
214
220
  className: cn(
215
221
  FIELD_TRIGGER,
216
222
  "pe-9",
217
223
  padded && FIELD_FLOATING_PAD,
218
224
  disabled && "cursor-not-allowed opacity-50",
219
- invalid && FIELD_INVALID,
225
+ // An `aria-invalid` from a Field spread paints too, as on Input and AmountInput.
226
+ isInvalid && FIELD_INVALID,
220
227
  ),
221
228
  };
222
229
  if (renderTrigger) return <CustomTrigger render={renderTrigger} attrs={attrs} valueId={valueId} />;
@@ -279,6 +286,10 @@ function DateField({
279
286
  sheetBackCloses = true,
280
287
  renderTrigger,
281
288
  children,
289
+ // The trigger's, not the wrapper's: `Field`'s render-prop spreads it with the id
290
+ // pair, and a required date read as optional while it sat on the role-less div.
291
+ // Taken here rather than in `splitTriggerAria`, which MonthPicker shares.
292
+ "aria-required": ariaRequired,
282
293
  ...rest
283
294
  }: Omit<ComponentPropsWithoutRef<"div">, "children"> & {
284
295
  triggerText: string;
@@ -393,6 +404,7 @@ function DateField({
393
404
  padded={label !== undefined}
394
405
  disabled={disabled}
395
406
  invalid={invalid}
407
+ required={ariaRequired}
396
408
  renderTrigger={renderTrigger}
397
409
  />
398
410
  );
@@ -577,6 +589,9 @@ export function DatePicker({
577
589
  clearLabel,
578
590
  disabled,
579
591
  invalid,
592
+ // Named so it reaches the field (and so its trigger) when a step/today row is the
593
+ // root and takes the rest of the caller's props.
594
+ "aria-required": ariaRequired,
580
595
  ...rest
581
596
  }: DatePickerProps) {
582
597
  const locale = useKitLocale(localeProp);
@@ -593,6 +608,7 @@ export function DatePicker({
593
608
  // With step/today buttons the flex row is the root and takes the caller's props —
594
609
  // except the ones that name the field, which still belong on its trigger.
595
610
  {...(wrapped ? triggerAria : rest)}
611
+ aria-required={ariaRequired}
596
612
  label={label}
597
613
  clearable={clearable}
598
614
  clearLabel={text.clear}
@@ -1,8 +1,20 @@
1
1
  import { createContext, useContext } from "react";
2
2
  import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
+ import { useKitLabels } from "../i18n/kit-labels";
4
5
  import { FieldHint } from "./ui";
5
6
 
7
+ /** The words a {@link DescriptionList} renders on its own behalf. */
8
+ export interface DescriptionListLabels {
9
+ /** What an item with no value shows (see {@link DescriptionItemProps.placeholder}).
10
+ * Default "—", an em dash. */
11
+ empty: string;
12
+ }
13
+
14
+ export const DEFAULT_DESCRIPTION_LIST_LABELS: DescriptionListLabels = {
15
+ empty: "—",
16
+ };
17
+
6
18
  export type DescriptionListLayout = "rows" | "cards" | "stacked";
7
19
  export type DescriptionListDensity = "comfortable" | "compact" | "tight";
8
20
  /** How many columns a `stacked` or `cards` list may use. */
@@ -14,6 +26,7 @@ interface ListContextValue {
14
26
  prose: boolean;
15
27
  density: DescriptionListDensity;
16
28
  columns: DescriptionListColumns;
29
+ placeholder?: ReactNode;
17
30
  }
18
31
 
19
32
  const ListContext = createContext<ListContextValue>({
@@ -103,6 +116,9 @@ export interface DescriptionListProps extends ComponentPropsWithoutRef<"dl"> {
103
116
  * all would not be an improvement. Per item: {@link DescriptionItemProps.prose}.
104
117
  */
105
118
  prose?: boolean;
119
+ /** What an item with no value shows, for every item that does not set its own —
120
+ * see {@link DescriptionItemProps.placeholder}. */
121
+ placeholder?: ReactNode;
106
122
  }
107
123
 
108
124
  /**
@@ -129,6 +145,7 @@ export function DescriptionList({
129
145
  density = "comfortable",
130
146
  minCardWidth = "10rem",
131
147
  columns,
148
+ placeholder,
132
149
  className,
133
150
  style,
134
151
  ...rest
@@ -147,7 +164,7 @@ export function DescriptionList({
147
164
  const grid: CSSProperties | undefined = cards
148
165
  ? { gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${track}), 1fr))` }
149
166
  : undefined;
150
- const ctx: ListContextValue = { layout, numeric, prose, density, columns: columns ?? 2 };
167
+ const ctx: ListContextValue = { layout, numeric, prose, density, columns: columns ?? 2, placeholder };
151
168
  const text = density === "tight" ? "text-[11px] leading-4" : density === "compact" ? "text-xs" : "text-sm";
152
169
 
153
170
  if (stacked) {
@@ -215,6 +232,20 @@ export interface DescriptionItemProps extends Omit<ComponentPropsWithoutRef<"div
215
232
  * because a card grid's column count is not known to CSS ahead of layout.
216
233
  */
217
234
  span?: number | "full";
235
+ /**
236
+ * Shown in place of a value that is not there — `children` of `null`, `undefined`,
237
+ * `false` or `""` (a `0` is a value and is shown). Default: the list's own
238
+ * `placeholder`, else `descriptionList.empty` from the provider, else "—". `null`
239
+ * shows nothing, as an empty detail did before. kastlan's DetailField put its
240
+ * `EMPTY_VALUE` into every one of its call sites' values by hand.
241
+ *
242
+ * Drawn in the muted ink, so an absent value does not read as a value of "—".
243
+ */
244
+ placeholder?: ReactNode;
245
+ }
246
+
247
+ function isEmptyValue(node: ReactNode): boolean {
248
+ return node === undefined || node === null || node === false || node === "";
218
249
  }
219
250
 
220
251
  const ROW_PAD: Record<DescriptionListDensity, string> = {
@@ -236,11 +267,23 @@ export function DescriptionItem({
236
267
  numeric: numericProp,
237
268
  prose: proseProp,
238
269
  span,
270
+ placeholder: placeholderProp,
239
271
  detailClassName,
240
272
  className,
241
273
  ...rest
242
274
  }: DescriptionItemProps) {
243
- const { layout, numeric: listNumeric, prose: listProse, density, columns } = useContext(ListContext);
275
+ const {
276
+ layout,
277
+ numeric: listNumeric,
278
+ prose: listProse,
279
+ density,
280
+ columns,
281
+ placeholder: listPlaceholder,
282
+ } = useContext(ListContext);
283
+ const labels = useKitLabels("descriptionList", DEFAULT_DESCRIPTION_LIST_LABELS);
284
+ const empty = isEmptyValue(children);
285
+ const placeholder =
286
+ placeholderProp !== undefined ? placeholderProp : listPlaceholder !== undefined ? listPlaceholder : labels.empty;
244
287
  const numeric = numericProp ?? listNumeric;
245
288
  const prose = proseProp ?? listProse;
246
289
  const cards = layout === "cards";
@@ -277,7 +320,13 @@ export function DescriptionItem({
277
320
  detailClassName,
278
321
  )}
279
322
  >
280
- {children}
323
+ {empty && !isEmptyValue(placeholder) ? (
324
+ <span data-slot="description-empty" className="font-normal text-[var(--text-muted)]">
325
+ {placeholder}
326
+ </span>
327
+ ) : (
328
+ children
329
+ )}
281
330
  </dd>
282
331
  );
283
332
 
@@ -105,6 +105,14 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
105
105
  debounceMs,
106
106
  loadErrorLabel,
107
107
  "aria-label": ariaLabel,
108
+ // The control's own wiring, taken off `rest` so it lands on the TRIGGER rather than
109
+ // the wrapper — see MultiEntityCombobox: `Field`'s render-prop spreads `{ id,
110
+ // aria-describedby, aria-invalid, aria-required }`, and on the role-less wrapper
111
+ // div the label's `htmlFor` named nothing and the hint described nothing.
112
+ id,
113
+ "aria-describedby": ariaDescribedBy,
114
+ "aria-invalid": ariaInvalid,
115
+ "aria-required": ariaRequired,
108
116
  ...rest
109
117
  }: EntityComboboxProps<V, C>) {
110
118
  const core = useComboboxCore<V>({
@@ -115,7 +123,11 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
115
123
  minChars,
116
124
  debounceMs,
117
125
  });
118
- const field = useComboboxFieldError(error, invalid);
126
+ const field = useComboboxFieldError(
127
+ error,
128
+ invalid || ariaInvalid === true || ariaInvalid === "true",
129
+ ariaDescribedBy,
130
+ );
119
131
  // The props are the per-instance overrides, the provider the app-wide ones; a
120
132
  // prop left `undefined` falls through to the provider rather than masking it.
121
133
  const labels = useKitLabels("combobox", DEFAULT_COMBOBOX_LABELS, {
@@ -151,13 +163,15 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
151
163
  };
152
164
 
153
165
  return (
154
- // `rest` dresses the wrapper, which has no role; the accessible NAME goes on
155
- // the trigger, which has one. Spread FIRST so the trigger's ARIA and the
166
+ // `rest` dresses the wrapper, which has no role; the accessible NAME — and the
167
+ // id, description, invalid and required state above — go on the trigger, which
168
+ // has one. Spread FIRST so the trigger's ARIA and the
156
169
  // handlers that open the panel cannot be clobbered from outside.
157
170
  <div {...rest} className={cn("relative", className)}>
158
171
  {label !== undefined && <FieldLabel>{label}</FieldLabel>}
159
172
  <button
160
173
  ref={core.triggerRef}
174
+ id={id}
161
175
  type="button"
162
176
  // A combobox, not a button. The distinction is not pedantry: this control
163
177
  // carried `aria-invalid`, which `button` does not support, so a required
@@ -185,6 +199,7 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
185
199
  disabled={disabled}
186
200
  aria-invalid={field.isInvalid || undefined}
187
201
  aria-describedby={field.describedBy}
202
+ aria-required={ariaRequired}
188
203
  onClick={() => !disabled && setOpen((o) => !o)}
189
204
  // Down/Up opens the list from the closed trigger, per the APG. Enter and
190
205
  // Space already do it through the button's own click.