@eifi1/ui-kit 0.39.1 → 0.40.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 (144) hide show
  1. package/README.md +8 -7
  2. package/dist/billing/billing-overview.d.ts +10 -0
  3. package/dist/components/autocomplete.js +5 -1
  4. package/dist/components/autocomplete.js.map +1 -1
  5. package/dist/components/combobox-core.d.ts +14 -1
  6. package/dist/components/combobox-core.js +4 -0
  7. package/dist/components/combobox-core.js.map +1 -1
  8. package/dist/components/combobox.js +9 -3
  9. package/dist/components/combobox.js.map +1 -1
  10. package/dist/components/data-table-labels.d.ts +5 -0
  11. package/dist/components/data-table-labels.js +2 -0
  12. package/dist/components/data-table-labels.js.map +1 -1
  13. package/dist/components/data-table-mobile.d.ts +36 -1
  14. package/dist/components/data-table-mobile.js +162 -15
  15. package/dist/components/data-table-mobile.js.map +1 -1
  16. package/dist/components/data-table.d.ts +39 -12
  17. package/dist/components/data-table.js +74 -13
  18. package/dist/components/data-table.js.map +1 -1
  19. package/dist/components/inline-entity-combobox.js +10 -3
  20. package/dist/components/inline-entity-combobox.js.map +1 -1
  21. package/dist/components/picker-sheet.d.ts +18 -1
  22. package/dist/components/picker-sheet.js +20 -2
  23. package/dist/components/picker-sheet.js.map +1 -1
  24. package/dist/components/tabs.d.ts +26 -2
  25. package/dist/components/tabs.js +41 -2
  26. package/dist/components/tabs.js.map +1 -1
  27. package/dist/components/translation-review-parts.d.ts +2 -1
  28. package/dist/components/translation-review-parts.js +1 -1
  29. package/dist/components/translation-review-parts.js.map +1 -1
  30. package/dist/components/ui-labels.js +3 -1
  31. package/dist/components/ui-labels.js.map +1 -1
  32. package/dist/eslint/index.js +1 -1
  33. package/dist/feedback/feedback-comment-thread.d.ts +8 -1
  34. package/dist/feedback/feedback-comment-thread.js +26 -1
  35. package/dist/feedback/feedback-comment-thread.js.map +1 -1
  36. package/dist/feedback/feedback-labels.d.ts +2 -1
  37. package/dist/feedback/feedback-labels.js +1 -0
  38. package/dist/feedback/feedback-labels.js.map +1 -1
  39. package/dist/feedback/feedback-menu-labels.js +3 -1
  40. package/dist/feedback/feedback-menu-labels.js.map +1 -1
  41. package/dist/feedback/feedback-menu.d.ts +31 -24
  42. package/dist/feedback/feedback-menu.js +16 -7
  43. package/dist/feedback/feedback-menu.js.map +1 -1
  44. package/dist/feedback/feedback-public-labels.d.ts +3 -2
  45. package/dist/feedback/feedback-public-labels.js +1 -1
  46. package/dist/feedback/feedback-public-labels.js.map +1 -1
  47. package/dist/feedback/feedback-public-list.d.ts +4 -1
  48. package/dist/feedback/feedback-public-list.js +67 -55
  49. package/dist/feedback/feedback-public-list.js.map +1 -1
  50. package/dist/feedback/feedback-public-types.d.ts +16 -3
  51. package/dist/feedback/feedback-record.d.ts +20 -3
  52. package/dist/feedback/feedback-record.js +15 -0
  53. package/dist/feedback/feedback-record.js.map +1 -1
  54. package/dist/feedback/feedback-replies.d.ts +13 -2
  55. package/dist/feedback/feedback-replies.js +67 -33
  56. package/dist/feedback/feedback-replies.js.map +1 -1
  57. package/dist/feedback/feedback-rework-entry-chip.d.ts +9 -0
  58. package/dist/feedback/feedback-rework-entry-chip.js +13 -0
  59. package/dist/feedback/feedback-rework-entry-chip.js.map +1 -0
  60. package/dist/feedback/feedback-row-detail.d.ts +31 -8
  61. package/dist/feedback/feedback-row-detail.js +31 -7
  62. package/dist/feedback/feedback-row-detail.js.map +1 -1
  63. package/dist/feedback/feedback-table-labels.js +1 -1
  64. package/dist/feedback/feedback-table-labels.js.map +1 -1
  65. package/dist/feedback/feedback-table.d.ts +5 -2
  66. package/dist/feedback/feedback-table.js +2 -2
  67. package/dist/feedback/feedback-table.js.map +1 -1
  68. package/dist/feedback/feedback-thread.js +3 -1
  69. package/dist/feedback/feedback-thread.js.map +1 -1
  70. package/dist/feedback/feedback-vocabulary.d.ts +9 -6
  71. package/dist/feedback/feedback-vocabulary.js +16 -1
  72. package/dist/feedback/feedback-vocabulary.js.map +1 -1
  73. package/dist/hooks/use-long-press.d.ts +30 -0
  74. package/dist/hooks/use-long-press.js +77 -0
  75. package/dist/hooks/use-long-press.js.map +1 -0
  76. package/dist/hooks/use-overlay-history.js +37 -3
  77. package/dist/hooks/use-overlay-history.js.map +1 -1
  78. package/dist/i18n/german.js +13 -5
  79. package/dist/i18n/german.js.map +1 -1
  80. package/dist/i18n/kit-labels.d.ts +3 -0
  81. package/dist/i18n/kit-labels.js.map +1 -1
  82. package/dist/i18n/locales/en.js +12 -5
  83. package/dist/i18n/locales/en.js.map +2 -2
  84. package/dist/i18n/locales/es.js +13 -5
  85. package/dist/i18n/locales/es.js.map +1 -1
  86. package/dist/i18n/locales/fr.js +13 -5
  87. package/dist/i18n/locales/fr.js.map +1 -1
  88. package/dist/i18n/locales/hu.js +13 -5
  89. package/dist/i18n/locales/hu.js.map +1 -1
  90. package/dist/i18n/locales/it.js +13 -5
  91. package/dist/i18n/locales/it.js.map +1 -1
  92. package/dist/i18n/locales/zh.js +13 -5
  93. package/dist/i18n/locales/zh.js.map +1 -1
  94. package/dist/i18n/shared-labels.js +1 -1
  95. package/dist/i18n/shared-labels.js.map +1 -1
  96. package/dist/shell/app-shell.js +9 -0
  97. package/dist/shell/app-shell.js.map +1 -1
  98. package/dist/shell/fill-height.d.ts +38 -0
  99. package/dist/shell/fill-height.js +12 -0
  100. package/dist/shell/fill-height.js.map +1 -0
  101. package/dist/shell.d.ts +2 -0
  102. package/dist/shell.js +3 -0
  103. package/dist/shell.js.map +1 -1
  104. package/package.json +1 -1
  105. package/src/billing/billing-overview.ts +10 -0
  106. package/src/components/autocomplete.tsx +5 -1
  107. package/src/components/combobox-core.tsx +20 -1
  108. package/src/components/combobox.tsx +9 -3
  109. package/src/components/data-table-labels.ts +7 -0
  110. package/src/components/data-table-mobile.tsx +193 -9
  111. package/src/components/data-table.tsx +141 -24
  112. package/src/components/inline-entity-combobox.tsx +12 -4
  113. package/src/components/picker-sheet.tsx +38 -1
  114. package/src/components/tabs.tsx +73 -3
  115. package/src/components/translation-review-parts.tsx +3 -2
  116. package/src/components/ui-labels.ts +2 -0
  117. package/src/feedback/feedback-comment-thread.tsx +39 -1
  118. package/src/feedback/feedback-labels.ts +3 -1
  119. package/src/feedback/feedback-menu-labels.ts +2 -0
  120. package/src/feedback/feedback-menu.tsx +48 -30
  121. package/src/feedback/feedback-public-labels.ts +4 -3
  122. package/src/feedback/feedback-public-list.tsx +15 -2
  123. package/src/feedback/feedback-public-types.ts +17 -3
  124. package/src/feedback/feedback-record.ts +34 -3
  125. package/src/feedback/feedback-replies.tsx +100 -31
  126. package/src/feedback/feedback-rework-entry-chip.tsx +18 -0
  127. package/src/feedback/feedback-row-detail.tsx +65 -12
  128. package/src/feedback/feedback-table-labels.ts +1 -1
  129. package/src/feedback/feedback-table.tsx +7 -4
  130. package/src/feedback/feedback-thread.tsx +5 -1
  131. package/src/feedback/feedback-vocabulary.ts +27 -6
  132. package/src/hooks/use-long-press.ts +126 -0
  133. package/src/hooks/use-overlay-history.ts +74 -3
  134. package/src/i18n/german.ts +10 -2
  135. package/src/i18n/kit-labels.tsx +3 -0
  136. package/src/i18n/locales/es.ts +10 -2
  137. package/src/i18n/locales/fr.ts +10 -2
  138. package/src/i18n/locales/hu.ts +10 -2
  139. package/src/i18n/locales/it.ts +10 -2
  140. package/src/i18n/locales/zh.ts +10 -2
  141. package/src/i18n/shared-labels.ts +1 -1
  142. package/src/shell/app-shell.tsx +9 -0
  143. package/src/shell/fill-height.tsx +45 -0
  144. package/src/shell.ts +3 -0
@@ -14,6 +14,7 @@ import {
14
14
  ComboboxFieldLabel,
15
15
  DISABLED_ROW_CLASS,
16
16
  endHintRowProps,
17
+ GROUP_HEADING_STICKY,
17
18
  isOptionEnabled,
18
19
  offersCreate,
19
20
  stepEnabled,
@@ -310,13 +311,14 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
310
311
  const q = foldForSearch(query, locale);
311
312
  const hit = (s: string | undefined) => s !== undefined && foldForSearch(s, locale).includes(q);
312
313
  // The label's whole name or its start first (`rankMatch` 0 and 1), then a match
313
- // anywhere in the label, the sublabel or the group.
314
+ // anywhere in the label or the sublabel. Not the group (0.40, kk run 79): with
315
+ // first-letter groups one typed letter matched its whole group.
314
316
  const leads = (o: ComboOption<V>) => {
315
317
  const rank = rankMatch(o.label, query, { locale });
316
318
  return rank === 0 || rank === 1;
317
319
  };
318
320
  const starts = options.filter(leads);
319
- const rest = options.filter((o) => !leads(o) && (hit(o.label) || hit(o.sublabel) || hit(o.group)));
321
+ const rest = options.filter((o) => !leads(o) && (hit(o.label) || hit(o.sublabel)));
320
322
  ranked = [...starts, ...rest];
321
323
  }
322
324
  if (!ranked.some((o) => o.group)) return ranked;
@@ -647,7 +649,10 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
647
649
  {o.group && o.group !== matches[i - 1]?.group && (
648
650
  <li
649
651
  role="presentation"
650
- className="px-4 pb-0.5 pt-3 text-caption font-semibold uppercase tracking-wide text-muted"
652
+ className={cn(
653
+ GROUP_HEADING_STICKY,
654
+ "px-4 pb-0.5 pt-3 text-caption font-semibold uppercase tracking-wide text-muted",
655
+ )}
651
656
  >
652
657
  {o.group}
653
658
  </li>
@@ -710,7 +715,10 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
710
715
  {o.group && o.group !== matches[i - 1]?.group && (
711
716
  <li
712
717
  role="presentation"
713
- className="px-3 pb-0.5 pt-2 text-caption font-semibold uppercase tracking-wide text-muted first:pt-1"
718
+ className={cn(
719
+ GROUP_HEADING_STICKY,
720
+ "px-3 pb-0.5 pt-2 text-caption font-semibold uppercase tracking-wide text-muted first:pt-1",
721
+ )}
714
722
  >
715
723
  {o.group}
716
724
  </li>
@@ -6,6 +6,7 @@ import { cn } from "../lib/cn";
6
6
  import { mergeRefs } from "./field-parts";
7
7
  import { CLIPS_ATTRIBUTE } from "../lib/clipping";
8
8
  import { DropdownSearchHeader } from "./dropdown";
9
+ import { Button } from "./button";
9
10
  import { useBodyScrollLock } from "../hooks/use-body-scroll-lock";
10
11
  import { useOverlayHistory } from "../hooks/use-overlay-history";
11
12
  import { useFocusTrap } from "../hooks/use-focus-trap";
@@ -46,6 +47,23 @@ export interface PickerSheetProps
46
47
  /** The option the arrow keys are on, for the search box's `aria-activedescendant`. */
47
48
  activeId?: string;
48
49
  children: ReactNode;
50
+ /**
51
+ * A row pinned under the scrolling list — {@link FullBleedDialog}'s `footer`, on the
52
+ * picker's sheet (kk run 79 gap 2). Outside the scroller, so it stays put however long
53
+ * the list; padded for the home indicator, since it is the sheet's bottom edge.
54
+ */
55
+ footer?: ReactNode;
56
+ /**
57
+ * The kit's one sticky "Done" at the foot of the sheet (Marcel, keksdose #412): the
58
+ * button a multi-select sheet is left by once its rows are ticked. Ticks apply as
59
+ * they are made, so it closes (`onClose`) rather than saves. After any `footer`.
60
+ * {@link ComboboxPanel} turns it on for every `multi` list — `MultiSelect` and
61
+ * `MultiEntityCombobox` — and a single-select sheet, which closes on the tap that
62
+ * picks, has no use for it. Label: `pickerSheet.done`, else "Done" (`doneLabel`).
63
+ */
64
+ done?: boolean;
65
+ /** Default: `pickerSheet.done` from the {@link UiKitProvider}, else "Done". */
66
+ doneLabel?: string;
49
67
  /** The sheet, where the rest of the props go (0.34, API-2, refactor audit 2026-10) —
50
68
  * merged with the sheet's own ref, which its focus trap holds. */
51
69
  ref?: Ref<HTMLDivElement>;
@@ -88,6 +106,9 @@ export function PickerSheet({
88
106
  listboxId,
89
107
  activeId,
90
108
  children,
109
+ footer,
110
+ done,
111
+ doneLabel,
91
112
  "aria-label": ariaLabel,
92
113
  className,
93
114
  style,
@@ -98,7 +119,8 @@ export function PickerSheet({
98
119
  // their own `closeLabel` through as `undefined` — so this lookup is what makes the
99
120
  // one way out of a full-screen sheet speak the app's language without each of
100
121
  // them having to (see the prop's note in `EntityCombobox`).
101
- const labels = useKitLabels("pickerSheet", DEFAULT_PICKER_SHEET_LABELS, { close: closeLabel });
122
+ const labels = useKitLabels("pickerSheet", DEFAULT_PICKER_SHEET_LABELS, { close: closeLabel, done: doneLabel });
123
+ const hasFooter = (footer !== undefined && footer !== null && footer !== false) || Boolean(done);
102
124
  useBodyScrollLock(open);
103
125
  /**
104
126
  * Back closes THE SHEET, not the dialog it was opened from (Keksdose live #309:
@@ -276,6 +298,21 @@ export function PickerSheet({
276
298
  <div {...{ [CLIPS_ATTRIBUTE]: "" }} className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
277
299
  {children}
278
300
  </div>
301
+ {hasFooter && (
302
+ <div
303
+ data-picker-sheet-footer=""
304
+ className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-subtle bg-surface px-3 pt-3"
305
+ style={{ paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))" }}
306
+ >
307
+ {footer}
308
+ {done && (
309
+ // Full width: the one thing left to do on the sheet, under the thumb.
310
+ <Button type="button" className="flex-1" onClick={onClose}>
311
+ {labels.done}
312
+ </Button>
313
+ )}
314
+ </div>
315
+ )}
279
316
  </div>,
280
317
  document.body,
281
318
  );
@@ -1,11 +1,12 @@
1
1
  import { useLayoutEffect, useMemo, useRef } from "react";
2
- import { Plus, X } from "lucide-react";
2
+ import { ChevronLeft, ChevronRight, Plus, X } from "lucide-react";
3
3
  import type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref } from "react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { FOCUS_RING_WIDTH } from "./focus-ring";
6
6
  import { scrollIntoStrip, useStripFade } from "../lib/strip-fade";
7
7
  import { horizontalStep } from "../lib/direction";
8
8
  import { usePhoneLayout } from "../hooks/use-breakpoint";
9
+ import { prefersReducedMotion } from "../lib/reduced-motion";
9
10
  import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
10
11
  import { mergeRefs } from "./field-parts";
11
12
  import { DEFAULT_TABS_LABELS } from "./ui-labels";
@@ -47,6 +48,10 @@ export interface TabsLabels {
47
48
  add: string;
48
49
  /** Accessible name of a tab's × — "Remove <tab>". */
49
50
  remove: (tab: string) => string;
51
+ /** The `overflowCue` caret while tabs lie past the strip's end: "More tabs". */
52
+ more: string;
53
+ /** The `overflowCue` caret at the strip's end, which goes back to the first tab. */
54
+ backToFirst: string;
50
55
  }
51
56
 
52
57
  // Defined in ./ui-labels, which holds no component code (0.34, KS U8).
@@ -94,8 +99,28 @@ export interface TabsProps<T extends string>
94
99
  *
95
100
  * Opt-in on purpose. The three-tab strips (invoices, statements, bank imports)
96
101
  * already fit a phone row, and turning those into chips would be an unrequested
97
- * redesign of three other pages. */
102
+ * redesign of three other pages.
103
+ *
104
+ * @deprecated 0.40 — use the scrolling strip with `overflowCue`; removed in 0.41. No
105
+ * consumer is left (keksdose went back to one scrolling row, live #410); the caret
106
+ * says a phone's row goes on. */
98
107
  wrap?: boolean;
108
+ /**
109
+ * The caret beside a SCROLLING strip below `md` (0.40, kk run 79 gap 1): a › while
110
+ * tabs lie past the strip's end — a tap scrolls it on by three quarters of its width —
111
+ * turning into a ‹ at the end, which goes back to the first. Shown only while the strip
112
+ * actually overflows.
113
+ *
114
+ * The edge fade alone could read as the end of the row: at the default scroll position
115
+ * the last visible tab can finish exactly where the fade begins, so nothing looks cut
116
+ * off (keksdose live #410, whose `tab-strip-more.tsx` this replaces). One button that
117
+ * turns round rather than two that come and go, so the strip's width never changes
118
+ * under the thumb. Out of the tab order — the keyboard walks the strip with the arrow
119
+ * keys, which bring the focused tab into view. From `md` up nothing changes. Ignored on
120
+ * a `wrap` strip and a vertical one (a side nav is a column, not a row). Names:
121
+ * `tabs.more` / `tabs.backToFirst`.
122
+ */
123
+ overflowCue?: boolean;
99
124
  /**
100
125
  * `id` of the element the caller renders the open tab's content into — the missing
101
126
  * half of `role="tab"`. A tab that controls nothing is a tab in name only: a screen
@@ -266,6 +291,7 @@ export function Tabs<T extends string>({
266
291
  onChange,
267
292
  className,
268
293
  wrap = false,
294
+ overflowCue = false,
269
295
  panelId,
270
296
  onRemove,
271
297
  removeOn = "every",
@@ -285,6 +311,36 @@ export function Tabs<T extends string>({
285
311
  const stripRef = useRef<HTMLDivElement>(null);
286
312
  const setStrip = useMemo(() => mergeRefs(stripRef, ref), [ref]);
287
313
  const fade = useStripFade(stripRef);
314
+ // See `overflowCue`. `start` alone means the strip is scrolled to its end.
315
+ const cue = overflowCue && !wrap && !vertical && fade.overflow !== undefined;
316
+ const cueAtEnd = fade.overflow === "start";
317
+ const onCue = () => {
318
+ const strip = stripRef.current;
319
+ if (!strip) return;
320
+ const behavior: ScrollBehavior = prefersReducedMotion() ? "auto" : "smooth";
321
+ if (cueAtEnd) {
322
+ strip.scrollTo({ left: 0, behavior });
323
+ return;
324
+ }
325
+ const rtl = getComputedStyle(strip).direction === "rtl";
326
+ strip.scrollBy({ left: (rtl ? -1 : 1) * Math.round(strip.clientWidth * 0.75), behavior });
327
+ };
328
+ const cueButton = cue ? (
329
+ // The strip's bottom rule continues under the caret, so the row reads as one.
330
+ <div data-tabs-overflow-cue="" className="flex shrink-0 items-center self-stretch border-b border-subtle md:hidden">
331
+ <IconButton
332
+ size="sm"
333
+ variant="ghost"
334
+ tone="muted"
335
+ tabIndex={-1}
336
+ type="button"
337
+ aria-label={cueAtEnd ? text.backToFirst : text.more}
338
+ onClick={onCue}
339
+ >
340
+ {cueAtEnd ? <ChevronLeft className="rtl:-scale-x-100" /> : <ChevronRight className="rtl:-scale-x-100" />}
341
+ </IconButton>
342
+ </div>
343
+ ) : null;
288
344
  // The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a
289
345
  // deep link to the seventh report. A selection behind the cut edge was the worst
290
346
  // case of the overflow above: the one tab the panel below belongs to, sliced to
@@ -410,7 +466,7 @@ export function Tabs<T extends string>({
410
466
  // — it is the box a caller's margin or width is meant for.
411
467
  className={cn(
412
468
  vertical ? TABLIST_VERTICAL_CLASSES : wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES,
413
- onAdd ? "min-w-0" : className,
469
+ onAdd ? "min-w-0" : overflowCue && !wrap && !vertical ? "min-w-0 flex-1" : className,
414
470
  )}
415
471
  >
416
472
  {tabs.map((tab) => {
@@ -598,6 +654,19 @@ export function Tabs<T extends string>({
598
654
  {text.remove(nameOf(activeTab))}
599
655
  </button>
600
656
  ) : null;
657
+ if (!onAdd && overflowCue && !wrap && !vertical) {
658
+ // The caret needs a row to sit in beside the strip, and `className` goes there — it
659
+ // is the box a caller's margin or width is meant for, as with an add button. The box
660
+ // is there whether or not the strip overflows right now, so a resize never moves
661
+ // the caller's class from one element to another.
662
+ return (
663
+ <div className={cn("flex items-end", className)}>
664
+ {tablist}
665
+ {removeButton}
666
+ {cueButton}
667
+ </div>
668
+ );
669
+ }
601
670
  if (!onAdd) {
602
671
  // No outer box: the strip stays the root, as callers' `className` and tests expect.
603
672
  return removeButton ? (
@@ -631,6 +700,7 @@ export function Tabs<T extends string>({
631
700
  >
632
701
  {tablist}
633
702
  {removeButton}
703
+ {cueButton}
634
704
  {/* Outside the tablist: an action, not a tab — and a tablist may own only
635
705
  tabs. The rule under the strip carries on beneath it. */}
636
706
  <div
@@ -169,7 +169,8 @@ export interface TranslationLocaleTabsProps {
169
169
  }
170
170
 
171
171
  /**
172
- * One tab per locale with its approved/total, wrapping on a phone. Nothing for a single
172
+ * One tab per locale with its approved/total, one scrolling row on a phone with the
173
+ * strip's caret saying it goes on (`overflowCue`; `wrap` is deprecated in 0.40). Nothing for a single
173
174
  * locale: a strip with one tab is a heading that looks clickable.
174
175
  */
175
176
  export function TranslationLocaleTabs({ locales, active, onChange, className, labels: labelsProp }: TranslationLocaleTabsProps) {
@@ -178,7 +179,7 @@ export function TranslationLocaleTabs({ locales, active, onChange, className, la
178
179
  return (
179
180
  <Tabs
180
181
  aria-label={labels.locales}
181
- wrap
182
+ overflowCue
182
183
  className={className}
183
184
  tabs={locales.map((locale) => {
184
185
  const summary = summariseRows(locale.rows);
@@ -19,4 +19,6 @@ export const DEFAULT_CHARACTER_COUNT_LABELS: CharacterCountLabels = {
19
19
  export const DEFAULT_TABS_LABELS: TabsLabels = {
20
20
  add: "Add tab",
21
21
  remove: (tab) => `Remove ${tab}`,
22
+ more: "More tabs",
23
+ backToFirst: "Back to the first tab",
22
24
  };
@@ -8,9 +8,10 @@ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
8
8
  import { FeedbackStatusBadge } from "./feedback-inbox";
9
9
  import type { FeedbackComment, FeedbackReply } from "./feedback-public-types";
10
10
  import { DEFAULT_FEEDBACK_PUBLIC_LABELS } from "./feedback-public-labels";
11
- import { FEEDBACK_COMMENT_MAX } from "./feedback-record";
11
+ import { FEEDBACK_COMMENT_MAX, attachmentName } from "./feedback-record";
12
12
  import { useFeedbackRepliesLabels } from "./feedback-replies";
13
13
  import { FeedbackRepliesDrawnAboveContext } from "./feedback-replies-context";
14
+ import { FeedbackReworkEntryChip } from "./feedback-rework-entry-chip";
14
15
  import { ChatComposer, FeedbackThread } from "./feedback-thread";
15
16
  import type { FeedbackThreadMessage } from "./feedback-thread";
16
17
  import { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels";
@@ -23,6 +24,10 @@ import { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels
23
24
  *
24
25
  * - **Replies** are drawn as staff posts with the status they set ("→ Done"); a reply
25
26
  * without an author is "Admin" (§9.7).
27
+ * - **Reworks** (0.40, §10.3, `kind: "rework"`) are the report's author's: the
28
+ * **Rework** chip, "→ status" when the entry moved one (a migrated one did not), the
29
+ * file when the view carries it; at the reading end for the author ({@link
30
+ * FeedbackCommentThreadProps.isAuthor}), and a `null` name is "A user", never "Admin".
26
31
  * - **Comments**: the viewer's own at the reading end ("You"); someone else's under the
27
32
  * first name the server sent, `null` as "A user" (§9.5, `feedbackPublic.anonymous`).
28
33
  * - **Hidden** (§9.8): others see "Hidden by an admin" and no name, no text; the writer
@@ -81,6 +86,9 @@ export interface FeedbackCommentThreadProps {
81
86
  comments: readonly FeedbackComment[];
82
87
  /** The viewer is the app's admin: Hide / Unhide, the hidden text struck through. */
83
88
  isAdmin: boolean;
89
+ /** 0.40: the viewer filed the report (the record's `mine`) — its rework entries are
90
+ * theirs, drawn at the reading end as "You". Default `false`. */
91
+ isAuthor?: boolean;
84
92
  /** `POST /feedback/{id}/comments`. Return the promise: the box clears when it
85
93
  * resolves, and keeps the draft when it rejects. A failure is the app's to toast —
86
94
  * the server's `detail`, else `feedbackToast.commentFailed` (0.38.1). Left out, no
@@ -125,6 +133,7 @@ export function FeedbackCommentThread({
125
133
  replies: repliesProp = [],
126
134
  comments,
127
135
  isAdmin,
136
+ isAuthor = false,
128
137
  onComment,
129
138
  onHide,
130
139
  onDelete,
@@ -143,6 +152,35 @@ export function FeedbackCommentThread({
143
152
 
144
153
  const entries: Entry[] = [];
145
154
  replies.forEach((reply, order) => {
155
+ if (reply.kind === "rework") {
156
+ entries.push({
157
+ at: instant(reply.created_at),
158
+ order,
159
+ message: {
160
+ id: `reply-${reply.id}`,
161
+ own: isAuthor,
162
+ author: isAuthor ? undefined : (reply.author_name ?? anonymous),
163
+ createdAt: reply.created_at,
164
+ body: reply.body,
165
+ attachments: reply.attachment_url
166
+ ? [{ name: attachmentName(reply.attachment_url), url: reply.attachment_url }]
167
+ : undefined,
168
+ badge: (
169
+ <span className="inline-flex min-w-0 max-w-full flex-wrap items-center gap-1" data-feedback-entry-kind="rework">
170
+ <FeedbackReworkEntryChip />
171
+ {reply.status_to && (
172
+ <span className="inline-flex items-center gap-1" data-feedback-reply-status={reply.status_to}>
173
+ <span className="sr-only">{replyLabels.statusChanged}</span>
174
+ <span aria-hidden>→</span>
175
+ <FeedbackStatusBadge status={reply.status_to} />
176
+ </span>
177
+ )}
178
+ </span>
179
+ ),
180
+ },
181
+ });
182
+ return;
183
+ }
146
184
  entries.push({
147
185
  at: instant(reply.created_at),
148
186
  order,
@@ -24,7 +24,8 @@ import type { FeedbackCategory, FeedbackStatus } from "./feedback-vocabulary";
24
24
  * `feedbackStatus` — one word per status, keyed by the enum value so
25
25
  * `labels[row.status]` is the whole lookup.
26
26
  *
27
- * de-CH canon: OPEN "Offen" · READY "Bereit zur Umsetzung" · IN_PROGRESS "In Bearbeitung" · IN_EVALUATION "Zur Prüfung"
27
+ * de-CH canon: OPEN "Offen" · READY "Bereit zur Umsetzung" · REWORK "Nacharbeit angefordert"
28
+ * (0.40, §10.6) · IN_PROGRESS "In Bearbeitung" · IN_EVALUATION "Zur Prüfung"
28
29
  * · NEEDS_LIVE_TEST "Live testen" · POSTPONED "Zurückgestellt" · DONE "Erledigt" ·
29
30
  * WONT_DO "Wird nicht umgesetzt".
30
31
  */
@@ -33,6 +34,7 @@ export type FeedbackStatusLabels = Record<FeedbackStatus, string>;
33
34
  export const DEFAULT_FEEDBACK_STATUS_LABELS: FeedbackStatusLabels = {
34
35
  OPEN: "Open",
35
36
  READY: "Ready to implement",
37
+ REWORK: "Rework requested",
36
38
  IN_PROGRESS: "In progress",
37
39
  IN_EVALUATION: "In evaluation",
38
40
  NEEDS_LIVE_TEST: "Test when live",
@@ -9,4 +9,6 @@ export const DEFAULT_FEEDBACK_MENU_LABELS: FeedbackMenuLabels = {
9
9
  myFeedback: "My feedback",
10
10
  viewFeedback: "View feedback",
11
11
  allFeedback: "All feedback",
12
+ discussion: "Feedback discussion",
13
+ inbox: "Feedback inbox",
12
14
  };
@@ -1,4 +1,4 @@
1
- import { Inbox, MessageSquare, Users } from "lucide-react";
1
+ import { Inbox, MessageSquare, User, Users } from "lucide-react";
2
2
  import type { UserAvatarBadge } from "../components/user-avatar";
3
3
  import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
4
4
  import { TopBarActionMenu, type TopBarMenuEntry } from "../shell/topbar-action-menu";
@@ -13,21 +13,26 @@ import { DEFAULT_FEEDBACK_MENU_LABELS } from "./feedback-menu-labels";
13
13
  /**
14
14
  * `feedbackMenu` — the top-bar feedback menu's own words (0.27.0). The four category rows
15
15
  * are `feedbackCategory`'s, shared with the badges and the dialog's picker. de-CH canon
16
- * (keksdose `de-CH.json`): `trigger` "Feedback senden", `myFeedback` "Mein Feedback",
17
- * `viewFeedback` "Feedback ansehen".
16
+ * (keksdose `de-CH.json`, §10.6): `trigger` "Feedback senden", `discussion`
17
+ * "Feedback-Diskussion", `myFeedback` "Mein Feedback", `inbox` "Feedback-Eingang".
18
18
  */
19
19
  export interface FeedbackMenuLabels {
20
20
  /** The trigger's accessible name, and the menu's. keksdose `feedback.send`. */
21
21
  trigger: string;
22
- /** The link to the reporter's own reports — everyone but an admin. keksdose
23
- * `feedback.my_title`. */
22
+ /** The link to the viewer's own reports — everyone's, an admin's too since 0.40
23
+ * (§10.2 decision 3). keksdose `feedback.my_title`. */
24
24
  myFeedback: string;
25
- /** The link to the inbox — an admin's one list link, in place of "My feedback".
26
- * keksdose `feedback.view_all`. */
27
- viewFeedback: string;
28
- /** The link to every public report, {@link FeedbackMenuProps.publicHref} (0.38,
29
- * docs/feedback-harmonization.md §9.2 decision 21). Optional, so a complete
30
- * `UiKitLabels` typed before 0.38 still compiles. de-CH "Alle Rückmeldungen". */
25
+ /** 0.40: the link to the public list, {@link FeedbackMenuProps.publicHref} (§10.2
26
+ * decision 3; "All feedback" before). de-CH "Feedback-Diskussion". */
27
+ discussion: string;
28
+ /** 0.40: the admin's link to the triage list, {@link FeedbackMenuProps.inboxHref}
29
+ * ("View feedback" before). de-CH "Feedback-Eingang". */
30
+ inbox: string;
31
+ /** @deprecated 0.40 — use {@link inbox}; removed in 0.41. Unused: the inbox row reads
32
+ * `inbox` (feedback §10.6). */
33
+ viewFeedback?: string;
34
+ /** @deprecated 0.40 — use {@link discussion}; removed in 0.41. Unused: the public row
35
+ * reads `discussion` (feedback §10.6). */
31
36
  allFeedback?: string;
32
37
  }
33
38
 
@@ -38,8 +43,8 @@ export interface FeedbackMenuProps {
38
43
  /** A category row was chosen — open the submit dialog on it (`useFeedbackSubmit`'s
39
44
  * `open`). Never called with `CRASH`: there is no row for it. */
40
45
  onFile: (category: FeedbackCategory) => void;
41
- /** The list link is "View feedback" → {@link inboxHref} INSTEAD of "My feedback" (§4.1,
42
- * keksdose `top-bar.tsx:205-214`). The admin check is the app's (kastlan's is
46
+ /** Adds the **Feedback inbox** row → {@link inboxHref} under "My feedback" (0.40, §10.2
47
+ * decision 11; until 0.39 it replaced it). The admin check is the app's (kastlan's is
43
48
  * company-scoped). Default `false`. */
44
49
  isAdmin?: boolean;
45
50
  /**
@@ -50,16 +55,16 @@ export interface FeedbackMenuProps {
50
55
  * `all-feedback`, `my-feedback`, `view-feedback`).
51
56
  */
52
57
  extraEntries?: TopBarMenuEntry[];
53
- /** The user's own reports, linked for everyone but an admin. Default `"/my-feedback"`
54
- * (§2.3). */
58
+ /** The viewer's own reports, linked for everyone (an admin too since 0.40). Default
59
+ * `"/my-feedback"` (§2.3). */
55
60
  myFeedbackHref?: string;
56
61
  /** The admin inbox, linked for an admin. Default `"/feedback"` (§2.3). */
57
62
  inboxHref?: string;
58
63
  /**
59
64
  * The public list (0.38, docs/feedback-harmonization.md §9.2 decision 21): when passed,
60
- * an **All feedback** row (`Users`, key `all-feedback`) above the list link, for
61
- * everyone — the admin too, who reads what the others read there. keksdose:
62
- * `"/all-feedback"`; "My feedback" stays as it is. Leave it out where the app has no
65
+ * a **Feedback discussion** row (`Users`, key `all-feedback`; "All feedback" until 0.40)
66
+ * first of the list links, for everyone — the admin too, who reads what the others read
67
+ * there. keksdose: `"/all-feedback"`. Leave it out where the app has no
63
68
  * public parts (kastlan) or the viewer may not see them (a Kurvenschmiede customer, a
64
69
  * demo — §9.2 decisions 7, 8).
65
70
  */
@@ -83,17 +88,19 @@ export interface FeedbackMenuProps {
83
88
  * the category badges' own glyphs), each calling `onFile(category)`;
84
89
  * 3. a divider, always;
85
90
  * 4. the app's `extraEntries`;
86
- * 5. with `publicHref` (0.38), **All feedback** → the public list, for everyone;
87
- * 6. ONE list link, `Inbox`: an admin's is "View feedback" → `/feedback`, everyone
88
- * else's "My feedback" → `/my-feedback` — keksdose's (`top-bar.tsx:205-214`, feedback
89
- * #331: admins get the management view, everyone else their own submissions).
91
+ * 5. the list links (0.40, §10.2 decision 11), in this order: **Feedback discussion**
92
+ * (`Users`, key `all-feedback`) → `publicHref`, when passed; **My feedback** (`User`,
93
+ * key `my-feedback`) → `/my-feedback`, for everyone, admins included; **Feedback
94
+ * inbox** (`Inbox`, key `view-feedback`) → `/feedback`, for an admin. An admin with
95
+ * public parts sees three, a member two; without them an admin two and anyone else
96
+ * one. Until 0.39 an admin had the inbox in place of their own list (feedback #331);
97
+ * Marcel's kk #419 gave them both.
90
98
  *
91
99
  * WHY A PRESET. Three apps had drawn three menus on the same primitive and they disagreed:
92
100
  * kastlan headed it "New submission", Kurvenschmiede "Send feedback" with a single link
93
101
  * that changed its words by role, keksdose had no heading and one link by role. keksdose
94
- * is the source (§2.4, §7.11): no heading, one link — the inbox for an admin, who reaches
95
- * their own reports there too — and the kit draws it, so the row order, the icons and the
96
- * words cannot drift again. Where the menu sits in the bar stays each app's.
102
+ * is the source (§2.4, §7.11): no heading, the list links by role — and the kit draws
103
+ * it, so the row order, the icons and the words cannot drift again. Where the menu sits in the bar stays each app's.
97
104
  *
98
105
  * `CRASH` has no row and never will: crash reports are filed by the error boundary with
99
106
  * the stack and a fingerprint (keksdose feedback #160), and a hand-filed "crash" would sort
@@ -136,17 +143,28 @@ export function FeedbackMenu({
136
143
  key: "all-feedback",
137
144
  to: publicHref,
138
145
  icon: <Users className="size-4" />,
139
- label: labels.allFeedback ?? DEFAULT_FEEDBACK_MENU_LABELS.allFeedback!,
146
+ label: labels.discussion,
140
147
  } satisfies TopBarMenuEntry,
141
148
  ]
142
149
  : []),
143
150
  {
144
151
  kind: "link",
145
- key: isAdmin ? "view-feedback" : "my-feedback",
146
- to: isAdmin ? inboxHref : myFeedbackHref,
147
- icon: <Inbox className="size-4" />,
148
- label: isAdmin ? labels.viewFeedback : labels.myFeedback,
152
+ key: "my-feedback",
153
+ to: myFeedbackHref,
154
+ icon: <User className="size-4" />,
155
+ label: labels.myFeedback,
149
156
  },
157
+ ...(isAdmin
158
+ ? [
159
+ {
160
+ kind: "link",
161
+ key: "view-feedback",
162
+ to: inboxHref,
163
+ icon: <Inbox className="size-4" />,
164
+ label: labels.inbox,
165
+ } satisfies TopBarMenuEntry,
166
+ ]
167
+ : []),
150
168
  ];
151
169
  return (
152
170
  <TopBarActionMenu
@@ -10,10 +10,11 @@
10
10
  * stay `feedbackPage`'s, so the two lists cannot disagree; the vote button's words are
11
11
  * `feedbackVote`'s, the thread's `feedbackComments`'.
12
12
  *
13
- * de-CH canon (§9.2, §9.5): `title` "Alle Rückmeldungen", `anonymous` "Eine Person".
13
+ * de-CH canon (§9.2, §9.5, §10.6): `title` "Feedback-Diskussion", `anonymous` "Eine Person".
14
14
  */
15
15
  export interface FeedbackPublicLabels {
16
- /** The page's heading — keksdose's `/all-feedback`, the menu's "All feedback". */
16
+ /** The page's heading — keksdose's `/all-feedback`, the menu's "Feedback discussion"
17
+ * (0.40, §10.2 decision 11; "All feedback" before). de-CH "Feedback-Diskussion". */
17
18
  title: string;
18
19
  /** The sort toggle's accessible name. */
19
20
  sort: string;
@@ -57,7 +58,7 @@ export interface FeedbackPublicLabels {
57
58
  }
58
59
 
59
60
  export const DEFAULT_FEEDBACK_PUBLIC_LABELS: FeedbackPublicLabels = {
60
- title: "All feedback",
61
+ title: "Feedback discussion",
61
62
  sort: "Sort",
62
63
  sortNewest: "Newest",
63
64
  sortVotes: "Most wanted",
@@ -9,6 +9,7 @@ import { ToggleGroup } from "../components/toggle-group";
9
9
  import type { CommitScope } from "../components/write-lock";
10
10
  import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
11
11
  import { cn } from "../lib/cn";
12
+ import { FILL_HEIGHT_CLASS } from "../shell/fill-height";
12
13
  import { EMPTY_FORMATTED_VALUE } from "../lib/format";
13
14
  import { FeedbackCategoryBadge, FeedbackDetail, FeedbackDetailSection, FeedbackProse, FeedbackStatusBadge } from "./feedback-inbox";
14
15
  import { useFeedbackCategoryLabels } from "./feedback-labels";
@@ -189,6 +190,9 @@ export interface FeedbackPublicListProps<T extends FeedbackPublicListItem = Feed
189
190
  * Subject, Status) are `feedbackPage`'s: pass `pageLabels` for those. */
190
191
  labels?: LabelOverride<FeedbackPublicLabels>;
191
192
  pageLabels?: LabelOverride<FeedbackPageLabels>;
193
+ /** 0.40.1: the list fills the window as DataTable's `fillHeight` (page layout §1) — the
194
+ * public feedback page passes §1.2's rule. Below md the page scrolls, as everywhere. */
195
+ fillHeight?: boolean;
192
196
  className?: string;
193
197
  }
194
198
 
@@ -236,6 +240,7 @@ export function FeedbackPublicList<T extends FeedbackPublicListItem = FeedbackPu
236
240
  renderDate: renderDateProp,
237
241
  labels: labelsProp,
238
242
  pageLabels: pageLabelsProp,
243
+ fillHeight,
239
244
  className,
240
245
  }: FeedbackPublicListProps<T>) {
241
246
  const labels = useFeedbackPublicLabels(labelsProp);
@@ -310,7 +315,11 @@ export function FeedbackPublicList<T extends FeedbackPublicListItem = FeedbackPu
310
315
  );
311
316
 
312
317
  return (
313
- <div className={cn("flex flex-col gap-3", className)} data-feedback-public-list="" aria-busy={loading || undefined}>
318
+ <div
319
+ className={cn("flex flex-col gap-3", fillHeight && FILL_HEIGHT_CLASS, className)}
320
+ data-feedback-public-list=""
321
+ aria-busy={loading || undefined}
322
+ >
314
323
  <div className="flex flex-wrap items-center gap-2">
315
324
  <ToggleGroup
316
325
  aria-label={labels.sort}
@@ -346,6 +355,7 @@ export function FeedbackPublicList<T extends FeedbackPublicListItem = FeedbackPu
346
355
  expandedRow={(row) => renderDetail(row)}
347
356
  mobileExpandAsDialog
348
357
  paginated={false}
358
+ fillHeight={fillHeight}
349
359
  empty={loading ? labels.loading : labels.empty}
350
360
  mobileCard={(row) => (
351
361
  <div className="min-w-0 space-y-1">
@@ -455,7 +465,8 @@ export function FeedbackPublicDetail({
455
465
  />
456
466
  </div>
457
467
  <FeedbackDetailSection title={labels.body}>
458
- {/* Rework rounds stay in it — the author's own words (§9.4). */}
468
+ {/* Rework rounds stay in it — the author's own words (§9.4) — on a server before
469
+ server-kit 0.13; since then each is an entry in the conversation (§10.3). */}
459
470
  <FeedbackProse>{row.body}</FeedbackProse>
460
471
  </FeedbackDetailSection>
461
472
  <FeedbackDetailSection title={labels.conversation}>
@@ -464,6 +475,8 @@ export function FeedbackPublicDetail({
464
475
  comments={comments ?? []}
465
476
  loading={comments === undefined}
466
477
  isAdmin={isAdmin}
478
+ // 0.40 (§10.3): the author's rework entries are theirs — at the reading end.
479
+ isAuthor={row.mine}
467
480
  onComment={onComment}
468
481
  onHide={onHide}
469
482
  onDelete={onDelete}
@@ -11,14 +11,28 @@ import type { FeedbackCategory, FeedbackStatus } from "./feedback-vocabulary";
11
11
  * account, a loop's reply). The client never builds one from an id.
12
12
  */
13
13
 
14
- /** One answer of the admin's — `FeedbackReplyResponse`, §9.7. Oldest first. */
14
+ /** The two kinds of entry in a report's timeline (0.40, §10.2 decision 7): the admin's
15
+ * `reply`, and the author's `rework` — the report sent back with a note. */
16
+ export type FeedbackEntryKind = "reply" | "rework";
17
+
18
+ /** One answer of the admin's — `FeedbackReplyResponse`, §9.7 — or, since 0.40, one
19
+ * rework of the author's (§10.3, `kind: "rework"`). Oldest first. */
15
20
  export interface FeedbackReply {
16
21
  id: number;
17
22
  /** `null`: a loop's reply, a migrated `outcome`, or an erased admin — drawn as the
18
- * app's word for its admin ("Admin"). */
23
+ * app's word for its admin ("Admin"). On a rework entry `null` is an erased or
24
+ * unattributed author, drawn as "A user" (`feedbackPublic.anonymous`), never as the
25
+ * admin word. */
19
26
  author_name: string | null;
20
27
  body: string;
21
- /** The status this reply moved the report to; `null` = no status change. */
28
+ /** 0.40 (§10.3): `"reply"` (absent: a server before server-kit 0.13, all replies) or
29
+ * `"rework"`, the author's entry. */
30
+ kind?: FeedbackEntryKind;
31
+ /** 0.40: a rework entry's one file (`/api/v1/feedback/attachments/<key>`) — the admin
32
+ * and author views only; `null` or absent otherwise (never in the public view, §9.10). */
33
+ attachment_url?: string | null;
34
+ /** The status this reply moved the report to; `null` = no status change — and on a
35
+ * rework migrated out of the body (§10.4), whose move was never recorded. */
22
36
  status_to: FeedbackStatus | null;
23
37
  /** Where it moved from — the admin view only. */
24
38
  status_from?: FeedbackStatus | null;