@eifi1/ui-kit 0.46.1 → 0.48.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 (216) hide show
  1. package/README.md +8 -6
  2. package/dist/admin/admin-sections.d.ts +8 -0
  3. package/dist/admin/admin-sections.js +2 -1
  4. package/dist/admin/admin-sections.js.map +1 -1
  5. package/dist/auth/verify-email.d.ts +9 -1
  6. package/dist/auth/verify-email.js +22 -13
  7. package/dist/auth/verify-email.js.map +1 -1
  8. package/dist/components/alert-banner.d.ts +29 -1
  9. package/dist/components/alert-banner.js +84 -7
  10. package/dist/components/alert-banner.js.map +1 -1
  11. package/dist/components/button-look.d.ts +4 -0
  12. package/dist/components/button-look.js +2 -0
  13. package/dist/components/button-look.js.map +1 -1
  14. package/dist/components/combobox-core.js +5 -1
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/data-table-column-settings.d.ts +6 -1
  17. package/dist/components/data-table-column-settings.js +12 -2
  18. package/dist/components/data-table-column-settings.js.map +1 -1
  19. package/dist/components/data-table-fit.d.ts +44 -0
  20. package/dist/components/data-table-fit.js +103 -0
  21. package/dist/components/data-table-fit.js.map +1 -0
  22. package/dist/components/data-table-groups.d.ts +95 -0
  23. package/dist/components/data-table-groups.js +165 -0
  24. package/dist/components/data-table-groups.js.map +1 -0
  25. package/dist/components/data-table-head.js +3 -1
  26. package/dist/components/data-table-head.js.map +1 -1
  27. package/dist/components/data-table-labels.d.ts +7 -0
  28. package/dist/components/data-table-labels.js +3 -0
  29. package/dist/components/data-table-labels.js.map +1 -1
  30. package/dist/components/data-table-leading-row.d.ts +17 -0
  31. package/dist/components/data-table-leading-row.js +41 -0
  32. package/dist/components/data-table-leading-row.js.map +1 -0
  33. package/dist/components/data-table-resize.js +1 -1
  34. package/dist/components/data-table-resize.js.map +1 -1
  35. package/dist/components/data-table.d.ts +78 -2
  36. package/dist/components/data-table.js +355 -247
  37. package/dist/components/data-table.js.map +1 -1
  38. package/dist/components/disclosure.d.ts +3 -1
  39. package/dist/components/disclosure.js +10 -4
  40. package/dist/components/disclosure.js.map +1 -1
  41. package/dist/components/dropdown.js +8 -1
  42. package/dist/components/dropdown.js.map +1 -1
  43. package/dist/components/floating-panel.d.ts +14 -1
  44. package/dist/components/floating-panel.js +7 -2
  45. package/dist/components/floating-panel.js.map +1 -1
  46. package/dist/components/hover-menu.js +5 -1
  47. package/dist/components/hover-menu.js.map +1 -1
  48. package/dist/components/icon-button.js +2 -1
  49. package/dist/components/icon-button.js.map +1 -1
  50. package/dist/components/popover.js +5 -1
  51. package/dist/components/popover.js.map +1 -1
  52. package/dist/components/row-actions.d.ts +25 -0
  53. package/dist/components/row-actions.js +13 -3
  54. package/dist/components/row-actions.js.map +1 -1
  55. package/dist/components/table-skeleton.d.ts +35 -0
  56. package/dist/components/table-skeleton.js +75 -0
  57. package/dist/components/table-skeleton.js.map +1 -0
  58. package/dist/components/tabs.d.ts +22 -16
  59. package/dist/components/tabs.js +47 -31
  60. package/dist/components/tabs.js.map +1 -1
  61. package/dist/components/ui-labels.js +1 -1
  62. package/dist/components/ui-labels.js.map +1 -1
  63. package/dist/components/use-table-state.d.ts +8 -7
  64. package/dist/components/use-table-state.js +37 -3
  65. package/dist/components/use-table-state.js.map +1 -1
  66. package/dist/data-table.d.ts +1 -0
  67. package/dist/data-table.js.map +1 -1
  68. package/dist/eslint/index.js +1 -1
  69. package/dist/feedback/feedback-attachment-download.d.ts +19 -0
  70. package/dist/feedback/feedback-attachment-download.js +59 -0
  71. package/dist/feedback/feedback-attachment-download.js.map +1 -0
  72. package/dist/feedback/feedback-comment-thread.d.ts +17 -6
  73. package/dist/feedback/feedback-comment-thread.js +21 -2
  74. package/dist/feedback/feedback-comment-thread.js.map +1 -1
  75. package/dist/feedback/feedback-entry-file.d.ts +61 -0
  76. package/dist/feedback/feedback-entry-file.js +76 -0
  77. package/dist/feedback/feedback-entry-file.js.map +1 -0
  78. package/dist/feedback/feedback-public-list.d.ts +18 -5
  79. package/dist/feedback/feedback-public-list.js +8 -0
  80. package/dist/feedback/feedback-public-list.js.map +1 -1
  81. package/dist/feedback/feedback-public-types.d.ts +7 -2
  82. package/dist/feedback/feedback-replies.d.ts +26 -9
  83. package/dist/feedback/feedback-replies.js +20 -10
  84. package/dist/feedback/feedback-replies.js.map +1 -1
  85. package/dist/feedback/feedback-row-detail-labels.js +0 -1
  86. package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
  87. package/dist/feedback/feedback-row-detail.d.ts +23 -10
  88. package/dist/feedback/feedback-row-detail.js +10 -54
  89. package/dist/feedback/feedback-row-detail.js.map +1 -1
  90. package/dist/feedback/feedback-table.d.ts +5 -0
  91. package/dist/feedback/feedback-table.js +30 -7
  92. package/dist/feedback/feedback-table.js.map +1 -1
  93. package/dist/feedback.d.ts +1 -0
  94. package/dist/feedback.js +1 -0
  95. package/dist/feedback.js.map +1 -1
  96. package/dist/hooks/use-dismiss.d.ts +30 -1
  97. package/dist/hooks/use-dismiss.js +30 -2
  98. package/dist/hooks/use-dismiss.js.map +1 -1
  99. package/dist/i18n/german.js +7 -7
  100. package/dist/i18n/german.js.map +1 -1
  101. package/dist/i18n/kit-labels.d.ts +4 -0
  102. package/dist/i18n/kit-labels.js.map +1 -1
  103. package/dist/i18n/locales/en.js +7 -7
  104. package/dist/i18n/locales/en.js.map +2 -2
  105. package/dist/i18n/locales/es.js +7 -7
  106. package/dist/i18n/locales/es.js.map +1 -1
  107. package/dist/i18n/locales/fr.js +7 -7
  108. package/dist/i18n/locales/fr.js.map +1 -1
  109. package/dist/i18n/locales/hu.js +7 -7
  110. package/dist/i18n/locales/hu.js.map +1 -1
  111. package/dist/i18n/locales/it.js +7 -7
  112. package/dist/i18n/locales/it.js.map +1 -1
  113. package/dist/i18n/locales/zh.js +7 -7
  114. package/dist/i18n/locales/zh.js.map +1 -1
  115. package/dist/i18n/review.js +0 -1
  116. package/dist/i18n/review.js.map +1 -1
  117. package/dist/i18n/shared-labels.js +3 -1
  118. package/dist/i18n/shared-labels.js.map +1 -1
  119. package/dist/index.d.ts +4 -4
  120. package/dist/index.js +13 -4
  121. package/dist/index.js.map +1 -1
  122. package/dist/lib/float-clearance.d.ts +8 -0
  123. package/dist/lib/float-clearance.js +29 -0
  124. package/dist/lib/float-clearance.js.map +1 -0
  125. package/dist/notifications/device-zone-report.d.ts +1 -1
  126. package/dist/notifications/device-zone-report.js.map +1 -1
  127. package/dist/notifications/notification-types.d.ts +0 -3
  128. package/dist/notifications/notifications-labels.d.ts +2 -15
  129. package/dist/notifications/notifications-labels.js +0 -4
  130. package/dist/notifications/notifications-labels.js.map +1 -1
  131. package/dist/notifications/quiet-hours-setting.d.ts +3 -8
  132. package/dist/notifications/quiet-hours-setting.js +8 -3
  133. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  134. package/dist/settings/settings-catalogue.d.ts +10 -0
  135. package/dist/settings/settings-catalogue.js.map +1 -1
  136. package/dist/settings/settings-layout.js +10 -3
  137. package/dist/settings/settings-layout.js.map +1 -1
  138. package/dist/shell/app-shell.d.ts +5 -0
  139. package/dist/shell/app-shell.js +5 -3
  140. package/dist/shell/app-shell.js.map +1 -1
  141. package/dist/shell/option-switcher-menu.d.ts +2 -0
  142. package/dist/shell/option-switcher-menu.js +6 -2
  143. package/dist/shell/option-switcher-menu.js.map +1 -1
  144. package/dist/shell/topbar-action-menu.js +2 -1
  145. package/dist/shell/topbar-action-menu.js.map +1 -1
  146. package/dist/shell/topbar-controls.js +5 -3
  147. package/dist/shell/topbar-controls.js.map +1 -1
  148. package/dist/theme/theme-store.d.ts +4 -4
  149. package/dist/theme/theme-store.js +1 -0
  150. package/dist/theme/theme-store.js.map +1 -1
  151. package/dist/tour/tour.js +1 -0
  152. package/dist/tour/tour.js.map +1 -1
  153. package/package.json +2 -2
  154. package/src/admin/admin-sections.tsx +8 -0
  155. package/src/auth/verify-email.tsx +34 -9
  156. package/src/components/alert-banner.tsx +131 -7
  157. package/src/components/button-look.ts +6 -0
  158. package/src/components/combobox-core.tsx +7 -1
  159. package/src/components/data-table-column-settings.tsx +23 -2
  160. package/src/components/data-table-fit.ts +187 -0
  161. package/src/components/data-table-groups.tsx +275 -0
  162. package/src/components/data-table-head.tsx +4 -1
  163. package/src/components/data-table-labels.ts +13 -0
  164. package/src/components/data-table-leading-row.tsx +55 -0
  165. package/src/components/data-table-resize.ts +2 -1
  166. package/src/components/data-table.tsx +274 -45
  167. package/src/components/disclosure.tsx +22 -5
  168. package/src/components/dropdown.tsx +11 -1
  169. package/src/components/floating-panel.tsx +24 -1
  170. package/src/components/hover-menu.tsx +6 -1
  171. package/src/components/icon-button.tsx +3 -3
  172. package/src/components/popover.tsx +7 -1
  173. package/src/components/row-actions.tsx +42 -3
  174. package/src/components/table-skeleton.tsx +133 -0
  175. package/src/components/tabs.tsx +102 -50
  176. package/src/components/ui-labels.ts +1 -1
  177. package/src/components/use-table-state.ts +54 -3
  178. package/src/data-table.ts +2 -0
  179. package/src/feedback/feedback-attachment-download.tsx +77 -0
  180. package/src/feedback/feedback-comment-thread.tsx +42 -7
  181. package/src/feedback/feedback-entry-file.tsx +150 -0
  182. package/src/feedback/feedback-public-list.tsx +25 -4
  183. package/src/feedback/feedback-public-types.ts +7 -2
  184. package/src/feedback/feedback-replies.tsx +46 -19
  185. package/src/feedback/feedback-row-detail-labels.ts +0 -1
  186. package/src/feedback/feedback-row-detail.tsx +35 -86
  187. package/src/feedback/feedback-table.tsx +33 -1
  188. package/src/feedback.ts +1 -0
  189. package/src/hooks/use-dismiss.ts +65 -3
  190. package/src/i18n/german.ts +6 -6
  191. package/src/i18n/kit-labels.tsx +4 -0
  192. package/src/i18n/locales/es.ts +6 -6
  193. package/src/i18n/locales/fr.ts +6 -6
  194. package/src/i18n/locales/hu.ts +6 -6
  195. package/src/i18n/locales/it.ts +6 -6
  196. package/src/i18n/locales/zh.ts +6 -6
  197. package/src/i18n/review.ts +0 -1
  198. package/src/i18n/shared-labels.ts +2 -0
  199. package/src/index.ts +13 -3
  200. package/src/lib/float-clearance.ts +54 -0
  201. package/src/notifications/device-zone-report.ts +1 -1
  202. package/src/notifications/notification-types.ts +0 -3
  203. package/src/notifications/notifications-labels.ts +2 -19
  204. package/src/notifications/quiet-hours-setting.tsx +16 -11
  205. package/src/settings/settings-catalogue.ts +10 -0
  206. package/src/settings/settings-layout.tsx +18 -4
  207. package/src/shell/app-shell.tsx +17 -3
  208. package/src/shell/option-switcher-menu.tsx +16 -4
  209. package/src/shell/topbar-action-menu.tsx +22 -10
  210. package/src/shell/topbar-controls.tsx +18 -12
  211. package/src/theme/theme-store.ts +13 -5
  212. package/src/tour/tour.tsx +5 -0
  213. package/dist/notifications/time-zone-select.d.ts +0 -54
  214. package/dist/notifications/time-zone-select.js +0 -71
  215. package/dist/notifications/time-zone-select.js.map +0 -1
  216. package/src/notifications/time-zone-select.tsx +0 -125
@@ -17,6 +17,17 @@ import {
17
17
  } from "./data-table-shared";
18
18
  import { MobileFilters, MobileRow, MobileSelectBar, MobileSort, StackedFields } from "./data-table-mobile";
19
19
  import { DataTableHead } from "./data-table-head";
20
+ import { DataTableLeadingRow } from "./data-table-leading-row";
21
+ import {
22
+ DataTableGroupBand,
23
+ DataTableLoadMoreFooter,
24
+ groupLevels,
25
+ groupRows,
26
+ useGroupBandTops,
27
+ type DataTableGroupBy,
28
+ type DataTableLoadMore,
29
+ } from "./data-table-groups";
30
+ export type { DataTableGroupBy, DataTableLoadMore } from "./data-table-groups";
20
31
  import { ColumnSettingsPanel } from "./data-table-column-settings";
21
32
  import { useLargeText } from "../hooks/use-large-text";
22
33
  import { cn } from "../lib/cn";
@@ -127,6 +138,44 @@ export interface DataTableColumn<T> {
127
138
  * end-aligned), its resized width, and drops out with the column when it is hidden.
128
139
  */
129
140
  footer?: (rows: T[], page: T[]) => ReactNode;
141
+ /**
142
+ * This column takes the width the others leave, and its text ellipsises (0.48,
143
+ * keksdose live #451): `w-full max-w-0` on its cells, so in the table's auto layout it
144
+ * is "the rest of the row" and one long title can no longer widen the table. One per
145
+ * table. It has no resize handle, and a width stored for it (before it grew) is
146
+ * ignored: it always takes the rest. Resize the others to change it. Opt-in: without it nothing
147
+ * changes. It keeps at least 10rem: with {@link fitPriority} columns the table leaves
148
+ * those out first, and scrolls sideways once none is left. Every other cell keeps one
149
+ * line at Normal text size (`whitespace-nowrap`; a column's own
150
+ * `not-large:whitespace-normal` lets its prose wrap), so a figure or a name is never
151
+ * broken in two to make room for it.
152
+ */
153
+ grow?: boolean;
154
+ /**
155
+ * Leave this column out while the TABLE is too narrow for it (0.48, keksdose live
156
+ * #451) — measured on the table's own scroller, so a sidebar, the window and Large
157
+ * text count alike. The lowest number goes first (ties: the later column first), and
158
+ * comes back last, once the scroller is as wide as the table needed with it. Columns
159
+ * without it never go. Desktop only: the phone's card list is not narrowed.
160
+ *
161
+ * The Columns panel counts only what is drawn ("Columns (8/10)") and marks a column
162
+ * left out this way "hidden for width". Ticking it there keeps it in for as long as
163
+ * the table is mounted (not persisted): the table makes room by leaving out the next
164
+ * one instead, and scrolls sideways once none is left. Unticking hides it as any column.
165
+ */
166
+ fitPriority?: number;
167
+ /**
168
+ * Start this column HIDDEN (0.48, keksdose dev #617) — for as long as the view saved
169
+ * under `storageKey` holds no entry for it: on a first visit, on a device whose saved
170
+ * view predates the column (or the flag), and for a column the app adds later. The
171
+ * user's own choice in the Columns panel always wins: ticking it is remembered
172
+ * (`shown` in the saved view), unticking it again stores it as any hidden column. The
173
+ * default itself is never written into the saved view, so dropping the flag brings the
174
+ * column back on every device where nobody chose. In the panel it is simply unticked —
175
+ * not "hidden for width", which `fitPriority` alone says. Ignored, like every hidden
176
+ * column, with `columnSettings={false}`: nothing could bring it back.
177
+ */
178
+ defaultHidden?: boolean;
130
179
  }
131
180
 
132
181
  /** A `data-*` attribute, typed so `{ "data-private": "" }` needs no cast. */
@@ -433,7 +482,9 @@ export interface DataTableProps<T> {
433
482
  * contiguous (pair with a matching default sort). `mobileGroupLabel` formats
434
483
  * the header. Desktop is unaffected. Feedback #317.
435
484
  */
436
- mobileGroupBy?: (row: T) => string;
485
+ mobileGroupBy?: ((row: T) => string) | DataTableGroupBy<T>;
486
+ /** The section header's words for `mobileGroupBy`'s key. Wins over a level's own
487
+ * `label` when `mobileGroupBy` is a {@link DataTableGroupBy}. */
437
488
  mobileGroupLabel?: (key: string) => ReactNode;
438
489
  /**
439
490
  * Fully replace the default mobile card body (bold primary + labelled
@@ -505,6 +556,40 @@ export interface DataTableProps<T> {
505
556
  * "hide upcoming/scheduled" toggle).
506
557
  */
507
558
  leadingRow?: ReactNode;
559
+ /**
560
+ * 0.48 (keksdose dev #603: "it should rather be sticky, so the user can show hide them
561
+ * everywhere on click"): pin the {@link leadingRow} under the sticky header while the
562
+ * rows scroll beneath both — its `top` is the header's measured height. Desktop only,
563
+ * as `leadingRow` is; it sticks within the table's own scroller (a bounded body:
564
+ * `fillHeight` or `maxBodyHeight`). Default: it scrolls with the rows.
565
+ */
566
+ leadingRowSticky?: boolean;
567
+ /**
568
+ * 0.48 (keksdose dev #605): group the desktop table's rows under full-width bands, one
569
+ * level or two (period → day). Each band is a row header spanning the visible columns
570
+ * (selection column included, hidden and fit-hidden columns not) with a label and an
571
+ * end slot (counts, totals), and opens its own `<tbody>`, so a screen reader announces
572
+ * it as the reader enters the group; the period band opens the first day's. Rows must
573
+ * already be ordered so equal keys are contiguous (as for `mobileGroupBy`); grouping
574
+ * runs over the rows on screen, page by page.
575
+ *
576
+ * The bands are sticky: the period under the header (and under a sticky
577
+ * {@link leadingRow}), the day under the period — measured offsets, inside the table's
578
+ * own scroller (`fillHeight` or `maxBodyHeight`). The `leadingRow` stays above the first
579
+ * group. On a phone pass the same level to `mobileGroupBy`:
580
+ * `groupBy={[period, day]} mobileGroupBy={period}`. See {@link DataTableGroupBy}.
581
+ */
582
+ groupBy?: DataTableGroupBy<T> | readonly [DataTableGroupBy<T>, DataTableGroupBy<T>?];
583
+ /**
584
+ * 0.48 (keksdose dev #605): a "load more" footer in place of the pager. The table
585
+ * renders every row it is given (as `paginated={false}` does — pass that too, to say
586
+ * so), and the footer asks the owner for the next slice: as it scrolls into view (an
587
+ * IntersectionObserver, guarded against double calls while one is in flight) and with
588
+ * a button the keyboard reaches, named by the app ("Load August 2026"). With
589
+ * `serverPagination` it replaces that pager as well. Both layouts: on a phone it is the
590
+ * card list's last item. See {@link DataTableLoadMore}.
591
+ */
592
+ loadMore?: DataTableLoadMore;
508
593
  /**
509
594
  * Row height. `"compact"` is lenkbank's in-card tables: five `text-xs` tables with
510
595
  * `px-2 py-1` cells sitting inside cards, which at the default padding came out at
@@ -760,14 +845,17 @@ export const DEFAULT_DATA_TABLE_SORT_LABELS: DataTableSortLabels = {
760
845
 
761
846
  const flipDir = (d: SortDir): SortDir => (d === "asc" ? "desc" : "asc");
762
847
 
763
- /** Arrow/Home/End between the focusable rows of one `<tbody>` — the roving half of
848
+ /** Arrow/Home/End between the focusable rows of the table's bodies — the roving half of
764
849
  * the keyboard-activatable rows (see the row's `onKeyDown`). */
765
850
  function moveRowFocus(from: HTMLElement, key: string): boolean {
766
851
  if (!(key in ROW_NAV_KEYS)) return false;
767
- const body = from.parentElement;
768
- if (!body) return false;
769
- const rows = Array.from(body.children).filter(
770
- (el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute("data-row-nav"),
852
+ // Every body of the table: a grouped table (`groupBy`, 0.48) has one per group.
853
+ const table = from.closest("table");
854
+ if (!table) return false;
855
+ const rows = Array.from(table.tBodies).flatMap((body) =>
856
+ Array.from(body.children).filter(
857
+ (el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute("data-row-nav"),
858
+ ),
771
859
  );
772
860
  const i = rows.indexOf(from);
773
861
  const target =
@@ -793,6 +881,7 @@ const ROW_NAV_KEYS = { ArrowDown: 1, ArrowUp: 1, Home: 1, End: 1 } as const;
793
881
 
794
882
  import { DEFAULT_PERSIST_PREFIX, useTableState, type TableUrlSync } from "./use-table-state";
795
883
  import { useMobileReveal } from "./use-mobile-reveal";
884
+ import { GROW_CELL_CLASS, GROW_PEER_CELL_CLASS, useColumnFit } from "./data-table-fit";
796
885
 
797
886
  export function DataTable<T>({
798
887
  rows,
@@ -833,6 +922,9 @@ export function DataTable<T>({
833
922
  mobileSwipeActions,
834
923
  desktopSwipeActions,
835
924
  leadingRow,
925
+ leadingRowSticky = false,
926
+ groupBy,
927
+ loadMore,
836
928
  density = "comfortable",
837
929
  sortCycle = "tri",
838
930
  chrome = "full",
@@ -921,7 +1013,17 @@ export function DataTable<T>({
921
1013
  // columns are ignored rather than applied: a table cannot be left in a state the
922
1014
  // user has no control to get out of. They stay in storage untouched, so turning
923
1015
  // the control back on brings them back.
924
- const widths = useMemo(() => (resizable ? storedWidths : {}), [resizable, storedWidths]);
1016
+ // A `grow` column takes the rest of the row by design (0.48): a width stored for it
1017
+ // (a drag or "Auto-size columns" before it grew, KS N2) is ignored, and it has no
1018
+ // handle, so it can never pin it again. Ignored rather than deleted from storage, as
1019
+ // the widths above; the next resize or auto-size writes a blob without it.
1020
+ const widths = useMemo(() => {
1021
+ if (!resizable) return {};
1022
+ const grow = columns.find((c) => c.grow)?.key;
1023
+ if (!grow || !(grow in storedWidths)) return storedWidths;
1024
+ const { [grow]: _ignored, ...rest } = storedWidths;
1025
+ return rest;
1026
+ }, [resizable, storedWidths, columns]);
925
1027
  const hiddenCols = useMemo(
926
1028
  () => (columnSettings ? storedHiddenCols : new Set<string>()),
927
1029
  [columnSettings, storedHiddenCols],
@@ -1017,7 +1119,7 @@ export function DataTable<T>({
1017
1119
  // `paginated={false}` is expressed as "page size = everything" rather than as a
1018
1120
  // second code path, so slicing, the page clamp and the range summary all keep
1019
1121
  // exactly one definition.
1020
- const unpaged = !isServer && !paginated;
1122
+ const unpaged = !isServer && (!paginated || !!loadMore);
1021
1123
  const effectivePageSize = isServer
1022
1124
  ? serverPagination!.pageSize
1023
1125
  : unpaged || pageSize === Infinity
@@ -1145,10 +1247,22 @@ export function DataTable<T>({
1145
1247
  announce(labels.pageChanged(p + 1, totalPages));
1146
1248
  };
1147
1249
 
1250
+ // 0.48 (keksdose live #451): the `fitPriority` columns the table's width leaves out
1251
+ // (data-table-fit.ts). Desktop only; the scroller is read after render.
1252
+ const { autoHidden, keep: keepColumn } = useColumnFit({
1253
+ columns,
1254
+ hiddenCols,
1255
+ headRefs,
1256
+ scroller: () => desktopScroller.current,
1257
+ enabled: !usePhoneLayout(),
1258
+ });
1148
1259
  const visibleColumns = useMemo(
1149
- () => columns.filter((c) => !hiddenCols.has(c.key)),
1150
- [columns, hiddenCols],
1260
+ () => columns.filter((c) => !hiddenCols.has(c.key) && !autoHidden.has(c.key)),
1261
+ [columns, hiddenCols, autoHidden],
1151
1262
  );
1263
+ // With a `grow` column drawn, every other cell keeps one line at Normal (kastlan S2,
1264
+ // 0.48): the `w-full` column squeezed them to their min-content width.
1265
+ const growDrawn = visibleColumns.some((c) => c.grow);
1152
1266
 
1153
1267
  // Which cell carries the row's link when `rowHref` is set (feedback #451). The
1154
1268
  // `mobilePrimary` column is the row's headline by declaration, so it is both the
@@ -1174,7 +1288,8 @@ export function DataTable<T>({
1174
1288
  widths,
1175
1289
  setWidths,
1176
1290
  columns,
1177
- hiddenCols,
1291
+ // The columns left out for width have no header to measure either.
1292
+ hiddenCols: autoHidden.size > 0 ? new Set([...hiddenCols, ...autoHidden]) : hiddenCols,
1178
1293
  });
1179
1294
 
1180
1295
  const visibleCount = visibleColumns.length;
@@ -1213,6 +1328,19 @@ export function DataTable<T>({
1213
1328
  const isMdUp = !usePhoneLayout();
1214
1329
  // `edgeFade` (0.25) on the desktop scroller; the phone list never scrolls sideways.
1215
1330
  const desktopScroller = useRef<HTMLDivElement | null>(null);
1331
+ // 0.47 (keksdose S4): the scroller's visible width, as `--dt-visible-w` on it, for the
1332
+ // expansion row's pinned content (below). Set on the element, not in state: a resize
1333
+ // re-renders nothing. Only a table that can expand a row measures.
1334
+ // The group bands and the load-more footer pin their content the same way (0.48).
1335
+ const levels = groupLevels(groupBy);
1336
+ const measuresVisibleWidth = isMdUp && (!!expandedRow || levels.length > 0 || !!loadMore);
1337
+ useEffect(() => {
1338
+ const el = desktopScroller.current;
1339
+ if (!measuresVisibleWidth || !el || typeof ResizeObserver === "undefined") return;
1340
+ const ro = new ResizeObserver(() => el.style.setProperty("--dt-visible-w", `${el.clientWidth}px`));
1341
+ ro.observe(el);
1342
+ return () => ro.disconnect();
1343
+ }, [measuresVisibleWidth]);
1216
1344
  const edgeFadeProps = useEdgeFade(desktopScroller, edgeFade && isMdUp);
1217
1345
 
1218
1346
  // ---- Mobile card list (md:hidden) ----
@@ -1408,9 +1536,20 @@ export function DataTable<T>({
1408
1536
  // Consecutive grouping of the (already-sorted) mobile slice into labelled
1409
1537
  // sections for the "assistance"-style list (feedback #317). Null unless the
1410
1538
  // caller opts in via `mobileGroupBy`.
1411
- const mobileGroups: { key: string; rows: T[] }[] | null = mobileGroupBy
1539
+ // The desktop's bands (0.48, keksdose dev #605), over the rows on screen.
1540
+ const tableRef = useRef<HTMLTableElement | null>(null);
1541
+ const desktopGroups = isMdUp && levels.length > 0 ? groupRows(slice, levels) : null;
1542
+ const bandTops = useGroupBandTops(
1543
+ tableRef,
1544
+ !!desktopGroups,
1545
+ `${levels.length}:${leadingRowSticky && !!leadingRow}:${desktopGroups?.[0]?.key ?? ""}`,
1546
+ );
1547
+ // `mobileGroupBy` may be a desktop `groupBy` level (0.48), its label and end slot too.
1548
+ const mobileLevel = typeof mobileGroupBy === "function" ? null : (mobileGroupBy ?? null);
1549
+ const mobileKey = typeof mobileGroupBy === "function" ? mobileGroupBy : mobileGroupBy?.key;
1550
+ const mobileGroups: { key: string; rows: T[] }[] | null = mobileKey
1412
1551
  ? mobileSlice.reduce<{ key: string; rows: T[] }[]>((acc, row) => {
1413
- const key = mobileGroupBy(row);
1552
+ const key = mobileKey(row);
1414
1553
  const last = acc[acc.length - 1];
1415
1554
  if (last && last.key === key) last.rows.push(row);
1416
1555
  else acc.push({ key, rows: [row] });
@@ -1548,8 +1687,15 @@ export function DataTable<T>({
1548
1687
  {mobileGroups
1549
1688
  ? mobileGroups.map((g) => (
1550
1689
  <Fragment key={`hb-group:${g.key}`}>
1551
- <li className="sticky top-0 z-10 bg-surface-2 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted backdrop-blur">
1552
- {mobileGroupLabel ? mobileGroupLabel(g.key) : g.key}
1690
+ <li className="sticky top-0 z-10 flex flex-wrap items-baseline justify-between gap-x-3 bg-surface-2 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted backdrop-blur">
1691
+ <span className="min-w-0">
1692
+ {mobileGroupLabel
1693
+ ? mobileGroupLabel(g.key)
1694
+ : mobileLevel?.label
1695
+ ? mobileLevel.label(g.key, g.rows)
1696
+ : g.key}
1697
+ </span>
1698
+ {mobileLevel?.end && <span className="ms-auto tabular-nums">{mobileLevel.end(g.key, g.rows)}</span>}
1553
1699
  </li>
1554
1700
  {g.rows.map(renderMobileRow)}
1555
1701
  </Fragment>
@@ -1576,6 +1722,16 @@ export function DataTable<T>({
1576
1722
  <bdi>{labels.loading}</bdi>
1577
1723
  </li>
1578
1724
  )}
1725
+ {/* The owner's next slice (0.48), once every row held is revealed. */}
1726
+ {loadMore && !canRevealMoreMobile && (
1727
+ <DataTableLoadMoreFooter
1728
+ as="li"
1729
+ loadMore={loadMore}
1730
+ rowCount={sorted.length}
1731
+ labels={labels}
1732
+ compact={compact}
1733
+ />
1734
+ )}
1579
1735
  </ul>
1580
1736
  {/* The phone's totals: a summary card after the list rather than one more
1581
1737
  `<li>` in it. The list reveals rows in chunks as it is scrolled, so a last
@@ -1622,7 +1778,7 @@ export function DataTable<T>({
1622
1778
  )}
1623
1779
  {/* Client-side mobile lists scroll endlessly (sentinel above); only
1624
1780
  server-paginated tables keep the pager here. */}
1625
- {isServer && (
1781
+ {isServer && !loadMore && (
1626
1782
  <Pagination
1627
1783
  page={safePage}
1628
1784
  totalPages={totalPages}
@@ -1683,7 +1839,9 @@ export function DataTable<T>({
1683
1839
  the rows that are about to be replaced, and the dimmed body says the same
1684
1840
  to the eye. The pager stays live — see `ServerPagination.isLoading`. */}
1685
1841
  <table
1842
+ ref={tableRef}
1686
1843
  className={cn("w-full", compact ? "text-xs" : "text-sm")}
1844
+ data-fit-grow={growDrawn ? "" : undefined}
1687
1845
  aria-label={labels.table}
1688
1846
  aria-busy={isLoading || undefined}
1689
1847
  >
@@ -1720,17 +1878,14 @@ export function DataTable<T>({
1720
1878
  moveResize={moveResize}
1721
1879
  finishResize={finishResize}
1722
1880
  />
1723
- <tbody
1724
- className={cn(isLoading && slice.length > 0 && "opacity-60 transition-opacity")}
1725
- >
1726
- {leadingRow && (
1727
- <tr className="border-t border-subtle">
1728
- <td colSpan={totalColSpan} className="p-0">
1729
- {leadingRow}
1730
- </td>
1731
- </tr>
1732
- )}
1733
- {slice.map((row, rowIndex) => {
1881
+ {(() => {
1882
+ const bodyClass = cn(isLoading && slice.length > 0 && "opacity-60 transition-opacity");
1883
+ const leading = leadingRow ? (
1884
+ <DataTableLeadingRow colSpan={totalColSpan} sticky={leadingRowSticky}>
1885
+ {leadingRow}
1886
+ </DataTableLeadingRow>
1887
+ ) : null;
1888
+ const renderRow = (row: T, rowIndex: number) => {
1734
1889
  const expanded = isExpanded?.(row) ?? false;
1735
1890
  const expansion = expanded ? expandedRow?.(row) : null;
1736
1891
  const rowInteractive = !!onRowClick || !!selection;
@@ -1895,6 +2050,7 @@ export function DataTable<T>({
1895
2050
  "align-top",
1896
2051
  compact ? "px-2 py-1" : "px-3 py-2",
1897
2052
  width && "overflow-hidden text-ellipsis",
2053
+ col.grow ? GROW_CELL_CLASS : growDrawn && GROW_PEER_CELL_CLASS,
1898
2054
  logicalAlign(own.className),
1899
2055
  logicalAlign(col.className),
1900
2056
  )}
@@ -1926,30 +2082,95 @@ export function DataTable<T>({
1926
2082
  content in `w-0 min-w-full` pins the cell's preferred
1927
2083
  width to ~0 (the explicit width:0 child), so it can never
1928
2084
  perturb the columns; the div then fills to the full row
1929
- width only at paint time, and long text wraps within it. */}
2085
+ width only at paint time, and long text wraps within it.
2086
+ Inside it the content is pinned to what the scroller SHOWS
2087
+ (0.47, keksdose S4): on a table wider than its scroller a
2088
+ table-wide panel put an inline editor's Save/Cancel off-screen.
2089
+ `sticky start-0` holds it at the scroller's start edge (the
2090
+ right one in RTL) as the row scrolls sideways, and
2091
+ `--dt-visible-w` (the scroller's clientWidth, measured above)
2092
+ sizes it; before the first measure, and in a table no wider
2093
+ than its scroller, it is the full width as before. */}
1930
2094
  <td colSpan={totalColSpan} className="p-0">
1931
- <div className={cn("w-0 min-w-full", compact ? "px-2 py-2" : "px-3 py-3")}>
1932
- {expansion}
2095
+ <div className="w-0 min-w-full">
2096
+ <div
2097
+ className={cn(
2098
+ "sticky start-0 w-[var(--dt-visible-w,100%)] max-w-full",
2099
+ compact ? "px-2 py-2" : "px-3 py-3",
2100
+ )}
2101
+ >
2102
+ {expansion}
2103
+ </div>
1933
2104
  </div>
1934
2105
  </td>
1935
2106
  </tr>
1936
2107
  )}
1937
2108
  </Fragment>
1938
2109
  );
1939
- })}
1940
- {slice.length === 0 && (
1941
- <tr>
1942
- <td
1943
- colSpan={totalColSpan}
1944
- className={cn("text-center text-muted", compact ? "px-2 py-2" : "px-3 py-4")}
1945
- >
1946
- {/* Nothing is not the same as not-yet: "no results" while the first
1947
- page is still in flight is a claim the table cannot make. */}
1948
- {isLoading ? <LoadingText label={labels.loading} /> : (empty ?? labels.empty)}
1949
- </td>
1950
- </tr>
1951
- )}
1952
- </tbody>
2110
+ };
2111
+ const emptyRow =
2112
+ slice.length === 0 ? (
2113
+ <tr>
2114
+ <td
2115
+ colSpan={totalColSpan}
2116
+ className={cn("text-center text-muted", compact ? "px-2 py-2" : "px-3 py-4")}
2117
+ >
2118
+ {/* Nothing is not the same as not-yet: "no results" while the first
2119
+ page is still in flight is a claim the table cannot make. */}
2120
+ {isLoading ? <LoadingText label={labels.loading} /> : (empty ?? labels.empty)}
2121
+ </td>
2122
+ </tr>
2123
+ ) : null;
2124
+ if (!desktopGroups) {
2125
+ return (
2126
+ <tbody className={bodyClass}>
2127
+ {leading}
2128
+ {slice.map(renderRow)}
2129
+ {emptyRow}
2130
+ </tbody>
2131
+ );
2132
+ }
2133
+ // Grouped (0.48, keksdose dev #605): one `<tbody>` per innermost group, its
2134
+ // band the row header; the leading row (and the empty state) in their own
2135
+ // before the first group.
2136
+ const band = (level: 1 | 2, def: DataTableGroupBy<T>, g: { key: string; rows: T[] }) => (
2137
+ <DataTableGroupBand
2138
+ level={level}
2139
+ colSpan={totalColSpan}
2140
+ top={level === 1 ? bandTops[0] : bandTops[1]}
2141
+ compact={compact}
2142
+ label={def.label ? def.label(g.key, g.rows) : g.key}
2143
+ end={def.end?.(g.key, g.rows)}
2144
+ />
2145
+ );
2146
+ const rowsOf = (g: { rows: T[]; start: number }) => g.rows.map((r, i) => renderRow(r, g.start + i));
2147
+ return (
2148
+ <>
2149
+ {(leading || emptyRow) && (
2150
+ <tbody className={bodyClass}>
2151
+ {leading}
2152
+ {emptyRow}
2153
+ </tbody>
2154
+ )}
2155
+ {desktopGroups.map((g1) =>
2156
+ g1.children ? (
2157
+ g1.children.map((g2, j) => (
2158
+ <tbody key={`g:${g1.key}:${g2.key}:${g2.start}`} className={bodyClass}>
2159
+ {j === 0 && band(1, levels[0], g1)}
2160
+ {band(2, levels[1], g2)}
2161
+ {rowsOf(g2)}
2162
+ </tbody>
2163
+ ))
2164
+ ) : (
2165
+ <tbody key={`g:${g1.key}:${g1.start}`} className={bodyClass}>
2166
+ {band(1, levels[0], g1)}
2167
+ {rowsOf(g1)}
2168
+ </tbody>
2169
+ ),
2170
+ )}
2171
+ </>
2172
+ );
2173
+ })()}
1953
2174
  {showFooter && (
1954
2175
  // Sticky to the scroller's bottom edge for the same reason the header is
1955
2176
  // sticky to its top: the body scrolls inside a bounded wrapper, and a
@@ -1993,6 +2214,7 @@ export function DataTable<T>({
1993
2214
  // A `<th>` centres by default; the label reads from the start
1994
2215
  // like the cells above it, unless the column says otherwise.
1995
2216
  isLabel && "text-start",
2217
+ col.grow ? GROW_CELL_CLASS : growDrawn && GROW_PEER_CELL_CLASS,
1996
2218
  logicalAlign(col.className),
1997
2219
  )}
1998
2220
  >
@@ -2004,8 +2226,13 @@ export function DataTable<T>({
2004
2226
  </tfoot>
2005
2227
  )}
2006
2228
  </table>
2229
+ {/* In the scroller, after the rows (0.48): it comes into view as the last row
2230
+ does, under `fillHeight` too, where the body is the scroller. */}
2231
+ {loadMore && (
2232
+ <DataTableLoadMoreFooter loadMore={loadMore} rowCount={sorted.length} labels={labels} compact={compact} />
2233
+ )}
2007
2234
  </div>
2008
- {!unpaged && (
2235
+ {!unpaged && !loadMore && (
2009
2236
  <Pagination
2010
2237
  page={safePage}
2011
2238
  totalPages={totalPages}
@@ -2033,6 +2260,8 @@ export function DataTable<T>({
2033
2260
  columns={columns}
2034
2261
  hiddenCols={hiddenCols}
2035
2262
  setHiddenCols={setHiddenCols}
2263
+ autoHidden={autoHidden}
2264
+ keepColumn={keepColumn}
2036
2265
  showSettings={showSettings}
2037
2266
  setShowSettings={setShowSettings}
2038
2267
  columnsCountLabel={columnsCountLabel}
@@ -1,4 +1,4 @@
1
- import { useEffect, useId, useState } from "react";
1
+ import { Children, Fragment, isValidElement, useEffect, useId, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, ComponentPropsWithRef, ReactNode } from "react";
3
3
  import { ChevronDown } from "lucide-react";
4
4
 
@@ -262,7 +262,9 @@ export interface DisclosureProps extends Omit<ComponentPropsWithRef<"div">, "tit
262
262
  * ```
263
263
  */
264
264
  controls?: string;
265
- /** The body. Not rendered in trigger-only mode ({@link DisclosureProps.controls}). */
265
+ /** The body. Not rendered in trigger-only mode ({@link DisclosureProps.controls}),
266
+ * nor when there is none (0.48): no children, no empty box under the header.
267
+ * `null`, `false`, `undefined` and an empty fragment (`<></>`) count as none. */
266
268
  children?: ReactNode;
267
269
  }
268
270
 
@@ -312,8 +314,13 @@ export function Disclosure({
312
314
  const chevronAtEnd = chevronAt === "end";
313
315
  const chevronAfterTitle = chevronAt === "after-title";
314
316
  const triggerOnly = controls !== undefined;
317
+ // 0.48 (keksdose dev #603): no children, no body — an empty padded box under the
318
+ // header made an open disclosure 8px taller than a shut one, and its `aria-controls`
319
+ // named a region with nothing in it. `null`, `false` and `undefined` are nothing, and
320
+ // so is a fragment of nothing (`cond ? <X /> : <></>`, keksdose N1).
321
+ const hasBody = !triggerOnly && hasBodyContent(children);
315
322
  // The card's header squares its lower corners only when a body opens under it.
316
- const joined = open && !triggerOnly;
323
+ const joined = open && hasBody;
317
324
  const hasTrailing = trailing !== undefined && trailing !== null && trailing !== false;
318
325
  // `trailing` beside the button, which then stretches under it; inside the button,
319
326
  // the row is simply the button.
@@ -334,7 +341,7 @@ export function Disclosure({
334
341
  type="button"
335
342
  aria-expanded={open}
336
343
  // `||`: an empty `controls` is trigger-only with nothing to name, not `aria-controls=""`.
337
- aria-controls={triggerOnly ? controls || undefined : bodyId}
344
+ aria-controls={triggerOnly ? controls || undefined : hasBody ? bodyId : undefined}
338
345
  disabled={disabled}
339
346
  onClick={toggle}
340
347
  data-disclosure-trigger=""
@@ -424,7 +431,7 @@ export function Disclosure({
424
431
  ) : (
425
432
  header
426
433
  )}
427
- {!triggerOnly && (
434
+ {hasBody && (
428
435
  <Collapse id={bodyId} open={open} keepMounted={keepMounted}>
429
436
  <div className={cn(card ? "space-y-3 px-4 pb-4" : !menu && "space-y-2 pt-2", bodyClassName)}>{children}</div>
430
437
  </Collapse>
@@ -461,3 +468,13 @@ function Chevron({
461
468
  />
462
469
  );
463
470
  }
471
+
472
+ /** Whether `children` holds anything to draw: `null`/`false`/`undefined`/`""` are nothing,
473
+ * and a fragment counts by what is in it (an empty `<></>` is nothing). */
474
+ function hasBodyContent(children: ReactNode): boolean {
475
+ return Children.toArray(children).some((child) =>
476
+ isValidElement<{ children?: ReactNode }>(child) && child.type === Fragment
477
+ ? hasBodyContent(child.props.children)
478
+ : child !== "",
479
+ );
480
+ }
@@ -3,6 +3,7 @@ import type {
3
3
  ComponentPropsWithoutRef,
4
4
  ComponentPropsWithRef,
5
5
  KeyboardEvent as ReactKeyboardEvent,
6
+ PointerEvent as ReactPointerEvent,
6
7
  ReactNode,
7
8
  Ref,
8
9
  RefObject,
@@ -10,7 +11,7 @@ import type {
10
11
  import { createPortal } from "react-dom";
11
12
  import { Search } from "lucide-react";
12
13
  import { anchoredInline, useAnchoredPanel } from "../hooks/use-anchored-panel";
13
- import { runOwnKeyDown, takeEscape, useDismissLayer, useOutsideClick } from "../hooks/use-dismiss";
14
+ import { pressInside, runOwnKeyDown, takeEscape, useDismissLayer, useOutsideClick } from "../hooks/use-dismiss";
14
15
  import type { DismissLayer } from "../hooks/use-dismiss";
15
16
  import { useOverlayHistory } from "../hooks/use-overlay-history";
16
17
  import { cn } from "../lib/cn";
@@ -371,6 +372,13 @@ export function DropdownPanel({
371
372
  runOwnKeyDown(e, onKeyDown, panelRef && listLayers.get(panelRef));
372
373
  takeEscape(e);
373
374
  };
375
+ // A press in a panel opened from a row (a popover, its lists) is inside the list for
376
+ // `useDropdown`'s outside press, though portalled elsewhere (dev#623).
377
+ const markInside = pressInside(panelRef);
378
+ const handlePointerDownCapture = (e: ReactPointerEvent<HTMLDivElement>) => {
379
+ rest.onPointerDownCapture?.(e);
380
+ markInside?.(e);
381
+ };
374
382
  const body = (
375
383
  <>
376
384
  {header}
@@ -399,6 +407,7 @@ export function DropdownPanel({
399
407
  style={style}
400
408
  ref={setPanel}
401
409
  onKeyDown={handleKeyDown}
410
+ onPointerDownCapture={handlePointerDownCapture}
402
411
  className={cn(
403
412
  "absolute z-30 mt-1 rounded-md border border-subtle bg-surface shadow-lg",
404
413
  "[&>ul]:max-h-64",
@@ -425,6 +434,7 @@ export function DropdownPanel({
425
434
  ref={setPanel}
426
435
  dir={dir}
427
436
  onKeyDown={handleKeyDown}
437
+ onPointerDownCapture={handlePointerDownCapture}
428
438
  // The caller's `style` is kept underneath, but the placement is measured rather
429
439
  // than chosen (dev#548: an `overflow` ancestor clipped a third of this panel
430
440
  // away), so these values are the panel's own. Flipped above, it hangs from its