@eifi1/ui-kit 0.46.0 → 0.47.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 (148) hide show
  1. package/README.md +12 -11
  2. package/dist/admin/user-roster.d.ts +6 -2
  3. package/dist/admin/user-roster.js +7 -3
  4. package/dist/admin/user-roster.js.map +1 -1
  5. package/dist/components/data-table-row-actions.d.ts +22 -5
  6. package/dist/components/data-table-row-actions.js +14 -5
  7. package/dist/components/data-table-row-actions.js.map +1 -1
  8. package/dist/components/data-table.d.ts +16 -1
  9. package/dist/components/data-table.js +20 -1
  10. package/dist/components/data-table.js.map +1 -1
  11. package/dist/components/row-actions.d.ts +37 -5
  12. package/dist/components/row-actions.js +22 -5
  13. package/dist/components/row-actions.js.map +1 -1
  14. package/dist/components/tabs.d.ts +24 -16
  15. package/dist/components/tabs.js +37 -31
  16. package/dist/components/tabs.js.map +1 -1
  17. package/dist/components/translation-review.js +3 -1
  18. package/dist/components/translation-review.js.map +1 -1
  19. package/dist/components/ui-labels.js +1 -1
  20. package/dist/components/ui-labels.js.map +1 -1
  21. package/dist/data-table.d.ts +1 -0
  22. package/dist/data-table.js +2 -0
  23. package/dist/data-table.js.map +1 -1
  24. package/dist/eslint/index.js +1 -1
  25. package/dist/feedback/feedback-attachment-download.d.ts +19 -0
  26. package/dist/feedback/feedback-attachment-download.js +59 -0
  27. package/dist/feedback/feedback-attachment-download.js.map +1 -0
  28. package/dist/feedback/feedback-comment-thread.d.ts +17 -6
  29. package/dist/feedback/feedback-comment-thread.js +21 -2
  30. package/dist/feedback/feedback-comment-thread.js.map +1 -1
  31. package/dist/feedback/feedback-entry-file.d.ts +61 -0
  32. package/dist/feedback/feedback-entry-file.js +76 -0
  33. package/dist/feedback/feedback-entry-file.js.map +1 -0
  34. package/dist/feedback/feedback-public-list.d.ts +18 -5
  35. package/dist/feedback/feedback-public-list.js +8 -0
  36. package/dist/feedback/feedback-public-list.js.map +1 -1
  37. package/dist/feedback/feedback-public-types.d.ts +7 -2
  38. package/dist/feedback/feedback-replies.d.ts +26 -9
  39. package/dist/feedback/feedback-replies.js +20 -10
  40. package/dist/feedback/feedback-replies.js.map +1 -1
  41. package/dist/feedback/feedback-row-detail-labels.js +0 -1
  42. package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
  43. package/dist/feedback/feedback-row-detail.d.ts +23 -10
  44. package/dist/feedback/feedback-row-detail.js +10 -54
  45. package/dist/feedback/feedback-row-detail.js.map +1 -1
  46. package/dist/feedback.d.ts +1 -0
  47. package/dist/feedback.js +1 -0
  48. package/dist/feedback.js.map +1 -1
  49. package/dist/i18n/german.js +1 -6
  50. package/dist/i18n/german.js.map +1 -1
  51. package/dist/i18n/locales/en.js +1 -6
  52. package/dist/i18n/locales/en.js.map +2 -2
  53. package/dist/i18n/locales/es.js +1 -6
  54. package/dist/i18n/locales/es.js.map +1 -1
  55. package/dist/i18n/locales/fr.js +1 -6
  56. package/dist/i18n/locales/fr.js.map +1 -1
  57. package/dist/i18n/locales/hu.js +1 -6
  58. package/dist/i18n/locales/hu.js.map +1 -1
  59. package/dist/i18n/locales/it.js +1 -6
  60. package/dist/i18n/locales/it.js.map +1 -1
  61. package/dist/i18n/locales/zh.js +1 -6
  62. package/dist/i18n/locales/zh.js.map +1 -1
  63. package/dist/i18n/review.js +0 -1
  64. package/dist/i18n/review.js.map +1 -1
  65. package/dist/index.d.ts +5 -7
  66. package/dist/index.js +14 -5
  67. package/dist/index.js.map +1 -1
  68. package/dist/notifications/device-zone-report.d.ts +7 -0
  69. package/dist/notifications/device-zone-report.js +8 -1
  70. package/dist/notifications/device-zone-report.js.map +1 -1
  71. package/dist/notifications/feedback-notification-types.d.ts +14 -1
  72. package/dist/notifications/feedback-notification-types.js +5 -1
  73. package/dist/notifications/feedback-notification-types.js.map +1 -1
  74. package/dist/notifications/notification-bell.d.ts +1 -1
  75. package/dist/notifications/notification-bell.js.map +1 -1
  76. package/dist/notifications/notification-inbox.d.ts +12 -3
  77. package/dist/notifications/notification-inbox.js +2 -2
  78. package/dist/notifications/notification-inbox.js.map +1 -1
  79. package/dist/notifications/notification-types.d.ts +0 -3
  80. package/dist/notifications/notifications-labels.d.ts +2 -13
  81. package/dist/notifications/notifications-labels.js +0 -4
  82. package/dist/notifications/notifications-labels.js.map +1 -1
  83. package/dist/notifications/quiet-hours-setting.d.ts +3 -8
  84. package/dist/notifications/quiet-hours-setting.js +8 -3
  85. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  86. package/dist/offline/local-first.d.ts +9 -0
  87. package/dist/offline/local-first.js +14 -0
  88. package/dist/offline/local-first.js.map +1 -1
  89. package/dist/offline.d.ts +1 -1
  90. package/dist/offline.js +2 -1
  91. package/dist/offline.js.map +1 -1
  92. package/dist/sync/db.d.ts +17 -0
  93. package/dist/sync/db.js +10 -1
  94. package/dist/sync/db.js.map +1 -1
  95. package/dist/sync/engine.d.ts +11 -9
  96. package/dist/sync/engine.js.map +1 -1
  97. package/dist/sync/hooks.d.ts +5 -0
  98. package/dist/sync/hooks.js.map +1 -1
  99. package/dist/sync.d.ts +2 -2
  100. package/dist/sync.js +5 -2
  101. package/dist/sync.js.map +1 -1
  102. package/dist/theme/theme-store.d.ts +4 -4
  103. package/dist/theme/theme-store.js +1 -0
  104. package/dist/theme/theme-store.js.map +1 -1
  105. package/package.json +2 -2
  106. package/src/admin/user-roster.tsx +12 -3
  107. package/src/components/data-table-row-actions.tsx +33 -9
  108. package/src/components/data-table.tsx +46 -3
  109. package/src/components/row-actions.tsx +73 -10
  110. package/src/components/tabs.tsx +66 -44
  111. package/src/components/translation-review.tsx +3 -1
  112. package/src/components/ui-labels.ts +1 -1
  113. package/src/data-table.ts +3 -0
  114. package/src/feedback/feedback-attachment-download.tsx +77 -0
  115. package/src/feedback/feedback-comment-thread.tsx +42 -7
  116. package/src/feedback/feedback-entry-file.tsx +150 -0
  117. package/src/feedback/feedback-public-list.tsx +25 -4
  118. package/src/feedback/feedback-public-types.ts +7 -2
  119. package/src/feedback/feedback-replies.tsx +46 -19
  120. package/src/feedback/feedback-row-detail-labels.ts +0 -1
  121. package/src/feedback/feedback-row-detail.tsx +35 -86
  122. package/src/feedback.ts +1 -0
  123. package/src/i18n/german.ts +1 -6
  124. package/src/i18n/locales/es.ts +1 -6
  125. package/src/i18n/locales/fr.ts +1 -6
  126. package/src/i18n/locales/hu.ts +1 -6
  127. package/src/i18n/locales/it.ts +1 -6
  128. package/src/i18n/locales/zh.ts +1 -6
  129. package/src/i18n/review.ts +0 -1
  130. package/src/index.ts +19 -5
  131. package/src/notifications/device-zone-report.ts +14 -1
  132. package/src/notifications/feedback-notification-types.ts +19 -1
  133. package/src/notifications/notification-bell.tsx +1 -1
  134. package/src/notifications/notification-inbox.tsx +15 -5
  135. package/src/notifications/notification-types.ts +0 -3
  136. package/src/notifications/notifications-labels.ts +2 -17
  137. package/src/notifications/quiet-hours-setting.tsx +16 -11
  138. package/src/offline/local-first.ts +24 -0
  139. package/src/offline.ts +1 -1
  140. package/src/sync/db.ts +22 -0
  141. package/src/sync/engine.ts +11 -9
  142. package/src/sync/hooks.ts +5 -0
  143. package/src/sync.ts +2 -2
  144. package/src/theme/theme-store.ts +13 -5
  145. package/dist/notifications/time-zone-select.d.ts +0 -53
  146. package/dist/notifications/time-zone-select.js +0 -76
  147. package/dist/notifications/time-zone-select.js.map +0 -1
  148. package/src/notifications/time-zone-select.tsx +0 -129
@@ -609,6 +609,14 @@ export interface DataTableProps<T> {
609
609
  * `mobileSwipeActions` wins over the swipes derived from here.
610
610
  */
611
611
  rowActions?: DataTableRowAction<T>[] | ((row: T) => DataTableRowAction<T>[]);
612
+ /**
613
+ * The `rowActions` column's heading (0.46.1, keksdose dev#598 rework — Marcel: a
614
+ * VISIBLE "Actions" heading on every table with an actions column). Default: the
615
+ * provider's `dataTable.actions` ("Actions"), shown, end-aligned over the buttons. A
616
+ * node replaces it; `false` keeps the pre-0.46.1 heading, named for a screen reader
617
+ * only.
618
+ */
619
+ rowActionsHeader?: ReactNode | false;
612
620
  /**
613
621
  * The bar above the table — a search field, filter chips, a create button. Inside
614
622
  * the table's frame, above the header, on desktop and phone alike (resource-list
@@ -703,6 +711,13 @@ export interface DataTableDesktopSwipeActions<T> {
703
711
  * no commit scope (the plan decides).
704
712
  */
705
713
  commit?: CommitScope | ((action: SwipeAction, row: T) => CommitScope | undefined);
714
+ /**
715
+ * The column's heading (0.46.1, keksdose dev#598 rework — Marcel: a VISIBLE "Actions"
716
+ * heading on every table with an actions column). Default: the provider's
717
+ * `dataTable.actions` ("Actions"), shown, end-aligned over the buttons. A node
718
+ * replaces it; `false` keeps the 0.46.0 heading, named for a screen reader only.
719
+ */
720
+ header?: ReactNode | false;
706
721
  }
707
722
 
708
723
  /**
@@ -828,6 +843,7 @@ export function DataTable<T>({
828
843
  edgeFade = false,
829
844
  className,
830
845
  rowActions,
846
+ rowActionsHeader,
831
847
  toolbar,
832
848
  footerLabel,
833
849
  footer: footerProp,
@@ -860,6 +876,7 @@ export function DataTable<T>({
860
876
  const { columns, swipesFromActions } = useRowActionColumn({
861
877
  columns: columnsProp,
862
878
  rowActions,
879
+ rowActionsHeader,
863
880
  rowName,
864
881
  labels,
865
882
  density,
@@ -1196,6 +1213,17 @@ export function DataTable<T>({
1196
1213
  const isMdUp = !usePhoneLayout();
1197
1214
  // `edgeFade` (0.25) on the desktop scroller; the phone list never scrolls sideways.
1198
1215
  const desktopScroller = useRef<HTMLDivElement | null>(null);
1216
+ // 0.47 (keksdose S4): the scroller's visible width, as `--dt-visible-w` on it, for the
1217
+ // expansion row's pinned content (below). Set on the element, not in state: a resize
1218
+ // re-renders nothing. Only a table that can expand a row measures.
1219
+ const measuresVisibleWidth = isMdUp && !!expandedRow;
1220
+ useEffect(() => {
1221
+ const el = desktopScroller.current;
1222
+ if (!measuresVisibleWidth || !el || typeof ResizeObserver === "undefined") return;
1223
+ const ro = new ResizeObserver(() => el.style.setProperty("--dt-visible-w", `${el.clientWidth}px`));
1224
+ ro.observe(el);
1225
+ return () => ro.disconnect();
1226
+ }, [measuresVisibleWidth]);
1199
1227
  const edgeFadeProps = useEdgeFade(desktopScroller, edgeFade && isMdUp);
1200
1228
 
1201
1229
  // ---- Mobile card list (md:hidden) ----
@@ -1909,10 +1937,25 @@ export function DataTable<T>({
1909
1937
  content in `w-0 min-w-full` pins the cell's preferred
1910
1938
  width to ~0 (the explicit width:0 child), so it can never
1911
1939
  perturb the columns; the div then fills to the full row
1912
- width only at paint time, and long text wraps within it. */}
1940
+ width only at paint time, and long text wraps within it.
1941
+ Inside it the content is pinned to what the scroller SHOWS
1942
+ (0.47, keksdose S4): on a table wider than its scroller a
1943
+ table-wide panel put an inline editor's Save/Cancel off-screen.
1944
+ `sticky start-0` holds it at the scroller's start edge (the
1945
+ right one in RTL) as the row scrolls sideways, and
1946
+ `--dt-visible-w` (the scroller's clientWidth, measured above)
1947
+ sizes it; before the first measure, and in a table no wider
1948
+ than its scroller, it is the full width as before. */}
1913
1949
  <td colSpan={totalColSpan} className="p-0">
1914
- <div className={cn("w-0 min-w-full", compact ? "px-2 py-2" : "px-3 py-3")}>
1915
- {expansion}
1950
+ <div className="w-0 min-w-full">
1951
+ <div
1952
+ className={cn(
1953
+ "sticky start-0 w-[var(--dt-visible-w,100%)] max-w-full",
1954
+ compact ? "px-2 py-2" : "px-3 py-3",
1955
+ )}
1956
+ >
1957
+ {expansion}
1958
+ </div>
1916
1959
  </div>
1917
1960
  </td>
1918
1961
  </tr>
@@ -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
  /**
@@ -522,10 +523,57 @@ function InlineAction({
522
523
  );
523
524
  }
524
525
 
525
- /** The actions column's hidden header, in the provider's words. */
526
- function RowActionsHeader({ text, labels }: { text?: string; labels?: LabelOverride<RowActionsLabels> }) {
526
+ /** The actions column's heading, in the provider's words: on screen since 0.46.1 (keksdose
527
+ * dev#598 rework), a screen reader's name only with `hidden`. */
528
+ function RowActionsHeader({
529
+ text,
530
+ labels,
531
+ hidden,
532
+ }: {
533
+ text?: string;
534
+ labels?: LabelOverride<RowActionsLabels>;
535
+ hidden?: boolean;
536
+ }) {
527
537
  const resolved = useKitLabels("rowActions", DEFAULT_ROW_ACTIONS_LABELS, labels);
528
- return <span className="sr-only">{text ?? resolved.actions}</span>;
538
+ const word = text ?? resolved.actions;
539
+ return hidden ? <span className="sr-only">{word}</span> : <>{word}</>;
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
+ );
529
577
  }
530
578
 
531
579
  export interface RowActionsColumnOptions<T> {
@@ -546,10 +594,17 @@ export interface RowActionsColumnOptions<T> {
546
594
  /**
547
595
  * The column's name as plain text — the column-settings checklist and the phone
548
596
  * card's label read it (`DataTableColumn.headerText`). Pass the app's own word; left
549
- * out, the header shows the provider's `rowActions.actions` to a screen reader and
550
- * the plain-text places fall back to the column's `key`.
597
+ * out, the heading shows the provider's `rowActions.actions` and the plain-text
598
+ * places fall back to the column's `key`.
551
599
  */
552
600
  headerText?: string;
601
+ /**
602
+ * The heading (0.46.1, keksdose dev#598 rework — Marcel: a VISIBLE "Actions" heading on
603
+ * every table with an actions column). Default: `headerText`, else the provider's
604
+ * `rowActions.actions` ("Actions"), shown, end-aligned over the buttons. A node
605
+ * replaces it; `false` keeps the pre-0.46.1 heading, named for a screen reader only.
606
+ */
607
+ header?: ReactNode | false;
553
608
  labels?: LabelOverride<RowActionsLabels>;
554
609
  /** Anything else for the column (`mobileHidden`, `cellProps`…), merged last. */
555
610
  column?: Partial<DataTableColumn<T>>;
@@ -561,8 +616,8 @@ export interface RowActionsColumnOptions<T> {
561
616
  *
562
617
  * columns={[...columns, rowActionsColumn({ actions: (r) => [...], name: (r) => r.name })]}
563
618
  *
564
- * The header is visually hidden (the icons say what the column is), the column hugs its
565
- * content at the row's end, and `noRowLink` keeps a `rowHref` anchor off it, so the
619
+ * The heading says "Actions" on screen (0.46.1; `header` changes or hides it), the
620
+ * column hugs its content at the row's end, and `noRowLink` keeps a `rowHref` anchor off it, so the
566
621
  * buttons keep their own clicks. On a phone it is a line of the card like any column;
567
622
  * set `column: { mobileHidden: true }` to drop it there (with `mobileDetailsInRow` it
568
623
  * then shows in the opened row instead).
@@ -576,12 +631,18 @@ export function rowActionsColumn<T>({
576
631
  glyphSize,
577
632
  tooltipSide,
578
633
  headerText,
634
+ header,
579
635
  labels,
580
636
  column,
581
637
  }: RowActionsColumnOptions<T>): DataTableColumn<T> {
582
638
  return {
583
639
  key,
584
- header: <RowActionsHeader text={headerText} labels={labels} />,
640
+ header:
641
+ header === false || header == null ? (
642
+ <RowActionsHeader text={headerText} labels={labels} hidden={header === false} />
643
+ ) : (
644
+ header
645
+ ),
585
646
  headerText,
586
647
  cell: (row) => (
587
648
  <RowActions
@@ -594,8 +655,10 @@ export function rowActionsColumn<T>({
594
655
  labels={labels}
595
656
  />
596
657
  ),
597
- className: "w-px whitespace-nowrap text-end",
598
- headClassName: "w-px",
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,
599
662
  noRowLink: true,
600
663
  ...column,
601
664
  };
@@ -48,10 +48,16 @@ export interface TabsLabels {
48
48
  add: string;
49
49
  /** Accessible name of a tab's × — "Remove <tab>". */
50
50
  remove: (tab: string) => string;
51
- /** The `overflowCue` caret while tabs lie past the strip's end: "More tabs". */
51
+ /** The `overflowCue` › over the strip's end, while tabs lie past it: "More tabs". */
52
52
  more: string;
53
- /** The `overflowCue` caret at the strip's end, which goes back to the first tab. */
54
- backToFirst: string;
53
+ /** The `overflowCue` ‹ over the strip's start, while tabs lie before it: "Previous tabs". */
54
+ previous: string;
55
+ /**
56
+ * @deprecated 0.47: not drawn any more — the caret that turned into "back to the
57
+ * first tab" at the strip's end is now a ‹ of its own (`previous`). Accepted and
58
+ * ignored; removed in 0.48.
59
+ */
60
+ backToFirst?: string;
55
61
  }
56
62
 
57
63
  // Defined in ./ui-labels, which holds no component code (0.34, KS U8).
@@ -78,20 +84,22 @@ export interface TabsProps<T extends string>
78
84
  onChange: (id: T) => void;
79
85
  className?: string;
80
86
  /**
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.
87
+ * Carets over the ends of a SCROLLING strip (0.40, kk run 79 gap 1; 0.47, keksdose
88
+ * live #410 round 4): a › over the strip's end while tabs lie past it, and a ‹ over
89
+ * its start while tabs lie before it — each there only while something is hidden
90
+ * past ITS edge (1 px slack, so a few cut-off pixels count), each a step of three
91
+ * quarters of the strip's width toward its own side. Neither ever changes direction:
92
+ * until 0.46 one caret turned round into "back to the first" at the end, and read as
93
+ * an arrow pointing the wrong way. Start and end follow the reading direction.
85
94
  *
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`.
95
+ * The edge fade alone could read as the end of the row: the last visible tab can
96
+ * finish exactly where the fade begins, so nothing looks cut off (keksdose live #410).
97
+ * The carets sit OVER the strip's ends (positioned, on the page's surface), not beside
98
+ * it, so the strip's width never changes as they come and go. Out of the tab order —
99
+ * the keyboard walks the strip with the arrow keys, which bring the focused tab into
100
+ * view. At every width. Ignored on a vertical strip (a side nav is a column, not a
101
+ * row). Names: `tabs.more` / `tabs.previous`. keksdose's `scroll-edge-cues.tsx` is
102
+ * the reference this replaces.
95
103
  */
96
104
  overflowCue?: boolean;
97
105
  /**
@@ -254,36 +262,43 @@ export function Tabs<T extends string>({
254
262
  const stripRef = useRef<HTMLDivElement>(null);
255
263
  const setStrip = useMemo(() => mergeRefs(stripRef, ref), [ref]);
256
264
  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 = () => {
265
+ // See `overflowCue`: a ‹ over the start while tabs lie before it, a › over the end
266
+ // while tabs lie past it — `fade.overflow` names the cut ends in reading-direction terms.
267
+ const cue = overflowCue && !vertical;
268
+ const cueStart = cue && (fade.overflow === "start" || fade.overflow === "both");
269
+ const cueEnd = cue && (fade.overflow === "end" || fade.overflow === "both");
270
+ // Three quarters of the visible width toward `side`; an RTL strip runs leftward.
271
+ const onCue = (side: "start" | "end") => {
261
272
  const strip = stripRef.current;
262
273
  if (!strip) return;
263
274
  const behavior: ScrollBehavior = prefersReducedMotion() ? "auto" : "smooth";
264
- if (cueAtEnd) {
265
- strip.scrollTo({ left: 0, behavior });
266
- return;
267
- }
268
275
  const rtl = getComputedStyle(strip).direction === "rtl";
269
- strip.scrollBy({ left: (rtl ? -1 : 1) * Math.round(strip.clientWidth * 0.75), behavior });
276
+ const toward = (side === "end" ? 1 : -1) * (rtl ? -1 : 1);
277
+ strip.scrollBy({ left: toward * Math.round(strip.clientWidth * 0.75), behavior });
270
278
  };
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">
279
+ // OVER the strip's end, on the page's surface, so the caret reads as a control and not
280
+ // as one more faded tab — and so it can come and go without the strip changing width
281
+ // under the thumb. The position sits on a wrapper of ours: IconButton puts its
282
+ // tooltip's span around the button, and a class on the button would land inside it.
283
+ const cueAt = (side: "start" | "end") => (
284
+ <div
285
+ data-tabs-overflow-cue={side}
286
+ className={cn("absolute top-1/2 z-10 flex -translate-y-1/2", side === "start" ? "start-0" : "end-0")}
287
+ >
274
288
  <IconButton
275
289
  size="sm"
276
- variant="ghost"
277
- tone="muted"
290
+ variant="secondary"
291
+ className="bg-surface shadow-sm"
278
292
  tabIndex={-1}
279
293
  type="button"
280
- aria-label={cueAtEnd ? text.backToFirst : text.more}
281
- onClick={onCue}
294
+ label={side === "start" ? text.previous : text.more}
295
+ labelVisible={false}
296
+ onClick={() => onCue(side)}
282
297
  >
283
- {cueAtEnd ? <ChevronLeft className="rtl:-scale-x-100" /> : <ChevronRight className="rtl:-scale-x-100" />}
298
+ {side === "start" ? <ChevronLeft className="rtl:-scale-x-100" /> : <ChevronRight className="rtl:-scale-x-100" />}
284
299
  </IconButton>
285
300
  </div>
286
- ) : null;
301
+ );
287
302
  // The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a
288
303
  // deep link to the seventh report. A selection behind the cut edge was the worst
289
304
  // case of the overflow above: the one tab the panel below belongs to, sliced to
@@ -407,7 +422,7 @@ export function Tabs<T extends string>({
407
422
  // — it is the box a caller's margin or width is meant for.
408
423
  className={cn(
409
424
  vertical ? TABLIST_VERTICAL_CLASSES : TABLIST_CLASSES,
410
- onAdd ? "min-w-0" : overflowCue && !vertical ? "min-w-0 flex-1" : className,
425
+ cue ? "min-w-0 flex-1" : onAdd ? "min-w-0" : className,
411
426
  )}
412
427
  >
413
428
  {tabs.map((tab) => {
@@ -575,16 +590,24 @@ export function Tabs<T extends string>({
575
590
  {text.remove(nameOf(activeTab))}
576
591
  </button>
577
592
  ) : 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.
593
+ // The positioned box the carets sit in, over the strip's two ends (see `overflowCue`).
594
+ // There whether or not the strip overflows right now, so a resize never re-parents it.
595
+ const stripBox = cue ? (
596
+ <div className={cn("relative flex min-w-0", !onAdd && "flex-1")}>
597
+ {tablist}
598
+ {cueStart && cueAt("start")}
599
+ {cueEnd && cueAt("end")}
600
+ </div>
601
+ ) : (
602
+ tablist
603
+ );
604
+ if (!onAdd && cue) {
605
+ // `className` goes on the row around the strip — it is the box a caller's margin or
606
+ // width is meant for, as with an add button — and stays there at every scroll position.
583
607
  return (
584
608
  <div className={cn("flex items-end", className)}>
585
- {tablist}
609
+ {stripBox}
586
610
  {removeButton}
587
- {cueButton}
588
611
  </div>
589
612
  );
590
613
  }
@@ -614,9 +637,8 @@ export function Tabs<T extends string>({
614
637
  }
615
638
  return (
616
639
  <div className={cn("flex items-end", className)}>
617
- {tablist}
640
+ {stripBox}
618
641
  {removeButton}
619
- {cueButton}
620
642
  {/* Outside the tablist: an action, not a tab — and a tablist may own only
621
643
  tabs. The rule under the strip carries on beneath it. */}
622
644
  <div
@@ -838,7 +838,9 @@ export function TranslationReviewPanel({
838
838
  } else if (editable) {
839
839
  columns.push({
840
840
  key: "approve",
841
- header: <span className="sr-only">{labels.approve}</span>,
841
+ // On screen, as every actions column's heading is (0.46.1, keksdose dev#598 rework).
842
+ header: rowActionLabels.actions,
843
+ headerText: rowActionLabels.actions,
842
844
  cell: (r) =>
843
845
  r.status === "approved" || r.text === "" ? null : (
844
846
  <IconButton
@@ -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
  };
package/src/data-table.ts CHANGED
@@ -27,6 +27,9 @@ export type {
27
27
  DataTableRowAction,
28
28
  DataTableDesktopSwipeActions,
29
29
  } from "./components/data-table";
30
+ // 0.46.1 (keksdose N3): a swipe plan as `RowActions`' actions — the desktop column's own
31
+ // derivation, for an app's hand-built actions column.
32
+ export { swipePlanRowActions } from "./components/data-table-row-actions";
30
33
  export * from "./components/data-table-cells";
31
34
  export { useTableUrlState, readTableUrlState } from "./components/use-table-state";
32
35
  export type {
@@ -0,0 +1,77 @@
1
+ import { useState } from "react";
2
+ import type { MouseEvent } from "react";
3
+ import { Download, FileText } from "lucide-react";
4
+ import { Button } from "../components/button";
5
+ import { toast } from "../components/toast";
6
+ import type { AuthedFetcher } from "../hooks/use-authed-src";
7
+ import { useKitLabels } from "../i18n/kit-labels";
8
+ import { filenameFromDisposition, saveBlob } from "../lib/download";
9
+ import { attachmentName } from "./feedback-record";
10
+ import { DEFAULT_FEEDBACK_DETAIL_LABELS } from "./feedback-row-detail-labels";
11
+
12
+ // Internal (not in the `./feedback` entry): the row detail's download button and its
13
+ // plain fetch, shared since 0.47 with ./feedback-entry-file's inline entry files.
14
+
15
+ /** The bytes behind a fetcher's answer — a `Blob` as it is, a `Response` checked. */
16
+ async function toBlob(answer: Blob | Response): Promise<Blob> {
17
+ if (typeof Response !== "undefined" && answer instanceof Response) {
18
+ if (!answer.ok) throw new Error(`HTTP ${answer.status}`);
19
+ return answer.blob();
20
+ }
21
+ return answer as Blob;
22
+ }
23
+
24
+ /** No fetcher: the browser's own `fetch`, with the cookies a same-origin app uses. */
25
+ export const plainFeedbackFetch: AuthedFetcher = (url, { signal }) =>
26
+ fetch(url, { signal, credentials: "same-origin" });
27
+
28
+ /**
29
+ * A PDF or a text log (keksdose `FeedbackFileDownload`, dev #578): the bytes sit behind
30
+ * an authenticated route that serves them as `attachment`, never inline (§3.5), so a
31
+ * plain link would be a 401 the moment it was opened. Fetched through the app's
32
+ * fetcher and saved with {@link saveBlob}; a failure says so in a toast.
33
+ *
34
+ * Saved under the name the server gives it when the fetcher answers with a `Response`
35
+ * (server-kit's `content_disposition` puts the uploader's own name in `filename*`), and
36
+ * under the URL's last segment — the name on the button — when it answers with a bare
37
+ * `Blob`, which has no headers to ask. Words: `feedbackDetail.download` /
38
+ * `downloadFailed`.
39
+ */
40
+ export function FeedbackAttachmentDownload({ url, fetcher }: { url: string; fetcher: AuthedFetcher }) {
41
+ const labels = useKitLabels("feedbackDetail", DEFAULT_FEEDBACK_DETAIL_LABELS);
42
+ const [busy, setBusy] = useState(false);
43
+ const name = attachmentName(url);
44
+ const download = async (event: MouseEvent) => {
45
+ // The row around the detail may toggle on a click.
46
+ event.stopPropagation();
47
+ setBusy(true);
48
+ try {
49
+ const answer = await fetcher(url, { signal: new AbortController().signal });
50
+ const blob = await toBlob(answer);
51
+ const saveAs =
52
+ typeof Response !== "undefined" && answer instanceof Response
53
+ ? filenameFromDisposition(answer.headers.get("Content-Disposition"), name)
54
+ : name;
55
+ saveBlob(blob, saveAs);
56
+ } catch {
57
+ toast.error(labels.downloadFailed);
58
+ } finally {
59
+ setBusy(false);
60
+ }
61
+ };
62
+ return (
63
+ <Button
64
+ type="button"
65
+ variant="secondary"
66
+ size="sm"
67
+ pending={busy}
68
+ onClick={(event) => void download(event)}
69
+ aria-label={labels.download(name)}
70
+ className="max-w-full"
71
+ >
72
+ <FileText className="size-3.5 shrink-0" aria-hidden />
73
+ <span className="min-w-0 max-w-[14rem] truncate-until-large">{name}</span>
74
+ <Download className="size-3.5 shrink-0" aria-hidden />
75
+ </Button>
76
+ );
77
+ }
@@ -4,11 +4,13 @@ import { EyeOff } from "lucide-react";
4
4
  import { Button } from "../components/button";
5
5
  import { useConfirm } from "../components/confirm-dialog";
6
6
  import type { CommitScope } from "../components/write-lock";
7
+ import type { AuthedFetcher } from "../hooks/use-authed-src";
7
8
  import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
8
9
  import { FeedbackStatusBadge } from "./feedback-inbox";
9
10
  import type { FeedbackComment, FeedbackReply } from "./feedback-public-types";
10
11
  import { DEFAULT_FEEDBACK_PUBLIC_LABELS } from "./feedback-public-labels";
11
12
  import { FEEDBACK_COMMENT_MAX, attachmentName } from "./feedback-record";
13
+ import { FeedbackEntryFile, useFeedbackEntryUpload, type FeedbackEntryUploadOptions } from "./feedback-entry-file";
12
14
  import { useFeedbackRepliesLabels } from "./feedback-replies";
13
15
  import { FeedbackRepliesDrawnAboveContext } from "./feedback-replies-context";
14
16
  import { FeedbackReworkEntryChip } from "./feedback-rework-entry-chip";
@@ -34,8 +36,13 @@ import { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels
34
36
  * sees their text with that line; the admin sees it struck through, with **Unhide**.
35
37
  * An admin has **Hide** on every visible comment; the writer **Delete** on their own,
36
38
  * after a confirm (it is a hard delete). Nobody edits a comment.
37
- * - **The composer** (1–4 000 characters, no files) only with `onComment`: left out for
38
- * a reader who may not write (a demo, a CRASH report — the app knows).
39
+ * - **The composer** (1–4 000 characters) only with `onComment`: left out for a reader
40
+ * who may not write (a demo, a CRASH report — the app knows). Since 0.47 (§12) with
41
+ * `onUpload` it takes one file as the rework note does — pasted into the box,
42
+ * captured or picked — uploaded first, its URL `onComment`'s second argument.
43
+ * - **Files** (0.47, §12): a comment's or an entry's file under its text, a picture
44
+ * INLINE — a thumbnail through `fetcher` that opens full size — any other file a
45
+ * download ({@link FeedbackEntryFile}). A veiled comment shows none.
39
46
  *
40
47
  * Delete asks through `useConfirm`, so a thread with `onDelete` and a comment of the
41
48
  * viewer's needs the app's `ConfirmProvider` (each app mounts one already).
@@ -75,7 +82,7 @@ export { DEFAULT_FEEDBACK_COMMENTS_LABELS };
75
82
 
76
83
  /* ── The thread ───────────────────────────────────────────────────────────── */
77
84
 
78
- export interface FeedbackCommentThreadProps {
85
+ export interface FeedbackCommentThreadProps extends FeedbackEntryUploadOptions {
79
86
  /** The report's replies (§9.7), oldest first. Left out or `[]`: none.
80
87
  *
81
88
  * Inside `FeedbackRowDetail`'s `comments` they are dropped (0.38.1): the detail's
@@ -92,8 +99,12 @@ export interface FeedbackCommentThreadProps {
92
99
  /** `POST /feedback/{id}/comments`. Return the promise: the box clears when it
93
100
  * resolves, and keeps the draft when it rejects. A failure is the app's to toast —
94
101
  * the server's `detail`, else `feedbackToast.commentFailed` (0.38.1). Left out, no
95
- * composer. */
96
- onComment?: (body: string) => void | Promise<unknown>;
102
+ * composer. 0.47 (§12): `attachmentUrl` is the one file uploaded with it (with
103
+ * `onUpload`), `undefined` for none — `attachment_url` in the body (server-kit 0.18). */
104
+ onComment?: (body: string, attachmentUrl?: string) => void | Promise<unknown>;
105
+ /** 0.47: the app's authenticated GET (`AuthedImage`'s) for the files in the thread.
106
+ * Left out, the browser's own fetch with same-origin cookies. */
107
+ fetcher?: AuthedFetcher | null;
97
108
  /** `PATCH /feedback/{id}/comments/{comment_id} {hidden}` — the admin's. A rejection
98
109
  * is caught (0.38.1) and is the app's to toast (`feedbackToast.updateFailed`). Left
99
110
  * out, no Hide / Unhide. */
@@ -141,9 +152,18 @@ export function FeedbackCommentThread({
141
152
  loading = false,
142
153
  commit,
143
154
  now,
155
+ fetcher,
156
+ onUpload,
157
+ onCaptureScreenshot,
158
+ attachment,
144
159
  labels: labelsProp,
145
160
  className,
146
161
  }: FeedbackCommentThreadProps) {
162
+ const { composerAttachment, upload, uploading } = useFeedbackEntryUpload({
163
+ onUpload,
164
+ onCaptureScreenshot,
165
+ attachment,
166
+ });
147
167
  const labels = useKitLabels("feedbackComments", DEFAULT_FEEDBACK_COMMENTS_LABELS, labelsProp);
148
168
  const anonymous = useKitLabels("feedbackPublic", DEFAULT_FEEDBACK_PUBLIC_LABELS).anonymous;
149
169
  const replyLabels = useFeedbackRepliesLabels();
@@ -189,6 +209,10 @@ export function FeedbackCommentThread({
189
209
  author: reply.author_name ?? replyLabels.admin,
190
210
  createdAt: reply.created_at,
191
211
  body: reply.body,
212
+ // 0.47 (§12): an admin reply's file, where the view carries it.
213
+ attachments: reply.attachment_url
214
+ ? [{ name: attachmentName(reply.attachment_url), url: reply.attachment_url }]
215
+ : undefined,
192
216
  staff: true,
193
217
  badge: reply.status_to ? (
194
218
  <span className="inline-flex min-w-0 max-w-full flex-wrap items-center gap-1" data-feedback-reply-status={reply.status_to}>
@@ -244,6 +268,11 @@ export function FeedbackCommentThread({
244
268
  author: veiled ? undefined : comment.mine ? undefined : (comment.author_name ?? anonymous),
245
269
  createdAt: comment.created_at,
246
270
  body: veiled ? "" : (comment.body ?? ""),
271
+ // 0.47 (§12): its one file; none when veiled (the server sends none either).
272
+ attachments:
273
+ !veiled && comment.attachment_url
274
+ ? [{ name: attachmentName(comment.attachment_url), url: comment.attachment_url }]
275
+ : undefined,
247
276
  staff: !veiled && comment.staff,
248
277
  struck: comment.hidden && isAdmin && !veiled,
249
278
  footer: actions.length > 0 ? actions : undefined,
@@ -258,13 +287,19 @@ export function FeedbackCommentThread({
258
287
  messages={entries.map((entry) => entry.message)}
259
288
  loading={loading}
260
289
  now={now}
290
+ renderAttachment={(file) => (file.url ? <FeedbackEntryFile url={file.url} fetcher={fetcher} /> : file.name)}
261
291
  labels={{ thread: labels.comments, empty: labels.empty }}
262
292
  />
263
293
  {onComment && (
264
294
  <ChatComposer
265
295
  className="mt-3"
266
- onSend={(body) => onComment(body)}
267
- pending={pending}
296
+ onSend={async (body, file) => {
297
+ const url = await upload(file);
298
+ // The second argument only with a file, so a text comment calls as before 0.47.
299
+ return url === undefined ? onComment(body) : onComment(body, url);
300
+ }}
301
+ pending={pending || uploading}
302
+ attachment={composerAttachment}
268
303
  commit={commit}
269
304
  maxLength={FEEDBACK_COMMENT_MAX}
270
305
  showCount