@eifi1/ui-kit 0.47.0 → 0.48.1

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 +95 -2
  36. package/dist/components/data-table.js +360 -267
  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 +279 -54
  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
@@ -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
@@ -17,6 +17,7 @@ import { cn } from "../lib/cn";
17
17
  import { FOCUS_RING } from "./focus-ring";
18
18
  import { dirOf, type Direction } from "../lib/direction";
19
19
  import { registerFloating } from "../lib/floating-stack";
20
+ import { useFloatClearance } from "../lib/float-clearance";
20
21
  import { isComposing } from "../lib/ime";
21
22
  import { takeEscape } from "../hooks/use-dismiss";
22
23
  import { useKitLabels, useKitLink } from "../i18n/kit-labels";
@@ -179,6 +180,11 @@ export interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTM
179
180
  *
180
181
  * While mounted it reports its box (`lib/floating-stack.ts`), so `ServerWakeNotice`
181
182
  * stacks above it rather than over it.
183
+ *
184
+ * 0.48: while mounted, `hidden` or not, it also publishes the room it takes above the
185
+ * nav (`offset` + 3rem + a 0.5rem gap) as `--app-float-clearance` on `<html>` (the
186
+ * largest of several wins), and `AppShell` pads its `<main>` past it on a phone, so the
187
+ * page's last row no longer ends under the button (keksdose dev#612).
182
188
  */
183
189
  export function FloatingActionButton({
184
190
  label,
@@ -206,6 +212,10 @@ export function FloatingActionButton({
206
212
  // The fixed element — the button, or the wrapper when there is a tooltip.
207
213
  const [fixed, setFixed] = useState<HTMLElement | null>(null);
208
214
  useEffect(() => (fixed ? registerFloating(fixed) : undefined), [fixed]);
215
+ // The room it takes above the nav, for AppShell's <main> to pad past (keksdose
216
+ // dev#612). Hidden too: the room is the launcher's while it is mounted, so the page
217
+ // does not change length under the reader as it hides and shows.
218
+ useFloatClearance(offset);
209
219
  const buttonRef = useCallback(
210
220
  (node: HTMLButtonElement | null) => {
211
221
  assignRef(ref, node);
@@ -332,6 +342,14 @@ export interface FloatingPanelProps {
332
342
  className?: string;
333
343
  /** Extra classes for the scrolling body. */
334
344
  bodyClassName?: string;
345
+ /**
346
+ * 0.48: take the FAB and the panel off screen while keeping both mounted — the open
347
+ * state and the bottom room (`--app-float-clearance`) stay, and an open panel is back
348
+ * when this turns `false`. keksdose's assistant launcher steps aside while an editor
349
+ * or a modal covers its corner (dev#612); unmounting it instead would give the room
350
+ * back and shorten the page under the reader's thumb.
351
+ */
352
+ hidden?: boolean;
335
353
  children: ReactNode;
336
354
  }
337
355
 
@@ -382,6 +400,7 @@ export function FloatingPanel({
382
400
  initialFocus,
383
401
  className,
384
402
  bodyClassName,
403
+ hidden = false,
385
404
  children,
386
405
  }: FloatingPanelProps) {
387
406
  const labels = useKitLabels(
@@ -457,8 +476,9 @@ export function FloatingPanel({
457
476
  offset={offset}
458
477
  nativeTitle={fabNativeTitle}
459
478
  tooltip={fabTooltip}
479
+ hidden={hidden || undefined}
460
480
  aria-expanded={open}
461
- aria-controls={open ? panelId : undefined}
481
+ aria-controls={open && !hidden ? panelId : undefined}
462
482
  onClick={() => {
463
483
  // A press ON the FAB closing the panel: the FAB keeps focus by being clicked,
464
484
  // nothing to hand back.
@@ -467,6 +487,7 @@ export function FloatingPanel({
467
487
  }}
468
488
  />
469
489
  {open &&
490
+ !hidden &&
470
491
  typeof document !== "undefined" &&
471
492
  createPortal(
472
493
  // A container for Escape bubbling up from anything inside it, as a dialog is.
@@ -572,6 +593,8 @@ export function FloatingActionGroup({
572
593
  ...rest
573
594
  }: FloatingActionGroupProps) {
574
595
  const [marker, dir] = usePortalDir();
596
+ // As the FAB's (keksdose dev#612): its 3rem members plus the frame's 1px borders.
597
+ useFloatClearance(offset, "2px");
575
598
  return (
576
599
  <>
577
600
  <span ref={marker} hidden />
@@ -163,7 +163,8 @@ export function HoverMenu({
163
163
  };
164
164
  }, [open, close]);
165
165
 
166
- useOutsideClick(wrapperRef, close, open);
166
+ // A press in a panel opened from a row (a popover, its lists) is inside (dev#623).
167
+ const markInside = useOutsideClick(wrapperRef, close, open);
167
168
 
168
169
  const menuRef = useRef<HTMLDivElement>(null);
169
170
  /** Set by a keyboard press on the trigger, so the open that follows moves focus into
@@ -290,6 +291,10 @@ export function HoverMenu({
290
291
  className={cn("relative", className)}
291
292
  onMouseEnter={handleMouseEnter}
292
293
  onMouseLeave={handleMouseLeave}
294
+ onPointerDownCapture={(e) => {
295
+ rest.onPointerDownCapture?.(e);
296
+ markInside(e);
297
+ }}
293
298
  onKeyDown={(e) => {
294
299
  // The caller's handler sees Escape only while this menu is the layer on top: a
295
300
  // tooltip on one of its rows takes that press first (round 0.35 R2, the rule
@@ -25,6 +25,7 @@ import {
25
25
  HOVER_INK,
26
26
  ICON_BUTTON_PRESSED,
27
27
  RenderedButtonLink,
28
+ HIT_AREA_LARGE,
28
29
  TOUCH_TARGET_LARGE,
29
30
  buttonVariantClasses,
30
31
  } from "./button-look";
@@ -106,7 +107,7 @@ const ICON_BUTTON_TEXT_STRETCH_SIZES: Record<IconButtonSize, string> = {
106
107
  // (2.75rem is 55 px at Large). `md` … `xs` grow their box to it. `2xs` is the action ON
107
108
  // a chip or a tab, whose box cannot grow without growing the chip: it keeps its box and
108
109
  // gets a 48 px hit area instead — an invisible `::after` centred on it, which takes the
109
- // taps a finger lands round the glyph.
110
+ // taps a finger lands round the glyph (`HIT_AREA_LARGE`).
110
111
  const ICON_BUTTON_TOUCH_LARGE: Record<IconButtonSize, string> = {
111
112
  "2xl": "",
112
113
  xl: "",
@@ -114,8 +115,7 @@ const ICON_BUTTON_TOUCH_LARGE: Record<IconButtonSize, string> = {
114
115
  md: TOUCH_TARGET_LARGE,
115
116
  sm: TOUCH_TARGET_LARGE,
116
117
  xs: TOUCH_TARGET_LARGE,
117
- "2xs":
118
- "relative large:after:absolute large:after:inset-[calc((100%-48px)/2)] large:after:content-['']",
118
+ "2xs": HIT_AREA_LARGE,
119
119
  };
120
120
 
121
121
  // A tone re-colours the glyph without changing what the variant draws around it.
@@ -157,7 +157,9 @@ export function Popover({
157
157
  }
158
158
  : null;
159
159
 
160
- useOutsideClick([triggerRef, panelRef], close, open);
160
+ // A press in a list opened from inside — a CurrencySelect's, a combobox's, another
161
+ // popover's — reaches the panel through the React tree and is inside (dev#623).
162
+ const markInside = useOutsideClick([triggerRef, panelRef], close, open);
161
163
  // A layer on the Escape stack while open (Wave 2, DUP-1): a list opened inside closes
162
164
  // first, and the dialog this popover sits in only after it — the calculator on an
163
165
  // amount field in a Modal is exactly that.
@@ -216,6 +218,10 @@ export function Popover({
216
218
  // silently do nothing on a panel whose children are not yet tabbable.
217
219
  tabIndex={-1}
218
220
  onKeyDown={handleKeyDown}
221
+ onPointerDownCapture={(e) => {
222
+ rest.onPointerDownCapture?.(e);
223
+ markInside(e);
224
+ }}
219
225
  style={{
220
226
  // Merged over the caller's, never replaced by it: these are measured
221
227
  // against the trigger every scroll and resize, and a panel that dropped
@@ -0,0 +1,133 @@
1
+ import type { ComponentPropsWithRef } from "react";
2
+ import { cn } from "../lib/cn";
3
+ import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
4
+ import { Skeleton } from "./skeleton";
5
+
6
+ export interface TableSkeletonProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
7
+ /** Rows per group (or in all, without `groups`). Default 5. */
8
+ rows?: number;
9
+ /** Columns, the name column included. Default 3. */
10
+ columns?: number;
11
+ /**
12
+ * `list` (default): a name bar of varying width, then the other columns as figures at
13
+ * the row's end — from three columns up they go under the name on a phone, as a card
14
+ * list draws them. `grid`: a label column, then one equal block per column, clipped
15
+ * at the edge — a heatmap or a matrix.
16
+ */
17
+ variant?: "list" | "grid";
18
+ /** This many group header bars, each over `rows` rows (a budget's groups). Default none. */
19
+ groups?: number;
20
+ /** A row of this many stat-tile outlines above the panel. Default none. */
21
+ tiles?: number;
22
+ /** A title and a hint line at the top of the panel, as a card's head. */
23
+ heading?: boolean;
24
+ /** The panel's border and surface. Default `true`; `false` for a panel the caller frames. */
25
+ framed?: boolean;
26
+ /** What is said, once. Default `common.loading` ("Loading…") from the provider. */
27
+ label?: string;
28
+ }
29
+
30
+ /** Name widths that differ from row to row, so the outline reads as a list rather than
31
+ * a striped block. */
32
+ const NAME_WIDTHS = ["w-2/5", "w-1/2", "w-1/3", "w-5/12"];
33
+
34
+ /**
35
+ * The outline of a table or a list while its data loads (0.48, keksdose dev #628) — so a
36
+ * page does not jump from a centred spinner to a long list when the data lands.
37
+ * keksdose hand-built three of these (TabSkeleton, BudgetSkeleton,
38
+ * VarianceHeatmapSkeleton), each a stack of `Skeleton`s with its own sr-only line.
39
+ *
40
+ * ONE `role="status"` region says "Loading…" once; every placeholder inside is
41
+ * `aria-hidden` — not one announcement per row. The pulse stops under reduced motion
42
+ * (`Skeleton`'s own rule); the shapes stay. Props and `ref` go to the region.
43
+ */
44
+ export function TableSkeleton({
45
+ rows = 5,
46
+ columns = 3,
47
+ variant = "list",
48
+ groups = 0,
49
+ tiles = 0,
50
+ heading = false,
51
+ framed = true,
52
+ label,
53
+ className,
54
+ ...rest
55
+ }: TableSkeletonProps) {
56
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
57
+ const figures = Math.max(0, columns - 1);
58
+ const grid = variant === "grid";
59
+ // Three figures don't fit beside a name on a phone; one or two do.
60
+ const stack = !grid && figures >= 2;
61
+
62
+ const row = (key: number) =>
63
+ grid ? (
64
+ <div key={key} className="flex items-center gap-1" data-skeleton-row="">
65
+ <Skeleton className="h-7 w-28 shrink-0" />
66
+ {Array.from({ length: figures }, (_, c) => (
67
+ <Skeleton key={c} className="h-7 w-12 shrink-0" />
68
+ ))}
69
+ </div>
70
+ ) : (
71
+ <div
72
+ key={key}
73
+ className={cn("flex items-center gap-4 px-4 py-3", stack && "max-sm:flex-col max-sm:items-stretch max-sm:gap-2")}
74
+ data-skeleton-row=""
75
+ >
76
+ <Skeleton className={cn("h-4", NAME_WIDTHS[key % NAME_WIDTHS.length])} />
77
+ {figures > 0 && (
78
+ <div className={cn("ms-auto flex gap-6", stack && "max-sm:ms-0 max-sm:justify-between max-sm:gap-2")}>
79
+ {Array.from({ length: figures }, (_, f) => (
80
+ <Skeleton key={f} className="h-4 w-16 md:w-24" />
81
+ ))}
82
+ </div>
83
+ )}
84
+ </div>
85
+ );
86
+
87
+ const block = (offset: number) => (
88
+ <div className={grid ? "space-y-1 overflow-hidden" : "divide-y divide-subtle"}>
89
+ {Array.from({ length: rows }, (_, r) => row(offset + r))}
90
+ </div>
91
+ );
92
+
93
+ return (
94
+ // `relative`: the containing block for the sr-only text (sr-only-containment.test).
95
+ <div {...rest} role="status" className={cn("relative", className)} data-skeleton-preset="table">
96
+ <span className="sr-only">{label ?? common.loading}</span>
97
+ <div aria-hidden="true" className="flex flex-col gap-4">
98
+ {tiles > 0 && (
99
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
100
+ {Array.from({ length: tiles }, (_, i) => (
101
+ <div key={i} className="rounded-lg border border-subtle bg-surface p-4" data-skeleton-tile="">
102
+ <Skeleton className="h-3 w-24 max-w-full" />
103
+ <Skeleton className="mt-3 h-7 w-32 max-w-full" />
104
+ </div>
105
+ ))}
106
+ </div>
107
+ )}
108
+ <div
109
+ className={cn("overflow-clip", framed && "rounded-lg border border-subtle bg-surface", grid && "p-3")}
110
+ data-skeleton-panel=""
111
+ >
112
+ {heading && (
113
+ <div className={cn(grid ? "mb-3" : "px-4 pt-4 pb-2")} data-skeleton-heading="">
114
+ <Skeleton className="h-4 w-40 max-w-full" />
115
+ <Skeleton className="mt-2 h-3 w-64 max-w-full" />
116
+ </div>
117
+ )}
118
+ {groups > 0
119
+ ? Array.from({ length: groups }, (_, g) => (
120
+ <div key={g} className={cn(g > 0 && "border-t border-subtle")}>
121
+ <div className="flex items-center gap-3 bg-surface-2 px-4 py-2.5" data-skeleton-group="">
122
+ <Skeleton className={cn("h-4", g % 2 ? "w-24" : "w-28")} />
123
+ <Skeleton className="ms-auto h-4 w-12" />
124
+ </div>
125
+ {block(g * rows)}
126
+ </div>
127
+ ))
128
+ : block(0)}
129
+ </div>
130
+ </div>
131
+ </div>
132
+ );
133
+ }
@@ -11,6 +11,7 @@ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
11
11
  import { mergeRefs } from "./field-parts";
12
12
  import { DEFAULT_TABS_LABELS } from "./ui-labels";
13
13
  import { IconButton } from "./icon-button";
14
+ import { HIT_AREA_LARGE } from "./button-look";
14
15
 
15
16
  /** One tab of a {@link Tabs} strip. */
16
17
  export interface TabItem<T extends string> {
@@ -48,16 +49,10 @@ export interface TabsLabels {
48
49
  add: string;
49
50
  /** Accessible name of a tab's × — "Remove <tab>". */
50
51
  remove: (tab: string) => string;
51
- /** The `overflowCue` › over the strip's end, while tabs lie past it: "More tabs". */
52
+ /** The `overflowCue` › after the strip's end, while tabs lie past it: "More tabs". */
52
53
  more: string;
53
- /** The `overflowCue` ‹ over the strip's start, while tabs lie before it: "Previous tabs". */
54
+ /** The `overflowCue` ‹ before the strip's start, while tabs lie before it: "Previous tabs". */
54
55
  previous: string;
55
- /**
56
- * @deprecated 0.47: not drawn any more — the caret that turned into "back to the
57
- * first tab" at the strip's end is now a ‹ of its own (`previous`). Accepted and
58
- * ignored; removed in 0.48.
59
- */
60
- backToFirst?: string;
61
56
  }
62
57
 
63
58
  // Defined in ./ui-labels, which holds no component code (0.34, KS U8).
@@ -84,8 +79,8 @@ export interface TabsProps<T extends string>
84
79
  onChange: (id: T) => void;
85
80
  className?: string;
86
81
  /**
87
- * Carets over the ends of a SCROLLING strip (0.40, kk run 79 gap 1; 0.47, keksdose
88
- * live #410 round 4): a › over the strip's end while tabs lie past it, and a ‹ over
82
+ * Carets at the ends of a SCROLLING strip (0.40, kk run 79 gap 1; 0.47, keksdose
83
+ * live #410 round 4): a › after the strip's end while tabs lie past it, and a ‹ before
89
84
  * its start while tabs lie before it — each there only while something is hidden
90
85
  * past ITS edge (1 px slack, so a few cut-off pixels count), each a step of three
91
86
  * quarters of the strip's width toward its own side. Neither ever changes direction:
@@ -94,8 +89,12 @@ export interface TabsProps<T extends string>
94
89
  *
95
90
  * The edge fade alone could read as the end of the row: the last visible tab can
96
91
  * finish exactly where the fade begins, so nothing looks cut off (keksdose live #410).
97
- * The carets sit OVER the strip's ends (positioned, on the page's surface), not beside
98
- * it, so the strip's width never changes as they come and go. Out of the tab order —
92
+ * Since 0.48 (live #410 rounds 5–6) the carets are bare glyphs BESIDE the strip — the ‹
93
+ * before it, the › after it — centred on the labels, muted and primary on hover, with
94
+ * no frame, fill or shadow; until 0.47 they were framed buttons laid over the strip's
95
+ * last visible tabs. While the strip overflows at all it keeps a 24px gutter at both
96
+ * ends, and each caret sits in its own, so scrolling never changes the strip's width
97
+ * or moves its labels; a strip that fits has no gutters. Out of the tab order —
99
98
  * the keyboard walks the strip with the arrow keys, which bring the focused tab into
100
99
  * view. At every width. Ignored on a vertical strip (a side nav is a column, not a
101
100
  * row). Names: `tabs.more` / `tabs.previous`. keksdose's `scroll-edge-cues.tsx` is
@@ -262,7 +261,7 @@ export function Tabs<T extends string>({
262
261
  const stripRef = useRef<HTMLDivElement>(null);
263
262
  const setStrip = useMemo(() => mergeRefs(stripRef, ref), [ref]);
264
263
  const fade = useStripFade(stripRef);
265
- // See `overflowCue`: a ‹ over the start while tabs lie before it, a › over the end
264
+ // See `overflowCue`: a ‹ before the strip while tabs lie before it, a › after it
266
265
  // while tabs lie past it — `fade.overflow` names the cut ends in reading-direction terms.
267
266
  const cue = overflowCue && !vertical;
268
267
  const cueStart = cue && (fade.overflow === "start" || fade.overflow === "both");
@@ -276,27 +275,56 @@ export function Tabs<T extends string>({
276
275
  const toward = (side === "end" ? 1 : -1) * (rtl ? -1 : 1);
277
276
  strip.scrollBy({ left: toward * Math.round(strip.clientWidth * 0.75), behavior });
278
277
  };
279
- // OVER the strip's end, on the page's surface, so the caret reads as a control and not
280
- // as one more faded tab — and so it can come and go without the strip changing width
281
- // under the thumb. The position sits on a wrapper of ours: IconButton puts its
282
- // tooltip's span around the button, and a class on the button would land inside it.
283
- const cueAt = (side: "start" | "end") => (
278
+ // In a GUTTER of its own at the strip's end (0.48, keksdose live #410 rounds 5–6): a
279
+ // bare glyph — no frame, fill or shadow — muted until the pointer comes. Round 5 put
280
+ // each cue in the flow beside the strip, so it took 24px the moment it appeared and the
281
+ // labels jumped sideways at every edge the scroll crossed ("It has problems with moving
282
+ // to left and right especially when moving the scroll bar"). Now the strip box keeps a
283
+ // 24px gutter at BOTH ends for as long as the strip overflows at all (`px-[24px]`, see
284
+ // `stripBox`), and each gutter is absolute in that padding, so a cue coming or going
285
+ // never moves the strip, its labels or the scroll position under the thumb. A strip
286
+ // that fits has no gutters: its width changes only when it starts or stops overflowing.
287
+ // The gutter carries the strip's 1px rule on beneath the glyph, and lifts the glyph
288
+ // onto the LABELS' centre rather than the box's: a tab's 2px underline (`border-b-2`)
289
+ // sits on the rule (`-mb-px`), so the labels are centred 2px above the box's bottom —
290
+ // the gutter's 1px rule (`border-b`) plus 1px more (`pb-px`).
291
+ // 24px wide at every text size (KS N3): at Large `sm`'s 48px minimum made each cue
292
+ // 48px, and at 360px the two left a two-tab strip 100px. 24 PIXELS, not `w-6`: the rem
293
+ // grows with the text size (24 / 30 / 36px), and at Extra large on a 360px phone the two
294
+ // gutters took 72px of a 226px strip and a sheet tab wrapped to seven lines (0.48 build
295
+ // review 2, KS N1). The 48px touch target is an
296
+ // invisible hit area round the glyph instead (`HIT_AREA_LARGE`, as `2xs`), over the
297
+ // strip's faded edge, not room taken from the row. The wrapper inside the gutter is
298
+ // ours because IconButton puts its tooltip's span around the button.
299
+ const gutterAt = (side: "start" | "end", shown: boolean) => (
284
300
  <div
285
- data-tabs-overflow-cue={side}
286
- className={cn("absolute top-1/2 z-10 flex -translate-y-1/2", side === "start" ? "start-0" : "end-0")}
301
+ data-tabs-cue-gutter={side}
302
+ className={cn(
303
+ "absolute inset-y-0 flex w-[24px] items-center justify-center border-b border-subtle pb-px",
304
+ side === "start" ? "start-0" : "end-0",
305
+ )}
287
306
  >
288
- <IconButton
289
- size="sm"
290
- variant="secondary"
291
- className="bg-surface shadow-sm"
292
- tabIndex={-1}
293
- type="button"
294
- label={side === "start" ? text.previous : text.more}
295
- labelVisible={false}
296
- onClick={() => onCue(side)}
297
- >
298
- {side === "start" ? <ChevronLeft className="rtl:-scale-x-100" /> : <ChevronRight className="rtl:-scale-x-100" />}
299
- </IconButton>
307
+ {shown && (
308
+ <div data-tabs-overflow-cue={side} className="flex">
309
+ <IconButton
310
+ size="sm"
311
+ variant="ghost"
312
+ className={cn(
313
+ "w-[24px] large:min-w-0 bg-transparent text-muted hover:bg-transparent hover:text-primary",
314
+ // Over the strip's faded edge, for ‹ too.
315
+ HIT_AREA_LARGE,
316
+ "large:after:z-[1]",
317
+ )}
318
+ tabIndex={-1}
319
+ type="button"
320
+ label={side === "start" ? text.previous : text.more}
321
+ labelVisible={false}
322
+ onClick={() => onCue(side)}
323
+ >
324
+ {side === "start" ? <ChevronLeft className="rtl:-scale-x-100" /> : <ChevronRight className="rtl:-scale-x-100" />}
325
+ </IconButton>
326
+ </div>
327
+ )}
300
328
  </div>
301
329
  );
302
330
  // The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a
@@ -590,13 +618,15 @@ export function Tabs<T extends string>({
590
618
  {text.remove(nameOf(activeTab))}
591
619
  </button>
592
620
  ) : null;
593
- // The positioned box the carets sit in, over the strip's two ends (see `overflowCue`).
594
- // There whether or not the strip overflows right now, so a resize never re-parents it.
621
+ // The box the cues sit in, ‹ in the gutter before the strip and › in the one after it
622
+ // (see `gutterAt`). There whether or not the strip overflows right now, so a resize
623
+ // never re-parents it; the gutters only while it overflows at all.
624
+ const gutters = cue && fade.overflow !== undefined;
595
625
  const stripBox = cue ? (
596
- <div className={cn("relative flex min-w-0", !onAdd && "flex-1")}>
626
+ <div className={cn("relative flex min-w-0", !onAdd && "flex-1", gutters && "px-[24px]")}>
597
627
  {tablist}
598
- {cueStart && cueAt("start")}
599
- {cueEnd && cueAt("end")}
628
+ {gutters && gutterAt("start", cueStart)}
629
+ {gutters && gutterAt("end", cueEnd)}
600
630
  </div>
601
631
  ) : (
602
632
  tablist
@@ -1,4 +1,5 @@
1
- import { useEffect, useMemo, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { SetStateAction } from "react";
2
3
  import { useLocation, useSearchParams } from "react-router";
3
4
  import { useKitTableStorageKeyPrefix } from "../i18n/kit-labels";
4
5
  import { readStored, writeStored } from "../lib/safe-storage";
@@ -24,6 +25,17 @@ interface PersistedState {
24
25
  pageSize: number | "all";
25
26
  widths?: Record<string, number>;
26
27
  hidden?: string[];
28
+ /** 0.48: the `defaultHidden` columns the user ticked back on — an entry that beats the
29
+ * default. Written only when non-empty, so a table without such columns stores what
30
+ * it always did. */
31
+ shown?: string[];
32
+ }
33
+
34
+ /** The hidden set as drawn: the stored one plus every `defaultHidden` column the view
35
+ * holds no entry for (neither hidden nor `shown`). */
36
+ function withDefaultHidden(view: { hidden: Set<string>; shown: Set<string> }, defaults: string[]): Set<string> {
37
+ const extra = defaults.filter((k) => !view.hidden.has(k) && !view.shown.has(k));
38
+ return extra.length === 0 ? view.hidden : new Set([...view.hidden, ...extra]);
27
39
  }
28
40
 
29
41
  export const DEFAULT_PERSIST_PREFIX = "hbui-table:";
@@ -338,7 +350,45 @@ export function useTableState<T>({
338
350
  );
339
351
  const pageSize = pageSizeProp ?? internalPageSize;
340
352
  const [widths, setWidths] = useState<Record<string, number>>(() => initial.widths ?? {});
341
- const [hiddenCols, setHiddenCols] = useState<Set<string>>(() => new Set(initial.hidden ?? []));
353
+ // Hidden columns (0.48, keksdose #617): what the user chose — `hidden`, and `shown`
354
+ // for a `defaultHidden` column ticked back on — kept apart from the defaults, which
355
+ // are applied on read and never stored. Keyed on the default keys as a string: every
356
+ // call site builds `columns` inline, so the array is new on every render.
357
+ const [view, setView] = useState(() => ({
358
+ hidden: new Set<string>(initial.hidden ?? []),
359
+ shown: new Set<string>(initial.shown ?? []),
360
+ }));
361
+ const defaultHiddenSig = columns
362
+ .filter((c) => c.defaultHidden)
363
+ .map((c) => c.key)
364
+ .join("\u0000");
365
+ const hiddenCols = useMemo(
366
+ () => withDefaultHidden(view, defaultHiddenSig ? defaultHiddenSig.split("\u0000") : []),
367
+ [view, defaultHiddenSig],
368
+ );
369
+ // The panel hands over the next DRAWN set; what changed is recorded as a choice.
370
+ const setHiddenCols = useCallback(
371
+ (action: SetStateAction<Set<string>>) =>
372
+ setView((v) => {
373
+ const defaults = defaultHiddenSig ? defaultHiddenSig.split("\u0000") : [];
374
+ const prev = withDefaultHidden(v, defaults);
375
+ const next = typeof action === "function" ? action(prev) : action;
376
+ const hidden = new Set(v.hidden);
377
+ const shown = new Set(v.shown);
378
+ for (const k of next) {
379
+ if (prev.has(k)) continue;
380
+ hidden.add(k);
381
+ shown.delete(k);
382
+ }
383
+ for (const k of prev) {
384
+ if (next.has(k)) continue;
385
+ hidden.delete(k);
386
+ if (defaults.includes(k)) shown.add(k);
387
+ }
388
+ return { hidden, shown };
389
+ }),
390
+ [defaultHiddenSig],
391
+ );
342
392
  const [showSettings, setShowSettings] = useState(false);
343
393
  const headRefs = useMemo(() => new Map<string, HTMLTableCellElement>(), []);
344
394
 
@@ -360,7 +410,8 @@ export function useTableState<T>({
360
410
  filters,
361
411
  pageSize: pageSize === Infinity ? "all" : pageSize,
362
412
  widths,
363
- hidden: Array.from(hiddenCols),
413
+ hidden: Array.from(view.hidden),
414
+ ...(view.shown.size > 0 ? { shown: Array.from(view.shown) } : {}),
364
415
  });
365
416
  useEffect(() => {
366
417
  if (!storageKey) return;
package/src/data-table.ts CHANGED
@@ -30,6 +30,8 @@ export type {
30
30
  // 0.46.1 (keksdose N3): a swipe plan as `RowActions`' actions — the desktop column's own
31
31
  // derivation, for an app's hand-built actions column.
32
32
  export { swipePlanRowActions } from "./components/data-table-row-actions";
33
+ // 0.48 (keksdose dev #605): a `groupBy` level (also `mobileGroupBy`'s) and the `loadMore` footer.
34
+ export type { DataTableGroupBy, DataTableLoadMore } from "./components/data-table-groups";
33
35
  export * from "./components/data-table-cells";
34
36
  export { useTableUrlState, readTableUrlState } from "./components/use-table-state";
35
37
  export type {