@eifi1/ui-kit 0.48.1 → 0.49.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 (318) hide show
  1. package/README.md +28 -13
  2. package/dist/admin/admin-action-confirm.js +1 -1
  3. package/dist/admin/admin-action-confirm.js.map +1 -1
  4. package/dist/admin/invitations-panel.js +5 -1
  5. package/dist/admin/invitations-panel.js.map +1 -1
  6. package/dist/admin/user-roster.d.ts +11 -5
  7. package/dist/admin/user-roster.js +15 -4
  8. package/dist/admin/user-roster.js.map +1 -1
  9. package/dist/auth/accept-invitation.js +2 -2
  10. package/dist/auth/accept-invitation.js.map +1 -1
  11. package/dist/auth/not-found-page.js +1 -1
  12. package/dist/auth/not-found-page.js.map +1 -1
  13. package/dist/auth/reset-password-form.js +2 -2
  14. package/dist/auth/reset-password-form.js.map +1 -1
  15. package/dist/auth/verify-email.js +2 -2
  16. package/dist/auth/verify-email.js.map +1 -1
  17. package/dist/billing/plan-card.js +2 -2
  18. package/dist/billing/plan-card.js.map +1 -1
  19. package/dist/components/breadcrumbs.d.ts +19 -1
  20. package/dist/components/breadcrumbs.js +11 -4
  21. package/dist/components/breadcrumbs.js.map +1 -1
  22. package/dist/components/button-look.d.ts +37 -7
  23. package/dist/components/button-look.js +33 -6
  24. package/dist/components/button-look.js.map +1 -1
  25. package/dist/components/button.d.ts +4 -1
  26. package/dist/components/button.js +2 -2
  27. package/dist/components/button.js.map +1 -1
  28. package/dist/components/calendar-heatmap.js +2 -1
  29. package/dist/components/calendar-heatmap.js.map +1 -1
  30. package/dist/components/chart-edge.d.ts +10 -0
  31. package/dist/components/chart-edge.js +57 -0
  32. package/dist/components/chart-edge.js.map +1 -0
  33. package/dist/components/chart.js +2 -1
  34. package/dist/components/chart.js.map +1 -1
  35. package/dist/components/chip.d.ts +8 -1
  36. package/dist/components/chip.js +4 -1
  37. package/dist/components/chip.js.map +1 -1
  38. package/dist/components/confirm-context.d.ts +5 -0
  39. package/dist/components/confirm-context.js +7 -0
  40. package/dist/components/confirm-context.js.map +1 -0
  41. package/dist/components/confirm-dialog.js +3 -3
  42. package/dist/components/confirm-dialog.js.map +1 -1
  43. package/dist/components/danger-confirm.d.ts +2 -2
  44. package/dist/components/danger-confirm.js +1 -1
  45. package/dist/components/danger-confirm.js.map +1 -1
  46. package/dist/components/data-table-fit.d.ts +8 -1
  47. package/dist/components/data-table-fit.js +28 -4
  48. package/dist/components/data-table-fit.js.map +1 -1
  49. package/dist/components/data-table-groups.d.ts +3 -1
  50. package/dist/components/data-table-groups.js +11 -5
  51. package/dist/components/data-table-groups.js.map +1 -1
  52. package/dist/components/data-table-head.d.ts +8 -1
  53. package/dist/components/data-table-head.js +11 -3
  54. package/dist/components/data-table-head.js.map +1 -1
  55. package/dist/components/data-table-leading-row.d.ts +3 -1
  56. package/dist/components/data-table-leading-row.js +2 -1
  57. package/dist/components/data-table-leading-row.js.map +1 -1
  58. package/dist/components/data-table-mobile.d.ts +48 -2
  59. package/dist/components/data-table-mobile.js +87 -10
  60. package/dist/components/data-table-mobile.js.map +1 -1
  61. package/dist/components/data-table-row-actions.d.ts +2 -1
  62. package/dist/components/data-table-row-actions.js +9 -2
  63. package/dist/components/data-table-row-actions.js.map +1 -1
  64. package/dist/components/data-table-row.d.ts +84 -0
  65. package/dist/components/data-table-row.js +261 -0
  66. package/dist/components/data-table-row.js.map +1 -0
  67. package/dist/components/data-table-selection.d.ts +3 -0
  68. package/dist/components/data-table-selection.js +12 -1
  69. package/dist/components/data-table-selection.js.map +1 -1
  70. package/dist/components/data-table-virtual.d.ts +98 -0
  71. package/dist/components/data-table-virtual.js +369 -0
  72. package/dist/components/data-table-virtual.js.map +1 -0
  73. package/dist/components/data-table.d.ts +74 -2
  74. package/dist/components/data-table.js +241 -221
  75. package/dist/components/data-table.js.map +1 -1
  76. package/dist/components/error-boundary.js +2 -2
  77. package/dist/components/error-boundary.js.map +1 -1
  78. package/dist/components/floating-panel.d.ts +36 -13
  79. package/dist/components/floating-panel.js +27 -7
  80. package/dist/components/floating-panel.js.map +1 -1
  81. package/dist/components/form-actions.d.ts +1 -1
  82. package/dist/components/form-actions.js +1 -1
  83. package/dist/components/form-actions.js.map +1 -1
  84. package/dist/components/icon-button.d.ts +11 -0
  85. package/dist/components/icon-button.js +7 -5
  86. package/dist/components/icon-button.js.map +1 -1
  87. package/dist/components/nav-pills.d.ts +8 -1
  88. package/dist/components/nav-pills.js +61 -38
  89. package/dist/components/nav-pills.js.map +1 -1
  90. package/dist/components/page-header-context.d.ts +8 -0
  91. package/dist/components/page-header-context.js +7 -0
  92. package/dist/components/page-header-context.js.map +1 -0
  93. package/dist/components/page-header.d.ts +14 -2
  94. package/dist/components/page-header.js +10 -1
  95. package/dist/components/page-header.js.map +1 -1
  96. package/dist/components/pie-chart.js +2 -1
  97. package/dist/components/pie-chart.js.map +1 -1
  98. package/dist/components/progress-bar.js +5 -4
  99. package/dist/components/progress-bar.js.map +1 -1
  100. package/dist/components/reauth-dialog.js +1 -1
  101. package/dist/components/reauth-dialog.js.map +1 -1
  102. package/dist/components/row-actions-labels.js +3 -1
  103. package/dist/components/row-actions-labels.js.map +1 -1
  104. package/dist/components/row-actions.d.ts +43 -9
  105. package/dist/components/row-actions.js +78 -15
  106. package/dist/components/row-actions.js.map +1 -1
  107. package/dist/components/section-nav.d.ts +37 -0
  108. package/dist/components/section-nav.js +33 -0
  109. package/dist/components/section-nav.js.map +1 -0
  110. package/dist/components/series-chart.js +8 -3
  111. package/dist/components/series-chart.js.map +1 -1
  112. package/dist/components/share-card.js +1 -1
  113. package/dist/components/share-card.js.map +1 -1
  114. package/dist/components/signature-pad.js +1 -1
  115. package/dist/components/signature-pad.js.map +1 -1
  116. package/dist/components/sparkline.js +2 -1
  117. package/dist/components/sparkline.js.map +1 -1
  118. package/dist/components/stat-tile.js +14 -7
  119. package/dist/components/stat-tile.js.map +1 -1
  120. package/dist/components/status-dot.d.ts +5 -1
  121. package/dist/components/status-dot.js +4 -1
  122. package/dist/components/status-dot.js.map +1 -1
  123. package/dist/components/tooltip.js +1 -1
  124. package/dist/components/tooltip.js.map +1 -1
  125. package/dist/components/translation-review-editor.js +2 -2
  126. package/dist/components/translation-review-editor.js.map +1 -1
  127. package/dist/components/translation-review.js +2 -2
  128. package/dist/components/translation-review.js.map +1 -1
  129. package/dist/components/treemap.d.ts +5 -2
  130. package/dist/components/treemap.js +26 -6
  131. package/dist/components/treemap.js.map +1 -1
  132. package/dist/components/use-table-state.js +37 -6
  133. package/dist/components/use-table-state.js.map +1 -1
  134. package/dist/data-table.d.ts +1 -0
  135. package/dist/data-table.js.map +1 -1
  136. package/dist/demo/demo-banner.js +1 -1
  137. package/dist/demo/demo-banner.js.map +1 -1
  138. package/dist/demo/demo-ended.js +1 -1
  139. package/dist/demo/demo-ended.js.map +1 -1
  140. package/dist/demo/demo-start.js +1 -1
  141. package/dist/demo/demo-start.js.map +1 -1
  142. package/dist/eslint/index.js +1 -1
  143. package/dist/feedback/feedback-inbox.js +1 -1
  144. package/dist/feedback/feedback-inbox.js.map +1 -1
  145. package/dist/hooks/use-anchored-panel.js +2 -1
  146. package/dist/hooks/use-anchored-panel.js.map +1 -1
  147. package/dist/hooks/use-dismiss.d.ts +9 -1
  148. package/dist/hooks/use-dismiss.js +25 -5
  149. package/dist/hooks/use-dismiss.js.map +1 -1
  150. package/dist/hooks/use-document-title.d.ts +14 -0
  151. package/dist/hooks/use-document-title.js +22 -0
  152. package/dist/hooks/use-document-title.js.map +1 -0
  153. package/dist/i18n/german.js +6 -2
  154. package/dist/i18n/german.js.map +1 -1
  155. package/dist/i18n/kit-labels.d.ts +31 -1
  156. package/dist/i18n/kit-labels.js +43 -2
  157. package/dist/i18n/kit-labels.js.map +1 -1
  158. package/dist/i18n/locales/en.js +6 -2
  159. package/dist/i18n/locales/en.js.map +2 -2
  160. package/dist/i18n/locales/es.js +6 -2
  161. package/dist/i18n/locales/es.js.map +1 -1
  162. package/dist/i18n/locales/fr.js +6 -2
  163. package/dist/i18n/locales/fr.js.map +1 -1
  164. package/dist/i18n/locales/hu.js +6 -2
  165. package/dist/i18n/locales/hu.js.map +1 -1
  166. package/dist/i18n/locales/it.js +6 -2
  167. package/dist/i18n/locales/it.js.map +1 -1
  168. package/dist/i18n/locales/zh.js +6 -2
  169. package/dist/i18n/locales/zh.js.map +1 -1
  170. package/dist/i18n/review.js +2 -0
  171. package/dist/i18n/review.js.map +1 -1
  172. package/dist/i18n/shared-labels.js +1 -0
  173. package/dist/i18n/shared-labels.js.map +1 -1
  174. package/dist/index.d.ts +8 -1
  175. package/dist/index.js +11 -0
  176. package/dist/index.js.map +1 -1
  177. package/dist/landing/landing-actions.js +2 -2
  178. package/dist/landing/landing-actions.js.map +1 -1
  179. package/dist/landing/public-header.js +2 -2
  180. package/dist/landing/public-header.js.map +1 -1
  181. package/dist/lib/clipboard-minus.d.ts +27 -0
  182. package/dist/lib/clipboard-minus.js +63 -0
  183. package/dist/lib/clipboard-minus.js.map +1 -0
  184. package/dist/lib/document-title.d.ts +17 -0
  185. package/dist/lib/document-title.js +29 -0
  186. package/dist/lib/document-title.js.map +1 -0
  187. package/dist/lib/float-clearance.js +8 -2
  188. package/dist/lib/float-clearance.js.map +1 -1
  189. package/dist/lib/format.d.ts +35 -10
  190. package/dist/lib/format.js +55 -11
  191. package/dist/lib/format.js.map +1 -1
  192. package/dist/lib/minus.d.ts +22 -0
  193. package/dist/lib/minus.js +14 -0
  194. package/dist/lib/minus.js.map +1 -0
  195. package/dist/lib/status-tone.d.ts +30 -0
  196. package/dist/lib/status-tone.js +17 -0
  197. package/dist/lib/status-tone.js.map +1 -0
  198. package/dist/settings/settings-layout.js +3 -2
  199. package/dist/settings/settings-layout.js.map +1 -1
  200. package/dist/shell/account-menu-language.d.ts +1 -1
  201. package/dist/shell/account-menu-language.js +9 -1
  202. package/dist/shell/account-menu-language.js.map +1 -1
  203. package/dist/shell/app-shell-labels.js +2 -1
  204. package/dist/shell/app-shell-labels.js.map +1 -1
  205. package/dist/shell/app-shell.d.ts +58 -16
  206. package/dist/shell/app-shell.js +158 -44
  207. package/dist/shell/app-shell.js.map +1 -1
  208. package/dist/shell/language-code.d.ts +3 -0
  209. package/dist/shell/language-code.js +8 -0
  210. package/dist/shell/language-code.js.map +1 -0
  211. package/dist/shell/topbar-controls.d.ts +14 -6
  212. package/dist/shell/topbar-controls.js +14 -17
  213. package/dist/shell/topbar-controls.js.map +1 -1
  214. package/dist/testing/css-variables.generated.d.ts +1 -1
  215. package/dist/testing/css-variables.generated.js +1 -1
  216. package/dist/testing/css-variables.generated.js.map +1 -1
  217. package/dist/theme/chart-palette.d.ts +82 -15
  218. package/dist/theme/chart-palette.js +51 -24
  219. package/dist/theme/chart-palette.js.map +1 -1
  220. package/dist/theme/palette-presets.d.ts +9 -0
  221. package/dist/theme/palette-presets.js +77 -62
  222. package/dist/theme/palette-presets.js.map +1 -1
  223. package/dist/tour/tour.js +1 -1
  224. package/dist/tour/tour.js.map +1 -1
  225. package/dist/wizard/stepper-nav.d.ts +1 -1
  226. package/dist/wizard/stepper-nav.js +3 -3
  227. package/dist/wizard/stepper-nav.js.map +1 -1
  228. package/package.json +1 -1
  229. package/src/admin/admin-action-confirm.tsx +1 -1
  230. package/src/admin/invitations-panel.tsx +7 -3
  231. package/src/admin/user-roster.tsx +23 -6
  232. package/src/auth/accept-invitation.tsx +3 -3
  233. package/src/auth/not-found-page.tsx +1 -1
  234. package/src/auth/reset-password-form.tsx +3 -3
  235. package/src/auth/verify-email.tsx +3 -3
  236. package/src/billing/plan-card.tsx +2 -2
  237. package/src/components/breadcrumbs.tsx +32 -4
  238. package/src/components/button-look.ts +86 -15
  239. package/src/components/button.tsx +6 -3
  240. package/src/components/calendar-heatmap.tsx +2 -1
  241. package/src/components/chart-edge.ts +73 -0
  242. package/src/components/chart.tsx +3 -1
  243. package/src/components/chip.tsx +10 -1
  244. package/src/components/confirm-context.ts +7 -0
  245. package/src/components/confirm-dialog.tsx +3 -3
  246. package/src/components/danger-confirm.tsx +3 -3
  247. package/src/components/data-table-fit.ts +58 -8
  248. package/src/components/data-table-groups.tsx +15 -4
  249. package/src/components/data-table-head.tsx +25 -2
  250. package/src/components/data-table-leading-row.tsx +4 -1
  251. package/src/components/data-table-mobile.tsx +162 -11
  252. package/src/components/data-table-row-actions.tsx +12 -3
  253. package/src/components/data-table-row.tsx +485 -0
  254. package/src/components/data-table-selection.ts +17 -1
  255. package/src/components/data-table-virtual.ts +547 -0
  256. package/src/components/data-table.tsx +367 -326
  257. package/src/components/error-boundary.tsx +2 -2
  258. package/src/components/floating-panel.tsx +67 -16
  259. package/src/components/form-actions.tsx +2 -2
  260. package/src/components/icon-button.tsx +21 -8
  261. package/src/components/nav-pills.tsx +29 -2
  262. package/src/components/page-header-context.ts +8 -0
  263. package/src/components/page-header.tsx +34 -2
  264. package/src/components/pie-chart.tsx +2 -1
  265. package/src/components/progress-bar.tsx +6 -3
  266. package/src/components/reauth-dialog.tsx +1 -1
  267. package/src/components/row-actions-labels.ts +2 -0
  268. package/src/components/row-actions.tsx +154 -33
  269. package/src/components/section-nav.tsx +65 -0
  270. package/src/components/series-chart.tsx +12 -3
  271. package/src/components/share-card.tsx +1 -1
  272. package/src/components/signature-pad.tsx +1 -1
  273. package/src/components/sparkline.tsx +2 -1
  274. package/src/components/stat-tile.tsx +19 -7
  275. package/src/components/status-dot.tsx +7 -1
  276. package/src/components/tooltip.tsx +3 -1
  277. package/src/components/translation-review-editor.tsx +2 -2
  278. package/src/components/translation-review.tsx +2 -2
  279. package/src/components/treemap.tsx +34 -8
  280. package/src/components/use-table-state.ts +63 -8
  281. package/src/data-table.ts +5 -3
  282. package/src/demo/demo-banner.tsx +1 -1
  283. package/src/demo/demo-ended.tsx +1 -1
  284. package/src/demo/demo-start.tsx +1 -1
  285. package/src/feedback/feedback-inbox.tsx +1 -1
  286. package/src/hooks/use-anchored-panel.ts +9 -1
  287. package/src/hooks/use-dismiss.ts +43 -4
  288. package/src/hooks/use-document-title.ts +31 -0
  289. package/src/i18n/german.ts +4 -0
  290. package/src/i18n/kit-labels.tsx +76 -1
  291. package/src/i18n/locales/es.ts +4 -0
  292. package/src/i18n/locales/fr.ts +4 -0
  293. package/src/i18n/locales/hu.ts +4 -0
  294. package/src/i18n/locales/it.ts +4 -0
  295. package/src/i18n/locales/zh.ts +4 -0
  296. package/src/i18n/review.ts +2 -0
  297. package/src/i18n/shared-labels.ts +1 -0
  298. package/src/index.ts +13 -0
  299. package/src/landing/landing-actions.tsx +2 -2
  300. package/src/landing/public-header.tsx +2 -2
  301. package/src/lib/clipboard-minus.ts +94 -0
  302. package/src/lib/document-title.ts +41 -0
  303. package/src/lib/float-clearance.ts +19 -3
  304. package/src/lib/format.ts +115 -30
  305. package/src/lib/minus.ts +40 -0
  306. package/src/lib/status-tone.ts +43 -0
  307. package/src/settings/settings-layout.tsx +3 -2
  308. package/src/shell/account-menu-language.tsx +14 -2
  309. package/src/shell/app-shell-labels.ts +1 -0
  310. package/src/shell/app-shell.tsx +298 -69
  311. package/src/shell/language-code.ts +5 -0
  312. package/src/shell/topbar-controls.tsx +35 -15
  313. package/src/testing/css-variables.generated.ts +6 -6
  314. package/src/theme/chart-palette.ts +102 -20
  315. package/src/theme/palette-presets.ts +121 -84
  316. package/src/tour/tour.tsx +1 -1
  317. package/src/wizard/stepper-nav.tsx +4 -4
  318. package/tokens.css +28 -18
@@ -15,7 +15,9 @@ import type { DataTableColumn } from "./data-table";
15
15
  // room for them (its need re-measured, plus the column's own header width from when
16
16
  // it was left out), so narrower content (a filter, another page) counts as much as a
17
17
  // wider scroller. The scroller's own width, through a ResizeObserver: a sidebar, a
18
- // narrower window, the Columns drawer and Large text all count the same.
18
+ // narrower window, the Columns drawer and Large text all count the same. A column
19
+ // whose return the very next measure undoes stays out until the table has more room
20
+ // for it than it had then (0.48.3, keksdose B2: see the bring-back in `measure`).
19
21
 
20
22
  /** The least a `grow` column keeps before the table starts leaving columns out: about
21
23
  * a sentence of a title. In rem, so Large text asks for more. */
@@ -65,6 +67,7 @@ export function useColumnFit<T>({
65
67
  headRefs,
66
68
  scroller,
67
69
  enabled,
70
+ content,
68
71
  }: {
69
72
  columns: DataTableColumn<T>[];
70
73
  /** The user's hidden columns: never candidates (they are out already). */
@@ -74,6 +77,13 @@ export function useColumnFit<T>({
74
77
  scroller: () => HTMLElement | null;
75
78
  /** The wide layout. On a phone (the card list) nothing is left out for width. */
76
79
  enabled: boolean;
80
+ /**
81
+ * What can change the columns' widths besides the scroller (0.48.2): the rows on
82
+ * screen, the columns, the stored widths, the text size. Compared item by item after
83
+ * each render, and the table is measured only when one changed — a selection tick no
84
+ * longer forces a layout of the whole table. Without it, every render measures.
85
+ */
86
+ content?: readonly unknown[];
77
87
  }): {
78
88
  /** The columns left out for width now — empty while off. */
79
89
  autoHidden: Set<string>;
@@ -86,6 +96,13 @@ export function useColumnFit<T>({
86
96
  // table's need as it is now, plus this, fits the scroller (keksdose S3, 0.48: not the
87
97
  // whole table's need at hide time, which went stale when the content narrowed).
88
98
  const ownWidth = useRef(new Map<string, number>());
99
+ // A column whose return the very next measure undid, at the same scroller width: the
100
+ // room the table had for it when it came back (keksdose B2, 0.48.3). It stays out
101
+ // until the table has more. Dropped once the column is no longer left out for width.
102
+ const stuck = useRef(new Map<string, number>());
103
+ // What the last measure brought back, each column with the room it had then, and at
104
+ // which scroller width: read (and cleared) by the measure right after it.
105
+ const lastShown = useRef<{ avail: number; rooms: Map<string, number> } | null>(null);
89
106
 
90
107
  const hasFit = columns.some((c) => c.fitPriority != null);
91
108
  const active = enabled && hasFit;
@@ -110,6 +127,10 @@ export function useColumnFit<T>({
110
127
 
111
128
  const measure = useCallback(() => {
112
129
  const { order, current, growKey, active, scroller } = latest.current;
130
+ // Only the measure right after a bring-back can undo it.
131
+ const flip = lastShown.current;
132
+ lastShown.current = null;
133
+ for (const k of stuck.current.keys()) if (!current.includes(k)) stuck.current.delete(k);
113
134
  const el = scroller();
114
135
  if (!active || !el) return;
115
136
  const table = el.querySelector("table");
@@ -142,25 +163,54 @@ export function useColumnFit<T>({
142
163
  need -= w;
143
164
  next.push(col.key);
144
165
  }
145
- if (next.length !== current.length) setHidden(next);
166
+ if (next.length === current.length) return;
167
+ // The last measure brought these back at this same width, and the table cannot
168
+ // hold them: their header width fell short of what they cost drawn (see below).
169
+ if (flip && Math.abs(flip.avail - avail) <= 0.5)
170
+ for (const k of next.slice(current.length)) {
171
+ const room = flip.rooms.get(k);
172
+ if (room != null) stuck.current.set(k, room);
173
+ }
174
+ setHidden(next);
146
175
  return;
147
176
  }
148
177
  // Bring back, the last left out first, while the table as it is now has room for
149
- // them. Should the column have grown since (wider content), the next measure leaves
150
- // it out again before paint and records its new width, so it cannot flicker.
178
+ // them by their recorded header width. A column that costs more drawn than that is
179
+ // left out again by the next measure, before paint. That alone does not settle it:
180
+ // the re-hide records its header's width again, and the header can measure the same
181
+ // while the column costs more (`virtualize`'s locked widths, keksdose B2, 0.48.3: the
182
+ // drawn column cost the table ~80–110px more than its header), so it came back, went, came back … until React's
183
+ // update limit (#185). So a column whose return the next measure undid, at the same
184
+ // scroller width, waits for more room than it had then: a wider scroller or narrower
185
+ // content. Whatever the error in its width, that is one invisible flip per room it is
186
+ // tried at.
151
187
  let n = current.length;
188
+ const rooms = new Map<string, number>();
152
189
  while (n > 0) {
153
- const w = ownWidth.current.get(current[n - 1]);
190
+ const k = current[n - 1];
191
+ const w = ownWidth.current.get(k);
154
192
  if (w == null || need + w > avail + 0.5) break;
193
+ const room = stuck.current.get(k);
194
+ if (room != null && avail - need <= room + 0.5) break;
195
+ rooms.set(k, avail - need);
155
196
  need += w;
156
197
  n--;
157
198
  }
158
- if (n !== current.length) setHidden(current.slice(0, n));
199
+ if (n === current.length) return;
200
+ lastShown.current = { avail, rooms };
201
+ setHidden(current.slice(0, n));
159
202
  }, [headRefs]);
160
203
 
161
- // After every render: rows, a filter or a page can widen a column as much as a resize.
162
- // Before paint, so a column the table cannot hold is never drawn first.
204
+ // After a render that changed what is drawn: rows, a filter or a page can widen a
205
+ // column as much as a resize. Before paint, so a column the table cannot hold is never
206
+ // drawn first. A render that changed none of it (a ticked box, a focused row) skips the
207
+ // measure, which forces a layout of the whole table (0.48.2, keksdose dev #632).
208
+ const lastContent = useRef<readonly unknown[] | null>(null);
163
209
  useLayoutEffect(() => {
210
+ const now = content ? [...content, order, current, growKey, active] : null;
211
+ const prev = lastContent.current;
212
+ lastContent.current = now;
213
+ if (now && prev && prev.length === now.length && prev.every((v, i) => Object.is(v, now[i]))) return;
164
214
  measure();
165
215
  });
166
216
 
@@ -94,19 +94,27 @@ export function useGroupBandTops(
94
94
  useLayoutEffect(() => {
95
95
  const table = tableRef.current;
96
96
  if (!enabled || !table) return;
97
- const parts = [
97
+ // Looked up afresh on each measure: under `virtualize` (0.48.2) the first band can
98
+ // leave the DOM as the rows scroll, and a detached cell measures 0 — the bands would
99
+ // stack on one another. A band missing for now keeps the height it had.
100
+ const parts = () => [
98
101
  table.tHead,
99
102
  table.querySelector<HTMLElement>("[data-leading-row='sticky']"),
100
103
  table.querySelector<HTMLElement>("th[data-group-band='1']"),
101
104
  ];
105
+ let bandHeight = 0;
102
106
  const measure = () => {
103
- const [head, lead, band] = parts.map((el) => (el ? el.getBoundingClientRect().height : 0));
107
+ const [headEl, leadEl, bandEl] = parts();
108
+ const head = headEl ? headEl.getBoundingClientRect().height : 0;
109
+ const lead = leadEl ? leadEl.getBoundingClientRect().height : 0;
110
+ if (bandEl) bandHeight = bandEl.getBoundingClientRect().height;
111
+ const band = bandHeight;
104
112
  setTops((prev) => (prev[0] === head + lead && prev[1] === head + lead + band ? prev : [head + lead, head + lead + band]));
105
113
  };
106
114
  measure();
107
115
  if (typeof ResizeObserver === "undefined") return;
108
116
  const observer = new ResizeObserver(measure);
109
- for (const el of parts) if (el) observer.observe(el);
117
+ for (const el of parts()) if (el) observer.observe(el);
110
118
  return () => observer.disconnect();
111
119
  }, [tableRef, enabled, signature]);
112
120
  return tops;
@@ -127,6 +135,7 @@ export function DataTableGroupBand({
127
135
  compact,
128
136
  label,
129
137
  end,
138
+ ariaRowIndex,
130
139
  }: {
131
140
  level: 1 | 2;
132
141
  colSpan: number;
@@ -134,9 +143,11 @@ export function DataTableGroupBand({
134
143
  compact: boolean;
135
144
  label: ReactNode;
136
145
  end: ReactNode;
146
+ /** Under `virtualize` (0.48.2). */
147
+ ariaRowIndex?: number;
137
148
  }) {
138
149
  return (
139
- <tr data-group-row={level}>
150
+ <tr data-group-row={level} aria-rowindex={ariaRowIndex}>
140
151
  <th
141
152
  scope="rowgroup"
142
153
  colSpan={colSpan}
@@ -45,6 +45,9 @@ export function DataTableHead<T>({
45
45
  startResize,
46
46
  moveResize,
47
47
  finishResize,
48
+ ariaRowIndex,
49
+ lockedWidths,
50
+ selectionLockKey,
48
51
  }: {
49
52
  visibleColumns: DataTableColumn<T>[];
50
53
  selection: DataTableProps<T>["selection"];
@@ -71,13 +74,25 @@ export function DataTableHead<T>({
71
74
  startResize: (e: React.PointerEvent<HTMLElement>, key: string) => void;
72
75
  moveResize: (e: React.PointerEvent<HTMLElement>) => void;
73
76
  finishResize: (e: React.PointerEvent<HTMLElement>, commit: boolean) => void;
77
+ /** Under `virtualize` (0.48.2): 1. */
78
+ ariaRowIndex?: number;
79
+ /** Under `virtualize` (0.48.2): each column's locked width, as the header cell's
80
+ * `min-width` — a `<col>` width alone gave way to a `grow` column's 100%. */
81
+ lockedWidths?: Record<string, number> | null;
82
+ /** The selection column's key in `lockedWidths`. */
83
+ selectionLockKey?: string;
74
84
  }) {
75
85
  return (
76
86
  <thead className="bg-surface-2 text-secondary">
77
- <tr className="text-start">
87
+ <tr className="text-start" aria-rowindex={ariaRowIndex}>
78
88
  {selection && (
79
89
  <th
80
90
  scope="col"
91
+ style={
92
+ selectionLockKey && lockedWidths?.[selectionLockKey] !== undefined
93
+ ? { minWidth: lockedWidths[selectionLockKey] }
94
+ : undefined
95
+ }
81
96
  className={cn(
82
97
  "sticky top-0 z-10 bg-surface-2 align-middle backdrop-blur-sm",
83
98
  compact ? "w-8 px-2 py-1" : "w-10 px-3 py-2",
@@ -140,7 +155,15 @@ export function DataTableHead<T>({
140
155
  : "none"
141
156
  : undefined
142
157
  }
143
- style={ownHead.style || pinnedHead ? { ...ownHead.style, ...pinnedHead } : undefined}
158
+ style={
159
+ ownHead.style || pinnedHead || lockedWidths?.[col.key] !== undefined
160
+ ? {
161
+ ...ownHead.style,
162
+ ...(lockedWidths?.[col.key] !== undefined ? { minWidth: lockedWidths[col.key] } : null),
163
+ ...pinnedHead,
164
+ }
165
+ : undefined
166
+ }
144
167
  className={cn(
145
168
  "relative font-medium align-middle whitespace-nowrap",
146
169
  compact ? "px-2 py-1" : "px-3 py-2",
@@ -16,10 +16,13 @@ import { cn } from "../lib/cn";
16
16
  export function DataTableLeadingRow({
17
17
  colSpan,
18
18
  sticky = false,
19
+ ariaRowIndex,
19
20
  children,
20
21
  }: {
21
22
  colSpan: number;
22
23
  sticky?: boolean;
24
+ /** Under `virtualize` (0.48.2). */
25
+ ariaRowIndex?: number;
23
26
  children: ReactNode;
24
27
  }) {
25
28
  const cellRef = useRef<HTMLTableCellElement>(null);
@@ -36,7 +39,7 @@ export function DataTableLeadingRow({
36
39
  return () => observer.disconnect();
37
40
  }, [sticky]);
38
41
  return (
39
- <tr className={cn(!sticky && "border-t border-subtle")}>
42
+ <tr aria-rowindex={ariaRowIndex} className={cn(!sticky && "border-t border-subtle")}>
40
43
  <td
41
44
  ref={cellRef}
42
45
  colSpan={colSpan}
@@ -1,4 +1,4 @@
1
- import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
1
+ import { Fragment, isValidElement, useEffect, useId, useMemo, useRef, useState } from "react";
2
2
  import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";
3
3
  import { ArrowDownWideNarrow, ArrowUpNarrowWide, ChevronDown, ChevronRight, Filter } from "lucide-react";
4
4
  import { FOCUS_RING } from "./focus-ring";
@@ -13,6 +13,9 @@ import { FilterPopover } from "./data-table-filter-popover";
13
13
  import { SwipeableRow, type SwipeAction } from "./swipeable-row";
14
14
  import { DialogFrame } from "./dialog-frame";
15
15
  import { Checkbox } from "./checkbox";
16
+ import { Chip } from "./chip";
17
+ import { BooleanMark } from "./data-table-cells";
18
+ import { EMPTY_FORMATTED_VALUE } from "../lib/format";
16
19
  import { useLongPress } from "../hooks/use-long-press";
17
20
  import type { DataTableLabels } from "./data-table-labels";
18
21
  import { RowLink, cleanAttrs, columnLabel, fromOutside, fromOwnControl } from "./data-table-shared";
@@ -70,19 +73,21 @@ const CARD_NO_TRUNCATE =
70
73
  * header's `sr-only` text (an actions column's), as every kit container must.
71
74
  */
72
75
  export function StackedFields<T>({ columns, row, compact }: { columns: DataTableColumn<T>[]; row: T; compact: boolean }) {
76
+ const fields = filledFields(columns, row);
77
+ if (fields.length === 0) return null;
73
78
  return (
74
79
  <dl
75
80
  data-card-fields="stacked"
76
81
  className={cn("relative flex min-w-0 flex-col", compact ? "gap-1.5 text-xs" : "gap-2.5 text-sm", CARD_NO_TRUNCATE)}
77
82
  >
78
- {columns.map((col) => (
83
+ {fields.map(({ col, value }) => (
79
84
  <div key={col.key} className="min-w-0">
80
85
  <dt className="text-xs uppercase tracking-wide text-muted">{col.header}</dt>
81
86
  <dd
82
87
  {...dataAttrsOf(col.cellProps?.(row))}
83
88
  className="mt-0.5 min-w-0 break-words text-secondary"
84
89
  >
85
- {col.cell(row)}
90
+ {value}
86
91
  </dd>
87
92
  </div>
88
93
  ))}
@@ -90,6 +95,112 @@ export function StackedFields<T>({ columns, row, compact }: { columns: DataTable
90
95
  );
91
96
  }
92
97
 
98
+ /** The most fields the default phone card shows under its title (0.49 KIT-7). */
99
+ export const MOBILE_CARD_FIELDS = 3;
100
+
101
+ /**
102
+ * The default phone card's columns (0.49 KIT-7), out of the phone's columns (visible,
103
+ * not `mobileHidden`): the title — `mobile: "primary"` or `mobilePrimary`, else the
104
+ * first that is not a status — the `role: "status"` columns for beside it, at most
105
+ * {@link MOBILE_CARD_FIELDS} fields: those marked `mobile: "secondary"` when any is,
106
+ * else the first after the title, and the `role: "actions"` columns, drawn after the
107
+ * fields. An actions column is never the title and never one of the fields, so the
108
+ * fields and the marks leave it on the card; only `mobileHidden` / `mobile: "hidden"`
109
+ * (which keep it out of `columns`) remove it.
110
+ */
111
+ export function mobileCardColumns<T>(columns: DataTableColumn<T>[]): {
112
+ primary: DataTableColumn<T> | null;
113
+ status: DataTableColumn<T>[];
114
+ fields: DataTableColumn<T>[];
115
+ actions: DataTableColumn<T>[];
116
+ } {
117
+ const titles = columns.filter((c) => c.role !== "actions");
118
+ const primary =
119
+ titles.find((c) => c.mobile === "primary") ??
120
+ titles.find((c) => c.mobilePrimary) ??
121
+ titles.find((c) => c.role !== "status") ??
122
+ titles[0] ??
123
+ null;
124
+ const status = columns.filter((c) => c !== primary && c.role === "status");
125
+ const actions = columns.filter((c) => c.role === "actions");
126
+ const rest = titles.filter((c) => c !== primary && c.role !== "status");
127
+ const marked = rest.filter((c) => c.mobile === "secondary");
128
+ return { primary, status, fields: (marked.length > 0 ? marked : rest).slice(0, MOBILE_CARD_FIELDS), actions };
129
+ }
130
+
131
+ /** A column whose cell keeps its own clicks — `noRowLink`, or `role: "actions"`: no row
132
+ * link may wrap it, and a card that draws it is not a link itself. */
133
+ export function keepsOwnClicks<T>(col: DataTableColumn<T>): boolean {
134
+ return !!col.noRowLink || col.role === "actions";
135
+ }
136
+
137
+ /** The kit's dev switch, as `lib/logger.ts` reads it: a development build, never a
138
+ * production one, never under vitest. Read per call so a test can switch it on. */
139
+ function devWarnings(): boolean {
140
+ return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
141
+ }
142
+
143
+ /**
144
+ * A development build's note on the default phone card (0.49 KIT-7): a `noRowLink`
145
+ * column past its three fields. Such a column holds the cell's own controls — a per-row
146
+ * delete, a link — and the card leaves it out with them, so on a phone the action is
147
+ * gone. Never for a `role: "actions"` column, which the card draws after its fields.
148
+ * Read off the declared `columns` (not the ones a person or the width hid), and only
149
+ * while the table lets the card choose: `enabled` is false with a `mobileCard` or
150
+ * `mobileDetailsInRow` (the column is then in the opened row), and a column marked
151
+ * `mobile: "secondary"` means the app chose. Once per table and column key; `name`
152
+ * (the table's `storageKey` or `urlSync` prefix) says which table, else its title
153
+ * column does.
154
+ */
155
+ export function useCardColumnWarnings<T>(columns: DataTableColumn<T>[], enabled: boolean, name?: string): void {
156
+ const warned = useRef<Set<string> | null>(null);
157
+ useEffect(() => {
158
+ if (!enabled || !devWarnings()) return;
159
+ const phone = columns.filter((c) => !c.mobileHidden && c.mobile !== "hidden");
160
+ if (phone.some((c) => c.mobile === "secondary")) return;
161
+ const { primary, status, fields, actions } = mobileCardColumns(phone);
162
+ const onCard = new Set<DataTableColumn<T> | null>([primary, ...status, ...fields, ...actions]);
163
+ const table = name ? ` "${name}"` : primary ? ` (title column "${primary.key}")` : "";
164
+ warned.current ??= new Set();
165
+ for (const col of phone) {
166
+ if (onCard.has(col) || !col.noRowLink || warned.current.has(col.key)) continue;
167
+ warned.current.add(col.key);
168
+ console.warn(
169
+ `[ui-kit] DataTable${table}: column "${col.key}" (noRowLink) is past the phone card's three fields and ` +
170
+ 'leaves the card — give it role: "actions" (drawn after the fields), or move its action to rowActions.',
171
+ );
172
+ }
173
+ }, [columns, enabled, name]);
174
+ }
175
+
176
+ /**
177
+ * Whether a rendered cell shows nothing worth a line (0.49 KIT-7): `null`, `undefined`,
178
+ * a boolean (React draws neither), `""` or blank text, and the formatters' empty value
179
+ * "—" ({@link EMPTY_FORMATTED_VALUE}), and `booleanColumn`'s mark for an unset value.
180
+ * Any other element is never empty here — its output is the component's — so a column
181
+ * whose empty value should drop off the card returns `null` (or "—") from `cell` for it.
182
+ */
183
+ export function isEmptyCell(value: ReactNode): boolean {
184
+ if (value === null || value === undefined || typeof value === "boolean") return true;
185
+ if (isValidElement<{ value?: unknown }>(value) && value.type === BooleanMark) return value.props.value == null;
186
+ if (typeof value === "string") {
187
+ const text = value.trim();
188
+ return text === "" || text === EMPTY_FORMATTED_VALUE;
189
+ }
190
+ return false;
191
+ }
192
+
193
+ /** Each column's rendered cell, without the empty ones. */
194
+ function filledFields<T>(columns: DataTableColumn<T>[], row: T): { col: DataTableColumn<T>; value: ReactNode }[] {
195
+ return columns.map((col) => ({ col, value: col.cell(row) })).filter(({ value }) => !isEmptyCell(value));
196
+ }
197
+
198
+ /** A status column's value beside the card's title: text in a neutral chip, an element
199
+ * (the app's own Chip or StatusDot) as it is. */
200
+ function StatusChip({ value }: { value: ReactNode }) {
201
+ return typeof value === "string" || typeof value === "number" ? <Chip size="sm">{value}</Chip> : <>{value}</>;
202
+ }
203
+
93
204
  // ---------- Phone sort control (keksdose K10) ----------
94
205
 
95
206
  /**
@@ -199,7 +310,11 @@ export interface MobileRowProps<T> {
199
310
  mobileCardLinkable: boolean;
200
311
  mobileCard: DataTableProps<T>["mobileCard"];
201
312
  mobilePrimaryCol: DataTableColumn<T> | null;
313
+ /** The `role: "status"` columns, drawn as chips beside the title (0.49 KIT-7). */
314
+ mobileStatusColumns: DataTableColumn<T>[];
202
315
  mobileSecondaryColumns: DataTableColumn<T>[];
316
+ /** The `role: "actions"` columns, drawn after the fields at the card's end (0.49 KIT-7). */
317
+ mobileActionColumns: DataTableColumn<T>[];
203
318
  mobileExpandAsDialog: boolean;
204
319
  compact: boolean;
205
320
  /** The text size is Large or Extra large (§4). */
@@ -236,7 +351,9 @@ export function MobileRow<T>({
236
351
  mobileCardLinkable,
237
352
  mobileCard,
238
353
  mobilePrimaryCol,
354
+ mobileStatusColumns,
239
355
  mobileSecondaryColumns,
356
+ mobileActionColumns,
240
357
  mobileExpandAsDialog,
241
358
  compact,
242
359
  large,
@@ -305,6 +422,11 @@ export function MobileRow<T>({
305
422
  // itself is the anchor, and routing it through `linkColumn` too would nest an
306
423
  // <a> inside an <a>. Desktop and mobile are mutually exclusive (`isMdUp`), so
307
424
  // exactly one anchor exists per row.
425
+ // The default card's filled fields and status values (0.49 KIT-7): an empty one
426
+ // gets no line. Not computed for a `mobileCard`, which draws its own.
427
+ const fields = mobileCard ? [] : filledFields(mobileSecondaryColumns, row);
428
+ const statuses = mobileCard ? [] : filledFields(mobileStatusColumns, row);
429
+ const actionCells = mobileCard ? [] : filledFields(mobileActionColumns, row);
308
430
  const cardInner = (
309
431
  <>
310
432
  {/* The card's own content keeps the column it always had; the chevron sits
@@ -323,24 +445,42 @@ export function MobileRow<T>({
323
445
  mobileCard(row)
324
446
  ) : (
325
447
  <>
326
- {/* Only the `data-*` half of `cellProps` — see its note. */}
327
- {mobilePrimaryCol && (
328
- <div {...dataAttrsOf(mobilePrimaryCol.cellProps?.(row))} className="min-w-0 break-words font-medium">
329
- {mobilePrimaryCol.cell(row)}
448
+ {/* Only the `data-*` half of `cellProps` — see its note. The title row
449
+ carries the status chips at its end (0.49 KIT-7). */}
450
+ {(mobilePrimaryCol || statuses.length > 0) && (
451
+ <div data-card-head="" className="flex min-w-0 items-start justify-between gap-2">
452
+ {mobilePrimaryCol && (
453
+ <div
454
+ {...dataAttrsOf(mobilePrimaryCol.cellProps?.(row))}
455
+ data-card-title=""
456
+ className="min-w-0 flex-1 break-words font-medium"
457
+ >
458
+ {mobilePrimaryCol.cell(row)}
459
+ </div>
460
+ )}
461
+ {statuses.length > 0 && (
462
+ <div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
463
+ {statuses.map(({ col, value }) => (
464
+ <span key={col.key} {...dataAttrsOf(col.cellProps?.(row))} data-card-status="" className="inline-flex">
465
+ <StatusChip value={value} />
466
+ </span>
467
+ ))}
468
+ </div>
469
+ )}
330
470
  </div>
331
471
  )}
332
- {mobileSecondaryColumns.length > 0 && large ? (
472
+ {fields.length > 0 && large ? (
333
473
  // §4: each label ABOVE its value at Large. Side by side, a label column
334
474
  // sized to the widest label leaves a value a sliver of a 240 px card.
335
475
  <StackedFields columns={mobileSecondaryColumns} row={row} compact={compact} />
336
- ) : mobileSecondaryColumns.length > 0 && (
476
+ ) : fields.length > 0 && (
337
477
  <dl
338
478
  className={cn(
339
479
  "grid grid-cols-[auto_1fr] gap-x-3",
340
480
  compact ? "gap-y-0.5 text-xs" : "gap-y-1 text-sm",
341
481
  )}
342
482
  >
343
- {mobileSecondaryColumns.map((col) => (
483
+ {fields.map(({ col, value }) => (
344
484
  <Fragment key={col.key}>
345
485
  <dt className="text-xs uppercase tracking-wide text-muted self-center">
346
486
  {col.header}
@@ -349,12 +489,23 @@ export function MobileRow<T>({
349
489
  {...dataAttrsOf(col.cellProps?.(row))}
350
490
  className="min-w-0 text-secondary self-center"
351
491
  >
352
- {col.cell(row)}
492
+ {value}
353
493
  </dd>
354
494
  </Fragment>
355
495
  ))}
356
496
  </dl>
357
497
  )}
498
+ {/* The row's actions after the fields, at the card's end (0.49 KIT-7): its
499
+ buttons keep their own clicks, and RowActions folds into "⋯" at Large. */}
500
+ {actionCells.length > 0 && (
501
+ <div data-card-actions="" className="flex min-w-0 flex-wrap items-center justify-end gap-2">
502
+ {actionCells.map(({ col, value }) => (
503
+ <div key={col.key} {...dataAttrsOf(col.cellProps?.(row))} className="flex min-w-0 justify-end">
504
+ {value}
505
+ </div>
506
+ ))}
507
+ </div>
508
+ )}
358
509
  </>
359
510
  )}
360
511
  </div>
@@ -64,7 +64,8 @@ function swipeTone(action: SwipeAction): RowActionTone {
64
64
  * `end` then `start` — the physical `right` / `left` only where the logical side is
65
65
  * missing, as the card reads them — each action once, told apart by its `id`, else by its
66
66
  * label (two stages with one label would be one button twice). Each button runs the
67
- * swipe's own `onCommit`; a swipe's `danger` / `warning` / `info` tone carries over, any
67
+ * swipe's own `onCommit` — with no confirmation of the kit's (`confirm: false`), as the
68
+ * swipe has none; a swipe's `danger` / `warning` / `info` tone carries over, any
68
69
  * other reads `muted`. `options.omit` leaves an action out, `options.commit` gives it a
69
70
  * `CommitScope` (see {@link DataTableDesktopSwipeActions}). A `null` plan is `[]`.
70
71
  *
@@ -94,6 +95,10 @@ export function swipePlanRowActions<T>(
94
95
  : undefined,
95
96
  tone: swipeTone(action),
96
97
  commit,
98
+ // A swipe commits without the kit asking (the plan's `onCommit` asks, as keksdose's
99
+ // does), so its button does not ask twice (0.49 KIT-12); a danger one still sits in
100
+ // the row's menu.
101
+ confirm: false,
97
102
  onSelect: action.onCommit,
98
103
  });
99
104
  }
@@ -142,6 +147,9 @@ function toRowAction<T>(a: ResolvedRowAction<T>, row: T): RowAction {
142
147
  icon: isValidElement(a.icon) ? a.icon : a.icon != null && a.icon !== false ? <>{a.icon}</> : undefined,
143
148
  tone: a.tone,
144
149
  disabledReason: a.action.disabledReason?.(row),
150
+ // The table's own `confirm` asks in `runRowAction`; RowActions' default question
151
+ // (0.49 KIT-12) is for a delete that brought none.
152
+ confirm: a.action.confirm ? false : undefined,
145
153
  onSelect: () => void runRowAction(a.action, row),
146
154
  };
147
155
  }
@@ -272,7 +280,8 @@ export function useRowActionColumn<T>({
272
280
  () => (swipeColumn ? [...actionColumns, swipeColumn] : actionColumns),
273
281
  [actionColumns, swipeColumn],
274
282
  );
275
- const swipesFromActions = (row: T): MobileSwipeActions | null => {
283
+ // A stable function (0.48.2): the phone cards compare it by identity.
284
+ const swipesFromActions = useCallback((row: T): MobileSwipeActions | null => {
276
285
  const swipes = rowActionsFor(row).filter((a) => a.swipe && !a.action.disabledReason?.(row));
277
286
  if (!swipes.length) return null;
278
287
  const toSwipe = (a: ResolvedRowAction<T>): SwipeAction => ({
@@ -287,6 +296,6 @@ export function useRowActionColumn<T>({
287
296
  start: swipes.filter((a) => a.swipe === "start").map(toSwipe),
288
297
  end: swipes.filter((a) => a.swipe === "end").map(toSwipe),
289
298
  };
290
- };
299
+ }, [rowActionsFor]);
291
300
  return { columns, swipesFromActions };
292
301
  }