@eifi1/ui-kit 0.46.1 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/README.md +8 -6
  2. package/dist/admin/admin-sections.d.ts +8 -0
  3. package/dist/admin/admin-sections.js +2 -1
  4. package/dist/admin/admin-sections.js.map +1 -1
  5. package/dist/auth/verify-email.d.ts +9 -1
  6. package/dist/auth/verify-email.js +22 -13
  7. package/dist/auth/verify-email.js.map +1 -1
  8. package/dist/components/alert-banner.d.ts +29 -1
  9. package/dist/components/alert-banner.js +84 -7
  10. package/dist/components/alert-banner.js.map +1 -1
  11. package/dist/components/button-look.d.ts +4 -0
  12. package/dist/components/button-look.js +2 -0
  13. package/dist/components/button-look.js.map +1 -1
  14. package/dist/components/combobox-core.js +5 -1
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/data-table-column-settings.d.ts +6 -1
  17. package/dist/components/data-table-column-settings.js +12 -2
  18. package/dist/components/data-table-column-settings.js.map +1 -1
  19. package/dist/components/data-table-fit.d.ts +44 -0
  20. package/dist/components/data-table-fit.js +103 -0
  21. package/dist/components/data-table-fit.js.map +1 -0
  22. package/dist/components/data-table-groups.d.ts +95 -0
  23. package/dist/components/data-table-groups.js +165 -0
  24. package/dist/components/data-table-groups.js.map +1 -0
  25. package/dist/components/data-table-head.js +3 -1
  26. package/dist/components/data-table-head.js.map +1 -1
  27. package/dist/components/data-table-labels.d.ts +7 -0
  28. package/dist/components/data-table-labels.js +3 -0
  29. package/dist/components/data-table-labels.js.map +1 -1
  30. package/dist/components/data-table-leading-row.d.ts +17 -0
  31. package/dist/components/data-table-leading-row.js +41 -0
  32. package/dist/components/data-table-leading-row.js.map +1 -0
  33. package/dist/components/data-table-resize.js +1 -1
  34. package/dist/components/data-table-resize.js.map +1 -1
  35. package/dist/components/data-table.d.ts +78 -2
  36. package/dist/components/data-table.js +355 -247
  37. package/dist/components/data-table.js.map +1 -1
  38. package/dist/components/disclosure.d.ts +3 -1
  39. package/dist/components/disclosure.js +10 -4
  40. package/dist/components/disclosure.js.map +1 -1
  41. package/dist/components/dropdown.js +8 -1
  42. package/dist/components/dropdown.js.map +1 -1
  43. package/dist/components/floating-panel.d.ts +14 -1
  44. package/dist/components/floating-panel.js +7 -2
  45. package/dist/components/floating-panel.js.map +1 -1
  46. package/dist/components/hover-menu.js +5 -1
  47. package/dist/components/hover-menu.js.map +1 -1
  48. package/dist/components/icon-button.js +2 -1
  49. package/dist/components/icon-button.js.map +1 -1
  50. package/dist/components/popover.js +5 -1
  51. package/dist/components/popover.js.map +1 -1
  52. package/dist/components/row-actions.d.ts +25 -0
  53. package/dist/components/row-actions.js +13 -3
  54. package/dist/components/row-actions.js.map +1 -1
  55. package/dist/components/table-skeleton.d.ts +35 -0
  56. package/dist/components/table-skeleton.js +75 -0
  57. package/dist/components/table-skeleton.js.map +1 -0
  58. package/dist/components/tabs.d.ts +22 -16
  59. package/dist/components/tabs.js +47 -31
  60. package/dist/components/tabs.js.map +1 -1
  61. package/dist/components/ui-labels.js +1 -1
  62. package/dist/components/ui-labels.js.map +1 -1
  63. package/dist/components/use-table-state.d.ts +8 -7
  64. package/dist/components/use-table-state.js +37 -3
  65. package/dist/components/use-table-state.js.map +1 -1
  66. package/dist/data-table.d.ts +1 -0
  67. package/dist/data-table.js.map +1 -1
  68. package/dist/eslint/index.js +1 -1
  69. package/dist/feedback/feedback-attachment-download.d.ts +19 -0
  70. package/dist/feedback/feedback-attachment-download.js +59 -0
  71. package/dist/feedback/feedback-attachment-download.js.map +1 -0
  72. package/dist/feedback/feedback-comment-thread.d.ts +17 -6
  73. package/dist/feedback/feedback-comment-thread.js +21 -2
  74. package/dist/feedback/feedback-comment-thread.js.map +1 -1
  75. package/dist/feedback/feedback-entry-file.d.ts +61 -0
  76. package/dist/feedback/feedback-entry-file.js +76 -0
  77. package/dist/feedback/feedback-entry-file.js.map +1 -0
  78. package/dist/feedback/feedback-public-list.d.ts +18 -5
  79. package/dist/feedback/feedback-public-list.js +8 -0
  80. package/dist/feedback/feedback-public-list.js.map +1 -1
  81. package/dist/feedback/feedback-public-types.d.ts +7 -2
  82. package/dist/feedback/feedback-replies.d.ts +26 -9
  83. package/dist/feedback/feedback-replies.js +20 -10
  84. package/dist/feedback/feedback-replies.js.map +1 -1
  85. package/dist/feedback/feedback-row-detail-labels.js +0 -1
  86. package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
  87. package/dist/feedback/feedback-row-detail.d.ts +23 -10
  88. package/dist/feedback/feedback-row-detail.js +10 -54
  89. package/dist/feedback/feedback-row-detail.js.map +1 -1
  90. package/dist/feedback/feedback-table.d.ts +5 -0
  91. package/dist/feedback/feedback-table.js +30 -7
  92. package/dist/feedback/feedback-table.js.map +1 -1
  93. package/dist/feedback.d.ts +1 -0
  94. package/dist/feedback.js +1 -0
  95. package/dist/feedback.js.map +1 -1
  96. package/dist/hooks/use-dismiss.d.ts +30 -1
  97. package/dist/hooks/use-dismiss.js +30 -2
  98. package/dist/hooks/use-dismiss.js.map +1 -1
  99. package/dist/i18n/german.js +7 -7
  100. package/dist/i18n/german.js.map +1 -1
  101. package/dist/i18n/kit-labels.d.ts +4 -0
  102. package/dist/i18n/kit-labels.js.map +1 -1
  103. package/dist/i18n/locales/en.js +7 -7
  104. package/dist/i18n/locales/en.js.map +2 -2
  105. package/dist/i18n/locales/es.js +7 -7
  106. package/dist/i18n/locales/es.js.map +1 -1
  107. package/dist/i18n/locales/fr.js +7 -7
  108. package/dist/i18n/locales/fr.js.map +1 -1
  109. package/dist/i18n/locales/hu.js +7 -7
  110. package/dist/i18n/locales/hu.js.map +1 -1
  111. package/dist/i18n/locales/it.js +7 -7
  112. package/dist/i18n/locales/it.js.map +1 -1
  113. package/dist/i18n/locales/zh.js +7 -7
  114. package/dist/i18n/locales/zh.js.map +1 -1
  115. package/dist/i18n/review.js +0 -1
  116. package/dist/i18n/review.js.map +1 -1
  117. package/dist/i18n/shared-labels.js +3 -1
  118. package/dist/i18n/shared-labels.js.map +1 -1
  119. package/dist/index.d.ts +4 -4
  120. package/dist/index.js +13 -4
  121. package/dist/index.js.map +1 -1
  122. package/dist/lib/float-clearance.d.ts +8 -0
  123. package/dist/lib/float-clearance.js +29 -0
  124. package/dist/lib/float-clearance.js.map +1 -0
  125. package/dist/notifications/device-zone-report.d.ts +1 -1
  126. package/dist/notifications/device-zone-report.js.map +1 -1
  127. package/dist/notifications/notification-types.d.ts +0 -3
  128. package/dist/notifications/notifications-labels.d.ts +2 -15
  129. package/dist/notifications/notifications-labels.js +0 -4
  130. package/dist/notifications/notifications-labels.js.map +1 -1
  131. package/dist/notifications/quiet-hours-setting.d.ts +3 -8
  132. package/dist/notifications/quiet-hours-setting.js +8 -3
  133. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  134. package/dist/settings/settings-catalogue.d.ts +10 -0
  135. package/dist/settings/settings-catalogue.js.map +1 -1
  136. package/dist/settings/settings-layout.js +10 -3
  137. package/dist/settings/settings-layout.js.map +1 -1
  138. package/dist/shell/app-shell.d.ts +5 -0
  139. package/dist/shell/app-shell.js +5 -3
  140. package/dist/shell/app-shell.js.map +1 -1
  141. package/dist/shell/option-switcher-menu.d.ts +2 -0
  142. package/dist/shell/option-switcher-menu.js +6 -2
  143. package/dist/shell/option-switcher-menu.js.map +1 -1
  144. package/dist/shell/topbar-action-menu.js +2 -1
  145. package/dist/shell/topbar-action-menu.js.map +1 -1
  146. package/dist/shell/topbar-controls.js +5 -3
  147. package/dist/shell/topbar-controls.js.map +1 -1
  148. package/dist/theme/theme-store.d.ts +4 -4
  149. package/dist/theme/theme-store.js +1 -0
  150. package/dist/theme/theme-store.js.map +1 -1
  151. package/dist/tour/tour.js +1 -0
  152. package/dist/tour/tour.js.map +1 -1
  153. package/package.json +2 -2
  154. package/src/admin/admin-sections.tsx +8 -0
  155. package/src/auth/verify-email.tsx +34 -9
  156. package/src/components/alert-banner.tsx +131 -7
  157. package/src/components/button-look.ts +6 -0
  158. package/src/components/combobox-core.tsx +7 -1
  159. package/src/components/data-table-column-settings.tsx +23 -2
  160. package/src/components/data-table-fit.ts +187 -0
  161. package/src/components/data-table-groups.tsx +275 -0
  162. package/src/components/data-table-head.tsx +4 -1
  163. package/src/components/data-table-labels.ts +13 -0
  164. package/src/components/data-table-leading-row.tsx +55 -0
  165. package/src/components/data-table-resize.ts +2 -1
  166. package/src/components/data-table.tsx +274 -45
  167. package/src/components/disclosure.tsx +22 -5
  168. package/src/components/dropdown.tsx +11 -1
  169. package/src/components/floating-panel.tsx +24 -1
  170. package/src/components/hover-menu.tsx +6 -1
  171. package/src/components/icon-button.tsx +3 -3
  172. package/src/components/popover.tsx +7 -1
  173. package/src/components/row-actions.tsx +42 -3
  174. package/src/components/table-skeleton.tsx +133 -0
  175. package/src/components/tabs.tsx +102 -50
  176. package/src/components/ui-labels.ts +1 -1
  177. package/src/components/use-table-state.ts +54 -3
  178. package/src/data-table.ts +2 -0
  179. package/src/feedback/feedback-attachment-download.tsx +77 -0
  180. package/src/feedback/feedback-comment-thread.tsx +42 -7
  181. package/src/feedback/feedback-entry-file.tsx +150 -0
  182. package/src/feedback/feedback-public-list.tsx +25 -4
  183. package/src/feedback/feedback-public-types.ts +7 -2
  184. package/src/feedback/feedback-replies.tsx +46 -19
  185. package/src/feedback/feedback-row-detail-labels.ts +0 -1
  186. package/src/feedback/feedback-row-detail.tsx +35 -86
  187. package/src/feedback/feedback-table.tsx +33 -1
  188. package/src/feedback.ts +1 -0
  189. package/src/hooks/use-dismiss.ts +65 -3
  190. package/src/i18n/german.ts +6 -6
  191. package/src/i18n/kit-labels.tsx +4 -0
  192. package/src/i18n/locales/es.ts +6 -6
  193. package/src/i18n/locales/fr.ts +6 -6
  194. package/src/i18n/locales/hu.ts +6 -6
  195. package/src/i18n/locales/it.ts +6 -6
  196. package/src/i18n/locales/zh.ts +6 -6
  197. package/src/i18n/review.ts +0 -1
  198. package/src/i18n/shared-labels.ts +2 -0
  199. package/src/index.ts +13 -3
  200. package/src/lib/float-clearance.ts +54 -0
  201. package/src/notifications/device-zone-report.ts +1 -1
  202. package/src/notifications/notification-types.ts +0 -3
  203. package/src/notifications/notifications-labels.ts +2 -19
  204. package/src/notifications/quiet-hours-setting.tsx +16 -11
  205. package/src/settings/settings-catalogue.ts +10 -0
  206. package/src/settings/settings-layout.tsx +18 -4
  207. package/src/shell/app-shell.tsx +17 -3
  208. package/src/shell/option-switcher-menu.tsx +16 -4
  209. package/src/shell/topbar-action-menu.tsx +22 -10
  210. package/src/shell/topbar-controls.tsx +18 -12
  211. package/src/theme/theme-store.ts +13 -5
  212. package/src/tour/tour.tsx +5 -0
  213. package/dist/notifications/time-zone-select.d.ts +0 -54
  214. package/dist/notifications/time-zone-select.js +0 -71
  215. package/dist/notifications/time-zone-select.js.map +0 -1
  216. package/src/notifications/time-zone-select.tsx +0 -125
@@ -17,6 +17,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
@@ -13,6 +13,7 @@ import { useCommitReason, type CommitScope } from "./write-lock";
13
13
  import type { IconButtonGlyphSize, IconButtonSize } from "./icon-button";
14
14
  import type { TooltipSide } from "./tooltip";
15
15
  import type { DataTableColumn } from "./data-table";
16
+ import { TableHeaderCell, type TableHeaderCellProps } from "./table";
16
17
  import { DEFAULT_ROW_ACTIONS_LABELS } from "./row-actions-labels";
17
18
 
18
19
  /**
@@ -538,6 +539,43 @@ function RowActionsHeader({
538
539
  return hidden ? <span className="sr-only">{word}</span> : <>{word}</>;
539
540
  }
540
541
 
542
+ /** The actions cell's classes — `rowActionsColumn`'s `className`: the column hugs its
543
+ * buttons, on one line, at the row's end. A hand-built table's `<TableCell>` takes them
544
+ * around its {@link RowActions} (0.47, keksdose N17). */
545
+ export const ROW_ACTIONS_CELL_CLASS = "w-px whitespace-nowrap text-end";
546
+ /** The actions heading's classes — `rowActionsColumn`'s `headClassName`, which
547
+ * {@link RowActionsHead} draws: hugging, end-aligned over the buttons (0.47, N17). */
548
+ export const ROW_ACTIONS_HEAD_CLASS = "w-px text-end";
549
+
550
+ export interface RowActionsHeadProps extends Omit<TableHeaderCellProps, "children"> {
551
+ /** The heading. Default: the provider's `rowActions.actions` ("Actions"). */
552
+ children?: ReactNode;
553
+ /** Named for a screen reader only — `rowActionsColumn`'s `header: false`. */
554
+ hideLabel?: boolean;
555
+ labels?: LabelOverride<RowActionsLabels>;
556
+ }
557
+
558
+ /**
559
+ * A hand-built table's actions heading (0.47, keksdose N17): the `<th>` `rowActionsColumn`
560
+ * gives a DataTable — "Actions" on screen in the provider's words, end-aligned over the
561
+ * buttons, hugging them. Pair it with `<TableCell className={ROW_ACTIONS_CELL_CLASS}>`
562
+ * around each row's {@link RowActions}. Every other `TableHeaderCell` prop passes on
563
+ * (`size`, the app's `className`, merged last).
564
+ *
565
+ * <RowActionsHead size="sm" className={TH} />
566
+ */
567
+ export function RowActionsHead({ children, hideLabel, labels, className, ...rest }: RowActionsHeadProps) {
568
+ return (
569
+ <TableHeaderCell align="end" {...rest} className={cn(ROW_ACTIONS_HEAD_CLASS, className)}>
570
+ {typeof children === "string" || children == null ? (
571
+ <RowActionsHeader text={children ?? undefined} labels={labels} hidden={hideLabel} />
572
+ ) : (
573
+ children
574
+ )}
575
+ </TableHeaderCell>
576
+ );
577
+ }
578
+
541
579
  export interface RowActionsColumnOptions<T> {
542
580
  /** The column's key. Default `"actions"`. */
543
581
  key?: string;
@@ -617,9 +655,10 @@ export function rowActionsColumn<T>({
617
655
  labels={labels}
618
656
  />
619
657
  ),
620
- className: "w-px whitespace-nowrap text-end",
621
- // The heading over the buttons, at the row's end (0.46.1).
622
- headClassName: "w-px text-end",
658
+ className: ROW_ACTIONS_CELL_CLASS,
659
+ // The heading over the buttons, at the row's end (0.46.1); a hand-built table's
660
+ // `RowActionsHead` (0.47).
661
+ headClassName: ROW_ACTIONS_HEAD_CLASS,
623
662
  noRowLink: true,
624
663
  ...column,
625
664
  };
@@ -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,10 +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` caret while tabs lie past the strip's end: "More tabs". */
52
+ /** The `overflowCue` › after the strip's end, while tabs lie past it: "More tabs". */
52
53
  more: string;
53
- /** The `overflowCue` caret at the strip's end, which goes back to the first tab. */
54
- backToFirst: string;
54
+ /** The `overflowCue` ‹ before the strip's start, while tabs lie before it: "Previous tabs". */
55
+ previous: string;
55
56
  }
56
57
 
57
58
  // Defined in ./ui-labels, which holds no component code (0.34, KS U8).
@@ -78,20 +79,26 @@ export interface TabsProps<T extends string>
78
79
  onChange: (id: T) => void;
79
80
  className?: string;
80
81
  /**
81
- * The caret beside a SCROLLING strip (0.40, kk run 79 gap 1): a › while
82
- * tabs lie past the strip's end — a tap scrolls it on by three quarters of its width —
83
- * turning into a ‹ at the end, which goes back to the first. Shown only while the strip
84
- * actually overflows.
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
84
+ * its start while tabs lie before it — each there only while something is hidden
85
+ * past ITS edge (1 px slack, so a few cut-off pixels count), each a step of three
86
+ * quarters of the strip's width toward its own side. Neither ever changes direction:
87
+ * until 0.46 one caret turned round into "back to the first" at the end, and read as
88
+ * an arrow pointing the wrong way. Start and end follow the reading direction.
85
89
  *
86
- * The edge fade alone could read as the end of the row: at the default scroll position
87
- * the last visible tab can finish exactly where the fade begins, so nothing looks cut
88
- * off (keksdose live #410, whose `tab-strip-more.tsx` this replaces). One button that
89
- * turns round rather than two that come and go, so the strip's width never changes
90
- * under the thumb. Out of the tab order — the keyboard walks the strip with the arrow
91
- * keys, which bring the focused tab into view. At every width since 0.46 (keksdose live
92
- * #410 round 3: eleven report tabs overflow a laptop too); before, `md:hidden`. Ignored on
93
- * a vertical strip (a side nav is a column, not a row). Names:
94
- * `tabs.more` / `tabs.backToFirst`.
90
+ * The edge fade alone could read as the end of the row: the last visible tab can
91
+ * finish exactly where the fade begins, so nothing looks cut off (keksdose live #410).
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 —
98
+ * the keyboard walks the strip with the arrow keys, which bring the focused tab into
99
+ * view. At every width. Ignored on a vertical strip (a side nav is a column, not a
100
+ * row). Names: `tabs.more` / `tabs.previous`. keksdose's `scroll-edge-cues.tsx` is
101
+ * the reference this replaces.
95
102
  */
96
103
  overflowCue?: boolean;
97
104
  /**
@@ -254,36 +261,72 @@ export function Tabs<T extends string>({
254
261
  const stripRef = useRef<HTMLDivElement>(null);
255
262
  const setStrip = useMemo(() => mergeRefs(stripRef, ref), [ref]);
256
263
  const fade = useStripFade(stripRef);
257
- // See `overflowCue`. `start` alone means the strip is scrolled to its end.
258
- const cue = overflowCue && !vertical && fade.overflow !== undefined;
259
- const cueAtEnd = fade.overflow === "start";
260
- const onCue = () => {
264
+ // See `overflowCue`: a ‹ before the strip while tabs lie before it, a › after it
265
+ // while tabs lie past it — `fade.overflow` names the cut ends in reading-direction terms.
266
+ const cue = overflowCue && !vertical;
267
+ const cueStart = cue && (fade.overflow === "start" || fade.overflow === "both");
268
+ const cueEnd = cue && (fade.overflow === "end" || fade.overflow === "both");
269
+ // Three quarters of the visible width toward `side`; an RTL strip runs leftward.
270
+ const onCue = (side: "start" | "end") => {
261
271
  const strip = stripRef.current;
262
272
  if (!strip) return;
263
273
  const behavior: ScrollBehavior = prefersReducedMotion() ? "auto" : "smooth";
264
- if (cueAtEnd) {
265
- strip.scrollTo({ left: 0, behavior });
266
- return;
267
- }
268
274
  const rtl = getComputedStyle(strip).direction === "rtl";
269
- strip.scrollBy({ left: (rtl ? -1 : 1) * Math.round(strip.clientWidth * 0.75), behavior });
275
+ const toward = (side === "end" ? 1 : -1) * (rtl ? -1 : 1);
276
+ strip.scrollBy({ left: toward * Math.round(strip.clientWidth * 0.75), behavior });
270
277
  };
271
- const cueButton = cue ? (
272
- // The strip's bottom rule continues under the caret, so the row reads as one.
273
- <div data-tabs-overflow-cue="" className="flex shrink-0 items-center self-stretch border-b border-subtle">
274
- <IconButton
275
- size="sm"
276
- variant="ghost"
277
- tone="muted"
278
- tabIndex={-1}
279
- type="button"
280
- aria-label={cueAtEnd ? text.backToFirst : text.more}
281
- onClick={onCue}
282
- >
283
- {cueAtEnd ? <ChevronLeft className="rtl:-scale-x-100" /> : <ChevronRight className="rtl:-scale-x-100" />}
284
- </IconButton>
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) => (
300
+ <div
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
+ )}
306
+ >
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
+ )}
285
328
  </div>
286
- ) : null;
329
+ );
287
330
  // The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a
288
331
  // deep link to the seventh report. A selection behind the cut edge was the worst
289
332
  // case of the overflow above: the one tab the panel below belongs to, sliced to
@@ -407,7 +450,7 @@ export function Tabs<T extends string>({
407
450
  // — it is the box a caller's margin or width is meant for.
408
451
  className={cn(
409
452
  vertical ? TABLIST_VERTICAL_CLASSES : TABLIST_CLASSES,
410
- onAdd ? "min-w-0" : overflowCue && !vertical ? "min-w-0 flex-1" : className,
453
+ cue ? "min-w-0 flex-1" : onAdd ? "min-w-0" : className,
411
454
  )}
412
455
  >
413
456
  {tabs.map((tab) => {
@@ -575,16 +618,26 @@ export function Tabs<T extends string>({
575
618
  {text.remove(nameOf(activeTab))}
576
619
  </button>
577
620
  ) : null;
578
- if (!onAdd && overflowCue && !vertical) {
579
- // The caret needs a row to sit in beside the strip, and `className` goes there — it
580
- // is the box a caller's margin or width is meant for, as with an add button. The box
581
- // is there whether or not the strip overflows right now, so a resize never moves
582
- // the caller's class from one element to another.
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;
625
+ const stripBox = cue ? (
626
+ <div className={cn("relative flex min-w-0", !onAdd && "flex-1", gutters && "px-[24px]")}>
627
+ {tablist}
628
+ {gutters && gutterAt("start", cueStart)}
629
+ {gutters && gutterAt("end", cueEnd)}
630
+ </div>
631
+ ) : (
632
+ tablist
633
+ );
634
+ if (!onAdd && cue) {
635
+ // `className` goes on the row around the strip — it is the box a caller's margin or
636
+ // width is meant for, as with an add button — and stays there at every scroll position.
583
637
  return (
584
638
  <div className={cn("flex items-end", className)}>
585
- {tablist}
639
+ {stripBox}
586
640
  {removeButton}
587
- {cueButton}
588
641
  </div>
589
642
  );
590
643
  }
@@ -614,9 +667,8 @@ export function Tabs<T extends string>({
614
667
  }
615
668
  return (
616
669
  <div className={cn("flex items-end", className)}>
617
- {tablist}
670
+ {stripBox}
618
671
  {removeButton}
619
- {cueButton}
620
672
  {/* Outside the tablist: an action, not a tab — and a tablist may own only
621
673
  tabs. The rule under the strip carries on beneath it. */}
622
674
  <div
@@ -20,5 +20,5 @@ export const DEFAULT_TABS_LABELS: TabsLabels = {
20
20
  add: "Add tab",
21
21
  remove: (tab) => `Remove ${tab}`,
22
22
  more: "More tabs",
23
- backToFirst: "Back to the first tab",
23
+ previous: "Previous tabs",
24
24
  };