@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
@@ -1,3 +1,4 @@
1
+ import { useId, useLayoutEffect, useRef, useState } from "react";
1
2
  import type { ComponentPropsWithoutRef, MouseEvent, ReactNode, Ref } from "react";
2
3
  import { AlertTriangle, CheckCircle2, ChevronRight, Info, X } from "lucide-react";
3
4
  import { cn } from "../lib/cn";
@@ -195,6 +196,34 @@ interface AlertBannerBaseProps extends Omit<ComponentPropsWithoutRef<"div">, "on
195
196
  /** Accessible name of the ×. Default: `common.dismiss` from the
196
197
  * {@link UiKitProvider}, else "Dismiss". */
197
198
  dismissLabel?: string;
199
+ /**
200
+ * 0.48: on a phone (below the kit's text-size-aware `md`) hold the message to ONE line,
201
+ * with a "More" / "Less" toggle after it (`aria-expanded`, `aria-controls` the message;
202
+ * `common.showMore` / `common.showLess`). From `md` up nothing changes. The toggle only
203
+ * shows while the line actually cuts the message off.
204
+ *
205
+ * keksdose dev#611 (UX-1.2): its beta notice took three to four lines (~100px) above
206
+ * every phone screen, and the app built the clamp and the button around the banner's
207
+ * children by hand. Ignored on a whole-row banner (`href` / `onClick`): a toggle cannot
208
+ * sit inside the row's own link or button.
209
+ */
210
+ clampMobile?: boolean;
211
+ /**
212
+ * Where a box's {@link action} goes on a phone (below `sm`). 0.48.
213
+ *
214
+ * `own-line` (default): a line of its own under the message, lined up with it
215
+ * (kastlan 53 — a long message beside a long button squeezed into a column).
216
+ *
217
+ * `flow`: on the message's line while the message (with its glyph) keeps 10rem, else at the END of the
218
+ * next line — for a short message and a short action, which a line of their own each
219
+ * would stack for nothing. keksdose dev #623: the budget card's "5 underfunded
220
+ * categories" + "Assign" built this with descendant overrides on the kit's classes.
221
+ * The basis is in rem, so it grows with the text size: at Large the action drops a
222
+ * line sooner, by itself. The × stays at the top end either way; `clampMobile` works
223
+ * alongside. From `sm` up nothing changes. Ignored by `strip` (which flows like this
224
+ * already) and `inline`.
225
+ */
226
+ actionPlacement?: "own-line" | "flow";
198
227
  children: ReactNode;
199
228
  /**
200
229
  * The banner's frame — where the rest of the props go (0.34, API-2, refactor audit
@@ -245,6 +274,8 @@ export function AlertBanner({
245
274
  icon,
246
275
  onDismiss,
247
276
  dismissLabel,
277
+ clampMobile = false,
278
+ actionPlacement = "own-line",
248
279
  href,
249
280
  onClick,
250
281
  className,
@@ -262,6 +293,26 @@ export function AlertBanner({
262
293
  const inlineRow = inline && !block;
263
294
  const resolvedRole = role ?? (inline && live ? (tone === "danger" ? "alert" : "status") : undefined);
264
295
 
296
+ // `clampMobile`: the message's one-line clamp below `md`, and whether that line holds
297
+ // it all (then there is nothing to show "More" of). Measured while collapsed — the
298
+ // clamp is what makes `scrollHeight` exceed the box — and again whenever its box
299
+ // changes size, which a rotation or crossing `md` does. With no layout (a test, the
300
+ // server) the box is 0 tall, which reads as "may not fit": the toggle stays.
301
+ const clamp = clampMobile && !interactive;
302
+ const bodyId = useId();
303
+ const bodyRef = useRef<HTMLSpanElement>(null);
304
+ const [expanded, setExpanded] = useState(false);
305
+ const [fits, setFits] = useState(false);
306
+ useLayoutEffect(() => {
307
+ const el = bodyRef.current;
308
+ if (!clamp || expanded || !el) return;
309
+ const check = () => setFits(el.clientHeight > 0 && el.scrollHeight <= el.clientHeight + 1);
310
+ check();
311
+ const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(check);
312
+ ro?.observe(el);
313
+ return () => ro?.disconnect();
314
+ }, [clamp, expanded, children]);
315
+
265
316
  const Glyph = TONE_GLYPH[tone];
266
317
  // Onto the first line of a box's text: 2px at 14px/20px, 1px at 12px/16px.
267
318
  const nudge = inlineRow || strip ? undefined : sm ? "mt-px" : "mt-0.5";
@@ -285,7 +336,14 @@ export function AlertBanner({
285
336
  // squeezed "Please confirm your email address…" into a ~100px column beside a
286
337
  // "Resend verification email" button. There the action takes a line of its own under
287
338
  // the message, and the × stays at the top end; from sm up the box is one row as before.
288
- const boxWraps = !inline && !strip && action !== undefined && action !== null;
339
+ const boxAction = !inline && !strip && action !== undefined && action !== null;
340
+ // `flow` (0.48): glyph + message and the action in a row of their own that wraps
341
+ // below sm; the × stays outside it, at the top end.
342
+ const flow = boxAction && actionPlacement === "flow";
343
+ const boxWraps = boxAction && !flow;
344
+ const flowRow = cn("flex min-w-0 flex-1 items-center max-sm:flex-wrap", sm ? "gap-1.5" : "gap-2");
345
+ // The 10rem the message keeps beside the action before the action wraps.
346
+ const flowBasis = flow ? "max-sm:basis-40" : undefined;
289
347
  const row = cn(
290
348
  inlineRow
291
349
  ? "inline-flex items-center"
@@ -309,10 +367,49 @@ export function AlertBanner({
309
367
  );
310
368
  const focusRing = cn(FOCUS_RING, "focus-visible:ring-offset-1 focus-visible:ring-offset-surface");
311
369
 
370
+ const contentBody = clamp ? (
371
+ // Below `md` a row: the message, clamped, then the toggle on its baseline. From
372
+ // `md` up a plain span with the message in it, as without the prop.
373
+ <span
374
+ className={cn(
375
+ !inlineRow && grow,
376
+ "min-w-0 text-start max-md:flex max-md:items-baseline max-md:gap-2",
377
+ )}
378
+ >
379
+ {/* The clamp is deliberate: the rest is one tap away behind More, so the size sweep's
380
+ truncation gate (scripts/screenshot-sizes.mjs) leaves it alone. */}
381
+ <span
382
+ id={bodyId}
383
+ ref={bodyRef}
384
+ data-truncate-ok=""
385
+ className={cn("max-md:min-w-0", !expanded && "max-md:line-clamp-1")}
386
+ >
387
+ {children}
388
+ </span>
389
+ {(expanded || !fits) && (
390
+ <button
391
+ type="button"
392
+ aria-expanded={expanded}
393
+ aria-controls={bodyId}
394
+ onClick={() => setExpanded((v) => !v)}
395
+ className={cn(
396
+ "relative z-10 shrink-0 rounded-sm font-medium underline underline-offset-2 md:hidden",
397
+ focusRing,
398
+ )}
399
+ >
400
+ {expanded ? common.showLess : common.showMore}
401
+ </button>
402
+ )}
403
+ </span>
404
+ ) : (
405
+ <span className={cn(!inlineRow && (nested ? "flex-1" : grow), "min-w-0 text-start")}>
406
+ {children}
407
+ </span>
408
+ );
312
409
  const content = (
313
410
  <>
314
411
  {glyph}
315
- <span className={cn(!inlineRow && (nested ? "flex-1" : grow), "min-w-0 text-start")}>{children}</span>
412
+ {contentBody}
316
413
  {interactive && (
317
414
  <ChevronRight
318
415
  aria-hidden
@@ -354,7 +451,7 @@ export function AlertBanner({
354
451
  className={cn(
355
452
  "relative z-10 flex max-w-full shrink-0 flex-wrap items-center gap-2",
356
453
  !strip && !inlineRow && "self-center",
357
- strip && "ms-auto",
454
+ (strip || flow) && "ms-auto",
358
455
  // Last, on a line of its own, lined up with the message (past the glyph).
359
456
  boxWraps && cn("max-sm:order-last max-sm:basis-full", glyph && (sm ? "max-sm:ps-5" : "max-sm:ps-6")),
360
457
  )}
@@ -378,8 +475,24 @@ export function AlertBanner({
378
475
  role={resolvedRole}
379
476
  className={cn(row, frame, TONE_TEXT[tone], className)}
380
477
  >
381
- {content}
382
- {trailing}
478
+ {flow ? (
479
+ <>
480
+ <div className={flowRow} data-action-flow="">
481
+ {/* The glyph rides with the message, on its first line; the two are
482
+ centred against the action as one, as the whole-row button is. */}
483
+ <span className={cn("flex min-w-0 flex-1 items-start", flowBasis, sm ? "gap-1.5" : "gap-2")}>
484
+ {glyph}
485
+ {contentBody}
486
+ </span>
487
+ {trailing}
488
+ </div>
489
+ </>
490
+ ) : (
491
+ <>
492
+ {content}
493
+ {trailing}
494
+ </>
495
+ )}
383
496
  {dismiss}
384
497
  </div>
385
498
  );
@@ -393,6 +506,7 @@ export function AlertBanner({
393
506
  ? cn(
394
507
  "flex min-w-0 text-start",
395
508
  grow,
509
+ flowBasis,
396
510
  sm ? "gap-1.5" : "gap-2",
397
511
  inlineRow || strip ? "items-center" : "items-start",
398
512
  "outline-none after:absolute after:inset-0 after:content-['']",
@@ -440,8 +554,18 @@ export function AlertBanner({
440
554
  role={resolvedRole}
441
555
  className={cn("relative", row, frame, TONE_TEXT[tone], hover, className)}
442
556
  >
443
- {rowAction}
444
- {trailing}
557
+ {flow ? (
558
+ // Not positioned: the row's stretched `after:` target must still cover the frame.
559
+ <div className={flowRow} data-action-flow="">
560
+ {rowAction}
561
+ {trailing}
562
+ </div>
563
+ ) : (
564
+ <>
565
+ {rowAction}
566
+ {trailing}
567
+ </>
568
+ )}
445
569
  {dismiss}
446
570
  </div>
447
571
  );
@@ -41,6 +41,12 @@ const BUTTON_BASE = cn(
41
41
  */
42
42
  export const TOUCH_TARGET_LARGE = "large:min-h-[48px] large:min-w-[48px]";
43
43
 
44
+ /** The 48 px target at Large for a control whose BOX cannot grow (a chip's or a tab's
45
+ * action, IconButton `2xs`; Tabs' overflow cues, 0.48 KS N3): an invisible `::after`
46
+ * centred on it takes the taps a finger lands round the glyph. Not public. */
47
+ export const HIT_AREA_LARGE =
48
+ "relative large:after:absolute large:after:inset-[calc((100%-48px)/2)] large:after:content-['']";
49
+
44
50
  export type ButtonSize = "sm" | "md";
45
51
 
46
52
  // Box geometry per size, split out of the base so the two cannot be merged into one
@@ -19,7 +19,7 @@ import { Spinner } from "./spinner";
19
19
  import { useAnchorDir } from "./use-anchor-dir";
20
20
  import { type AnchorRect } from "../hooks/use-anchored-rect";
21
21
  import { anchoredInline, useAnchoredPanel, viewportSize, type AnchoredPanel } from "../hooks/use-anchored-panel";
22
- import { takeEscape, useDismissLayer, useOutsideClick } from "../hooks/use-dismiss";
22
+ import { pressInside, takeEscape, useDismissLayer, useOutsideClick } from "../hooks/use-dismiss";
23
23
  import { useOverlayHistory } from "../hooks/use-overlay-history";
24
24
  import { isImeEnter } from "../lib/ime";
25
25
  import { foldForSearch } from "../lib/text-match";
@@ -1066,6 +1066,12 @@ export function ComboboxPanel<V extends string | number>({
1066
1066
  ref={panelRef}
1067
1067
  dir={dir}
1068
1068
  onKeyDown={onKeyDown}
1069
+ // A press in a panel opened from a row is inside this list for the core's outside
1070
+ // press, though portalled elsewhere (dev#623).
1071
+ onPointerDownCapture={(e) => {
1072
+ rest.onPointerDownCapture?.(e);
1073
+ pressInside(panelRef)?.(e);
1074
+ }}
1069
1075
  // `placement` keeps the panel inside the region actually on screen: on a phone
1070
1076
  // the search box below pulls up the keyboard, and a panel pinned under a
1071
1077
  // low trigger would otherwise sit entirely behind it (feedback #135). Flipped
@@ -17,6 +17,8 @@ export function ColumnSettingsPanel<T>({
17
17
  columns,
18
18
  hiddenCols,
19
19
  setHiddenCols,
20
+ autoHidden,
21
+ keepColumn,
20
22
  showSettings,
21
23
  setShowSettings,
22
24
  columnsCountLabel,
@@ -27,6 +29,11 @@ export function ColumnSettingsPanel<T>({
27
29
  columns: DataTableColumn<T>[];
28
30
  hiddenCols: Set<string>;
29
31
  setHiddenCols: Dispatch<SetStateAction<Set<string>>>;
32
+ /** The columns the table's width leaves out (`fitPriority`, 0.48): unticked, and
33
+ * marked "hidden for width". */
34
+ autoHidden: Set<string>;
35
+ /** A tick keeps a column in whatever the width; an untick drops that (data-table-fit.ts). */
36
+ keepColumn: (key: string, on: boolean) => void;
30
37
  showSettings: boolean;
31
38
  setShowSettings: (open: boolean) => void;
32
39
  /** "Columns (n/m)", the rail's text and name. */
@@ -102,7 +109,10 @@ export function ColumnSettingsPanel<T>({
102
109
  <div className="flex flex-col gap-0.5">
103
110
  {columns.map((col) => {
104
111
  if (isTableOwnColumn(col.key)) return null;
105
- const checked = !hiddenCols.has(col.key);
112
+ // Ticked = drawn: a column left out for width is unticked, so the boxes
113
+ // and "Columns (8/10)" say the same (keksdose live #451).
114
+ const forWidth = !hiddenCols.has(col.key) && autoHidden.has(col.key);
115
+ const checked = !hiddenCols.has(col.key) && !forWidth;
106
116
  const headerLabel = columnLabel(col);
107
117
  return (
108
118
  <label
@@ -114,6 +124,7 @@ export function ColumnSettingsPanel<T>({
114
124
  className="size-3.5 shrink-0"
115
125
  checked={checked}
116
126
  onChange={(e) => {
127
+ keepColumn(col.key, e.target.checked);
117
128
  setHiddenCols((s) => {
118
129
  const next = new Set(s);
119
130
  if (e.target.checked) next.delete(col.key);
@@ -125,7 +136,17 @@ export function ColumnSettingsPanel<T>({
125
136
  {/* The panel is `w-56`: a long column name wraps from Large up rather
126
137
  than reading "Accoun…" beside its box (text size §10.17, round
127
138
  0.34 T1). */}
128
- <span className="min-w-0 truncate-until-large">{headerLabel}</span>
139
+ {forWidth ? (
140
+ // Under the name, as part of the box's name: "Email, hidden for width".
141
+ <span className="flex min-w-0 flex-col">
142
+ <span className="truncate-until-large">{headerLabel}</span>
143
+ <span className="text-micro text-muted" data-hidden-for-width="">
144
+ {labels.hiddenForWidth}
145
+ </span>
146
+ </span>
147
+ ) : (
148
+ <span className="min-w-0 truncate-until-large">{headerLabel}</span>
149
+ )}
129
150
  </label>
130
151
  );
131
152
  })}
@@ -0,0 +1,187 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import type { DataTableColumn } from "./data-table";
3
+
4
+ // DataTable's fit to its own width (0.48, keksdose live #451: "On expanded sidebar for
5
+ // desktop the table scrolls horizontally. On collapsed sidebar it fits […] Compute the
6
+ // width selectively"). Two column options, both opt-in:
7
+ //
8
+ // - `grow`: the column takes what the others leave and ellipsises (`w-full max-w-0` on
9
+ // its cells: in an auto table layout that is "the rest of the row", never its content's
10
+ // width), so one long title can no longer widen the table.
11
+ // - `fitPriority`: while the table is wider than its scroller — or, with a `grow` column,
12
+ // while that column would get less than {@link GROW_MIN_REM} — the table leaves out
13
+ // its `fitPriority` columns, the LOWEST number first (ties: the later column first),
14
+ // and brings them back in the reverse order as soon as the table, as it is NOW, has
15
+ // room for them (its need re-measured, plus the column's own header width from when
16
+ // it was left out), so narrower content (a filter, another page) counts as much as a
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.
19
+
20
+ /** The least a `grow` column keeps before the table starts leaving columns out: about
21
+ * a sentence of a title. In rem, so Large text asks for more. */
22
+ export const GROW_MIN_REM = 10;
23
+
24
+ /** The `<th>`/`<td>` classes of a `grow` column. `truncate-until-large`: one line and an
25
+ * ellipsis at Normal, a wrap from Large up (text size §10.17), as every kit title. The
26
+ * header's `min-w-[10rem]` ({@link GROW_MIN_REM}) is the floor once no `fitPriority`
27
+ * column is left to leave out: the table then scrolls sideways rather than crushing the
28
+ * column it exists for (Kurvenschmiede S1, 0.48: Subject was 24px at 1100px). */
29
+ export const GROW_HEAD_CLASS = "w-full max-w-0 min-w-[10rem]";
30
+ export const GROW_CELL_CLASS = "w-full max-w-0 truncate-until-large";
31
+
32
+ /** Every OTHER cell of a table with a `grow` column (kastlan S2, 0.48). A `w-full` column
33
+ * squeezes the rest to their min-content width, so any text with a space broke in two
34
+ * even at 1920px ("CHF 2'568.38", "Peter Mueller", a date with its time). One line at
35
+ * Normal, so a column is never narrower than its text and `fitPriority` measures what
36
+ * the columns really need; from Large up the cell wraps as before (§10.17). The headers
37
+ * are `whitespace-nowrap` already. A column whose prose should wrap anyway passes
38
+ * `not-large:whitespace-normal` in its `className`. */
39
+ export const GROW_PEER_CELL_CLASS = "not-large:whitespace-nowrap";
40
+
41
+ // A cell's own flex-wrap row (chips) is left to wrap beside a `grow` column: a row of
42
+ // badges reads as well stacked, and held to one line it widened the user roster's Status
43
+ // until kastlan's admin pane scrolled 21px at 1280px (0.48 build review 2, kastlan S1).
44
+
45
+ /** The auto-hide order: lowest `fitPriority` first, the later column first on a tie. */
46
+ export function fitHideOrder<T>(columns: DataTableColumn<T>[]): DataTableColumn<T>[] {
47
+ return columns
48
+ .map((col, index) => ({ col, index }))
49
+ .filter(({ col }) => col.fitPriority != null)
50
+ .sort((a, b) => a.col.fitPriority! - b.col.fitPriority! || b.index - a.index)
51
+ .map(({ col }) => col);
52
+ }
53
+
54
+ /**
55
+ * Which columns the table leaves out for width, and the Columns panel's "keep it".
56
+ *
57
+ * A column the user TICKS in the Columns panel is theirs: it is never left out for width
58
+ * again while the table is mounted (`keep`): the table makes room by leaving out the next
59
+ * candidate, and scrolls sideways once none is left — what they asked for. Unticking it hides it as before (the persisted `hiddenCols`) and
60
+ * drops the keep. Keeps are not persisted: on the next visit the table fits again.
61
+ */
62
+ export function useColumnFit<T>({
63
+ columns,
64
+ hiddenCols,
65
+ headRefs,
66
+ scroller,
67
+ enabled,
68
+ }: {
69
+ columns: DataTableColumn<T>[];
70
+ /** The user's hidden columns: never candidates (they are out already). */
71
+ hiddenCols: Set<string>;
72
+ headRefs: Map<string, HTMLTableCellElement>;
73
+ /** The desktop scroller, read after render (it is declared below the call). */
74
+ scroller: () => HTMLElement | null;
75
+ /** The wide layout. On a phone (the card list) nothing is left out for width. */
76
+ enabled: boolean;
77
+ }): {
78
+ /** The columns left out for width now — empty while off. */
79
+ autoHidden: Set<string>;
80
+ /** The user's choice in the Columns panel: `true` keeps the column in whatever the width. */
81
+ keep: (key: string, on: boolean) => void;
82
+ } {
83
+ const [kept, setKept] = useState<ReadonlySet<string>>(() => new Set());
84
+ const [hidden, setHidden] = useState<string[]>([]);
85
+ // A column's own header width, written when it is left out: it comes back once the
86
+ // table's need as it is now, plus this, fits the scroller (keksdose S3, 0.48: not the
87
+ // whole table's need at hide time, which went stale when the content narrowed).
88
+ const ownWidth = useRef(new Map<string, number>());
89
+
90
+ const hasFit = columns.some((c) => c.fitPriority != null);
91
+ const active = enabled && hasFit;
92
+ const order = useMemo(
93
+ () => fitHideOrder(columns).filter((c) => !hiddenCols.has(c.key) && !kept.has(c.key)),
94
+ [columns, hiddenCols, kept],
95
+ );
96
+ // Only the candidates still in play, in hide order — a column the user hid or kept
97
+ // since, or one the app dropped, is no longer "hidden for width".
98
+ const current = useMemo(
99
+ () => (active ? order.filter((c) => hidden.includes(c.key)).map((c) => c.key) : []),
100
+ [active, order, hidden],
101
+ );
102
+ const growKey = columns.find((c) => c.grow && !hiddenCols.has(c.key))?.key;
103
+
104
+ // What `measure` reads, refreshed after each render (before the measure below runs):
105
+ // the ResizeObserver's callback is set up once and must see the current columns.
106
+ const latest = useRef({ order, current, growKey, active, scroller });
107
+ useLayoutEffect(() => {
108
+ latest.current = { order, current, growKey, active, scroller };
109
+ });
110
+
111
+ const measure = useCallback(() => {
112
+ const { order, current, growKey, active, scroller } = latest.current;
113
+ const el = scroller();
114
+ if (!active || !el) return;
115
+ const table = el.querySelector("table");
116
+ const avail = el.clientWidth;
117
+ // Not laid out (display:none, jsdom): nothing to fit to.
118
+ if (!table || avail <= 0) return;
119
+ const growTh = growKey ? headRefs.get(growKey) : undefined;
120
+ const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
121
+ let need = growTh
122
+ ? table.offsetWidth - growTh.offsetWidth + GROW_MIN_REM * rem
123
+ : table.offsetWidth;
124
+ if (!growTh && need <= avail + 0.5 && current.length > 0) {
125
+ // A `w-full` table is at least the scroller's width, which says nothing about how
126
+ // much room its content leaves: probe its content's width (what it takes once it
127
+ // overflows) for the bring-back below. Read and put back in one layout pass.
128
+ const before = table.style.width;
129
+ table.style.width = "min-content";
130
+ need = table.offsetWidth;
131
+ table.style.width = before;
132
+ }
133
+ if (need > avail + 0.5) {
134
+ // Leave out, in order, until the rest fits: each column saves its header's width
135
+ // (the others are at their content's width while a `grow` column takes the rest).
136
+ const next = [...current];
137
+ for (const col of order) {
138
+ if (need <= avail + 0.5) break;
139
+ if (next.includes(col.key)) continue;
140
+ const w = headRefs.get(col.key)?.offsetWidth ?? 0;
141
+ ownWidth.current.set(col.key, w);
142
+ need -= w;
143
+ next.push(col.key);
144
+ }
145
+ if (next.length !== current.length) setHidden(next);
146
+ return;
147
+ }
148
+ // 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.
151
+ let n = current.length;
152
+ while (n > 0) {
153
+ const w = ownWidth.current.get(current[n - 1]);
154
+ if (w == null || need + w > avail + 0.5) break;
155
+ need += w;
156
+ n--;
157
+ }
158
+ if (n !== current.length) setHidden(current.slice(0, n));
159
+ }, [headRefs]);
160
+
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.
163
+ useLayoutEffect(() => {
164
+ measure();
165
+ });
166
+
167
+ useEffect(() => {
168
+ const el = latest.current.scroller();
169
+ if (!active || !el || typeof ResizeObserver === "undefined") return;
170
+ const ro = new ResizeObserver(() => measure());
171
+ ro.observe(el);
172
+ return () => ro.disconnect();
173
+ }, [active, measure]);
174
+
175
+ const keep = useCallback((key: string, on: boolean) => {
176
+ setKept((s) => {
177
+ if (s.has(key) === on) return s;
178
+ const next = new Set(s);
179
+ if (on) next.add(key);
180
+ else next.delete(key);
181
+ return next;
182
+ });
183
+ }, []);
184
+
185
+ const autoHidden = useMemo(() => new Set(current), [current]);
186
+ return { autoHidden, keep };
187
+ }