@eifi1/ui-kit 0.47.0 → 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 (152) hide show
  1. package/README.md +5 -3
  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 +348 -257
  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/table-skeleton.d.ts +35 -0
  53. package/dist/components/table-skeleton.js +75 -0
  54. package/dist/components/table-skeleton.js.map +1 -0
  55. package/dist/components/tabs.d.ts +10 -12
  56. package/dist/components/tabs.js +20 -10
  57. package/dist/components/tabs.js.map +1 -1
  58. package/dist/components/use-table-state.d.ts +8 -7
  59. package/dist/components/use-table-state.js +37 -3
  60. package/dist/components/use-table-state.js.map +1 -1
  61. package/dist/data-table.d.ts +1 -0
  62. package/dist/data-table.js.map +1 -1
  63. package/dist/eslint/index.js +1 -1
  64. package/dist/feedback/feedback-table.d.ts +5 -0
  65. package/dist/feedback/feedback-table.js +30 -7
  66. package/dist/feedback/feedback-table.js.map +1 -1
  67. package/dist/hooks/use-dismiss.d.ts +30 -1
  68. package/dist/hooks/use-dismiss.js +30 -2
  69. package/dist/hooks/use-dismiss.js.map +1 -1
  70. package/dist/i18n/german.js +6 -1
  71. package/dist/i18n/german.js.map +1 -1
  72. package/dist/i18n/kit-labels.d.ts +4 -0
  73. package/dist/i18n/kit-labels.js.map +1 -1
  74. package/dist/i18n/locales/en.js +6 -1
  75. package/dist/i18n/locales/en.js.map +2 -2
  76. package/dist/i18n/locales/es.js +6 -1
  77. package/dist/i18n/locales/es.js.map +1 -1
  78. package/dist/i18n/locales/fr.js +6 -1
  79. package/dist/i18n/locales/fr.js.map +1 -1
  80. package/dist/i18n/locales/hu.js +6 -1
  81. package/dist/i18n/locales/hu.js.map +1 -1
  82. package/dist/i18n/locales/it.js +6 -1
  83. package/dist/i18n/locales/it.js.map +1 -1
  84. package/dist/i18n/locales/zh.js +6 -1
  85. package/dist/i18n/locales/zh.js.map +1 -1
  86. package/dist/i18n/shared-labels.js +3 -1
  87. package/dist/i18n/shared-labels.js.map +1 -1
  88. package/dist/index.d.ts +2 -0
  89. package/dist/index.js +2 -0
  90. package/dist/index.js.map +1 -1
  91. package/dist/lib/float-clearance.d.ts +8 -0
  92. package/dist/lib/float-clearance.js +29 -0
  93. package/dist/lib/float-clearance.js.map +1 -0
  94. package/dist/settings/settings-catalogue.d.ts +10 -0
  95. package/dist/settings/settings-catalogue.js.map +1 -1
  96. package/dist/settings/settings-layout.js +10 -3
  97. package/dist/settings/settings-layout.js.map +1 -1
  98. package/dist/shell/app-shell.d.ts +5 -0
  99. package/dist/shell/app-shell.js +5 -3
  100. package/dist/shell/app-shell.js.map +1 -1
  101. package/dist/shell/option-switcher-menu.d.ts +2 -0
  102. package/dist/shell/option-switcher-menu.js +6 -2
  103. package/dist/shell/option-switcher-menu.js.map +1 -1
  104. package/dist/shell/topbar-action-menu.js +2 -1
  105. package/dist/shell/topbar-action-menu.js.map +1 -1
  106. package/dist/shell/topbar-controls.js +5 -3
  107. package/dist/shell/topbar-controls.js.map +1 -1
  108. package/dist/tour/tour.js +1 -0
  109. package/dist/tour/tour.js.map +1 -1
  110. package/package.json +1 -1
  111. package/src/admin/admin-sections.tsx +8 -0
  112. package/src/auth/verify-email.tsx +34 -9
  113. package/src/components/alert-banner.tsx +131 -7
  114. package/src/components/button-look.ts +6 -0
  115. package/src/components/combobox-core.tsx +7 -1
  116. package/src/components/data-table-column-settings.tsx +23 -2
  117. package/src/components/data-table-fit.ts +187 -0
  118. package/src/components/data-table-groups.tsx +275 -0
  119. package/src/components/data-table-head.tsx +4 -1
  120. package/src/components/data-table-labels.ts +13 -0
  121. package/src/components/data-table-leading-row.tsx +55 -0
  122. package/src/components/data-table-resize.ts +2 -1
  123. package/src/components/data-table.tsx +246 -43
  124. package/src/components/disclosure.tsx +22 -5
  125. package/src/components/dropdown.tsx +11 -1
  126. package/src/components/floating-panel.tsx +24 -1
  127. package/src/components/hover-menu.tsx +6 -1
  128. package/src/components/icon-button.tsx +3 -3
  129. package/src/components/popover.tsx +7 -1
  130. package/src/components/table-skeleton.tsx +133 -0
  131. package/src/components/tabs.tsx +67 -37
  132. package/src/components/use-table-state.ts +54 -3
  133. package/src/data-table.ts +2 -0
  134. package/src/feedback/feedback-table.tsx +33 -1
  135. package/src/hooks/use-dismiss.ts +65 -3
  136. package/src/i18n/german.ts +5 -0
  137. package/src/i18n/kit-labels.tsx +4 -0
  138. package/src/i18n/locales/es.ts +5 -0
  139. package/src/i18n/locales/fr.ts +5 -0
  140. package/src/i18n/locales/hu.ts +5 -0
  141. package/src/i18n/locales/it.ts +5 -0
  142. package/src/i18n/locales/zh.ts +5 -0
  143. package/src/i18n/shared-labels.ts +2 -0
  144. package/src/index.ts +3 -0
  145. package/src/lib/float-clearance.ts +54 -0
  146. package/src/settings/settings-catalogue.ts +10 -0
  147. package/src/settings/settings-layout.tsx +18 -4
  148. package/src/shell/app-shell.tsx +17 -3
  149. package/src/shell/option-switcher-menu.tsx +16 -4
  150. package/src/shell/topbar-action-menu.tsx +22 -10
  151. package/src/shell/topbar-controls.tsx +18 -12
  152. package/src/tour/tour.tsx +5 -0
@@ -0,0 +1,275 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import type { ReactNode, RefObject } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { Button } from "./button";
5
+ import { Spinner } from "./spinner";
6
+
7
+ /**
8
+ * One level of {@link DataTableProps.groupBy} (0.48, keksdose dev #605): the rows are cut
9
+ * into consecutive runs of equal `key` — the rows must already be ordered so equal keys
10
+ * are contiguous, as for `mobileGroupBy` — and each run is headed by a full-width band.
11
+ *
12
+ * The same object drives the phone: `mobileGroupBy` takes a level too, so
13
+ * `groupBy={[period, day]} mobileGroupBy={period}` is one definition for both layouts.
14
+ */
15
+ export interface DataTableGroupBy<T> {
16
+ /** The group a row belongs to. */
17
+ key: (row: T) => string;
18
+ /** The band's label. Default: the key itself. */
19
+ label?: (key: string, rows: T[]) => ReactNode;
20
+ /**
21
+ * The band's end slot — a count, a total ("176 · +8'782.30 · −3'200.10"). `rows` are
22
+ * the group's rows the table holds; an app whose total covers rows not yet loaded
23
+ * looks it up by `key` instead.
24
+ */
25
+ end?: (key: string, rows: T[]) => ReactNode;
26
+ }
27
+
28
+ /**
29
+ * {@link DataTableProps.loadMore} (0.48, keksdose dev #605): a footer in place of the
30
+ * pager that loads the next slice of rows — on its own as it scrolls into view (an
31
+ * IntersectionObserver), and through a button that is always there for the keyboard and
32
+ * the screen reader. The owner appends the rows; the table renders every row it is given.
33
+ */
34
+ export interface DataTableLoadMore {
35
+ /** Whether there is anything left to load. False draws the end state. */
36
+ hasMore: boolean;
37
+ /** Load the next slice. Called at most once per row count while `loading` is false. */
38
+ onLoadMore: () => void;
39
+ /** A load is in flight: the button says "Loading…" and ignores presses. */
40
+ loading?: boolean;
41
+ /** The button's words — what it loads ("Load August 2026"). Default: the kit's "Load more". */
42
+ label?: ReactNode;
43
+ /** What the footer says once `hasMore` is false. Default: the kit's "All rows loaded";
44
+ * `null` draws nothing. */
45
+ endLabel?: ReactNode;
46
+ /** Load as the footer scrolls into view. Default true; false leaves only the button. */
47
+ auto?: boolean;
48
+ }
49
+
50
+ /** A run of rows under one band; `start` is its first row's index in the slice. */
51
+ export interface RowGroup<T> {
52
+ key: string;
53
+ rows: T[];
54
+ start: number;
55
+ /** The second level's runs inside this one, or null with one level. */
56
+ children: RowGroup<T>[] | null;
57
+ }
58
+
59
+ /** Consecutive runs by `level.key`, nested once for a second level. */
60
+ export function groupRows<T>(rows: T[], levels: readonly DataTableGroupBy<T>[], offset = 0): RowGroup<T>[] {
61
+ const [level, ...rest] = levels;
62
+ if (!level) return [];
63
+ const out: RowGroup<T>[] = [];
64
+ rows.forEach((row, i) => {
65
+ const key = level.key(row);
66
+ const last = out[out.length - 1];
67
+ if (last && last.key === key) last.rows.push(row);
68
+ else out.push({ key, rows: [row], start: offset + i, children: null });
69
+ });
70
+ if (rest.length > 0) for (const g of out) g.children = groupRows(g.rows, rest.slice(0, 1), g.start);
71
+ return out;
72
+ }
73
+
74
+ /** `groupBy` as a list of at most two levels. */
75
+ export function groupLevels<T>(
76
+ groupBy: DataTableGroupBy<T> | readonly [DataTableGroupBy<T>, DataTableGroupBy<T>?] | undefined,
77
+ ): DataTableGroupBy<T>[] {
78
+ if (!groupBy) return [];
79
+ const list = "key" in groupBy ? [groupBy] : (groupBy as readonly (DataTableGroupBy<T> | undefined)[]);
80
+ return list.filter((l): l is DataTableGroupBy<T> => !!l).slice(0, 2);
81
+ }
82
+
83
+ /**
84
+ * The bands' sticky offsets: the first level under the header (and under a sticky
85
+ * `leadingRow`), the second under the first. Measured — the header grows at Large text,
86
+ * a band's end slot can wrap — and followed with a ResizeObserver.
87
+ */
88
+ export function useGroupBandTops(
89
+ tableRef: RefObject<HTMLTableElement | null>,
90
+ enabled: boolean,
91
+ signature: string,
92
+ ): [number, number] {
93
+ const [tops, setTops] = useState<[number, number]>([0, 0]);
94
+ useLayoutEffect(() => {
95
+ const table = tableRef.current;
96
+ if (!enabled || !table) return;
97
+ const parts = [
98
+ table.tHead,
99
+ table.querySelector<HTMLElement>("[data-leading-row='sticky']"),
100
+ table.querySelector<HTMLElement>("th[data-group-band='1']"),
101
+ ];
102
+ const measure = () => {
103
+ const [head, lead, band] = parts.map((el) => (el ? el.getBoundingClientRect().height : 0));
104
+ setTops((prev) => (prev[0] === head + lead && prev[1] === head + lead + band ? prev : [head + lead, head + lead + band]));
105
+ };
106
+ measure();
107
+ if (typeof ResizeObserver === "undefined") return;
108
+ const observer = new ResizeObserver(measure);
109
+ for (const el of parts) if (el) observer.observe(el);
110
+ return () => observer.disconnect();
111
+ }, [tableRef, enabled, signature]);
112
+ return tops;
113
+ }
114
+
115
+ /**
116
+ * A band row. A `<th scope="rowgroup">` spanning the row — the header of the `<tbody>` it
117
+ * opens, which a screen reader announces as the reader enters the group — and sticky on
118
+ * the cell, not the `<tr>`, as the header's and the leading row's are. Opaque, under the
119
+ * sticky leading row's `z-[5]`: the first level `z-[4]` over the second's `z-[3]`, so a
120
+ * period arriving from below covers the day still pinned under the one before. The line
121
+ * over it is the cell's own `::before`, which moves with the stuck cell.
122
+ */
123
+ export function DataTableGroupBand({
124
+ level,
125
+ colSpan,
126
+ top,
127
+ compact,
128
+ label,
129
+ end,
130
+ }: {
131
+ level: 1 | 2;
132
+ colSpan: number;
133
+ top: number;
134
+ compact: boolean;
135
+ label: ReactNode;
136
+ end: ReactNode;
137
+ }) {
138
+ return (
139
+ <tr data-group-row={level}>
140
+ <th
141
+ scope="rowgroup"
142
+ colSpan={colSpan}
143
+ data-group-band={level}
144
+ style={{ top }}
145
+ className={cn(
146
+ "sticky p-0 text-start align-middle",
147
+ "before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:z-[1] before:h-px before:bg-[var(--border)] before:content-['']",
148
+ level === 1 ? "z-[4] bg-surface-2 font-semibold text-primary" : "z-[3] bg-surface font-medium text-secondary",
149
+ level === 1 ? (compact ? "text-xs" : "text-sm") : "text-xs",
150
+ )}
151
+ >
152
+ {/* Pinned to what the scroller shows, as the expansion row's panel is: the end
153
+ slot stays at the visible end of a table wider than its scroller. */}
154
+ <div className="w-0 min-w-full">
155
+ <div
156
+ className={cn(
157
+ "sticky start-0 flex w-[var(--dt-visible-w,100%)] max-w-full flex-wrap items-baseline justify-between gap-x-3",
158
+ compact ? "px-2 py-1" : level === 1 ? "px-3 py-2" : "px-3 py-1.5",
159
+ )}
160
+ >
161
+ <span className="min-w-0">{label}</span>
162
+ {end != null && end !== false && <span className="ms-auto tabular-nums">{end}</span>}
163
+ </div>
164
+ </div>
165
+ </th>
166
+ </tr>
167
+ );
168
+ }
169
+
170
+ /**
171
+ * The load-more footer. Observed against the viewport (an implicit root sees through the
172
+ * clipping scroller, so it works in a bounded body, under `fillHeight`, and in a page that
173
+ * scrolls), and guarded: one call per row count, none while `loading`, so an observer
174
+ * that reports twice — or a footer still in view while the owner sets `loading` — loads
175
+ * once. When the rows arrive and the footer is still in view (a short slice), it loads
176
+ * again, until the scroller is full.
177
+ */
178
+ export function DataTableLoadMoreFooter({
179
+ loadMore,
180
+ rowCount,
181
+ labels,
182
+ compact,
183
+ as: Tag = "div",
184
+ }: {
185
+ loadMore: DataTableLoadMore;
186
+ rowCount: number;
187
+ labels: { loadMore: string; allLoaded: string; loading: string };
188
+ compact: boolean;
189
+ as?: "div" | "li";
190
+ }) {
191
+ const { hasMore, onLoadMore, loading = false, auto = true, label, endLabel } = loadMore;
192
+ const rootRef = useRef<HTMLElement | null>(null);
193
+ const endRef = useRef<HTMLParagraphElement | null>(null);
194
+ const firedAt = useRef<number | null>(null);
195
+ const buttonFocused = useRef(false);
196
+ // What the observer's callback acts on: the latest props, not the ones it was made with.
197
+ const latest = useRef({ onLoadMore, loading, rowCount });
198
+ useEffect(() => {
199
+ latest.current = { onLoadMore, loading, rowCount };
200
+ });
201
+
202
+ // A fresh observer whenever the rows or the in-flight flag change: its first report is
203
+ // the footer's position AFTER the new rows are laid out. Acting on a remembered "in
204
+ // view" instead fired a second load the moment the rows arrived, while the footer was
205
+ // still where the old last row had been (headless Chromium caught it).
206
+ useEffect(() => {
207
+ const el = rootRef.current;
208
+ if (!auto || !hasMore || loading || !el || typeof IntersectionObserver === "undefined") return;
209
+ const io = new IntersectionObserver(
210
+ (entries) => {
211
+ const now = latest.current;
212
+ if (!entries.some((e) => e.isIntersecting) || now.loading || firedAt.current === now.rowCount) return;
213
+ firedAt.current = now.rowCount;
214
+ now.onLoadMore();
215
+ },
216
+ { rootMargin: "200px" },
217
+ );
218
+ io.observe(el);
219
+ return () => io.disconnect();
220
+ }, [auto, hasMore, loading, rowCount]);
221
+
222
+ // A focused button that goes away (nothing left) hands its focus to the end line.
223
+ useEffect(() => {
224
+ if (!hasMore && buttonFocused.current) {
225
+ buttonFocused.current = false;
226
+ endRef.current?.focus();
227
+ }
228
+ }, [hasMore]);
229
+
230
+ const end = endLabel === undefined ? labels.allLoaded : endLabel;
231
+ return (
232
+ <Tag
233
+ ref={(el: HTMLElement | null) => {
234
+ rootRef.current = el;
235
+ }}
236
+ data-table-load-more=""
237
+ className={cn(
238
+ "sticky start-0 flex w-[var(--dt-visible-w,100%)] max-w-full flex-col items-center border-t border-subtle text-center",
239
+ compact ? "px-2 py-2" : "px-3 py-3",
240
+ )}
241
+ >
242
+ {hasMore ? (
243
+ <Button
244
+ variant="secondary"
245
+ size="sm"
246
+ aria-busy={loading || undefined}
247
+ onFocus={() => (buttonFocused.current = true)}
248
+ onBlur={() => (buttonFocused.current = false)}
249
+ onClick={() => {
250
+ if (loading) return;
251
+ firedAt.current = rowCount;
252
+ onLoadMore();
253
+ }}
254
+ >
255
+ {loading ? (
256
+ <span className="inline-flex items-center gap-2">
257
+ <Spinner label={null} className="size-4" />
258
+ <bdi>{labels.loading}</bdi>
259
+ </span>
260
+ ) : (
261
+ (label ?? labels.loadMore)
262
+ )}
263
+ </Button>
264
+ ) : end != null && end !== false ? (
265
+ <p ref={endRef} tabIndex={-1} className="text-xs text-muted outline-none">
266
+ {end}
267
+ </p>
268
+ ) : null}
269
+ {/* Mounted throughout, so a screen reader hears the load start. */}
270
+ <span role="status" className="sr-only">
271
+ {loading ? labels.loading : ""}
272
+ </span>
273
+ </Tag>
274
+ );
275
+ }
@@ -10,6 +10,7 @@ import { Tooltip } from "./tooltip";
10
10
  import type { DataTableLabels } from "./data-table-labels";
11
11
  import { isTableOwnColumn } from "./data-table-row-actions";
12
12
  import { columnLabel, logicalAlign, splitCellProps } from "./data-table-shared";
13
+ import { GROW_HEAD_CLASS } from "./data-table-fit";
13
14
  import type { DataTableColumn, DataTableProps, FilterState } from "./data-table";
14
15
 
15
16
  // DataTable's `<thead>` on a wide screen: the select-all box, and per column the sort
@@ -147,6 +148,7 @@ export function DataTableHead<T>({
147
148
  // Each <th> carries its own bg so the row doesn't render
148
149
  // transparent over the data rows underneath.
149
150
  "sticky top-0 z-10 bg-surface-2 backdrop-blur-sm",
151
+ col.grow && GROW_HEAD_CLASS,
150
152
  logicalAlign(ownHead.className),
151
153
  headClass,
152
154
  )}
@@ -223,7 +225,8 @@ export function DataTableHead<T>({
223
225
  </Popover>
224
226
  )}
225
227
  </div>
226
- {resizable && !isTableOwnColumn(col.key) && (
228
+ {/* A `grow` column takes the rest: no width of its own to drag (0.48, KS N2). */}
229
+ {resizable && !isTableOwnColumn(col.key) && !col.grow && (
227
230
  /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a
228
231
  pointer-only drag handle for a visual preference (column width); it is
229
232
  deliberately not a tab stop, and no content or action depends on it. */
@@ -32,6 +32,9 @@ export interface DataTableLabels extends DataTableSortLabels {
32
32
  */
33
33
  rowDetails: string;
34
34
  autoSize: string;
35
+ /** Beside a column in the Columns panel that the table's width leaves out
36
+ * (`fitPriority`, 0.48): "hidden for width". */
37
+ hiddenForWidth: string;
35
38
  loading: string;
36
39
  filters: string;
37
40
  clearAll: string;
@@ -112,6 +115,13 @@ export interface DataTableLabels extends DataTableSortLabels {
112
115
  booleanTrue: string;
113
116
  booleanFalse: string;
114
117
  booleanUnset: string;
118
+
119
+ // ---- Load more (0.48, keksdose dev #605) ----
120
+
121
+ /** The `loadMore` footer's button when the app names nothing ("Load August 2026"). */
122
+ loadMore: string;
123
+ /** The `loadMore` footer once nothing is left, unless the app says `endLabel`. */
124
+ allLoaded: string;
115
125
  }
116
126
 
117
127
  export const DEFAULT_DATA_TABLE_LABELS: DataTableLabels = {
@@ -125,6 +135,7 @@ export const DEFAULT_DATA_TABLE_LABELS: DataTableLabels = {
125
135
  cancelSelection: "Cancel",
126
136
  rowDetails: "Details",
127
137
  autoSize: "Auto-size columns",
138
+ hiddenForWidth: "hidden for width",
128
139
  loading: "Loading…",
129
140
  filters: "Filters",
130
141
  clearAll: "Clear all",
@@ -174,6 +185,8 @@ export const DEFAULT_DATA_TABLE_LABELS: DataTableLabels = {
174
185
  booleanTrue: "Yes",
175
186
  booleanFalse: "No",
176
187
  booleanUnset: "Not set",
188
+ loadMore: "Load more",
189
+ allLoaded: "All rows loaded",
177
190
  // The phone sort control (0.22.0). The same words as `DEFAULT_DATA_TABLE_SORT_LABELS`
178
191
  // in data-table.tsx, written out rather than spread in: importing that VALUE here
179
192
  // would make data-table.tsx and this module import each other (data-table.tsx reads
@@ -0,0 +1,55 @@
1
+ import { useLayoutEffect, useRef, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+
5
+ /**
6
+ * The desktop table's `leadingRow`: one full-width cell above the data rows. With
7
+ * `sticky` (0.48, keksdose dev #603) the cell is pinned under the sticky header while the
8
+ * rows scroll — `top` is the header's height, measured and followed (a header that wraps
9
+ * at Large text grows). On the cell, not the `<tr>`: position:sticky on a table row is not
10
+ * reliable across browsers, the reason the header's sticky sits on each `<th>`. Opaque
11
+ * (`bg-surface`), so the rows pass beneath it; `z-[5]`, under the header's `z-10` and over
12
+ * the rows' own sticky cells. The line over it is the cell's own `::before`, drawn over
13
+ * the content (a full-width disclosure header paints its own fill) and moving with the
14
+ * cell; a collapsed table's row border would stay behind at the row's place.
15
+ */
16
+ export function DataTableLeadingRow({
17
+ colSpan,
18
+ sticky = false,
19
+ children,
20
+ }: {
21
+ colSpan: number;
22
+ sticky?: boolean;
23
+ children: ReactNode;
24
+ }) {
25
+ const cellRef = useRef<HTMLTableCellElement>(null);
26
+ const [top, setTop] = useState(0);
27
+ useLayoutEffect(() => {
28
+ if (!sticky) return;
29
+ const head = cellRef.current?.closest("table")?.tHead;
30
+ if (!head) return;
31
+ const measure = () => setTop(head.getBoundingClientRect().height);
32
+ measure();
33
+ if (typeof ResizeObserver === "undefined") return;
34
+ const observer = new ResizeObserver(measure);
35
+ observer.observe(head);
36
+ return () => observer.disconnect();
37
+ }, [sticky]);
38
+ return (
39
+ <tr className={cn(!sticky && "border-t border-subtle")}>
40
+ <td
41
+ ref={cellRef}
42
+ colSpan={colSpan}
43
+ data-leading-row={sticky ? "sticky" : ""}
44
+ className={cn(
45
+ "p-0",
46
+ sticky &&
47
+ "sticky z-[5] bg-surface before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:z-[1] before:h-px before:bg-[var(--border)] before:content-['']",
48
+ )}
49
+ style={sticky ? { top } : undefined}
50
+ >
51
+ {children}
52
+ </td>
53
+ </tr>
54
+ );
55
+ }
@@ -175,7 +175,8 @@ export function useColumnResize<T>({
175
175
  const next: Record<string, number> = {};
176
176
  const cols = columns.filter((c) => !hiddenCols.has(c.key));
177
177
  cols.forEach((col, idx) => {
178
- if (idx === cols.length - 1) return;
178
+ // A `grow` column takes the rest by design: pinning it would undo that (0.48).
179
+ if (idx === cols.length - 1 || col.grow) return;
179
180
  const th = headRefs.get(col.key);
180
181
  if (th) next[col.key] = Math.ceil(th.getBoundingClientRect().width);
181
182
  });