@eifi1/ui-kit 0.24.0 → 0.25.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 +6 -6
  2. package/dist/chart.d.ts +2 -2
  3. package/dist/components/account-chips.d.ts +1 -1
  4. package/dist/components/amount-input.d.ts +2 -2
  5. package/dist/components/button-group.d.ts +2 -2
  6. package/dist/components/calculator.d.ts +2 -2
  7. package/dist/components/column-mapper.d.ts +28 -9
  8. package/dist/components/column-mapper.js +63 -77
  9. package/dist/components/column-mapper.js.map +1 -1
  10. package/dist/components/confirm-dialog.d.ts +2 -2
  11. package/dist/components/copy-button.d.ts +2 -2
  12. package/dist/components/danger-confirm.d.ts +2 -2
  13. package/dist/components/data-table-cells.d.ts +1 -1
  14. package/dist/components/data-table-filter-popover.d.ts +1 -1
  15. package/dist/components/data-table-filters.d.ts +1 -1
  16. package/dist/components/data-table-labels.d.ts +1 -1
  17. package/dist/components/data-table-pagination.d.ts +1 -1
  18. package/dist/components/data-table-pagination.js +6 -1
  19. package/dist/components/data-table-pagination.js.map +1 -1
  20. package/dist/components/data-table.d.ts +1 -1
  21. package/dist/components/data-table.js +7 -1
  22. package/dist/components/data-table.js.map +1 -1
  23. package/dist/components/facing-pair.d.ts +2 -2
  24. package/dist/components/file-button.d.ts +2 -2
  25. package/dist/components/file-dropzone.d.ts +2 -2
  26. package/dist/components/form-actions.d.ts +2 -2
  27. package/dist/components/iban-input.d.ts +2 -2
  28. package/dist/components/language-select.d.ts +2 -2
  29. package/dist/components/money-field.d.ts +2 -2
  30. package/dist/components/number-field.d.ts +2 -2
  31. package/dist/components/number-input.d.ts +2 -2
  32. package/dist/components/numpad-sheet.d.ts +2 -2
  33. package/dist/components/phone-input.d.ts +2 -2
  34. package/dist/components/series-chart.d.ts +2 -2
  35. package/dist/components/settings-fields.d.ts +2 -2
  36. package/dist/components/share-card.d.ts +2 -2
  37. package/dist/components/swipeable-row.js +1 -1
  38. package/dist/components/swipeable-row.js.map +1 -1
  39. package/dist/components/table.d.ts +35 -1
  40. package/dist/components/table.js +4 -0
  41. package/dist/components/table.js.map +1 -1
  42. package/dist/components/text-link.d.ts +2 -2
  43. package/dist/components/time-input.d.ts +2 -2
  44. package/dist/components/tooltip.d.ts +103 -14
  45. package/dist/components/tooltip.js +194 -57
  46. package/dist/components/tooltip.js.map +1 -1
  47. package/dist/components/translation-review-editor.d.ts +9 -1
  48. package/dist/components/translation-review-editor.js +11 -1
  49. package/dist/components/translation-review-editor.js.map +1 -1
  50. package/dist/components/translation-review-labels.d.ts +10 -0
  51. package/dist/components/translation-review-labels.js +5 -1
  52. package/dist/components/translation-review-labels.js.map +1 -1
  53. package/dist/components/translation-review.d.ts +109 -8
  54. package/dist/components/translation-review.js +315 -91
  55. package/dist/components/translation-review.js.map +1 -1
  56. package/dist/components/ui.d.ts +2 -2
  57. package/dist/components/ui.js +55 -18
  58. package/dist/components/ui.js.map +1 -1
  59. package/dist/components/use-table-state.d.ts +1 -1
  60. package/dist/{data-table-labels-B7OdnM0S.d.ts → data-table-labels-Cdn1QS2k.d.ts} +21 -1
  61. package/dist/data-table.d.ts +1 -1
  62. package/dist/feedback/feedback-attachment.d.ts +2 -2
  63. package/dist/feedback/feedback-dialog.d.ts +2 -2
  64. package/dist/feedback/feedback-dialog.js +6 -1
  65. package/dist/feedback/feedback-dialog.js.map +1 -1
  66. package/dist/feedback/feedback-inbox.d.ts +2 -2
  67. package/dist/feedback/feedback-inbox.js +2 -0
  68. package/dist/feedback/feedback-inbox.js.map +1 -1
  69. package/dist/feedback/feedback-thread.d.ts +2 -2
  70. package/dist/{feedback-DOwPu-Il.d.ts → feedback-BXKH-ToU.d.ts} +32 -3
  71. package/dist/feedback.d.ts +2 -2
  72. package/dist/hooks/use-body-scroll-lock.js +16 -0
  73. package/dist/hooks/use-body-scroll-lock.js.map +1 -1
  74. package/dist/hooks/use-file-drop.d.ts +2 -2
  75. package/dist/hooks/use-search-param-state.d.ts +58 -3
  76. package/dist/hooks/use-search-param-state.js +100 -35
  77. package/dist/hooks/use-search-param-state.js.map +1 -1
  78. package/dist/i18n/defaults.d.ts +2 -2
  79. package/dist/i18n/german.d.ts +2 -2
  80. package/dist/i18n/german.js +8 -1
  81. package/dist/i18n/german.js.map +1 -1
  82. package/dist/i18n/kit-labels.d.ts +2 -2
  83. package/dist/i18n/languages.d.ts +2 -2
  84. package/dist/i18n/locales/de-CH.d.ts +2 -2
  85. package/dist/i18n/locales/en.d.ts +2 -2
  86. package/dist/i18n/locales/en.js +5 -1
  87. package/dist/i18n/locales/en.js.map +1 -1
  88. package/dist/i18n/locales/es.d.ts +2 -2
  89. package/dist/i18n/locales/es.js +7 -1
  90. package/dist/i18n/locales/es.js.map +1 -1
  91. package/dist/i18n/locales/fr.d.ts +2 -2
  92. package/dist/i18n/locales/fr.js +6 -1
  93. package/dist/i18n/locales/fr.js.map +1 -1
  94. package/dist/i18n/locales/hu.d.ts +2 -2
  95. package/dist/i18n/locales/hu.js +9 -1
  96. package/dist/i18n/locales/hu.js.map +1 -1
  97. package/dist/i18n/locales/it.d.ts +2 -2
  98. package/dist/i18n/locales/it.js +6 -1
  99. package/dist/i18n/locales/it.js.map +1 -1
  100. package/dist/i18n/locales/zh.d.ts +2 -2
  101. package/dist/i18n/locales/zh.js +6 -1
  102. package/dist/i18n/locales/zh.js.map +1 -1
  103. package/dist/i18n/review.d.ts +2 -2
  104. package/dist/i18n/review.js +11 -0
  105. package/dist/i18n/review.js.map +1 -1
  106. package/dist/index.d.ts +6 -6
  107. package/dist/index.js +9 -1
  108. package/dist/index.js.map +1 -1
  109. package/dist/lib/strip-fade.d.ts +84 -5
  110. package/dist/lib/strip-fade.js +109 -11
  111. package/dist/lib/strip-fade.js.map +1 -1
  112. package/dist/lib/translation-review.d.ts +45 -1
  113. package/dist/lib/translation-review.js +38 -1
  114. package/dist/lib/translation-review.js.map +1 -1
  115. package/dist/rhf/fields.d.ts +2 -2
  116. package/dist/rhf/form.d.ts +2 -2
  117. package/dist/rhf.d.ts +2 -2
  118. package/dist/shell/app-shell.d.ts +2 -2
  119. package/dist/shell/top-bar-brand.d.ts +2 -2
  120. package/dist/shell.d.ts +2 -2
  121. package/dist/wizard/stepper-nav.d.ts +2 -2
  122. package/dist/wizard.d.ts +2 -2
  123. package/package.json +2 -2
  124. package/src/components/column-mapper.tsx +120 -113
  125. package/src/components/data-table-pagination.tsx +10 -1
  126. package/src/components/data-table.tsx +34 -1
  127. package/src/components/swipeable-row.tsx +1 -1
  128. package/src/components/table.tsx +39 -0
  129. package/src/components/tooltip.tsx +487 -104
  130. package/src/components/translation-review-editor.tsx +21 -1
  131. package/src/components/translation-review-labels.ts +17 -0
  132. package/src/components/translation-review.tsx +497 -109
  133. package/src/components/ui.tsx +104 -4
  134. package/src/feedback/feedback-dialog.tsx +21 -0
  135. package/src/feedback/feedback-inbox.tsx +19 -2
  136. package/src/hooks/use-body-scroll-lock.ts +25 -0
  137. package/src/hooks/use-search-param-state.ts +268 -41
  138. package/src/i18n/german.ts +10 -0
  139. package/src/i18n/locales/en.ts +7 -0
  140. package/src/i18n/locales/es.ts +9 -0
  141. package/src/i18n/locales/fr.ts +9 -0
  142. package/src/i18n/locales/hu.ts +11 -0
  143. package/src/i18n/locales/it.ts +9 -0
  144. package/src/i18n/locales/zh.ts +8 -0
  145. package/src/i18n/review.ts +11 -0
  146. package/src/index.ts +12 -0
  147. package/src/lib/strip-fade.ts +220 -15
  148. package/src/lib/translation-review.ts +85 -0
@@ -1,16 +1,20 @@
1
- import { useMemo, useState } from "react";
1
+ import { useId, useMemo, useRef, useState } from "react";
2
2
  import type { ReactNode } from "react";
3
- import { Check, ListChecks, RotateCcw } from "lucide-react";
3
+ import { Check, ListChecks, PencilLine, RotateCcw } from "lucide-react";
4
4
 
5
+ import { useMediaQuery } from "../hooks/use-media-query";
5
6
  import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
6
7
  import { cn } from "../lib/cn";
7
8
  import {
8
9
  DEFAULT_TRANSLATION_REVIEW_FILTER,
9
10
  REVIEW_STATUSES,
10
11
  filterTranslationRows,
12
+ groupTranslationRows,
11
13
  keyInAreas,
14
+ reviewUndo,
12
15
  reviewWrite,
13
16
  summariseRows,
17
+ unreviewedRows,
14
18
  } from "../lib/translation-review";
15
19
  import type {
16
20
  ReviewStatus,
@@ -18,20 +22,24 @@ import type {
18
22
  TranslationReviewKey,
19
23
  TranslationReviewWrite,
20
24
  TranslationRow,
25
+ TranslationRowGroup,
21
26
  } from "../lib/translation-review";
22
27
  import { AlertBanner } from "./alert-banner";
23
28
  import { BulkActionBar } from "./bulk-action-bar";
24
29
  import { Checkbox } from "./checkbox";
25
30
  import { Chip } from "./chip";
26
31
  import { DataTable } from "./data-table";
27
- import type { DataTableColumn } from "./data-table";
32
+ import type { DataTableColumn, DataTableProps, MobileSwipeActions, SortState } from "./data-table";
33
+ import { Disclosure } from "./disclosure";
28
34
  import { SearchField } from "./search-field";
35
+ import { toast } from "./toast";
29
36
  import { ToggleGroup } from "./toggle-group";
30
37
  import { TranslationReviewEditor } from "./translation-review-editor";
31
38
  import { DEFAULT_TRANSLATION_REVIEW_LABELS, reviewStatusLabel } from "./translation-review-labels";
32
39
  import type { TranslationReviewLabels } from "./translation-review-labels";
33
40
  import { ReviewStatusChip, TranslationProgress } from "./translation-review-parts";
34
41
  import { Button, IconButton, Select } from "./ui";
42
+ import { useWriteLock } from "./write-lock";
35
43
 
36
44
  export {
37
45
  REVIEW_STATUS_TONES,
@@ -73,6 +81,33 @@ export type { TranslationReviewLabels } from "./translation-review-labels";
73
81
 
74
82
  type MaybePromise = void | Promise<unknown>;
75
83
 
84
+ /** What asked for a write — see {@link TranslationReviewSaveInfo}. */
85
+ export type TranslationReviewOrigin = "editor" | "row" | "swipe" | "group" | "selection" | "undo";
86
+
87
+ /**
88
+ * The second argument of `onSave` and `onClear` (0.25): where the write came from, and
89
+ * whether the panel answers it with a toast of its own.
90
+ *
91
+ * WHY THE APP IS TOLD. keksdose and kastlan toast "Saved" from their mutation on every
92
+ * save. With {@link TranslationReviewPanelProps.undo} on, an approval is answered by the
93
+ * kit's Undo toast instead — and a reviewer swiping through a list then read two toasts
94
+ * per card. The app skips its own where `toasted` is true:
95
+ *
96
+ * onSave={(writes, { toasted }) => save.mutateAsync({ items: writes.map(toApiWrite), quiet: toasted })}
97
+ *
98
+ * An app that ignores the argument keeps working; it only says "Saved" twice.
99
+ */
100
+ export interface TranslationReviewSaveInfo {
101
+ /** The open editor, a row's approve button, a swipe on a phone card, a group's
102
+ * "Approve unreviewed", the selection's bulk bar, or an Undo pressed on the toast. */
103
+ origin: TranslationReviewOrigin;
104
+ /** The panel shows its own toast for this write (an approval's Undo toast). */
105
+ toasted: boolean;
106
+ }
107
+
108
+ /** How {@link TranslationReviewPanelProps.groupBy} names a row's group. */
109
+ export type TranslationReviewGroupBy = "namespace" | "source" | ((row: TranslationRow) => string);
110
+
76
111
  export interface TranslationReviewPanelProps {
77
112
  /** One locale's rows, from `translationRows`. */
78
113
  rows: readonly TranslationRow[];
@@ -81,10 +116,12 @@ export interface TranslationReviewPanelProps {
81
116
  /** The reference language's own name — the reference column's header. */
82
117
  referenceLabel: string;
83
118
  /** Store verdicts: one for an editor or a row's approve button, many for a bulk
84
- * approve. Left out (or `readOnly`): nothing can be changed. */
85
- onSave?: (writes: TranslationReviewWrite[]) => MaybePromise;
86
- /** Clear verdicts — back to unreviewed. Left out: no reset. */
87
- onClear?: (keys: TranslationReviewKey[]) => MaybePromise;
119
+ * approve. Left out (or `readOnly`): nothing can be changed. The second argument
120
+ * says where the write came from ({@link TranslationReviewSaveInfo}). */
121
+ onSave?: (writes: TranslationReviewWrite[], info: TranslationReviewSaveInfo) => MaybePromise;
122
+ /** Clear verdicts — back to unreviewed. Left out: no reset, and an approval of a row
123
+ * that had no verdict cannot be undone. */
124
+ onClear?: (keys: TranslationReviewKey[], info: TranslationReviewSaveInfo) => MaybePromise;
88
125
  /** A locale the viewer may read but not review: the rows and their verdicts, no
89
126
  * actions, and a line saying so. */
90
127
  readOnly?: boolean;
@@ -104,9 +141,13 @@ export interface TranslationReviewPanelProps {
104
141
  sourceLabels?: Readonly<Record<string, string>>;
105
142
  /**
106
143
  * Controlled filters — for an app that keeps them in the URL (keksdose's `?status=`,
107
- * `?area=`, `?q=`). Whatever is left out is the panel's own. Pair with `onFilterChange`.
144
+ * `?ns=`, `?q=`). Whatever is left out is the panel's own. Pair with `onFilterChange`.
108
145
  */
109
146
  filter?: Partial<TranslationReviewFilter>;
147
+ /** Every filter, on every change — write them in ONE update: `useSearchParamsState`'s
148
+ * setter takes this object as it is (`filter={filter} onFilterChange={setFilter}`).
149
+ * Single `useSearchParamState` setters compose since 0.25; before, four in a row
150
+ * clobbered each other (keksdose live #378). */
110
151
  onFilterChange?: (filter: TranslationReviewFilter) => void;
111
152
  /** The progress bar over the filters. Default true. */
112
153
  progress?: boolean;
@@ -118,10 +159,78 @@ export interface TranslationReviewPanelProps {
118
159
  formatDate?: (iso: string) => string;
119
160
  /** Turns a callback's rejection into words. Default {@link TranslationReviewLabels.failed}. */
120
161
  formatError?: (error: unknown) => ReactNode;
162
+ /**
163
+ * Swipe the phone cards (0.25, keksdose live #377 — Marcel reviewing on a phone): toward
164
+ * the reading END to approve, toward the START for "Needs a change", which OPENS the
165
+ * editor with the cursor in the wording rather than sending the string back blind — a
166
+ * verdict without a note or a better wording tells whoever fixes it nothing. In a
167
+ * right-to-left page the two sides mirror with the reading direction.
168
+ *
169
+ * DataTable's swipe sides (`mobileSwipeActions`) on {@link SwipeableRow}: the same
170
+ * gesture, the same feedback, and the same keyboard path — every swipe is also a real
171
+ * button, read out and reachable by Tab, so the gesture is a shortcut and never the only
172
+ * way. A row has no swipe while the editor under it is open, while its own write is out,
173
+ * under a {@link WriteLockProvider} lock, in a `readOnly` locale, and toward "Approve"
174
+ * where there is nothing to approve (a missing string, an approved one). A reviewer
175
+ * limited to `areas` never sees a row outside them, so never swipes one.
176
+ *
177
+ * Off by default, so no existing page gains a gesture on the bump. Phones only; the
178
+ * desktop table keeps its approve button.
179
+ */
180
+ swipe?: boolean;
181
+ /**
182
+ * Answer every approval with the kit's Undo toast (`toast.undo`): a row's button, a
183
+ * swipe, the editor's Approve, a group's and the selection's bulk approve. Undo stores
184
+ * again what the rows said before ({@link reviewUndo}) — or clears them, for rows that
185
+ * had no verdict, which needs `onClear` (without it the toast reports and offers
186
+ * nothing). A batch has ONE Undo.
187
+ *
188
+ * Default: on with `swipe` or `groupBy` — the two ways to approve in one movement or many
189
+ * rows at once bring their way back — and off otherwise, so an existing page does not
190
+ * gain a toast on the bump. Needs the app's `<Toaster>`. Tell the app's own "Saved"
191
+ * toast apart with the `toasted` flag ({@link TranslationReviewSaveInfo}).
192
+ */
193
+ undo?: boolean;
194
+ /**
195
+ * Group the strings under a header per area (0.25, keksdose live #377: *"grouping to the
196
+ * translation review entries for faster reviewing"*): `"namespace"` — the row's
197
+ * `namespace`, which `translationRows` derives from the key (`budget`, `kit.dataTable`
198
+ * with keksdose's `namespaceOf`) — `"source"`, or a function of the row.
199
+ *
200
+ * Each group is a section with a heading ({@link groupHeadingAs}): its name, "n
201
+ * unreviewed / total", and "Approve unreviewed (n)", which approves the group's rows
202
+ * nobody has given a verdict ({@link unreviewedRows}) under the same rules as one
203
+ * approval — write lock, `readOnly`, one write at a time — with one Undo for the batch.
204
+ * A batch larger than `pageSize` asks first, inside the opened group: more than a page
205
+ * is more than the reviewer can have had on screen.
206
+ *
207
+ * WHAT IT MEANS FOR THE FILTERS, THE SORT AND THE PAGES. The filters narrow the rows
208
+ * first, then the rows are grouped: a header counts what the filters show in it (so
209
+ * "Approve unreviewed" never takes a row the reviewer filtered away), and an area with
210
+ * nothing left is not shown. Groups stand in the order their first string has in the
211
+ * reference; a column sort orders the rows INSIDE every group alike. Each group pages
212
+ * on its own, `pageSize` rows at a time — a header then always stands over its own
213
+ * rows, and paging one area does not move another — rather than one pager cutting
214
+ * through the headers. `storageKey` keeps persisting the ungrouped table only.
215
+ *
216
+ * Groups fold (each on its own, remembered while the panel lives). On a phone they
217
+ * start folded when there are several — the headers are then the area list, and the
218
+ * reviewer opens the one to work through; on a wide screen they start open.
219
+ */
220
+ groupBy?: TranslationReviewGroupBy;
221
+ /** A group's name. Default: `sourceLabels` for `groupBy="source"`, else the key. */
222
+ groupLabel?: (group: string) => string;
223
+ /** The level of the group headings. Default `h2`: the panel is a page's body, under
224
+ * the page's `h1`. */
225
+ groupHeadingAs?: "h2" | "h3" | "h4";
121
226
  className?: string;
122
227
  labels?: Partial<TranslationReviewLabels>;
123
228
  }
124
229
 
230
+ /** The wide screen's query — the same one DataTable switches its layout on, so "a phone"
231
+ * means the same here as in the table. */
232
+ const MD_UP = "(min-width: 768px)";
233
+
125
234
  /** The row's text — or, for a missing string, the line that says so. */
126
235
  function TextCell({ row, labels, clamp }: { row: TranslationRow; labels: TranslationReviewLabels; clamp?: boolean }) {
127
236
  return row.text === "" ? (
@@ -145,7 +254,14 @@ function TextCell({ row, labels, clamp }: { row: TranslationRow; labels: Transla
145
254
  * Bulk approve takes the selected rows that are not approved yet and have a text:
146
255
  * approving an absent string says nothing, and re-approving one says it again. On a
147
256
  * phone the table has no checkboxes, so "Select all shown" selects what the filters
148
- * show — filter to `Unreviewed` in one area, read, select, approve.
257
+ * show — filter to `Unreviewed` in one area, read, select, approve. With `groupBy`, a
258
+ * group's own "Approve unreviewed" is the shorter way, and with `swipe` a card is approved
259
+ * by a swipe.
260
+ *
261
+ * The panel's writes go out ONE AT A TIME, in the order they were asked for: two writes on
262
+ * one locale's verdicts can answer out of order, and an Undo must not overtake the
263
+ * approval it takes back. A swipe on the next card while one is out is queued rather than
264
+ * refused — a reviewer swiping down a list is faster than the server.
149
265
  */
150
266
  export function TranslationReviewPanel({
151
267
  rows: rowsProp,
@@ -165,12 +281,22 @@ export function TranslationReviewPanel({
165
281
  storageKey,
166
282
  formatDate,
167
283
  formatError,
284
+ swipe = false,
285
+ undo: undoProp,
286
+ groupBy,
287
+ groupLabel,
288
+ groupHeadingAs = "h2",
168
289
  className,
169
290
  labels: labelsProp,
170
291
  }: TranslationReviewPanelProps) {
171
292
  const labels = useKitLabels("translationReview", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);
172
293
  const locale = useKitLocale();
294
+ const lock = useWriteLock();
295
+ const phone = !useMediaQuery(MD_UP, true);
296
+ const baseId = useId();
297
+ const rootRef = useRef<HTMLDivElement>(null);
173
298
  const editable = !readOnly && onSave !== undefined;
299
+ const undoOn = undoProp ?? (swipe || groupBy !== undefined);
174
300
 
175
301
  const [ownFilter, setOwnFilter] = useState<TranslationReviewFilter>(DEFAULT_TRANSLATION_REVIEW_FILTER);
176
302
  // The app's fields over the panel's own; a field the app leaves `undefined` is the
@@ -180,11 +306,18 @@ export function TranslationReviewPanel({
180
306
  if (value !== undefined) Object.assign(filter, { [name]: value });
181
307
  }
182
308
  const [openId, setOpenId] = useState<string | null>(null);
309
+ // The row whose editor a swipe opened, to be written in: its wording field takes focus.
310
+ const [focusId, setFocusId] = useState<string | null>(null);
183
311
  const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set());
184
- // One bulk or row action at a time: two writes in flight on one locale's verdicts can
185
- // answer out of order. The editor keeps its own, for its own buttons.
186
- const [busy, setBusy] = useState<string | null>(null);
312
+ // The writes asked for and not yet answered, in order: the first is the one out. The
313
+ // editor keeps its own, for its own buttons.
314
+ const [queued, setQueued] = useState<readonly string[]>([]);
315
+ const claimed = useRef(new Set<string>());
316
+ const tail = useRef<Promise<unknown>>(Promise.resolve());
317
+ const undoCount = useRef(0);
187
318
  const [failure, setFailure] = useState<ReactNode>(null);
319
+ const busy = queued.length > 0;
320
+ const isQueued = (key: string) => queued.includes(key);
188
321
 
189
322
  const rows = useMemo(
190
323
  () => (areas ? rowsProp.filter((row) => keyInAreas(row.key, areas, inArea)) : [...rowsProp]),
@@ -206,6 +339,31 @@ export function TranslationReviewPanel({
206
339
  [rows, status, source, namespace, query, placeholdersOnly],
207
340
  );
208
341
 
342
+ // ── Groups ──
343
+ const groups = useMemo(() => {
344
+ if (groupBy === undefined) return null;
345
+ const of =
346
+ groupBy === "namespace"
347
+ ? (r: TranslationRow) => r.namespace
348
+ : groupBy === "source"
349
+ ? (r: TranslationRow) => r.source
350
+ : groupBy;
351
+ return groupTranslationRows(visible, of);
352
+ }, [visible, groupBy]);
353
+ const groupName = (key: string) =>
354
+ groupLabel?.(key) ?? (groupBy === "source" ? sourceLabels?.[key] : undefined) ?? (key || "—");
355
+ // Only what the reviewer chose; a group nobody has touched follows the default.
356
+ const [groupOpen, setGroupOpen] = useState<Readonly<Record<string, boolean>>>({});
357
+ const groupOpenByDefault = !(phone && (groups?.length ?? 0) > 1);
358
+ const isGroupOpen = (key: string) => groupOpen[key] ?? groupOpenByDefault;
359
+ const setGroupOpenFor = (key: string, open: boolean) => setGroupOpen((prev) => ({ ...prev, [key]: open }));
360
+ // The group whose large batch is waiting for a yes — and, until its confirm button
361
+ // has mounted and taken focus, the group that asked.
362
+ const [confirming, setConfirming] = useState<string | null>(null);
363
+ const confirmFocus = useRef<string | null>(null);
364
+ // One sort for every group's table, so a header click orders all of them alike.
365
+ const [groupSorts, setGroupSorts] = useState<SortState[]>([]);
366
+
209
367
  const setFilter = (patch: Partial<TranslationReviewFilter>) => {
210
368
  const next = { ...filter, ...patch };
211
369
  setOwnFilter(next);
@@ -214,35 +372,122 @@ export function TranslationReviewPanel({
214
372
  setSelected(new Set());
215
373
  };
216
374
 
217
- const run = async (key: string, action: () => MaybePromise): Promise<boolean> => {
218
- setBusy(key);
219
- setFailure(null);
220
- try {
221
- await action();
222
- return true;
223
- } catch (caught) {
224
- setFailure(formatError ? formatError(caught) : labels.failed);
225
- return false;
226
- } finally {
227
- setBusy(null);
375
+ /** Queue a write under `key` (one per key at a time); resolves whether it worked. */
376
+ const run = (key: string, action: () => MaybePromise): Promise<boolean> => {
377
+ if (claimed.current.has(key)) return Promise.resolve(false);
378
+ claimed.current.add(key);
379
+ setQueued((q) => [...q, key]);
380
+ const step = tail.current.then(async () => {
381
+ setFailure(null);
382
+ try {
383
+ await action();
384
+ return true;
385
+ } catch (caught) {
386
+ setFailure(formatError ? formatError(caught) : labels.failed);
387
+ return false;
388
+ } finally {
389
+ claimed.current.delete(key);
390
+ setQueued((q) => q.filter((k) => k !== key));
391
+ }
392
+ });
393
+ tail.current = step;
394
+ return step;
395
+ };
396
+
397
+ /** "String approved — Undo", once `targets` (as they were BEFORE the write) are
398
+ * approved. Undo goes through the same queue as every write. */
399
+ const sayApproved = (targets: readonly TranslationRow[], description?: ReactNode) => {
400
+ const back = reviewUndo(targets);
401
+ const message = labels.approvedToast(targets.length);
402
+ const save = onSave;
403
+ const clear = onClear;
404
+ // Taking back the approval of a row that had no verdict is a clear: without
405
+ // `onClear` there is no way to say it, so the toast reports and offers nothing.
406
+ if (!save || (back.clears.length > 0 && !clear)) {
407
+ toast.success(message, { description });
408
+ return;
409
+ }
410
+ const key = `undo:${++undoCount.current}`;
411
+ toast.undo(message, {
412
+ description,
413
+ onUndo: () =>
414
+ void run(key, async () => {
415
+ if (back.writes.length > 0) await save(back.writes, { origin: "undo", toasted: false });
416
+ if (back.clears.length > 0) await clear?.(back.clears, { origin: "undo", toasted: false });
417
+ }),
418
+ });
419
+ };
420
+
421
+ const approve = async (
422
+ key: string,
423
+ targets: readonly TranslationRow[],
424
+ origin: TranslationReviewOrigin,
425
+ description?: ReactNode,
426
+ ): Promise<boolean> => {
427
+ if (!onSave || targets.length === 0) return false;
428
+ const save = onSave;
429
+ const ok = await run(key, () => save(targets.map((r) => reviewWrite(r, "APPROVED")), { origin, toasted: undoOn }));
430
+ if (ok && undoOn) sayApproved(targets, description);
431
+ return ok;
432
+ };
433
+
434
+ /** The editor's own save: it waits, closes and shows its errors itself, so it does
435
+ * not go through the queue — but an approval there is answered like any other. */
436
+ const saveFromEditor = async (row: TranslationRow, write: TranslationReviewWrite) => {
437
+ const toasted = undoOn && write.verdict === "APPROVED";
438
+ await onSave?.([write], { origin: "editor", toasted });
439
+ if (toasted) sayApproved([row], row.key);
440
+ };
441
+
442
+ /**
443
+ * Focus back into the panel once a group's batch has settled, if it fell out: the
444
+ * button that was pressed goes with the rows it approved (and under the `Unreviewed`
445
+ * filter the whole group can go). Two frames, so the app's new rows have been drawn.
446
+ */
447
+ const keepFocusAt = (groupKey: string) => {
448
+ const check = () => {
449
+ const root = rootRef.current;
450
+ const active = document.activeElement;
451
+ if (!root || (active && active !== document.body && active.isConnected)) return;
452
+ const sections = [...root.querySelectorAll<HTMLElement>("[data-review-group]")];
453
+ const section = sections.find((el) => el.dataset.reviewGroup === groupKey) ?? sections[0];
454
+ section?.querySelector<HTMLElement>("[data-disclosure-trigger]")?.focus();
455
+ };
456
+ requestAnimationFrame(() => requestAnimationFrame(check));
457
+ };
458
+
459
+ const approveGroup = async (group: TranslationRowGroup, confirmed: boolean) => {
460
+ const targets = unreviewedRows(group.rows);
461
+ if (targets.length === 0) return;
462
+ if (!confirmed && targets.length > pageSize) {
463
+ // Asked inside the opened group, over the rows it is about.
464
+ setGroupOpenFor(group.key, true);
465
+ setConfirming(group.key);
466
+ confirmFocus.current = group.key;
467
+ return;
228
468
  }
469
+ const ok = await approve(`group:${group.key}`, targets, "group", groupName(group.key));
470
+ if (ok) setConfirming((key) => (key === group.key ? null : key));
471
+ keepFocusAt(group.key);
229
472
  };
230
473
 
231
474
  const visibleIds = new Set(visible.map((r) => r.id));
232
475
  const selectedRows = visible.filter((r) => selected.has(r.id));
233
- const allSelected = visible.length > 0 && selectedRows.length === visible.length;
476
+ // What "Select all shown" takes: grouped, the rows of the open groups — a folded
477
+ // group's rows are not shown.
478
+ const shownRows = groups ? groups.filter((g) => isGroupOpen(g.key)).flatMap((g) => g.rows) : visible;
479
+ const allShownSelected = shownRows.length > 0 && shownRows.every((r) => selected.has(r.id));
234
480
  const toApprove = selectedRows.filter((r) => r.text !== "" && r.status !== "approved");
235
481
  const toReset = selectedRows.filter((r) => r.review !== null);
236
482
 
237
483
  const approveSelected = async () => {
238
- if (!onSave || toApprove.length === 0) return;
239
- if (await run("bulk-approve", () => onSave(toApprove.map((r) => reviewWrite(r, "APPROVED"))))) {
240
- setSelected(new Set());
241
- }
484
+ if (await approve("bulk-approve", toApprove, "selection")) setSelected(new Set());
242
485
  };
243
486
  const resetSelected = async () => {
244
487
  if (!onClear || toReset.length === 0) return;
245
- if (await run("bulk-reset", () => onClear(toReset.map((r) => ({ locale: r.locale, key: r.key }))))) {
488
+ const clear = onClear;
489
+ const keys = toReset.map((r) => ({ locale: r.locale, key: r.key }));
490
+ if (await run("bulk-reset", () => clear(keys, { origin: "selection", toasted: false }))) {
246
491
  setSelected(new Set());
247
492
  }
248
493
  };
@@ -291,8 +536,7 @@ export function TranslationReviewPanel({
291
536
  sortBy: (r) => REVIEW_STATUSES.indexOf(r.status),
292
537
  },
293
538
  ];
294
- if (editable && onSave) {
295
- const save = onSave;
539
+ if (editable) {
296
540
  columns.push({
297
541
  key: "approve",
298
542
  header: <span className="sr-only">{labels.approve}</span>,
@@ -304,12 +548,12 @@ export function TranslationReviewPanel({
304
548
  tone="muted"
305
549
  commit
306
550
  label={labels.approve}
307
- pending={busy === `row:${r.id}`}
308
- disabled={busy !== null}
551
+ pending={isQueued(`row:${r.id}`)}
552
+ disabled={busy}
309
553
  onClick={(event) => {
310
554
  // The row's own click opens the editor; this one only approves.
311
555
  event.stopPropagation();
312
- void run(`row:${r.id}`, () => save([reviewWrite(r, "APPROVED")]));
556
+ void approve(`row:${r.id}`, [r], "row", r.key);
313
557
  }}
314
558
  >
315
559
  <Check />
@@ -318,8 +562,214 @@ export function TranslationReviewPanel({
318
562
  });
319
563
  }
320
564
 
565
+ /**
566
+ * A phone card's swipes: Approve toward the end, "Needs a change" toward the start.
567
+ * None where the row must not be acted on — the same conditions under which its
568
+ * buttons are locked or absent.
569
+ */
570
+ const swipeActions = (r: TranslationRow): MobileSwipeActions | null => {
571
+ if (!editable || lock.locked || isQueued(`row:${r.id}`)) return null;
572
+ const missing = r.text === "";
573
+ return {
574
+ end:
575
+ missing || r.status === "approved"
576
+ ? []
577
+ : [
578
+ {
579
+ label: labels.approve,
580
+ icon: <Check className="size-4" aria-hidden />,
581
+ onCommit: () => void approve(`row:${r.id}`, [r], "swipe", r.key),
582
+ className: "bg-[var(--success)]",
583
+ armedClassName: "bg-[var(--success)]",
584
+ },
585
+ ],
586
+ start: [
587
+ {
588
+ label: missing ? labels.suggest : labels.flag,
589
+ icon: <PencilLine className="size-4" aria-hidden />,
590
+ // Opens the editor rather than sending the string back: "needs a change"
591
+ // with neither a note nor a better wording tells nobody anything.
592
+ onCommit: () => {
593
+ setOpenId(r.id);
594
+ setFocusId(r.id);
595
+ },
596
+ className: missing ? "bg-[var(--brand)]" : "bg-[var(--danger)]",
597
+ armedClassName: missing ? "bg-[var(--brand-hover)]" : "bg-[var(--danger-hover)]",
598
+ },
599
+ ],
600
+ };
601
+ };
602
+
603
+ const toggleIn = (prev: ReadonlySet<string>, many: readonly TranslationRow[], checked: boolean) => {
604
+ const next = new Set([...prev].filter((id) => visibleIds.has(id)));
605
+ for (const r of many) {
606
+ if (checked) next.add(r.id);
607
+ else next.delete(r.id);
608
+ }
609
+ return next;
610
+ };
611
+ /** The checkbox column of a table over `list` — the whole list, or one group. */
612
+ const selectionFor = (list: readonly TranslationRow[]): DataTableProps<TranslationRow>["selection"] => {
613
+ if (!editable) return undefined;
614
+ const picked = list.filter((r) => selected.has(r.id)).length;
615
+ return {
616
+ isSelected: (r) => selected.has(r.id),
617
+ onToggle: (r, checked) => setSelected((prev) => toggleIn(prev, [r], checked)),
618
+ onToggleMany: (many, checked) => setSelected((prev) => toggleIn(prev, many, checked)),
619
+ allSelected: list.length > 0 && picked === list.length,
620
+ someSelected: picked > 0 && picked < list.length,
621
+ onToggleAll: (checked) => setSelected((prev) => toggleIn(prev, list, checked)),
622
+ };
623
+ };
624
+
625
+ const table = (list: TranslationRow[], group?: { name: string }) => (
626
+ <DataTable<TranslationRow>
627
+ rows={list}
628
+ rowKey={(r) => r.id}
629
+ columns={columns}
630
+ empty={labels.empty}
631
+ defaultPageSize={pageSize}
632
+ {...(group
633
+ ? {
634
+ // Inside the group's own card: no frame in a frame, no column rail per group,
635
+ // no scroller per group, and the group's name as the table's.
636
+ frame: false,
637
+ chrome: "minimal" as const,
638
+ maxBodyHeight: "none",
639
+ sorts: groupSorts,
640
+ onSortsChange: setGroupSorts,
641
+ labels: { table: group.name },
642
+ }
643
+ : { storageKey })}
644
+ isExpanded={(r) => r.id === openId}
645
+ onRowClick={(r) => {
646
+ setFocusId(null);
647
+ setOpenId((open) => (open === r.id ? null : r.id));
648
+ }}
649
+ expandedRow={(r) => (
650
+ <TranslationReviewEditor
651
+ key={r.id}
652
+ row={r}
653
+ referenceLabel={referenceLabel}
654
+ localeLabel={localeLabel}
655
+ readOnly={!editable}
656
+ focusWording={focusId === r.id}
657
+ onSave={onSave ? (write) => saveFromEditor(r, write) : undefined}
658
+ onClear={onClear ? (key) => onClear([key], { origin: "editor", toasted: false }) : undefined}
659
+ onClose={() => {
660
+ setOpenId(null);
661
+ setFocusId(null);
662
+ }}
663
+ formatDate={formatDate}
664
+ formatError={formatError}
665
+ labels={labelsProp}
666
+ />
667
+ )}
668
+ mobileCard={(r) => (
669
+ <div className="min-w-0 space-y-1">
670
+ <code className="block break-all text-xs text-[var(--text-muted)]">{r.key}</code>
671
+ <div className="text-sm text-[var(--text-primary)]">
672
+ <TextCell row={r} labels={labels} clamp />
673
+ </div>
674
+ <div className="flex flex-wrap gap-1">
675
+ <ReviewStatusChip status={r.status} labels={labelsProp} />
676
+ {r.placeholderMismatch && (
677
+ <Chip size="sm" tone="danger" variant="outline">
678
+ {labels.placeholderChip}
679
+ </Chip>
680
+ )}
681
+ </div>
682
+ </div>
683
+ )}
684
+ mobileSwipeActions={swipe ? swipeActions : undefined}
685
+ selection={selectionFor(list)}
686
+ />
687
+ );
688
+
689
+ const groupSection = (g: TranslationRowGroup, index: number) => {
690
+ const name = groupName(g.key);
691
+ const titleId = `${baseId}-group-${index}`;
692
+ const questionId = `${baseId}-confirm-${index}`;
693
+ const targets = editable ? unreviewedRows(g.rows) : [];
694
+ const key = `group:${g.key}`;
695
+ return (
696
+ <Disclosure
697
+ key={g.key}
698
+ data-review-group={g.key}
699
+ headingAs={groupHeadingAs}
700
+ title={<span id={titleId}>{name}</span>}
701
+ hint={labels.groupCount(g.summary.unreviewed, g.summary.total)}
702
+ open={isGroupOpen(g.key)}
703
+ onOpenChange={(open) => {
704
+ setGroupOpenFor(g.key, open);
705
+ if (!open) setConfirming((c) => (c === g.key ? null : c));
706
+ }}
707
+ trailing={
708
+ targets.length > 0 ? (
709
+ <Button
710
+ variant="secondary"
711
+ size="sm"
712
+ commit
713
+ // The visible words say what; the group's name, read after them, says where.
714
+ aria-describedby={titleId}
715
+ pending={isQueued(key) && confirming !== g.key}
716
+ disabled={busy}
717
+ onClick={() => void approveGroup(g, false)}
718
+ >
719
+ <Check className="size-4" aria-hidden />
720
+ {labels.approveGroup(targets.length)}
721
+ </Button>
722
+ ) : undefined
723
+ }
724
+ bodyClassName="space-y-0 px-0 pb-0 border-t border-[var(--border)]"
725
+ >
726
+ {confirming === g.key && targets.length > 0 && (
727
+ <div className="border-b border-[var(--border)] p-3">
728
+ <AlertBanner tone="warning" size="sm">
729
+ <div className="min-w-0 space-y-2">
730
+ <p id={questionId}>{labels.confirmGroup(targets.length, name)}</p>
731
+ <div className="flex flex-wrap gap-2">
732
+ <Button
733
+ // Focus lands here when the question appears: the expected answer to a
734
+ // question the reviewer just asked for, and Undo stays behind it.
735
+ ref={(el) => {
736
+ if (el && confirmFocus.current === g.key) {
737
+ confirmFocus.current = null;
738
+ el.focus();
739
+ }
740
+ }}
741
+ variant="primary"
742
+ size="sm"
743
+ commit
744
+ aria-describedby={questionId}
745
+ pending={isQueued(key)}
746
+ disabled={busy}
747
+ onClick={() => void approveGroup(g, true)}
748
+ >
749
+ {labels.approveGroup(targets.length)}
750
+ </Button>
751
+ <Button
752
+ variant="ghost"
753
+ size="sm"
754
+ disabled={isQueued(key)}
755
+ onClick={() => {
756
+ setConfirming(null);
757
+ keepFocusAt(g.key);
758
+ }}
759
+ >
760
+ {labels.cancel}
761
+ </Button>
762
+ </div>
763
+ </div>
764
+ </AlertBanner>
765
+ </div>
766
+ )}
767
+ {table(g.rows, { name })}
768
+ </Disclosure>
769
+ );
770
+ };
321
771
  return (
322
- <div className={cn("min-w-0 space-y-4", className)}>
772
+ <div ref={rootRef} className={cn("min-w-0 space-y-4", className)}>
323
773
  {scopeText && (
324
774
  <AlertBanner tone="info" variant="inline" size="sm" block live={false}>
325
775
  {labels.scope(scopeText)}
@@ -381,14 +831,14 @@ export function TranslationReviewPanel({
381
831
  />
382
832
  </div>
383
833
 
384
- {editable && visible.length > 0 && (
834
+ {editable && shownRows.length > 0 && (
385
835
  // The phone's table has no checkboxes; this is its way into the bulk actions.
386
836
  <Button
387
837
  variant="ghost"
388
838
  size="sm"
389
839
  className="md:hidden"
390
- disabled={allSelected}
391
- onClick={() => setSelected(new Set(visible.map((r) => r.id)))}
840
+ disabled={allShownSelected}
841
+ onClick={() => setSelected((prev) => toggleIn(prev, shownRows, true))}
392
842
  >
393
843
  <ListChecks className="size-4" aria-hidden />
394
844
  {labels.selectShown}
@@ -407,8 +857,8 @@ export function TranslationReviewPanel({
407
857
  variant="primary"
408
858
  size="sm"
409
859
  commit
410
- pending={busy === "bulk-approve"}
411
- disabled={busy !== null || toApprove.length === 0}
860
+ pending={isQueued("bulk-approve")}
861
+ disabled={busy || toApprove.length === 0}
412
862
  onClick={() => void approveSelected()}
413
863
  >
414
864
  <Check className="size-4" aria-hidden />
@@ -419,8 +869,8 @@ export function TranslationReviewPanel({
419
869
  variant="ghost"
420
870
  size="sm"
421
871
  commit
422
- pending={busy === "bulk-reset"}
423
- disabled={busy !== null || toReset.length === 0}
872
+ pending={isQueued("bulk-reset")}
873
+ disabled={busy || toReset.length === 0}
424
874
  onClick={() => void resetSelected()}
425
875
  >
426
876
  <RotateCcw className="size-4" aria-hidden />
@@ -430,73 +880,11 @@ export function TranslationReviewPanel({
430
880
  </BulkActionBar>
431
881
  )}
432
882
 
433
- <DataTable<TranslationRow>
434
- rows={visible}
435
- rowKey={(r) => r.id}
436
- columns={columns}
437
- empty={labels.empty}
438
- defaultPageSize={pageSize}
439
- storageKey={storageKey}
440
- isExpanded={(r) => r.id === openId}
441
- onRowClick={(r) => setOpenId((open) => (open === r.id ? null : r.id))}
442
- expandedRow={(r) => (
443
- <TranslationReviewEditor
444
- key={r.id}
445
- row={r}
446
- referenceLabel={referenceLabel}
447
- localeLabel={localeLabel}
448
- readOnly={!editable}
449
- onSave={onSave ? (write) => onSave([write]) : undefined}
450
- onClear={onClear ? (key) => onClear([key]) : undefined}
451
- onClose={() => setOpenId(null)}
452
- formatDate={formatDate}
453
- formatError={formatError}
454
- labels={labelsProp}
455
- />
456
- )}
457
- mobileCard={(r) => (
458
- <div className="min-w-0 space-y-1">
459
- <code className="block break-all text-xs text-[var(--text-muted)]">{r.key}</code>
460
- <div className="text-sm text-[var(--text-primary)]">
461
- <TextCell row={r} labels={labels} clamp />
462
- </div>
463
- <div className="flex flex-wrap gap-1">
464
- <ReviewStatusChip status={r.status} labels={labelsProp} />
465
- {r.placeholderMismatch && (
466
- <Chip size="sm" tone="danger" variant="outline">
467
- {labels.placeholderChip}
468
- </Chip>
469
- )}
470
- </div>
471
- </div>
472
- )}
473
- selection={
474
- editable
475
- ? {
476
- isSelected: (r) => selected.has(r.id),
477
- onToggle: (r, checked) =>
478
- setSelected((prev) => {
479
- const next = new Set([...prev].filter((id) => visibleIds.has(id)));
480
- if (checked) next.add(r.id);
481
- else next.delete(r.id);
482
- return next;
483
- }),
484
- onToggleMany: (many, checked) =>
485
- setSelected((prev) => {
486
- const next = new Set([...prev].filter((id) => visibleIds.has(id)));
487
- for (const r of many) {
488
- if (checked) next.add(r.id);
489
- else next.delete(r.id);
490
- }
491
- return next;
492
- }),
493
- allSelected,
494
- someSelected: selectedRows.length > 0 && !allSelected,
495
- onToggleAll: (checked) => setSelected(checked ? new Set(visible.map((r) => r.id)) : new Set()),
496
- }
497
- : undefined
498
- }
499
- />
883
+ {groups && groups.length > 0 ? (
884
+ <div className="space-y-3">{groups.map(groupSection)}</div>
885
+ ) : (
886
+ table(visible)
887
+ )}
500
888
  </div>
501
889
  );
502
890
  }