@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
@@ -7,6 +7,7 @@ import { EMPTY_FORMATTED_VALUE, formatDate, toDate } from "../lib/format";
7
7
  import { useKitDateFormatter, useKitLabels, useKitLink, useKitLocale, type LabelOverride } from "../i18n/kit-labels";
8
8
  import type { KitLinkProps } from "../i18n/kit-labels";
9
9
  import { Chip } from "../components/chip";
10
+ import { Tooltip } from "../components/tooltip";
10
11
  import { FloatingAction, FloatingActionGroup } from "../components/floating-panel";
11
12
  import type { FloatingCorner } from "../components/floating-panel";
12
13
  import { pickLinkRenderer } from "../components/text-link";
@@ -449,6 +450,11 @@ function statusRank(status: FeedbackStatus): number {
449
450
  * filter that never offered it.
450
451
  * - The status column owns the status filter, and only it (keksdose live #330's revert:
451
452
  * the phone's filter sheet already exposes every column filter).
453
+ * - Fitted to the table's width (0.48, keksdose live #451): Subject `grow`s and
454
+ * ellipsises, and a narrow table leaves out URL, then Email, then Resolved
455
+ * (`fitPriority` 1–3) — the open report repeats all three — then Votes and Category
456
+ * (4, 5) before Subject would drop below 10rem. Drop the two keys with
457
+ * `{ ...col, grow: undefined, fitPriority: undefined }` for the 0.47 layout.
452
458
  * - `/my-feedback` shows the same nine: the Email cell's context fallback exists for it
453
459
  * (the admin list's join is absent there, §3.1), and its phone card drops the submitter
454
460
  * instead ({@link FeedbackMobileCardProps.showSubmitter}).
@@ -479,6 +485,9 @@ export function feedbackColumns<T extends FeedbackTableRow>({
479
485
  sortBy: (row) => row.votes ?? 0,
480
486
  filter: { type: "number", getValue: (row) => row.votes ?? 0 },
481
487
  mobileHidden: true,
488
+ // Fourth to go (Kurvenschmiede S1, 0.48): with only the first three, Subject was
489
+ // crushed to 24px at 1100px. Still reachable from the Columns panel.
490
+ fitPriority: 4,
482
491
  },
483
492
  ]
484
493
  : [];
@@ -508,6 +517,9 @@ export function feedbackColumns<T extends FeedbackTableRow>({
508
517
  getValue: (row) => row.category,
509
518
  options: FEEDBACK_CATEGORY_ORDER.map((value) => ({ value, label: categoryLabels[value] })),
510
519
  },
520
+ // Last to go (Kurvenschmiede S1, 0.48), before Subject would drop below 10rem:
521
+ // the open report's Subject section repeats the badge.
522
+ fitPriority: 5,
511
523
  },
512
524
  {
513
525
  key: "title",
@@ -521,11 +533,25 @@ export function feedbackColumns<T extends FeedbackTableRow>({
521
533
  return env ? `${row.title} ${labels.environment(env)}` : row.title;
522
534
  },
523
535
  mobilePrimary: true,
536
+ // The rest of the row, one line and an ellipsis (0.48, keksdose live #451: one long
537
+ // crash title pushed Status and the row actions off the edge).
538
+ grow: true,
524
539
  },
525
540
  {
526
541
  key: "user",
527
542
  header: labels.columnUser,
528
- cell: (row) => <span className="text-secondary">{feedbackSubmitter(row, labels)}</span>,
543
+ // At most 12rem, one line and an ellipsis, the whole value in the kit's Tooltip (0.48 build
544
+ // review 2, keksdose N2): beside the grow Subject every other cell keeps one line, so
545
+ // an email standing in for a missing name widened the column and cost Votes and
546
+ // Category their room first. The open report shows both in full.
547
+ cell: (row) => {
548
+ const who = feedbackSubmitter(row, labels);
549
+ return (
550
+ <Tooltip label={who} portal lazy>
551
+ <span className="block max-w-[12rem] truncate-until-large text-secondary">{who}</span>
552
+ </Tooltip>
553
+ );
554
+ },
529
555
  sortBy: (row) => feedbackSubmitter(row, labels),
530
556
  // Name AND email, so either finds the person; the erased ones by the placeholder
531
557
  // (their context holds no name any more).
@@ -552,6 +578,8 @@ export function feedbackColumns<T extends FeedbackTableRow>({
552
578
  sortBy: feedbackSubmitterEmail,
553
579
  filterBy: feedbackSubmitterEmail,
554
580
  mobileHidden: true,
581
+ // Second to go when the table is narrow: the open report shows the submitter.
582
+ fitPriority: 2,
555
583
  },
556
584
  {
557
585
  key: "url",
@@ -566,6 +594,8 @@ export function feedbackColumns<T extends FeedbackTableRow>({
566
594
  sortBy: (row) => feedbackPagePath(row.context) ?? "",
567
595
  filterBy: (row) => feedbackPagePath(row.context) ?? "",
568
596
  mobileHidden: true,
597
+ // First to go when the table is narrow (0.48): the open report repeats the page.
598
+ fitPriority: 1,
569
599
  },
570
600
  ...votesColumn,
571
601
  {
@@ -600,6 +630,8 @@ export function feedbackColumns<T extends FeedbackTableRow>({
600
630
  sortBy: (row) => instant(row.resolved_at),
601
631
  filter: { type: "date", getValue: (row) => row.resolved_at },
602
632
  mobileHidden: true,
633
+ // Third to go (0.48).
634
+ fitPriority: 3,
603
635
  },
604
636
  ];
605
637
  }
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef } from "react";
1
+ import { useCallback, useEffect, useRef } from "react";
2
2
  import type { KeyboardEvent as ReactKeyboardEvent, RefObject } from "react";
3
3
  import { isComposing } from "../lib/ime";
4
4
 
@@ -220,6 +220,29 @@ export function useEscapeKey(handler: () => void, enabled = true): void {
220
220
  useDismissLayer(enabled, handler);
221
221
  }
222
222
 
223
+ /** Called with each native press an owner's React tree saw — see {@link useOutsideClick}. */
224
+ type PressMark = (press: Event) => void;
225
+
226
+ /** The outside-click hooks watching each ref, for {@link pressInside}. */
227
+ const watchers = new WeakMap<RefObject<HTMLElement | null>, Set<PressMark>>();
228
+
229
+ /** What an `onPointerDownCapture` hands over: a React event, its press inside. */
230
+ type ReactPress = { nativeEvent: Event };
231
+
232
+ /**
233
+ * An `onPointerDownCapture` for a panel whose `ref` an outside-click hook watches but
234
+ * whose element the hook's owner does not render — {@link DropdownPanel} and
235
+ * `ComboboxPanel` take `panelRef` from `useDropdown` / `useComboboxCore`. Every press
236
+ * that reaches the panel through the React tree, a portalled list opened inside it
237
+ * included, is marked inside for each hook watching `ref`. See {@link useOutsideClick}.
238
+ */
239
+ export function pressInside(ref: RefObject<HTMLElement | null> | undefined): ((e: ReactPress) => void) | undefined {
240
+ if (!ref) return undefined;
241
+ return (e) => {
242
+ watchers.get(ref)?.forEach((mark) => mark(e.nativeEvent));
243
+ };
244
+ }
245
+
223
246
  /**
224
247
  * Calls `handler` on a press that lands outside every supplied element, while
225
248
  * `enabled` — the outside-click-to-close half of the same dismissal story. Pass one
@@ -227,6 +250,23 @@ export function useEscapeKey(handler: () => void, enabled = true): void {
227
250
  * `handler` are read live, so an inline `[triggerRef, panelRef]` array is fine and
228
251
  * won't churn the listener.
229
252
  *
253
+ * ## A panel opened from inside is inside (0.48, keksdose dev#623)
254
+ *
255
+ * A CurrencySelect in a Popover: the currency list is portalled to `<body>`, so in the
256
+ * DOM it is outside the popover's panel, and the first press on a currency closed the
257
+ * POPOVER at pointerdown — the list went with it and the choice never landed. The same
258
+ * for a combobox, a menu, a date picker's popover, a dropdown, at any depth.
259
+ *
260
+ * In the React tree the portal is still where it was opened from, and React passes a
261
+ * press up that tree, through portals. So the hook hands back an
262
+ * `onPointerDownCapture`: put it on the element the press should count as inside of —
263
+ * the kit's Popover panel, HoverMenu, DropdownPanel and ComboboxPanel do — and every
264
+ * press that travels through that element in React, portalled panels opened inside it
265
+ * included, is not outside. React's listeners sit on the root and on each portal's
266
+ * container, so they hear the press before this document listener does. No owner chain
267
+ * to keep: depth is free, and an app's own portal inside a kit panel is covered too. A
268
+ * panel the hook's owner does not render takes {@link pressInside} with the watched ref.
269
+ *
230
270
  * **`pointerdown`, not `mousedown`** — one event covering mouse, touch and pen.
231
271
  * This listened for `mousedown` alone, which on a touch platform is a SYNTHESISED
232
272
  * event: the browser emits it for most taps, which is why nothing obviously broke,
@@ -245,17 +285,35 @@ export function useOutsideClick(
245
285
  refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
246
286
  handler: () => void,
247
287
  enabled = true,
248
- ): void {
288
+ ): (e: ReactPress) => void {
249
289
  const refsRef = useRef(refs);
250
290
  const handlerRef = useRef(handler);
291
+ // The last press this owner's React tree saw: the one the document listener is about
292
+ // to hear, when it went through a panel the owner marked.
293
+ const insideRef = useRef<Event | null>(null);
251
294
  // In an effect rather than during render — see the note in useDismissLayer.
252
295
  useEffect(() => {
253
296
  refsRef.current = refs;
254
297
  handlerRef.current = handler;
255
298
  });
299
+ const markInside = useCallback((e: ReactPress) => {
300
+ insideRef.current = e.nativeEvent;
301
+ }, []);
256
302
  useEffect(() => {
257
303
  if (!enabled) return;
304
+ const mark: PressMark = (press) => {
305
+ insideRef.current = press;
306
+ };
307
+ const watched = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];
308
+ for (const r of watched) {
309
+ const set = watchers.get(r) ?? new Set<PressMark>();
310
+ set.add(mark);
311
+ watchers.set(r, set);
312
+ }
258
313
  const onPointerDown = (e: Event) => {
314
+ const inside = insideRef.current === e;
315
+ insideRef.current = null;
316
+ if (inside) return;
259
317
  const target = e.target as Node;
260
318
  const list = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];
261
319
  for (const r of list) {
@@ -264,6 +322,10 @@ export function useOutsideClick(
264
322
  handlerRef.current();
265
323
  };
266
324
  document.addEventListener("pointerdown", onPointerDown);
267
- return () => document.removeEventListener("pointerdown", onPointerDown);
325
+ return () => {
326
+ document.removeEventListener("pointerdown", onPointerDown);
327
+ for (const r of watched) watchers.get(r)?.delete(mark);
328
+ };
268
329
  }, [enabled]);
330
+ return markInside;
269
331
  }
@@ -339,6 +339,8 @@ export function germanLabels(numberLocale: string): UiKitLabels {
339
339
  opensInNewTab: "öffnet in einem neuen Tab",
340
340
  aboutField: (field) => `Hilfe zu „${field}“`,
341
341
  help: "Hilfe",
342
+ showMore: "Mehr",
343
+ showLess: "Weniger",
342
344
  },
343
345
  dataTable: {
344
346
  columns: "Spalten",
@@ -351,6 +353,7 @@ export function germanLabels(numberLocale: string): UiKitLabels {
351
353
  cancelSelection: "Abbrechen",
352
354
  rowDetails: "Details",
353
355
  autoSize: "Spaltenbreiten anpassen",
356
+ hiddenForWidth: "aus Platzgründen ausgeblendet",
354
357
  loading: "Wird geladen…",
355
358
  filters: "Filter",
356
359
  // Resets every active filter; "Alle löschen" would read as deleting the filters.
@@ -402,6 +405,8 @@ export function germanLabels(numberLocale: string): UiKitLabels {
402
405
  booleanTrue: "Ja",
403
406
  booleanFalse: "Nein",
404
407
  booleanUnset: "Nicht festgelegt",
408
+ loadMore: "Mehr laden",
409
+ allLoaded: "Alle Zeilen geladen",
405
410
  sortBy: "Sortieren nach",
406
411
  sortDefault: "Standardreihenfolge",
407
412
  sortAscending: "Aufsteigend",
@@ -189,6 +189,10 @@ export interface CommonLabels {
189
189
  aboutField: (field: string) => string;
190
190
  /** …and of a `help` "?" on a field with no label and no `aria-label` to name it by. */
191
191
  help: string;
192
+ /** 0.48: the toggle that shows the rest of a clamped text (`AlertBanner clampMobile`)… */
193
+ showMore: string;
194
+ /** …and folds it back to its one line. */
195
+ showLess: string;
192
196
  }
193
197
 
194
198
  /** `DatePicker` / `DateRangePicker` chrome. The calendar inside has its own
@@ -330,6 +330,8 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
330
330
  opensInNewTab: "se abre en una pestaña nueva",
331
331
  aboutField: (field) => `Acerca de «${field}»`,
332
332
  help: "Ayuda",
333
+ showMore: "Más",
334
+ showLess: "Menos",
333
335
  },
334
336
  dataTable: {
335
337
  columns: "Columnas",
@@ -342,6 +344,7 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
342
344
  cancelSelection: "Cancelar",
343
345
  rowDetails: "Detalles",
344
346
  autoSize: "Ajustar ancho de columnas",
347
+ hiddenForWidth: "oculta por falta de espacio",
345
348
  loading: "Cargando…",
346
349
  filters: "Filtros",
347
350
  clearAll: "Borrar todo",
@@ -391,6 +394,8 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
391
394
  booleanTrue: "Sí",
392
395
  booleanFalse: "No",
393
396
  booleanUnset: "Sin definir",
397
+ loadMore: "Cargar más",
398
+ allLoaded: "Todas las filas cargadas",
394
399
  sortBy: "Ordenar por",
395
400
  sortDefault: "Orden predeterminado",
396
401
  sortAscending: "Ascendente",
@@ -336,6 +336,8 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
336
336
  opensInNewTab: "s’ouvre dans un nouvel onglet",
337
337
  aboutField: (field) => `À propos de «\u202f${field}\u202f»`,
338
338
  help: "Aide",
339
+ showMore: "Plus",
340
+ showLess: "Moins",
339
341
  },
340
342
  dataTable: {
341
343
  columns: "Colonnes",
@@ -348,6 +350,7 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
348
350
  cancelSelection: "Annuler",
349
351
  rowDetails: "Détails",
350
352
  autoSize: "Ajuster la largeur des colonnes",
353
+ hiddenForWidth: "masquée faute de place",
351
354
  loading: "Chargement…",
352
355
  filters: "Filtres",
353
356
  clearAll: "Tout effacer",
@@ -397,6 +400,8 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
397
400
  booleanTrue: "Oui",
398
401
  booleanFalse: "Non",
399
402
  booleanUnset: "Non défini",
403
+ loadMore: "Charger plus",
404
+ allLoaded: "Toutes les lignes sont chargées",
400
405
  sortBy: "Trier par",
401
406
  sortDefault: "Ordre par défaut",
402
407
  sortAscending: "Croissant",
@@ -328,6 +328,8 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
328
328
  opensInNewTab: "új lapon nyílik meg",
329
329
  aboutField: (field) => `Súgó: ${field}`,
330
330
  help: "Súgó",
331
+ showMore: "Több",
332
+ showLess: "Kevesebb",
331
333
  },
332
334
  dataTable: {
333
335
  columns: "Oszlopok",
@@ -340,6 +342,7 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
340
342
  cancelSelection: "Mégse",
341
343
  rowDetails: "Részletek",
342
344
  autoSize: "Oszlopszélességek igazítása",
345
+ hiddenForWidth: "szélesség miatt rejtve",
343
346
  loading: "Betöltés…",
344
347
  filters: "Szűrők",
345
348
  clearAll: "Összes törlése",
@@ -389,6 +392,8 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
389
392
  booleanTrue: "Igen",
390
393
  booleanFalse: "Nem",
391
394
  booleanUnset: "Nincs megadva",
395
+ loadMore: "Továbbiak betöltése",
396
+ allLoaded: "Minden sor betöltve",
392
397
  sortBy: "Rendezés alapja",
393
398
  sortDefault: "Alapértelmezett sorrend",
394
399
  sortAscending: "Növekvő",
@@ -332,6 +332,8 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
332
332
  opensInNewTab: "si apre in una nuova scheda",
333
333
  aboutField: (field) => `Informazioni su «${field}»`,
334
334
  help: "Aiuto",
335
+ showMore: "Altro",
336
+ showLess: "Meno",
335
337
  },
336
338
  dataTable: {
337
339
  columns: "Colonne",
@@ -344,6 +346,7 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
344
346
  cancelSelection: "Annulla",
345
347
  rowDetails: "Dettagli",
346
348
  autoSize: "Adatta larghezza colonne",
349
+ hiddenForWidth: "nascosta per mancanza di spazio",
347
350
  loading: "Caricamento…",
348
351
  filters: "Filtri",
349
352
  clearAll: "Cancella tutto",
@@ -393,6 +396,8 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
393
396
  booleanTrue: "Sì",
394
397
  booleanFalse: "No",
395
398
  booleanUnset: "Non impostato",
399
+ loadMore: "Carica altro",
400
+ allLoaded: "Tutte le righe caricate",
396
401
  sortBy: "Ordina per",
397
402
  sortDefault: "Ordine predefinito",
398
403
  sortAscending: "Crescente",
@@ -320,6 +320,8 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
320
320
  opensInNewTab: "在新标签页中打开",
321
321
  aboutField: (field) => `关于“${field}”`,
322
322
  help: "帮助",
323
+ showMore: "更多",
324
+ showLess: "收起",
323
325
  },
324
326
  dataTable: {
325
327
  columns: "列",
@@ -332,6 +334,7 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
332
334
  cancelSelection: "取消",
333
335
  rowDetails: "详情",
334
336
  autoSize: "自动调整列宽",
337
+ hiddenForWidth: "宽度不足,已隐藏",
335
338
  loading: "加载中…",
336
339
  filters: "筛选条件",
337
340
  clearAll: "全部清除",
@@ -380,6 +383,8 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
380
383
  booleanTrue: "是",
381
384
  booleanFalse: "否",
382
385
  booleanUnset: "未设置",
386
+ loadMore: "加载更多",
387
+ allLoaded: "已加载全部行",
383
388
  sortBy: "排序依据",
384
389
  sortDefault: "默认顺序",
385
390
  sortAscending: "升序",
@@ -39,6 +39,8 @@ export const DEFAULT_COMMON_LABELS: CommonLabels = {
39
39
  opensInNewTab: "opens in a new tab",
40
40
  aboutField: (field) => `About “${field}”`,
41
41
  help: "Help",
42
+ showMore: "More",
43
+ showLess: "Less",
42
44
  };
43
45
 
44
46
  export const DEFAULT_DATE_PICKER_LABELS: DatePickerLabels = {
package/src/index.ts CHANGED
@@ -430,6 +430,9 @@ export * from "./components/progress-bar";
430
430
  // Named: skeleton.tsx also holds SKELETON_CLASS, the look StatTile shares — internal.
431
431
  export { Skeleton } from "./components/skeleton";
432
432
  export type { SkeletonProps, SkeletonShape } from "./components/skeleton";
433
+ // 0.48 (keksdose dev #628): the table/list loading preset, one status label.
434
+ export { TableSkeleton } from "./components/table-skeleton";
435
+ export type { TableSkeletonProps } from "./components/table-skeleton";
433
436
  // 0.12.0: a centred spinner with words, signed figures and deltas, a render-error fallback.
434
437
  export * from "./components/loading-state";
435
438
  export * from "./components/signed-amount";
@@ -0,0 +1,54 @@
1
+ import { useEffect } from "react";
2
+
3
+ /**
4
+ * The room the floating launchers take above the bottom nav, published as
5
+ * `--app-float-clearance` on `<html>` so `AppShell` can pad `<main>` past them.
6
+ *
7
+ * keksdose dev#612 (UX-1.3, "the help button covers content"): the shell padded `<main>`
8
+ * for the nav plus 1.5rem, so on a phone the last row of every page ended under the help
9
+ * launcher — and on /transactions under the action group below it, exactly where the
10
+ * row's amount is. keksdose patched it with its own `--help-launcher-clearance` and an
11
+ * unlayered app.css rule. Here every kit launcher (`FloatingActionButton`, so
12
+ * `FloatingPanel`'s too, and `FloatingActionGroup`) declares its own room while it is
13
+ * mounted and shown, and the shell takes the largest.
14
+ *
15
+ * Declared, not measured: a launcher knows its `offset` and its 3rem height exactly, as
16
+ * CSS lengths, and the variable then follows the nav and the text size by itself. A
17
+ * `hidden` launcher keeps its claim: the room is its own while it is mounted, so the page
18
+ * does not change length as it hides and shows (keksdose's launcher steps aside while an
19
+ * editor covers its corner). Unmount it to give the room back.
20
+ *
21
+ * Internal: not exported from the barrel.
22
+ */
23
+
24
+ const claims = new Map<symbol, string>();
25
+
26
+ function publish() {
27
+ if (typeof document === "undefined") return;
28
+ const root = document.documentElement;
29
+ const values = [...claims.values()];
30
+ if (values.length === 0) root.style.removeProperty("--app-float-clearance");
31
+ else root.style.setProperty("--app-float-clearance", values.length === 1 ? values[0] : `max(${values.join(", ")})`);
32
+ }
33
+
34
+ /** Claim `value` (a CSS length: the launcher's top edge above the nav, plus a gap) until
35
+ * the returned function is called. */
36
+ export function claimFloatClearance(value: string): () => void {
37
+ const key = Symbol("float-clearance");
38
+ claims.set(key, value);
39
+ publish();
40
+ return () => {
41
+ claims.delete(key);
42
+ publish();
43
+ };
44
+ }
45
+
46
+ /**
47
+ * A launcher at `offset` above the nav claims its 3rem height plus a 0.5rem gap while
48
+ * mounted (`extra`: what its frame adds, the group's 1px borders).
49
+ */
50
+ export function useFloatClearance(offset: string, extra?: string): void {
51
+ useEffect(() => {
52
+ return claimFloatClearance(`calc(${offset} + 3.5rem${extra ? ` + ${extra}` : ""})`);
53
+ }, [offset, extra]);
54
+ }
@@ -23,6 +23,16 @@ export interface SettingsGroup<G extends string = string> {
23
23
  title: string;
24
24
  /** One line under the heading and in the phone list, where it is cut to one line. */
25
25
  help: string;
26
+ /**
27
+ * 0.48 (keksdose live #450): a count or a mark drawn on the group's ROW — at the end of
28
+ * its desktop sidebar row and before the chevron of its phone list row — never on the
29
+ * heading over its cards. The admin area's waiting work: `<AdminWaitingBadge count={n} />`
30
+ * (nothing for 0), so the account menu's badge leads to the section that holds it. It
31
+ * sits inside the row's link and is read with its name, so say what the number is (the
32
+ * waiting pill does: "3, 3 waiting"). While the page is searched the sidebar shows the
33
+ * hit count in its place. Default: none.
34
+ */
35
+ badge?: ReactNode;
26
36
  /**
27
37
  * `false` hides the group: no row, no hits, and its path acts as an unknown one — it
28
38
  * lands on the default group (desktop) or the list (phone), never on a 404 (§3.1).
@@ -369,7 +369,17 @@ export function SettingsLayout<G extends string>({
369
369
  }
370
370
  title={g.title}
371
371
  subtitle={g.help}
372
- trailing={<ChevronRight aria-hidden className="size-4 rtl:-scale-x-100" />}
372
+ trailing={
373
+ g.badge == null ? (
374
+ <ChevronRight aria-hidden className="size-4 rtl:-scale-x-100" />
375
+ ) : (
376
+ // The group's badge (0.48) before the chevron, the two as one end.
377
+ <span className="flex items-center gap-2">
378
+ {g.badge}
379
+ <ChevronRight aria-hidden className="size-4 rtl:-scale-x-100" />
380
+ </span>
381
+ )
382
+ }
373
383
  />
374
384
  ))}
375
385
  </List>
@@ -421,13 +431,17 @@ export function SettingsLayout<G extends string>({
421
431
  name: g.title,
422
432
  label: <span className={cn(dim && "opacity-40")}>{g.title}</span>,
423
433
  icon: <span className={cn("inline-flex [&_svg]:size-4", dim && "opacity-40")}>{g.icon}</span>,
424
- badge:
425
- searching && count > 0 ? (
434
+ // While searching, the hit count; otherwise the group's own badge (0.48).
435
+ badge: searching ? (
436
+ count > 0 ? (
426
437
  <Chip size="sm" tone="brand">
427
438
  <span aria-hidden>{count}</span>
428
439
  <span className="sr-only-fixed">{labels.matchCount(count)}</span>
429
440
  </Chip>
430
- ) : undefined,
441
+ ) : undefined
442
+ ) : (
443
+ g.badge
444
+ ),
431
445
  };
432
446
  })}
433
447
  active={(open?.id ?? route.groups[0]?.id) as G}
@@ -18,7 +18,7 @@ import { useAnchoredRect } from "../hooks/use-anchored-rect";
18
18
  import { anchoredInline } from "../hooks/use-anchored-panel";
19
19
  import { useDismissLayer } from "../hooks/use-dismiss";
20
20
  import { useBreakpoint } from "../hooks/use-breakpoint";
21
- import { useTextSize } from "../theme/text-size";
21
+ import { TEXT_SIZES, useTextSize } from "../theme/text-size";
22
22
  import type { TextSize } from "../theme/text-size";
23
23
  import { DEFAULT_APP_SHELL_LABELS, useKitLabels, useKitLink, type LabelOverride } from "../i18n/kit-labels";
24
24
  import type { KitLinkComponent, KitLinkProps } from "../i18n/kit-labels";
@@ -45,6 +45,11 @@ export interface AppShellNavItem {
45
45
  * shortening the real label, which the sidebar has ample room for and which is the
46
46
  * name the rest of the product uses. */
47
47
  shortLabel?: string;
48
+ /** 0.48: the text size from which the bar uses {@link shortLabel} — `"large"` keeps the
49
+ * full label at Normal, where five cells hold "Transactions" whole, and shortens it
50
+ * from Large up (keksdose dev#613, which read `useTextSize()` in its layout to decide).
51
+ * Default `"normal"`: the short label at every size, as before. */
52
+ shortLabelFrom?: TextSize;
48
53
  /** 0.31.0: leave this entry out of the MOBILE BOTTOM BAR; the sidebar keeps it.
49
54
  * For an entry a phone reaches another way — the brand link to the start page —
50
55
  * when one cell fewer is what keeps the others' labels whole (the bar divides its
@@ -544,9 +549,13 @@ export function AppShell({
544
549
  one that scrolls into empty space. Guarding it per component (see
545
550
  sr-only-containment.test) could never be complete: any page's own
546
551
  `sr-only` span re-opened it. Here it is closed for every child at once. */}
552
+ {/* Below `md` the bottom room is the nav plus the larger of 1.5rem and what the
553
+ floating launchers take above it (`--app-float-clearance`, published by
554
+ every kit FAB, FloatingPanel and FloatingActionGroup while mounted — keksdose
555
+ dev#612: the last row ended under the help launcher). */}
547
556
  <main
548
557
  className={cn(
549
- "relative flex-1 max-w-full overflow-x-clip pb-[calc(var(--app-nav-h,4rem)+1.5rem)] md:pb-0 md:min-h-0 md:overflow-y-auto md:[scrollbar-gutter:stable_both-edges]",
558
+ "relative flex-1 max-w-full overflow-x-clip pb-[calc(var(--app-nav-h,4rem)+max(1.5rem,var(--app-float-clearance,0px)))] md:pb-0 md:min-h-0 md:overflow-y-auto md:[scrollbar-gutter:stable_both-edges]",
550
559
  // The list page's fill (0.40, docs/page-layout-harmonization.md §1.3): while
551
560
  // a `fillHeight` DataTable is inside, a flex column — a banner above the
552
561
  // page keeps its own height and the page (a `FillHeight`) takes the rest,
@@ -764,6 +773,11 @@ function MobileCellLabel({ children }: { children: ReactNode }) {
764
773
  * nothing marked on every sub-page (the same defect the sidebar had). */
765
774
  function MobileNavItem({ item }: { item: AppShellNavItem }) {
766
775
  const subActive = useSubItemActive(item);
776
+ const { size } = useTextSize();
777
+ const short =
778
+ item.shortLabel !== undefined && TEXT_SIZES.indexOf(size) >= TEXT_SIZES.indexOf(item.shortLabelFrom ?? "normal")
779
+ ? item.shortLabel
780
+ : undefined;
767
781
  return (
768
782
  <NavLink
769
783
  to={item.to}
@@ -774,7 +788,7 @@ function MobileNavItem({ item }: { item: AppShellNavItem }) {
774
788
  <span className="relative">
775
789
  <item.icon className="size-5" />
776
790
  </span>
777
- <MobileCellLabel>{item.shortLabel ?? item.label}</MobileCellLabel>
791
+ <MobileCellLabel>{short ?? item.label}</MobileCellLabel>
778
792
  </NavLink>
779
793
  );
780
794
  }
@@ -28,6 +28,8 @@ export function OptionSwitcherMenu<T extends string>({
28
28
  /** Names the trigger and the menu. Required: the trigger is an icon (refactor audit
29
29
  * 2026-10, API-1 — the `ariaLabel` spelling went in 0.35). */
30
30
  "aria-label": string;
31
+ /** The trigger's tooltip. Default: `aria-label` (0.48) — the name, shown on hover and
32
+ * focus. A different text also describes the trigger (`aria-describedby`). */
31
33
  title?: string;
32
34
  heading?: string;
33
35
  options: OptionSwitcherOption<T>[];
@@ -37,13 +39,23 @@ export function OptionSwitcherMenu<T extends string>({
37
39
  return (
38
40
  <HoverMenu
39
41
  aria-label={ariaLabel}
40
- trigger={({ toggle }) => (
41
- <Tooltip label={title} portal>
42
+ trigger={({ toggle }) => {
43
+ const button = (
42
44
  <button type="button" onClick={toggle} aria-label={ariaLabel} className={TOPBAR_TRIGGER_CLASS}>
43
45
  {icon}
44
46
  </button>
45
- </Tooltip>
46
- )}
47
+ );
48
+ // The name on hover and focus even without a `title` (0.48, keksdose dev#610). A
49
+ // bubble that repeats the name sits round the button in a fragment, as IconButton's
50
+ // does, so it does not describe the button by its own name — a reader heard "Text
51
+ // size, button, Text size". A `title` that says more still describes it.
52
+ const tip = title ?? ariaLabel;
53
+ return (
54
+ <Tooltip label={tip} portal>
55
+ {tip === ariaLabel ? <>{button}</> : button}
56
+ </Tooltip>
57
+ );
58
+ }}
47
59
  >
48
60
  {(close) => (
49
61
  <ul className="py-1">
@@ -6,6 +6,7 @@ import { HoverMenu, type HoverMenuProps } from "../components/hover-menu";
6
6
  import { MenuItem } from "../components/menu-item";
7
7
  import type { MenuItemTone } from "../components/menu-item";
8
8
  import { StatusDot } from "../components/status-dot";
9
+ import { Tooltip } from "../components/tooltip";
9
10
  import type { UserAvatarBadge } from "../components/user-avatar";
10
11
  import { TOPBAR_TRIGGER_CLASS } from "./topbar-controls";
11
12
 
@@ -296,16 +297,27 @@ export function TopBarActionMenu({
296
297
  trigger={({ open, toggle, close }) => {
297
298
  // One element for both kinds: the router's link when `href` is set, else the
298
299
  // kit's button. Same classes and face either way.
299
- const control = (className: string, children: ReactNode, label?: string) =>
300
- href ? (
301
- <Link {...triggerAttributes} to={href} onClick={close} aria-label={label} className={className}>
302
- {children}
303
- </Link>
304
- ) : (
305
- <button {...triggerAttributes} type="button" onClick={toggle} aria-label={label} className={className}>
306
- {children}
307
- </button>
308
- );
300
+ //
301
+ // Its name in the kit's Tooltip on hover and focus (0.48, keksdose dev#610): an
302
+ // icon alone told a sighted user nothing the screen reader was told. In a
303
+ // fragment, as IconButton does it, so the bubble is not the trigger's
304
+ // `aria-describedby` — the name it repeats is the trigger's own, and a reader
305
+ // would hear it twice. Portalled, like the top bar's other triggers.
306
+ const control = (className: string, children: ReactNode, label?: string) => (
307
+ <Tooltip label={ariaLabel} portal>
308
+ <>
309
+ {href ? (
310
+ <Link {...triggerAttributes} to={href} onClick={close} aria-label={label} className={className}>
311
+ {children}
312
+ </Link>
313
+ ) : (
314
+ <button {...triggerAttributes} type="button" onClick={toggle} aria-label={label} className={className}>
315
+ {children}
316
+ </button>
317
+ )}
318
+ </>
319
+ </Tooltip>
320
+ );
309
321
  if (trigger) {
310
322
  return control(
311
323
  cn(TOPBAR_TRIGGER_CLASS, "relative", triggerClassName),