@eifi1/ui-kit 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (342) hide show
  1. package/README.md +57 -32
  2. package/dist/chart.d.ts +3 -1
  3. package/dist/chart.js +2 -0
  4. package/dist/chart.js.map +1 -1
  5. package/dist/components/account-settings-labels.d.ts +105 -0
  6. package/dist/components/account-settings-labels.js +81 -0
  7. package/dist/components/account-settings-labels.js.map +1 -0
  8. package/dist/components/account-settings.d.ts +36 -52
  9. package/dist/components/account-settings.js +44 -8
  10. package/dist/components/account-settings.js.map +1 -1
  11. package/dist/components/amount-input.d.ts +24 -4
  12. package/dist/components/amount-input.js +5 -2
  13. package/dist/components/amount-input.js.map +1 -1
  14. package/dist/components/authed-image.d.ts +68 -0
  15. package/dist/components/authed-image.js +117 -0
  16. package/dist/components/authed-image.js.map +1 -0
  17. package/dist/components/breadcrumbs.d.ts +2 -1
  18. package/dist/components/breadcrumbs.js +5 -2
  19. package/dist/components/breadcrumbs.js.map +1 -1
  20. package/dist/components/button-group.d.ts +51 -2
  21. package/dist/components/button-group.js +5 -1
  22. package/dist/components/button-group.js.map +1 -1
  23. package/dist/components/calculator.d.ts +15 -4
  24. package/dist/components/calendar-heatmap.d.ts +41 -3
  25. package/dist/components/calendar-heatmap.js +48 -7
  26. package/dist/components/calendar-heatmap.js.map +1 -1
  27. package/dist/components/chip.d.ts +2 -1
  28. package/dist/components/chip.js +6 -3
  29. package/dist/components/chip.js.map +1 -1
  30. package/dist/components/choice-card.d.ts +35 -3
  31. package/dist/components/choice-card.js +83 -25
  32. package/dist/components/choice-card.js.map +1 -1
  33. package/dist/components/combobox-core.js +2 -0
  34. package/dist/components/combobox-core.js.map +1 -1
  35. package/dist/components/combobox.d.ts +2 -2
  36. package/dist/components/combobox.js +31 -9
  37. package/dist/components/combobox.js.map +1 -1
  38. package/dist/components/copy-button.d.ts +51 -70
  39. package/dist/components/copy-button.js +1 -1
  40. package/dist/components/copy-button.js.map +1 -1
  41. package/dist/components/data-table-cells.d.ts +63 -0
  42. package/dist/components/data-table-cells.js +64 -0
  43. package/dist/components/data-table-cells.js.map +1 -0
  44. package/dist/components/data-table-filter-popover.d.ts +1 -1
  45. package/dist/components/data-table-filters.d.ts +1 -1
  46. package/dist/components/data-table-filters.js +31 -1
  47. package/dist/components/data-table-filters.js.map +1 -1
  48. package/dist/components/data-table-labels.d.ts +13 -0
  49. package/dist/components/data-table-labels.js +8 -1
  50. package/dist/components/data-table-labels.js.map +1 -1
  51. package/dist/components/data-table.d.ts +1 -1
  52. package/dist/components/data-table.js +102 -12
  53. package/dist/components/data-table.js.map +1 -1
  54. package/dist/components/date-picker.d.ts +3 -1
  55. package/dist/components/date-picker.js +15 -2
  56. package/dist/components/date-picker.js.map +1 -1
  57. package/dist/components/description-list.d.ts +23 -3
  58. package/dist/components/description-list.js +23 -3
  59. package/dist/components/description-list.js.map +1 -1
  60. package/dist/components/dialog-frame.d.ts +1 -0
  61. package/dist/components/entity-combobox.d.ts +1 -1
  62. package/dist/components/entity-combobox.js +18 -3
  63. package/dist/components/entity-combobox.js.map +1 -1
  64. package/dist/components/error-boundary.d.ts +92 -0
  65. package/dist/components/error-boundary.js +111 -0
  66. package/dist/components/error-boundary.js.map +1 -0
  67. package/dist/components/field.d.ts +30 -4
  68. package/dist/components/field.js +40 -17
  69. package/dist/components/field.js.map +1 -1
  70. package/dist/components/file-button.d.ts +50 -208
  71. package/dist/components/file-dropzone.d.ts +49 -2
  72. package/dist/components/floating-panel.d.ts +13 -4
  73. package/dist/components/floating-panel.js +7 -3
  74. package/dist/components/floating-panel.js.map +1 -1
  75. package/dist/components/form-actions.d.ts +53 -0
  76. package/dist/components/form-actions.js +98 -0
  77. package/dist/components/form-actions.js.map +1 -0
  78. package/dist/components/image-grid.d.ts +67 -0
  79. package/dist/components/image-grid.js +112 -0
  80. package/dist/components/image-grid.js.map +1 -0
  81. package/dist/components/lightbox.d.ts +104 -0
  82. package/dist/components/lightbox.js +229 -0
  83. package/dist/components/lightbox.js.map +1 -0
  84. package/dist/components/line-items.d.ts +114 -0
  85. package/dist/components/line-items.js +238 -0
  86. package/dist/components/line-items.js.map +1 -0
  87. package/dist/components/list.d.ts +23 -2
  88. package/dist/components/list.js +32 -13
  89. package/dist/components/list.js.map +1 -1
  90. package/dist/components/loading-state.d.ts +24 -0
  91. package/dist/components/loading-state.js +32 -0
  92. package/dist/components/loading-state.js.map +1 -0
  93. package/dist/components/menu-item.d.ts +2 -1
  94. package/dist/components/menu-item.js +5 -1
  95. package/dist/components/menu-item.js.map +1 -1
  96. package/dist/components/mini-calendar.d.ts +13 -1
  97. package/dist/components/mini-calendar.js +46 -2
  98. package/dist/components/mini-calendar.js.map +1 -1
  99. package/dist/components/modal.d.ts +20 -1
  100. package/dist/components/modal.js +26 -2
  101. package/dist/components/modal.js.map +1 -1
  102. package/dist/components/month-picker.d.ts +21 -1
  103. package/dist/components/month-picker.js +139 -26
  104. package/dist/components/month-picker.js.map +1 -1
  105. package/dist/components/multi-entity-combobox.d.ts +1 -1
  106. package/dist/components/multi-entity-combobox.js +19 -3
  107. package/dist/components/multi-entity-combobox.js.map +1 -1
  108. package/dist/components/nav-pills.d.ts +2 -1
  109. package/dist/components/nav-pills.js +5 -1
  110. package/dist/components/nav-pills.js.map +1 -1
  111. package/dist/components/number-field.d.ts +24 -5
  112. package/dist/components/number-field.js +5 -7
  113. package/dist/components/number-field.js.map +1 -1
  114. package/dist/components/number-input.d.ts +25 -5
  115. package/dist/components/number-input.js +7 -1
  116. package/dist/components/number-input.js.map +1 -1
  117. package/dist/components/numpad-sheet.d.ts +15 -4
  118. package/dist/components/passkeys-setting.d.ts +67 -0
  119. package/dist/components/passkeys-setting.js +214 -0
  120. package/dist/components/passkeys-setting.js.map +1 -0
  121. package/dist/components/picker-sheet.js +2 -1
  122. package/dist/components/picker-sheet.js.map +1 -1
  123. package/dist/components/pie-chart-labels.d.ts +20 -0
  124. package/dist/components/pie-chart-labels.js +12 -0
  125. package/dist/components/pie-chart-labels.js.map +1 -0
  126. package/dist/components/pie-chart.d.ts +101 -0
  127. package/dist/components/pie-chart.js +283 -0
  128. package/dist/components/pie-chart.js.map +1 -0
  129. package/dist/components/progress-bar.d.ts +44 -3
  130. package/dist/components/progress-bar.js +58 -22
  131. package/dist/components/progress-bar.js.map +1 -1
  132. package/dist/components/qr-code.d.ts +34 -0
  133. package/dist/components/qr-code.js +57 -0
  134. package/dist/components/qr-code.js.map +1 -0
  135. package/dist/components/search-field.d.ts +15 -3
  136. package/dist/components/search-field.js +5 -1
  137. package/dist/components/search-field.js.map +1 -1
  138. package/dist/components/settings-fields.d.ts +49 -1
  139. package/dist/components/signed-amount.d.ts +121 -0
  140. package/dist/components/signed-amount.js +152 -0
  141. package/dist/components/signed-amount.js.map +1 -0
  142. package/dist/components/skeleton.d.ts +10 -3
  143. package/dist/components/skeleton.js +12 -2
  144. package/dist/components/skeleton.js.map +1 -1
  145. package/dist/components/stat-tile.d.ts +2 -1
  146. package/dist/components/stat-tile.js +5 -2
  147. package/dist/components/stat-tile.js.map +1 -1
  148. package/dist/components/table.d.ts +39 -5
  149. package/dist/components/table.js +45 -11
  150. package/dist/components/table.js.map +1 -1
  151. package/dist/components/text-link.d.ts +176 -0
  152. package/dist/components/text-link.js +109 -0
  153. package/dist/components/text-link.js.map +1 -0
  154. package/dist/components/time-input.d.ts +49 -1
  155. package/dist/components/toggle-group.d.ts +13 -0
  156. package/dist/components/toggle-group.js +35 -6
  157. package/dist/components/toggle-group.js.map +1 -1
  158. package/dist/components/toggle-legend.d.ts +36 -3
  159. package/dist/components/toggle-legend.js +49 -0
  160. package/dist/components/toggle-legend.js.map +1 -1
  161. package/dist/components/ui.d.ts +51 -779
  162. package/dist/components/ui.js +215 -23
  163. package/dist/components/ui.js.map +1 -1
  164. package/dist/components/use-table-state.d.ts +3 -52
  165. package/dist/components/use-table-state.js +128 -30
  166. package/dist/components/use-table-state.js.map +1 -1
  167. package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
  168. package/dist/data-table.d.ts +2 -1
  169. package/dist/data-table.js +5 -1
  170. package/dist/data-table.js.map +1 -1
  171. package/dist/feedback/feedback-dialog.d.ts +26 -3
  172. package/dist/feedback/feedback-dialog.js +16 -2
  173. package/dist/feedback/feedback-dialog.js.map +1 -1
  174. package/dist/feedback/feedback-thread.d.ts +133 -0
  175. package/dist/feedback/feedback-thread.js +237 -0
  176. package/dist/feedback/feedback-thread.js.map +1 -0
  177. package/dist/feedback.d.ts +2 -1
  178. package/dist/feedback.js +1 -0
  179. package/dist/feedback.js.map +1 -1
  180. package/dist/hooks/use-authed-src.d.ts +71 -0
  181. package/dist/hooks/use-authed-src.js +63 -0
  182. package/dist/hooks/use-authed-src.js.map +1 -0
  183. package/dist/hooks/use-file-drop.d.ts +49 -2
  184. package/dist/hooks/use-hotkey.d.ts +59 -0
  185. package/dist/hooks/use-hotkey.js +105 -0
  186. package/dist/hooks/use-hotkey.js.map +1 -0
  187. package/dist/hooks/use-search-param-state.d.ts +83 -0
  188. package/dist/hooks/use-search-param-state.js +94 -0
  189. package/dist/hooks/use-search-param-state.js.map +1 -0
  190. package/dist/i18n/defaults.d.ts +15 -4
  191. package/dist/i18n/defaults.js +31 -1
  192. package/dist/i18n/defaults.js.map +1 -1
  193. package/dist/i18n/kit-labels.d.ts +46 -312
  194. package/dist/i18n/kit-labels.js +20 -5
  195. package/dist/i18n/kit-labels.js.map +1 -1
  196. package/dist/i18n/locales/de-CH-informal.d.ts +15 -4
  197. package/dist/i18n/locales/de-CH.d.ts +15 -4
  198. package/dist/i18n/locales/de-informal.d.ts +15 -4
  199. package/dist/i18n/locales/de.d.ts +15 -4
  200. package/dist/i18n/locales/de.js +156 -3
  201. package/dist/i18n/locales/de.js.map +1 -1
  202. package/dist/i18n/locales/es.d.ts +15 -4
  203. package/dist/i18n/locales/es.js +153 -3
  204. package/dist/i18n/locales/es.js.map +1 -1
  205. package/dist/i18n/locales/fr.d.ts +15 -4
  206. package/dist/i18n/locales/fr.js +153 -3
  207. package/dist/i18n/locales/fr.js.map +1 -1
  208. package/dist/i18n/locales/hu.d.ts +15 -4
  209. package/dist/i18n/locales/hu.js +153 -3
  210. package/dist/i18n/locales/hu.js.map +1 -1
  211. package/dist/i18n/locales/it.d.ts +15 -4
  212. package/dist/i18n/locales/it.js +153 -3
  213. package/dist/i18n/locales/it.js.map +1 -1
  214. package/dist/i18n/locales/zh.d.ts +15 -4
  215. package/dist/i18n/locales/zh.js +153 -3
  216. package/dist/i18n/locales/zh.js.map +1 -1
  217. package/dist/index.d.ts +37 -17
  218. package/dist/index.js +40 -1
  219. package/dist/index.js.map +1 -1
  220. package/dist/kit-labels-v3biUF1L.d.ts +1645 -0
  221. package/dist/lib/format.d.ts +144 -0
  222. package/dist/lib/format.js +149 -0
  223. package/dist/lib/format.js.map +1 -0
  224. package/dist/lib/qr-encode.d.ts +44 -0
  225. package/dist/lib/qr-encode.js +338 -0
  226. package/dist/lib/qr-encode.js.map +1 -0
  227. package/dist/rhf/fields.d.ts +241 -0
  228. package/dist/rhf/fields.js +601 -0
  229. package/dist/rhf/fields.js.map +1 -0
  230. package/dist/rhf/form.d.ts +49 -1
  231. package/dist/rhf/line-items.d.ts +31 -0
  232. package/dist/rhf/line-items.js +59 -0
  233. package/dist/rhf/line-items.js.map +1 -0
  234. package/dist/rhf.d.ts +61 -1
  235. package/dist/rhf.js +2 -0
  236. package/dist/rhf.js.map +1 -1
  237. package/dist/shell/app-shell.d.ts +99 -2
  238. package/dist/shell/app-shell.js +58 -3
  239. package/dist/shell/app-shell.js.map +1 -1
  240. package/dist/shell/auth-layout.d.ts +74 -0
  241. package/dist/shell/auth-layout.js +97 -0
  242. package/dist/shell/auth-layout.js.map +1 -0
  243. package/dist/shell/top-bar-brand.d.ts +81 -0
  244. package/dist/shell/top-bar-brand.js +47 -0
  245. package/dist/shell/top-bar-brand.js.map +1 -0
  246. package/dist/shell/topbar-action-menu.d.ts +12 -1
  247. package/dist/shell/topbar-action-menu.js +16 -1
  248. package/dist/shell/topbar-action-menu.js.map +1 -1
  249. package/dist/shell.d.ts +56 -1
  250. package/dist/shell.js +2 -0
  251. package/dist/shell.js.map +1 -1
  252. package/dist/wizard/stepper-nav.d.ts +48 -1
  253. package/dist/wizard/wizard-context.d.ts +24 -1
  254. package/dist/wizard/wizard-context.js +19 -2
  255. package/dist/wizard/wizard-context.js.map +1 -1
  256. package/dist/wizard/wizard-step.d.ts +20 -5
  257. package/dist/wizard/wizard-step.js +16 -3
  258. package/dist/wizard/wizard-step.js.map +1 -1
  259. package/dist/wizard.d.ts +50 -3
  260. package/package.json +1 -1
  261. package/src/chart.ts +4 -0
  262. package/src/components/account-settings-labels.ts +190 -0
  263. package/src/components/account-settings.tsx +110 -63
  264. package/src/components/amount-input.tsx +13 -1
  265. package/src/components/authed-image.tsx +190 -0
  266. package/src/components/breadcrumbs.tsx +8 -2
  267. package/src/components/button-group.tsx +7 -2
  268. package/src/components/calendar-heatmap.tsx +105 -10
  269. package/src/components/chip.tsx +8 -4
  270. package/src/components/choice-card.tsx +136 -25
  271. package/src/components/combobox-core.tsx +5 -0
  272. package/src/components/combobox.tsx +31 -9
  273. package/src/components/copy-button.tsx +3 -2
  274. package/src/components/data-table-cells.tsx +119 -0
  275. package/src/components/data-table-filters.ts +77 -0
  276. package/src/components/data-table-labels.ts +23 -0
  277. package/src/components/data-table.tsx +224 -13
  278. package/src/components/date-picker.tsx +19 -3
  279. package/src/components/description-list.tsx +52 -3
  280. package/src/components/entity-combobox.tsx +18 -3
  281. package/src/components/error-boundary.tsx +216 -0
  282. package/src/components/field.tsx +56 -5
  283. package/src/components/floating-panel.tsx +20 -7
  284. package/src/components/form-actions.tsx +191 -0
  285. package/src/components/image-grid.tsx +181 -0
  286. package/src/components/lightbox.tsx +374 -0
  287. package/src/components/line-items.tsx +385 -0
  288. package/src/components/list.tsx +65 -16
  289. package/src/components/loading-state.tsx +47 -0
  290. package/src/components/menu-item.tsx +7 -2
  291. package/src/components/mini-calendar.tsx +72 -3
  292. package/src/components/modal.tsx +61 -3
  293. package/src/components/month-picker.tsx +181 -26
  294. package/src/components/multi-entity-combobox.tsx +19 -3
  295. package/src/components/nav-pills.tsx +8 -1
  296. package/src/components/number-field.tsx +15 -10
  297. package/src/components/number-input.tsx +16 -1
  298. package/src/components/passkeys-setting.tsx +331 -0
  299. package/src/components/picker-sheet.tsx +7 -1
  300. package/src/components/pie-chart-labels.ts +32 -0
  301. package/src/components/pie-chart.tsx +479 -0
  302. package/src/components/progress-bar.tsx +129 -19
  303. package/src/components/qr-code.tsx +83 -0
  304. package/src/components/search-field.tsx +20 -4
  305. package/src/components/signed-amount.tsx +286 -0
  306. package/src/components/skeleton.tsx +21 -3
  307. package/src/components/stat-tile.tsx +8 -2
  308. package/src/components/table.tsx +102 -13
  309. package/src/components/text-link.tsx +248 -0
  310. package/src/components/toggle-group.tsx +58 -10
  311. package/src/components/toggle-legend.tsx +87 -2
  312. package/src/components/ui.tsx +426 -36
  313. package/src/components/use-table-state.ts +265 -33
  314. package/src/data-table.ts +10 -0
  315. package/src/feedback/feedback-dialog.tsx +42 -3
  316. package/src/feedback/feedback-thread.tsx +434 -0
  317. package/src/feedback.ts +1 -0
  318. package/src/hooks/use-authed-src.ts +161 -0
  319. package/src/hooks/use-hotkey.ts +172 -0
  320. package/src/hooks/use-search-param-state.ts +187 -0
  321. package/src/i18n/defaults.ts +30 -0
  322. package/src/i18n/kit-labels.tsx +102 -8
  323. package/src/i18n/locales/de.ts +153 -0
  324. package/src/i18n/locales/es.ts +150 -0
  325. package/src/i18n/locales/fr.ts +153 -0
  326. package/src/i18n/locales/hu.ts +151 -0
  327. package/src/i18n/locales/it.ts +151 -0
  328. package/src/i18n/locales/zh.ts +150 -0
  329. package/src/index.ts +59 -0
  330. package/src/lib/format.ts +325 -0
  331. package/src/lib/qr-encode.ts +420 -0
  332. package/src/rhf/fields.tsx +1054 -0
  333. package/src/rhf/line-items.tsx +130 -0
  334. package/src/rhf.ts +6 -0
  335. package/src/shell/app-shell.tsx +147 -2
  336. package/src/shell/auth-layout.tsx +190 -0
  337. package/src/shell/top-bar-brand.tsx +76 -0
  338. package/src/shell/topbar-action-menu.tsx +21 -0
  339. package/src/shell.ts +2 -0
  340. package/src/wizard/wizard-context.tsx +41 -1
  341. package/src/wizard/wizard-step.tsx +43 -6
  342. package/tokens.css +12 -0
@@ -0,0 +1,119 @@
1
+ import type { ReactNode } from "react";
2
+ import { Check, Minus, X } from "lucide-react";
3
+ import { cn } from "../lib/cn";
4
+ import { useKitLabels } from "../i18n/kit-labels";
5
+ import { DEFAULT_DATA_TABLE_LABELS, type DataTableLabels } from "./data-table-labels";
6
+ import type { DataTableColumn } from "./data-table";
7
+
8
+ export interface BooleanMarkProps {
9
+ /** `true` → a check, `false` → a cross (or a dash, see `falseAs`), `null` /
10
+ * `undefined` → a dash. */
11
+ value: boolean | null | undefined;
12
+ /**
13
+ * How `false` is drawn. `"cross"` (default) when "no" is news worth seeing — a
14
+ * failed check, a missing document. `"dash"` for a column that is mostly `false` and
15
+ * reads as a list of the `true` ones (kastlan's BoolCell): a column of crosses is
16
+ * noise. The screen-reader text says "No" either way.
17
+ */
18
+ falseAs?: "cross" | "dash";
19
+ /** The spoken words, over `dataTable.booleanTrue` / `booleanFalse` / `booleanUnset`
20
+ * from `<UiKitProvider labels>`. */
21
+ labels?: Partial<Pick<DataTableLabels, "booleanTrue" | "booleanFalse" | "booleanUnset">>;
22
+ className?: string;
23
+ }
24
+
25
+ /**
26
+ * A yes/no value in a table cell: a green check, a muted cross, or a faint dash for
27
+ * "not set" — with the word for a screen reader, since a glyph alone is announced as
28
+ * nothing at all (or as "image").
29
+ *
30
+ * kastlan's `BoolCell` (shared/components/data-table/bool-cell.tsx) drew the icons with
31
+ * no text, so a reader walking the table heard an empty cell for every row; the apps'
32
+ * Yes/No chips were the other answer, and too loud for a column of them.
33
+ */
34
+ export function BooleanMark({ value, falseAs = "cross", labels: labelsProp, className }: BooleanMarkProps) {
35
+ const labels = useKitLabels("dataTable", DEFAULT_DATA_TABLE_LABELS, labelsProp);
36
+ const state = value === true ? "true" : value === false ? "false" : "unset";
37
+ const text =
38
+ state === "true" ? labels.booleanTrue : state === "false" ? labels.booleanFalse : labels.booleanUnset;
39
+ const Icon = state === "true" ? Check : state === "false" && falseAs === "cross" ? X : Minus;
40
+ return (
41
+ <span data-value={state} className={cn("inline-flex items-center align-middle", className)}>
42
+ <Icon
43
+ aria-hidden
44
+ className={cn(
45
+ "size-4",
46
+ state === "true"
47
+ ? "text-[var(--success)]"
48
+ : state === "false" && falseAs === "cross"
49
+ ? "text-[var(--text-muted)]"
50
+ : "text-[var(--text-placeholder)]",
51
+ )}
52
+ />
53
+ <span className="sr-only">{text}</span>
54
+ </span>
55
+ );
56
+ }
57
+
58
+ export interface BooleanColumnOptions<T> {
59
+ key: string;
60
+ header: ReactNode;
61
+ /** The value of this row. */
62
+ value: (row: T) => boolean | null | undefined;
63
+ /** See {@link BooleanMarkProps.falseAs}. */
64
+ falseAs?: "cross" | "dash";
65
+ /** Sortable, `true` first on the first click (a column is sorted to find the yes
66
+ * rows). Default true. */
67
+ sortable?: boolean;
68
+ /**
69
+ * A select filter over the column, with these option labels — pass them translated.
70
+ * Omitted: no filter. The option values are `"true"` / `"false"` (and `"unset"` when
71
+ * `unset` is given), which is also what `filterHref` takes for this column.
72
+ */
73
+ filterOptions?: { true: string; false: string; unset?: string };
74
+ /** Further column settings (`mobileHidden`, `headClassName`, …), merged over. */
75
+ column?: Partial<DataTableColumn<T>>;
76
+ }
77
+
78
+ /**
79
+ * A {@link DataTableColumn} for a yes/no field: {@link BooleanMark} cells,
80
+ * sortable with `true` first, and optionally a Yes/No select filter.
81
+ */
82
+ export function booleanColumn<T>({
83
+ key,
84
+ header,
85
+ value,
86
+ falseAs,
87
+ sortable = true,
88
+ filterOptions,
89
+ column,
90
+ }: BooleanColumnOptions<T>): DataTableColumn<T> {
91
+ const asKey = (row: T) => {
92
+ const v = value(row);
93
+ return v === true ? "true" : v === false ? "false" : "unset";
94
+ };
95
+ return {
96
+ key,
97
+ header,
98
+ cell: (row) => <BooleanMark value={value(row)} falseAs={falseAs} />,
99
+ // 0 for yes, 1 for no, null for unset (which the table sorts last either way).
100
+ sortBy: sortable
101
+ ? (row) => {
102
+ const v = value(row);
103
+ return v == null ? null : v ? 0 : 1;
104
+ }
105
+ : undefined,
106
+ filter: filterOptions
107
+ ? {
108
+ type: "select",
109
+ getValue: asKey,
110
+ options: [
111
+ { value: "true", label: filterOptions.true },
112
+ { value: "false", label: filterOptions.false },
113
+ ...(filterOptions.unset !== undefined ? [{ value: "unset", label: filterOptions.unset }] : []),
114
+ ],
115
+ }
116
+ : undefined,
117
+ ...column,
118
+ };
119
+ }
@@ -174,3 +174,80 @@ export function decodeFilterValueOfType(type: FilterValue["type"], raw: string):
174
174
  export function decodeFilterValue<T>(filter: ColumnFilter<T>, raw: string): FilterValue {
175
175
  return decodeFilterValueOfType(filter.type, raw);
176
176
  }
177
+
178
+ // ---------- Filter builders ----------
179
+ //
180
+ // Terse builders for `DataTableColumn.filter`, lifted from kastlan's column-filters.ts,
181
+ // which every converted list page imports. The one that earns its keep is `selectFilter`:
182
+ // in `serverPagination` mode a select filter MUST declare its options (the table would
183
+ // otherwise derive them from the rows of the page on screen), and the options an app has
184
+ // to hand are usually a translated `{ value: label }` map.
185
+
186
+ /** A free-text filter over `getValue`. */
187
+ export function textFilter<T>(getValue: (row: T) => string): ColumnFilter<T> {
188
+ return { type: "text", getValue };
189
+ }
190
+
191
+ /**
192
+ * A multi-select filter. `options` is an options array, or a `{ value: label }` record
193
+ * (a translated status map) — kept in its key order. Left out, the table derives the
194
+ * options from the rows it holds.
195
+ */
196
+ export function selectFilter<T>(
197
+ getValue: (row: T) => string,
198
+ options?: { value: string; label?: string }[] | Record<string, string>,
199
+ ): ColumnFilter<T> {
200
+ const opts = Array.isArray(options)
201
+ ? options
202
+ : options
203
+ ? Object.entries(options).map(([value, label]) => ({ value, label }))
204
+ : undefined;
205
+ return { type: "select", getValue, options: opts };
206
+ }
207
+
208
+ /** A from/to date filter over an ISO date (or date-time) string. */
209
+ export function dateFilter<T>(getValue: (row: T) => string | null | undefined): ColumnFilter<T> {
210
+ return { type: "date", getValue };
211
+ }
212
+
213
+ /** A min/max (and absolute-value) filter over a number. */
214
+ export function numberFilter<T>(getValue: (row: T) => number | null | undefined): ColumnFilter<T> {
215
+ return { type: "number", getValue };
216
+ }
217
+
218
+ /**
219
+ * A link to a list page with one column filter already applied — in the table's own URL
220
+ * scheme (`f.<column>=<encoded>`), which a table with `urlSync` (or
221
+ * {@link useTableUrlState}) reads on mount. kastlan's `buildListUrl`, which had to
222
+ * restate the `f.` prefix to do it.
223
+ *
224
+ * `value` is a string for a text filter, an array for a select filter, or any
225
+ * {@link FilterValue}. A filter that narrows nothing returns `path` unchanged. A `path`
226
+ * that already carries a query keeps it; a `#fragment` stays at the end.
227
+ *
228
+ * filterHref("/units", "status", ["vacant"]) // "/units?f.status=vacant"
229
+ */
230
+ export function filterHref(
231
+ path: string,
232
+ column: string,
233
+ value: string | string[] | FilterValue,
234
+ /** The target table's `urlSync` prefix, if it has one (`{ prefix: "inv." }` →
235
+ * `inv.f.<column>`). */
236
+ options?: { prefix?: string },
237
+ ): string {
238
+ const state: FilterValue =
239
+ typeof value === "string"
240
+ ? { type: "text", q: value }
241
+ : Array.isArray(value)
242
+ ? { type: "select", values: value }
243
+ : value;
244
+ const encoded = encodeFilterValue(state);
245
+ if (encoded == null) return path;
246
+ const hashAt = path.indexOf("#");
247
+ const base = hashAt >= 0 ? path.slice(0, hashAt) : path;
248
+ const hash = hashAt >= 0 ? path.slice(hashAt) : "";
249
+ const queryAt = base.indexOf("?");
250
+ const params = new URLSearchParams(queryAt >= 0 ? base.slice(queryAt + 1) : "");
251
+ params.set(`${options?.prefix ?? ""}f.${column}`, encoded);
252
+ return `${queryAt >= 0 ? base.slice(0, queryAt) : base}?${params.toString()}${hash}`;
253
+ }
@@ -69,6 +69,22 @@ export interface DataTableLabels {
69
69
  rowCount: (total: number) => string;
70
70
  /** The column-settings rail and its heading: "Columns (4/9)". */
71
71
  columnsCount: (visible: number, total: number) => string;
72
+
73
+ // ---- Empty state, row actions, boolean cells (0.12.0) ----
74
+
75
+ /** The body of a table with no rows and no `empty` prop of its own. A sentence, not
76
+ * "—": a dash in the only row of a table reads as one row with a missing value. */
77
+ empty: string;
78
+ /** The accessible name of the `rowActions` column (its header is visually empty). */
79
+ actions: string;
80
+ /** The `kind: "edit"` row action's name — its tooltip and its swipe label. */
81
+ edit: string;
82
+ /** The `kind: "delete"` row action's name. */
83
+ delete: string;
84
+ /** What {@link BooleanMark} says to a screen reader for `true` / `false` / nothing. */
85
+ booleanTrue: string;
86
+ booleanFalse: string;
87
+ booleanUnset: string;
72
88
  }
73
89
 
74
90
  export const DEFAULT_DATA_TABLE_LABELS: DataTableLabels = {
@@ -120,6 +136,13 @@ export const DEFAULT_DATA_TABLE_LABELS: DataTableLabels = {
120
136
  pageRange: (from, to, total) => `${from}–${to} / ${total}`,
121
137
  rowCount: (total) => `${total}`,
122
138
  columnsCount: (visible, total) => `Columns (${visible}/${total})`,
139
+ empty: "No entries",
140
+ actions: "Actions",
141
+ edit: "Edit",
142
+ delete: "Delete",
143
+ booleanTrue: "Yes",
144
+ booleanFalse: "No",
145
+ booleanUnset: "Not set",
123
146
  };
124
147
 
125
148
  /**
@@ -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) => {
@@ -1445,6 +1641,19 @@ export function DataTable<T>({
1445
1641
  card list for the table wholesale, which would remount a region living
1446
1642
  inside either one. */}
1447
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
+ )}
1448
1657
  {!isMdUp && (
1449
1658
  <div ref={mobileRootRef}>
1450
1659
  {/* Mobile filter access (feedback #299): the per-column filter popovers
@@ -1489,7 +1698,7 @@ export function DataTable<T>({
1489
1698
  compact ? "px-3 py-3 text-xs" : "px-4 py-6 text-sm",
1490
1699
  )}
1491
1700
  >
1492
- {isLoading ? <LoadingText label={labels.loading} /> : (empty ?? "—")}
1701
+ {isLoading ? <LoadingText label={labels.loading} /> : (empty ?? labels.empty)}
1493
1702
  </li>
1494
1703
  )}
1495
1704
  {/* Endless-scroll sentinel: observed by IntersectionObserver to pull in
@@ -1708,7 +1917,7 @@ export function DataTable<T>({
1708
1917
  </Popover>
1709
1918
  )}
1710
1919
  </div>
1711
- {resizable && (
1920
+ {resizable && col.key !== ROW_ACTIONS_KEY && (
1712
1921
  <span
1713
1922
  role="separator"
1714
1923
  aria-orientation="vertical"
@@ -1800,6 +2009,7 @@ export function DataTable<T>({
1800
2009
  // not also open the row. Cells no longer have to
1801
2010
  // `stopPropagation` to get that.
1802
2011
  if (fromOwnControl(e.target, e.currentTarget)) return;
2012
+ if (fromOutside(e.target, e.currentTarget)) return;
1803
2013
  // Modifier-clicks anywhere in the row drive
1804
2014
  // selection instead of expanding it (feedback #289):
1805
2015
  // Ctrl/Cmd toggles a single row, Shift selects the
@@ -1935,7 +2145,7 @@ export function DataTable<T>({
1935
2145
  >
1936
2146
  {/* Nothing is not the same as not-yet: "no results" while the first
1937
2147
  page is still in flight is a claim the table cannot make. */}
1938
- {isLoading ? <LoadingText label={labels.loading} /> : (empty ?? "—")}
2148
+ {isLoading ? <LoadingText label={labels.loading} /> : (empty ?? labels.empty)}
1939
2149
  </td>
1940
2150
  </tr>
1941
2151
  )}
@@ -2018,6 +2228,7 @@ export function DataTable<T>({
2018
2228
  </button>
2019
2229
  <div className="flex flex-col gap-0.5">
2020
2230
  {columns.map((col) => {
2231
+ if (col.key === ROW_ACTIONS_KEY) return null;
2021
2232
  const checked = !hiddenCols.has(col.key);
2022
2233
  const headerLabel = columnLabel(col);
2023
2234
  return (