@eifi1/ui-kit 0.24.1 → 0.25.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 (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 +145 -8
  54. package/dist/components/translation-review.js +372 -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 +107 -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 +1 -1
  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 +657 -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 +283 -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 { useEffect, 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,18 @@ 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`.
145
+ *
146
+ * A field passed here is the app's, and the control shows what the app says: one the app
147
+ * passes but does not write back from `onFilterChange` is a control that does nothing.
148
+ * A development build warns about that once per field (0.25.1, Kurvenschmiede); a field
149
+ * left out entirely is fine — the panel keeps it.
108
150
  */
109
151
  filter?: Partial<TranslationReviewFilter>;
152
+ /** Every filter, on every change — write them in ONE update: `useSearchParamsState`'s
153
+ * setter takes this object as it is (`filter={filter} onFilterChange={setFilter}`).
154
+ * Single `useSearchParamState` setters compose since 0.25; before, four in a row
155
+ * clobbered each other (keksdose live #378). */
110
156
  onFilterChange?: (filter: TranslationReviewFilter) => void;
111
157
  /** The progress bar over the filters. Default true. */
112
158
  progress?: boolean;
@@ -118,10 +164,211 @@ export interface TranslationReviewPanelProps {
118
164
  formatDate?: (iso: string) => string;
119
165
  /** Turns a callback's rejection into words. Default {@link TranslationReviewLabels.failed}. */
120
166
  formatError?: (error: unknown) => ReactNode;
167
+ /**
168
+ * Swipe the phone cards (0.25, keksdose live #377 — Marcel reviewing on a phone): toward
169
+ * the reading END to approve, toward the START for "Needs a change", which OPENS the
170
+ * editor with the cursor in the wording rather than sending the string back blind — a
171
+ * verdict without a note or a better wording tells whoever fixes it nothing. In a
172
+ * right-to-left page the two sides mirror with the reading direction.
173
+ *
174
+ * DataTable's swipe sides (`mobileSwipeActions`) on {@link SwipeableRow}: the same
175
+ * gesture, the same feedback, and the same keyboard path — every swipe is also a real
176
+ * button, read out and reachable by Tab, so the gesture is a shortcut and never the only
177
+ * way. A row has no swipe while the editor under it is open, while its own write is out,
178
+ * under a {@link WriteLockProvider} lock, in a `readOnly` locale, and toward "Approve"
179
+ * where there is nothing to approve (a missing string, an approved one). A reviewer
180
+ * limited to `areas` never sees a row outside them, so never swipes one.
181
+ *
182
+ * Off by default, so no existing page gains a gesture on the bump. Phones only; the
183
+ * desktop table keeps its approve button.
184
+ */
185
+ swipe?: boolean;
186
+ /**
187
+ * Answer every approval with the kit's Undo toast (`toast.undo`): a row's button, a
188
+ * swipe, the editor's Approve, a group's and the selection's bulk approve. Undo stores
189
+ * again what the rows said before ({@link reviewUndo}) — or clears them, for rows that
190
+ * had no verdict, which needs `onClear` (without it the toast reports and offers
191
+ * nothing). A batch has ONE Undo.
192
+ *
193
+ * Default: on with `swipe` or `groupBy` — the two ways to approve in one movement or many
194
+ * rows at once bring their way back — and off otherwise, so an existing page does not
195
+ * gain a toast on the bump. Needs the app's `<Toaster>`. Tell the app's own "Saved"
196
+ * toast apart with the `toasted` flag ({@link TranslationReviewSaveInfo}).
197
+ */
198
+ undo?: boolean;
199
+ /**
200
+ * Group the strings under a header per area (0.25, keksdose live #377: *"grouping to the
201
+ * translation review entries for faster reviewing"*): `"namespace"` — the row's
202
+ * `namespace`, which `translationRows` derives from the key (`budget`, `kit.dataTable`
203
+ * with keksdose's `namespaceOf`) — `"source"`, or a function of the row.
204
+ *
205
+ * Each group is a section with a heading ({@link groupHeadingAs}): its name, "n
206
+ * unreviewed / total", and "Approve unreviewed (n)", which approves the group's rows
207
+ * nobody has given a verdict ({@link unreviewedRows}) under the same rules as one
208
+ * approval — write lock, `readOnly`, one write at a time — with one Undo for the batch.
209
+ * A batch larger than `pageSize` asks first, inside the opened group: more than a page
210
+ * is more than the reviewer can have had on screen.
211
+ *
212
+ * WHAT IT MEANS FOR THE FILTERS, THE SORT AND THE PAGES. The filters narrow the rows
213
+ * first, then the rows are grouped: a header counts what the filters show in it (so
214
+ * "Approve unreviewed" never takes a row the reviewer filtered away), and an area with
215
+ * nothing left is not shown. Groups stand in the order their first string has in the
216
+ * reference; a column sort orders the rows INSIDE every group alike. Each group pages
217
+ * on its own, `pageSize` rows at a time — a header then always stands over its own
218
+ * rows, and paging one area does not move another — rather than one pager cutting
219
+ * through the headers. `storageKey` keeps persisting the ungrouped table only.
220
+ *
221
+ * Groups fold (each on its own, remembered while the panel lives), and a folded group's
222
+ * rows are not rendered at all — only its header. Which groups start open is
223
+ * {@link defaultGroupsOpen}: by default, on a phone they start folded when there are
224
+ * several — the headers are then the area list, and the reviewer opens the one to work
225
+ * through — and on a wide screen they start open while that is cheap (see there).
226
+ */
227
+ groupBy?: TranslationReviewGroupBy;
228
+ /**
229
+ * Which groups start open under {@link groupBy} (0.25.1). A group the reviewer opens or
230
+ * folds keeps that; this is what every other group follows.
231
+ *
232
+ * - `"auto"` (default): on a phone, open when there is one group and folded when there
233
+ * are several, as in 0.25.0. On a wide screen, open while opening every group renders
234
+ * at most 100 rows (each group shows up to `pageSize` of them; the budget is one page
235
+ * where `pageSize` is larger) — and folded above that, the headers then being the area
236
+ * list, as on a phone.
237
+ * - `true`: every group starts open, on a phone too.
238
+ * - `false`: every group starts folded, on a wide screen too.
239
+ *
240
+ * WHY A BUDGET OF ROWS (keksdose, measured on 0.25.0): a wide screen opened EVERY group.
241
+ * keksdose's /translations has 122 areas and ~4700 strings — 122 tables and 2053 rows at
242
+ * once, ~46 000 elements, 5.2 s before the panel appeared and 2.5 s for a filter click
243
+ * to paint, against 50 rows and 0.34 s ungrouped; its page test went from 4 s to 146 s.
244
+ * kastlan (~31 areas, ~1300 rows open) and Kurvenschmiede (28, ~700) had the same page.
245
+ * What costs is the rows on screen, so the rule counts them rather than the groups:
246
+ * 100 rows is two of the panel's default pages — any one or two areas still open as
247
+ * before, however long, and so do a dozen small ones, or a search whose hits fall in
248
+ * many areas — while a whole catalogue starts as its list of areas: keksdose's shape
249
+ * in jsdom, 122 headers and ~2100 elements instead of ~44 000 (the ungrouped panel has
250
+ * ~1200). The rule follows the filters: narrow them and the groups open.
251
+ *
252
+ * A boolean rather than a controlled open state: the panel already keeps each group's
253
+ * own state, and an app that only wants "all open" or "all folded" should not have to
254
+ * keep a record of 122 flags to say it.
255
+ */
256
+ defaultGroupsOpen?: boolean | "auto";
257
+ /** A group's name. Default: `sourceLabels` for `groupBy="source"`, else the key. */
258
+ groupLabel?: (group: string) => string;
259
+ /** The level of the group headings. Default `h2`: the panel is a page's body, under
260
+ * the page's `h1`. */
261
+ groupHeadingAs?: "h2" | "h3" | "h4";
121
262
  className?: string;
122
263
  labels?: Partial<TranslationReviewLabels>;
123
264
  }
124
265
 
266
+ /** The wide screen's query — the same one DataTable switches its layout on, so "a phone"
267
+ * means the same here as in the table. */
268
+ const MD_UP = "(min-width: 768px)";
269
+
270
+ /**
271
+ * How many rows the groups may render at once on a wide screen before they start folded
272
+ * ({@link TranslationReviewPanelProps.defaultGroupsOpen}): two of the default 50-row pages.
273
+ * A row costs about twenty elements in either layout, so this is the cost of two pages of
274
+ * the ungrouped table — not of 2053 rows in 122 tables.
275
+ */
276
+ const GROUPS_OPEN_ROW_BUDGET = 100;
277
+
278
+ /**
279
+ * Would opening every group stay within the budget? Each group renders up to `pageSize`
280
+ * rows (it pages on its own); the budget is never less than one page, so a single group —
281
+ * which renders at most one — always starts open, as on a phone.
282
+ */
283
+ function groupsFitOpen(groups: readonly TranslationRowGroup[], pageSize: number): boolean {
284
+ const budget = Math.max(GROUPS_OPEN_ROW_BUDGET, pageSize);
285
+ let rendered = 0;
286
+ for (const group of groups) {
287
+ rendered += Math.min(group.rows.length, pageSize);
288
+ if (rendered > budget) return false;
289
+ }
290
+ return true;
291
+ }
292
+
293
+ /**
294
+ * The kit's dev switch, as NumberInput's money guard and `lib/logger.ts` read it: a
295
+ * development build — the consumer's bundler makes `import.meta.env.DEV` false in a
296
+ * production one, and the branch is dead code there — and never under vitest, where every
297
+ * consumer suite would print it; a test opts in with `vi.stubEnv("VITEST", "")`. Read per
298
+ * call, and optional-chained: `import.meta.env` is a Vite injection, absent under Node.
299
+ */
300
+ function devWarnings(): boolean {
301
+ return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
302
+ }
303
+
304
+ type FilterField = keyof TranslationReviewFilter;
305
+
306
+ /** How long after the last filter change the app's `filter` is looked at again: well past
307
+ * a router's transition, so a URL that is still being written is not mistaken for one
308
+ * that never will be. */
309
+ const FILTER_ECHO_MS = 1500;
310
+
311
+ /**
312
+ * The development warning for a controlled filter that does not follow (Kurvenschmiede,
313
+ * 0.25.1). A field the app passes in `filter` is the app's: the panel shows the app's
314
+ * value, whatever the reviewer picked. An app that passes a field but does not write the
315
+ * reported value back — its `onFilterChange` hands on only some fields, `filter` is built
316
+ * as `{ ...DEFAULT_TRANSLATION_REVIEW_FILTER, ...fromTheUrl }`, a URL parser rejects what
317
+ * its serialiser wrote, or there is no `onFilterChange` at all — has a control that
318
+ * silently does nothing: the box unticks itself, the select jumps back.
319
+ *
320
+ * So, after the panel reports a change of a field the app controls, it looks at the
321
+ * app's `filter` again once the change has had time to land ({@link FILTER_ECHO_MS}, read
322
+ * from the last COMMITTED props, so a router transition still rendering does not count),
323
+ * and warns — once per field — when that field still holds the value it had before.
324
+ *
325
+ * Quiet by construction for an app that passes no `filter`, and for a field the app LEAVES
326
+ * OUT: that field is the panel's own and works (keksdose keeps the placeholder switch so,
327
+ * on purpose). Quiet too when the app moved the field somewhere else itself. Development
328
+ * builds only (see {@link devWarnings}): in production and under vitest nothing is
329
+ * recorded and no timer is set.
330
+ */
331
+ function useFilterEchoCheck(filterProp: Partial<TranslationReviewFilter> | undefined) {
332
+ const committed = useRef(filterProp);
333
+ useEffect(() => {
334
+ committed.current = filterProp;
335
+ });
336
+ // Per field: what the app's filter said when the reviewer first changed it, and what
337
+ // the panel reported last.
338
+ const pending = useRef(new Map<FilterField, { from: unknown; to: unknown }>());
339
+ const warned = useRef(new Set<FilterField>());
340
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
341
+ useEffect(() => () => clearTimeout(timer.current), []);
342
+
343
+ return (next: TranslationReviewFilter) => {
344
+ if (filterProp === undefined || !devWarnings()) return;
345
+ for (const field of Object.keys(next) as FilterField[]) {
346
+ const from = filterProp[field];
347
+ if (from === undefined || warned.current.has(field)) continue;
348
+ const seen = pending.current.get(field);
349
+ if (seen) seen.to = next[field];
350
+ else if (next[field] !== from) pending.current.set(field, { from, to: next[field] });
351
+ }
352
+ if (pending.current.size === 0) return;
353
+ clearTimeout(timer.current);
354
+ timer.current = setTimeout(() => {
355
+ const now = committed.current;
356
+ for (const [field, { from, to }] of pending.current) {
357
+ if (to === from || now?.[field] !== from || warned.current.has(field)) continue;
358
+ warned.current.add(field);
359
+ console.warn(
360
+ `[ui-kit] TranslationReviewPanel: the "${field}" filter was changed to ${JSON.stringify(to)}, but the ` +
361
+ `\`filter\` prop still says ${JSON.stringify(from)}, so the control does nothing. A field passed in ` +
362
+ `\`filter\` is the app's: write back every field \`onFilterChange\` reports, in one update ` +
363
+ `(useSearchParamsState's setter takes the object as it is), or leave the field out of \`filter\` and ` +
364
+ `the panel keeps it. (Development builds only; once per field.)`,
365
+ );
366
+ }
367
+ pending.current.clear();
368
+ }, FILTER_ECHO_MS);
369
+ };
370
+ }
371
+
125
372
  /** The row's text — or, for a missing string, the line that says so. */
126
373
  function TextCell({ row, labels, clamp }: { row: TranslationRow; labels: TranslationReviewLabels; clamp?: boolean }) {
127
374
  return row.text === "" ? (
@@ -145,7 +392,14 @@ function TextCell({ row, labels, clamp }: { row: TranslationRow; labels: Transla
145
392
  * Bulk approve takes the selected rows that are not approved yet and have a text:
146
393
  * approving an absent string says nothing, and re-approving one says it again. On a
147
394
  * 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.
395
+ * show — filter to `Unreviewed` in one area, read, select, approve. With `groupBy`, a
396
+ * group's own "Approve unreviewed" is the shorter way, and with `swipe` a card is approved
397
+ * by a swipe.
398
+ *
399
+ * The panel's writes go out ONE AT A TIME, in the order they were asked for: two writes on
400
+ * one locale's verdicts can answer out of order, and an Undo must not overtake the
401
+ * approval it takes back. A swipe on the next card while one is out is queued rather than
402
+ * refused — a reviewer swiping down a list is faster than the server.
149
403
  */
150
404
  export function TranslationReviewPanel({
151
405
  rows: rowsProp,
@@ -165,12 +419,23 @@ export function TranslationReviewPanel({
165
419
  storageKey,
166
420
  formatDate,
167
421
  formatError,
422
+ swipe = false,
423
+ undo: undoProp,
424
+ groupBy,
425
+ groupLabel,
426
+ groupHeadingAs = "h2",
427
+ defaultGroupsOpen = "auto",
168
428
  className,
169
429
  labels: labelsProp,
170
430
  }: TranslationReviewPanelProps) {
171
431
  const labels = useKitLabels("translationReview", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);
172
432
  const locale = useKitLocale();
433
+ const lock = useWriteLock();
434
+ const phone = !useMediaQuery(MD_UP, true);
435
+ const baseId = useId();
436
+ const rootRef = useRef<HTMLDivElement>(null);
173
437
  const editable = !readOnly && onSave !== undefined;
438
+ const undoOn = undoProp ?? (swipe || groupBy !== undefined);
174
439
 
175
440
  const [ownFilter, setOwnFilter] = useState<TranslationReviewFilter>(DEFAULT_TRANSLATION_REVIEW_FILTER);
176
441
  // The app's fields over the panel's own; a field the app leaves `undefined` is the
@@ -179,12 +444,20 @@ export function TranslationReviewPanel({
179
444
  for (const [name, value] of Object.entries(filterProp ?? {})) {
180
445
  if (value !== undefined) Object.assign(filter, { [name]: value });
181
446
  }
447
+ const echoFilter = useFilterEchoCheck(filterProp);
182
448
  const [openId, setOpenId] = useState<string | null>(null);
449
+ // The row whose editor a swipe opened, to be written in: its wording field takes focus.
450
+ const [focusId, setFocusId] = useState<string | null>(null);
183
451
  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);
452
+ // The writes asked for and not yet answered, in order: the first is the one out. The
453
+ // editor keeps its own, for its own buttons.
454
+ const [queued, setQueued] = useState<readonly string[]>([]);
455
+ const claimed = useRef(new Set<string>());
456
+ const tail = useRef<Promise<unknown>>(Promise.resolve());
457
+ const undoCount = useRef(0);
187
458
  const [failure, setFailure] = useState<ReactNode>(null);
459
+ const busy = queued.length > 0;
460
+ const isQueued = (key: string) => queued.includes(key);
188
461
 
189
462
  const rows = useMemo(
190
463
  () => (areas ? rowsProp.filter((row) => keyInAreas(row.key, areas, inArea)) : [...rowsProp]),
@@ -206,43 +479,166 @@ export function TranslationReviewPanel({
206
479
  [rows, status, source, namespace, query, placeholdersOnly],
207
480
  );
208
481
 
482
+ // ── Groups ──
483
+ const groups = useMemo(() => {
484
+ if (groupBy === undefined) return null;
485
+ const of =
486
+ groupBy === "namespace"
487
+ ? (r: TranslationRow) => r.namespace
488
+ : groupBy === "source"
489
+ ? (r: TranslationRow) => r.source
490
+ : groupBy;
491
+ return groupTranslationRows(visible, of);
492
+ }, [visible, groupBy]);
493
+ const groupName = (key: string) =>
494
+ groupLabel?.(key) ?? (groupBy === "source" ? sourceLabels?.[key] : undefined) ?? (key || "—");
495
+ // Only what the reviewer chose; a group nobody has touched follows the default, which
496
+ // follows the filters (see `defaultGroupsOpen`).
497
+ const [groupOpen, setGroupOpen] = useState<Readonly<Record<string, boolean>>>({});
498
+ const groupOpenByDefault =
499
+ defaultGroupsOpen !== "auto"
500
+ ? defaultGroupsOpen
501
+ : groups !== null && (phone ? groups.length <= 1 : groupsFitOpen(groups, pageSize));
502
+ const isGroupOpen = (key: string) => groupOpen[key] ?? groupOpenByDefault;
503
+ const setGroupOpenFor = (key: string, open: boolean) => setGroupOpen((prev) => ({ ...prev, [key]: open }));
504
+ /** A group the reviewer works in — an editor opened in it — stays open when the default
505
+ * turns (a search cleared, the rows grown past the budget): folding it would drop the
506
+ * editor and whatever was typed in it. */
507
+ const keepGroupOpen = (key: string | undefined) => {
508
+ if (key !== undefined) setGroupOpen((prev) => (key in prev ? prev : { ...prev, [key]: true }));
509
+ };
510
+ // The group whose large batch is waiting for a yes — and, until its confirm button
511
+ // has mounted and taken focus, the group that asked.
512
+ const [confirming, setConfirming] = useState<string | null>(null);
513
+ const confirmFocus = useRef<string | null>(null);
514
+ // One sort for every group's table, so a header click orders all of them alike.
515
+ const [groupSorts, setGroupSorts] = useState<SortState[]>([]);
516
+
209
517
  const setFilter = (patch: Partial<TranslationReviewFilter>) => {
210
518
  const next = { ...filter, ...patch };
211
519
  setOwnFilter(next);
212
520
  onFilterChange?.(next);
521
+ echoFilter(next);
213
522
  // A selection the new filter hides would be acted on unseen.
214
523
  setSelected(new Set());
215
524
  };
216
525
 
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);
526
+ /** Queue a write under `key` (one per key at a time); resolves whether it worked. */
527
+ const run = (key: string, action: () => MaybePromise): Promise<boolean> => {
528
+ if (claimed.current.has(key)) return Promise.resolve(false);
529
+ claimed.current.add(key);
530
+ setQueued((q) => [...q, key]);
531
+ const step = tail.current.then(async () => {
532
+ setFailure(null);
533
+ try {
534
+ await action();
535
+ return true;
536
+ } catch (caught) {
537
+ setFailure(formatError ? formatError(caught) : labels.failed);
538
+ return false;
539
+ } finally {
540
+ claimed.current.delete(key);
541
+ setQueued((q) => q.filter((k) => k !== key));
542
+ }
543
+ });
544
+ tail.current = step;
545
+ return step;
546
+ };
547
+
548
+ /** "String approved — Undo", once `targets` (as they were BEFORE the write) are
549
+ * approved. Undo goes through the same queue as every write. */
550
+ const sayApproved = (targets: readonly TranslationRow[], description?: ReactNode) => {
551
+ const back = reviewUndo(targets);
552
+ const message = labels.approvedToast(targets.length);
553
+ const save = onSave;
554
+ const clear = onClear;
555
+ // Taking back the approval of a row that had no verdict is a clear: without
556
+ // `onClear` there is no way to say it, so the toast reports and offers nothing.
557
+ if (!save || (back.clears.length > 0 && !clear)) {
558
+ toast.success(message, { description });
559
+ return;
228
560
  }
561
+ const key = `undo:${++undoCount.current}`;
562
+ toast.undo(message, {
563
+ description,
564
+ onUndo: () =>
565
+ void run(key, async () => {
566
+ if (back.writes.length > 0) await save(back.writes, { origin: "undo", toasted: false });
567
+ if (back.clears.length > 0) await clear?.(back.clears, { origin: "undo", toasted: false });
568
+ }),
569
+ });
570
+ };
571
+
572
+ const approve = async (
573
+ key: string,
574
+ targets: readonly TranslationRow[],
575
+ origin: TranslationReviewOrigin,
576
+ description?: ReactNode,
577
+ ): Promise<boolean> => {
578
+ if (!onSave || targets.length === 0) return false;
579
+ const save = onSave;
580
+ const ok = await run(key, () => save(targets.map((r) => reviewWrite(r, "APPROVED")), { origin, toasted: undoOn }));
581
+ if (ok && undoOn) sayApproved(targets, description);
582
+ return ok;
583
+ };
584
+
585
+ /** The editor's own save: it waits, closes and shows its errors itself, so it does
586
+ * not go through the queue — but an approval there is answered like any other. */
587
+ const saveFromEditor = async (row: TranslationRow, write: TranslationReviewWrite) => {
588
+ const toasted = undoOn && write.verdict === "APPROVED";
589
+ await onSave?.([write], { origin: "editor", toasted });
590
+ if (toasted) sayApproved([row], row.key);
591
+ };
592
+
593
+ /**
594
+ * Focus back into the panel once a group's batch has settled, if it fell out: the
595
+ * button that was pressed goes with the rows it approved (and under the `Unreviewed`
596
+ * filter the whole group can go). Two frames, so the app's new rows have been drawn.
597
+ */
598
+ const keepFocusAt = (groupKey: string) => {
599
+ const check = () => {
600
+ const root = rootRef.current;
601
+ const active = document.activeElement;
602
+ if (!root || (active && active !== document.body && active.isConnected)) return;
603
+ const sections = [...root.querySelectorAll<HTMLElement>("[data-review-group]")];
604
+ const section = sections.find((el) => el.dataset.reviewGroup === groupKey) ?? sections[0];
605
+ section?.querySelector<HTMLElement>("[data-disclosure-trigger]")?.focus();
606
+ };
607
+ requestAnimationFrame(() => requestAnimationFrame(check));
608
+ };
609
+
610
+ const approveGroup = async (group: TranslationRowGroup, confirmed: boolean) => {
611
+ const targets = unreviewedRows(group.rows);
612
+ if (targets.length === 0) return;
613
+ if (!confirmed && targets.length > pageSize) {
614
+ // Asked inside the opened group, over the rows it is about.
615
+ setGroupOpenFor(group.key, true);
616
+ setConfirming(group.key);
617
+ confirmFocus.current = group.key;
618
+ return;
619
+ }
620
+ const ok = await approve(`group:${group.key}`, targets, "group", groupName(group.key));
621
+ if (ok) setConfirming((key) => (key === group.key ? null : key));
622
+ keepFocusAt(group.key);
229
623
  };
230
624
 
231
625
  const visibleIds = new Set(visible.map((r) => r.id));
232
626
  const selectedRows = visible.filter((r) => selected.has(r.id));
233
- const allSelected = visible.length > 0 && selectedRows.length === visible.length;
627
+ // What "Select all shown" takes: grouped, the rows of the open groups — a folded
628
+ // group's rows are not shown.
629
+ const shownRows = groups ? groups.filter((g) => isGroupOpen(g.key)).flatMap((g) => g.rows) : visible;
630
+ const allShownSelected = shownRows.length > 0 && shownRows.every((r) => selected.has(r.id));
234
631
  const toApprove = selectedRows.filter((r) => r.text !== "" && r.status !== "approved");
235
632
  const toReset = selectedRows.filter((r) => r.review !== null);
236
633
 
237
634
  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
- }
635
+ if (await approve("bulk-approve", toApprove, "selection")) setSelected(new Set());
242
636
  };
243
637
  const resetSelected = async () => {
244
638
  if (!onClear || toReset.length === 0) return;
245
- if (await run("bulk-reset", () => onClear(toReset.map((r) => ({ locale: r.locale, key: r.key }))))) {
639
+ const clear = onClear;
640
+ const keys = toReset.map((r) => ({ locale: r.locale, key: r.key }));
641
+ if (await run("bulk-reset", () => clear(keys, { origin: "selection", toasted: false }))) {
246
642
  setSelected(new Set());
247
643
  }
248
644
  };
@@ -291,8 +687,7 @@ export function TranslationReviewPanel({
291
687
  sortBy: (r) => REVIEW_STATUSES.indexOf(r.status),
292
688
  },
293
689
  ];
294
- if (editable && onSave) {
295
- const save = onSave;
690
+ if (editable) {
296
691
  columns.push({
297
692
  key: "approve",
298
693
  header: <span className="sr-only">{labels.approve}</span>,
@@ -304,12 +699,12 @@ export function TranslationReviewPanel({
304
699
  tone="muted"
305
700
  commit
306
701
  label={labels.approve}
307
- pending={busy === `row:${r.id}`}
308
- disabled={busy !== null}
702
+ pending={isQueued(`row:${r.id}`)}
703
+ disabled={busy}
309
704
  onClick={(event) => {
310
705
  // The row's own click opens the editor; this one only approves.
311
706
  event.stopPropagation();
312
- void run(`row:${r.id}`, () => save([reviewWrite(r, "APPROVED")]));
707
+ void approve(`row:${r.id}`, [r], "row", r.key);
313
708
  }}
314
709
  >
315
710
  <Check />
@@ -318,8 +713,223 @@ export function TranslationReviewPanel({
318
713
  });
319
714
  }
320
715
 
716
+ /**
717
+ * A phone card's swipes: Approve toward the end, "Needs a change" toward the start.
718
+ * None where the row must not be acted on — the same conditions under which its
719
+ * buttons are locked or absent.
720
+ */
721
+ const swipeActions = (r: TranslationRow, groupKey?: string): MobileSwipeActions | null => {
722
+ if (!editable || lock.locked || isQueued(`row:${r.id}`)) return null;
723
+ const missing = r.text === "";
724
+ return {
725
+ end:
726
+ missing || r.status === "approved"
727
+ ? []
728
+ : [
729
+ {
730
+ label: labels.approve,
731
+ icon: <Check className="size-4" aria-hidden />,
732
+ onCommit: () => void approve(`row:${r.id}`, [r], "swipe", r.key),
733
+ className: "bg-[var(--success)]",
734
+ armedClassName: "bg-[var(--success)]",
735
+ },
736
+ ],
737
+ start: [
738
+ {
739
+ label: missing ? labels.suggest : labels.flag,
740
+ icon: <PencilLine className="size-4" aria-hidden />,
741
+ // Opens the editor rather than sending the string back: "needs a change"
742
+ // with neither a note nor a better wording tells nobody anything.
743
+ onCommit: () => {
744
+ keepGroupOpen(groupKey);
745
+ setOpenId(r.id);
746
+ setFocusId(r.id);
747
+ },
748
+ className: missing ? "bg-[var(--brand)]" : "bg-[var(--danger)]",
749
+ armedClassName: missing ? "bg-[var(--brand-hover)]" : "bg-[var(--danger-hover)]",
750
+ },
751
+ ],
752
+ };
753
+ };
754
+
755
+ const toggleIn = (prev: ReadonlySet<string>, many: readonly TranslationRow[], checked: boolean) => {
756
+ const next = new Set([...prev].filter((id) => visibleIds.has(id)));
757
+ for (const r of many) {
758
+ if (checked) next.add(r.id);
759
+ else next.delete(r.id);
760
+ }
761
+ return next;
762
+ };
763
+ /** The checkbox column of a table over `list` — the whole list, or one group. */
764
+ const selectionFor = (list: readonly TranslationRow[]): DataTableProps<TranslationRow>["selection"] => {
765
+ if (!editable) return undefined;
766
+ const picked = list.filter((r) => selected.has(r.id)).length;
767
+ return {
768
+ isSelected: (r) => selected.has(r.id),
769
+ onToggle: (r, checked) => setSelected((prev) => toggleIn(prev, [r], checked)),
770
+ onToggleMany: (many, checked) => setSelected((prev) => toggleIn(prev, many, checked)),
771
+ allSelected: list.length > 0 && picked === list.length,
772
+ someSelected: picked > 0 && picked < list.length,
773
+ onToggleAll: (checked) => setSelected((prev) => toggleIn(prev, list, checked)),
774
+ };
775
+ };
776
+
777
+ const table = (list: TranslationRow[], group?: { key: string; name: string }) => (
778
+ <DataTable<TranslationRow>
779
+ rows={list}
780
+ rowKey={(r) => r.id}
781
+ columns={columns}
782
+ empty={labels.empty}
783
+ defaultPageSize={pageSize}
784
+ {...(group
785
+ ? {
786
+ // Inside the group's own card: no frame in a frame, no column rail per group,
787
+ // no scroller per group, and the group's name as the table's.
788
+ frame: false,
789
+ chrome: "minimal" as const,
790
+ maxBodyHeight: "none",
791
+ sorts: groupSorts,
792
+ onSortsChange: setGroupSorts,
793
+ labels: { table: group.name },
794
+ }
795
+ : { storageKey })}
796
+ isExpanded={(r) => r.id === openId}
797
+ onRowClick={(r) => {
798
+ if (openId !== r.id) keepGroupOpen(group?.key);
799
+ setFocusId(null);
800
+ setOpenId((open) => (open === r.id ? null : r.id));
801
+ }}
802
+ expandedRow={(r) => (
803
+ <TranslationReviewEditor
804
+ key={r.id}
805
+ row={r}
806
+ referenceLabel={referenceLabel}
807
+ localeLabel={localeLabel}
808
+ readOnly={!editable}
809
+ focusWording={focusId === r.id}
810
+ onSave={onSave ? (write) => saveFromEditor(r, write) : undefined}
811
+ onClear={onClear ? (key) => onClear([key], { origin: "editor", toasted: false }) : undefined}
812
+ onClose={() => {
813
+ setOpenId(null);
814
+ setFocusId(null);
815
+ }}
816
+ formatDate={formatDate}
817
+ formatError={formatError}
818
+ labels={labelsProp}
819
+ />
820
+ )}
821
+ mobileCard={(r) => (
822
+ <div className="min-w-0 space-y-1">
823
+ <code className="block break-all text-xs text-[var(--text-muted)]">{r.key}</code>
824
+ <div className="text-sm text-[var(--text-primary)]">
825
+ <TextCell row={r} labels={labels} clamp />
826
+ </div>
827
+ <div className="flex flex-wrap gap-1">
828
+ <ReviewStatusChip status={r.status} labels={labelsProp} />
829
+ {r.placeholderMismatch && (
830
+ <Chip size="sm" tone="danger" variant="outline">
831
+ {labels.placeholderChip}
832
+ </Chip>
833
+ )}
834
+ </div>
835
+ </div>
836
+ )}
837
+ mobileSwipeActions={swipe ? (r) => swipeActions(r, group?.key) : undefined}
838
+ selection={selectionFor(list)}
839
+ />
840
+ );
841
+
842
+ const groupSection = (g: TranslationRowGroup, index: number) => {
843
+ const name = groupName(g.key);
844
+ const titleId = `${baseId}-group-${index}`;
845
+ const questionId = `${baseId}-confirm-${index}`;
846
+ const targets = editable ? unreviewedRows(g.rows) : [];
847
+ const key = `group:${g.key}`;
848
+ const open = isGroupOpen(g.key);
849
+ // A folded group renders its header and nothing else. Its body is handed over only
850
+ // while it is open, or when the REVIEWER folded it — so the fold animates over the
851
+ // rows it hides. A group folded by the default gets none: when the default turns (a
852
+ // search cleared over 122 areas), the groups that close would otherwise render their
853
+ // grown tables for the length of the fold.
854
+ const body = open || groupOpen[g.key] === false;
855
+ return (
856
+ <Disclosure
857
+ key={g.key}
858
+ data-review-group={g.key}
859
+ headingAs={groupHeadingAs}
860
+ title={<span id={titleId}>{name}</span>}
861
+ hint={labels.groupCount(g.summary.unreviewed, g.summary.total)}
862
+ open={open}
863
+ onOpenChange={(next) => {
864
+ setGroupOpenFor(g.key, next);
865
+ if (!next) setConfirming((c) => (c === g.key ? null : c));
866
+ }}
867
+ trailing={
868
+ targets.length > 0 ? (
869
+ <Button
870
+ variant="secondary"
871
+ size="sm"
872
+ commit
873
+ // The visible words say what; the group's name, read after them, says where.
874
+ aria-describedby={titleId}
875
+ pending={isQueued(key) && confirming !== g.key}
876
+ disabled={busy}
877
+ onClick={() => void approveGroup(g, false)}
878
+ >
879
+ <Check className="size-4" aria-hidden />
880
+ {labels.approveGroup(targets.length)}
881
+ </Button>
882
+ ) : undefined
883
+ }
884
+ bodyClassName="space-y-0 px-0 pb-0 border-t border-[var(--border)]"
885
+ >
886
+ {body && confirming === g.key && targets.length > 0 && (
887
+ <div className="border-b border-[var(--border)] p-3">
888
+ <AlertBanner tone="warning" size="sm">
889
+ <div className="min-w-0 space-y-2">
890
+ <p id={questionId}>{labels.confirmGroup(targets.length, name)}</p>
891
+ <div className="flex flex-wrap gap-2">
892
+ <Button
893
+ // Focus lands here when the question appears: the expected answer to a
894
+ // question the reviewer just asked for, and Undo stays behind it.
895
+ ref={(el) => {
896
+ if (el && confirmFocus.current === g.key) {
897
+ confirmFocus.current = null;
898
+ el.focus();
899
+ }
900
+ }}
901
+ variant="primary"
902
+ size="sm"
903
+ commit
904
+ aria-describedby={questionId}
905
+ pending={isQueued(key)}
906
+ disabled={busy}
907
+ onClick={() => void approveGroup(g, true)}
908
+ >
909
+ {labels.approveGroup(targets.length)}
910
+ </Button>
911
+ <Button
912
+ variant="ghost"
913
+ size="sm"
914
+ disabled={isQueued(key)}
915
+ onClick={() => {
916
+ setConfirming(null);
917
+ keepFocusAt(g.key);
918
+ }}
919
+ >
920
+ {labels.cancel}
921
+ </Button>
922
+ </div>
923
+ </div>
924
+ </AlertBanner>
925
+ </div>
926
+ )}
927
+ {body && table(g.rows, { key: g.key, name })}
928
+ </Disclosure>
929
+ );
930
+ };
321
931
  return (
322
- <div className={cn("min-w-0 space-y-4", className)}>
932
+ <div ref={rootRef} className={cn("min-w-0 space-y-4", className)}>
323
933
  {scopeText && (
324
934
  <AlertBanner tone="info" variant="inline" size="sm" block live={false}>
325
935
  {labels.scope(scopeText)}
@@ -381,14 +991,14 @@ export function TranslationReviewPanel({
381
991
  />
382
992
  </div>
383
993
 
384
- {editable && visible.length > 0 && (
994
+ {editable && shownRows.length > 0 && (
385
995
  // The phone's table has no checkboxes; this is its way into the bulk actions.
386
996
  <Button
387
997
  variant="ghost"
388
998
  size="sm"
389
999
  className="md:hidden"
390
- disabled={allSelected}
391
- onClick={() => setSelected(new Set(visible.map((r) => r.id)))}
1000
+ disabled={allShownSelected}
1001
+ onClick={() => setSelected((prev) => toggleIn(prev, shownRows, true))}
392
1002
  >
393
1003
  <ListChecks className="size-4" aria-hidden />
394
1004
  {labels.selectShown}
@@ -407,8 +1017,8 @@ export function TranslationReviewPanel({
407
1017
  variant="primary"
408
1018
  size="sm"
409
1019
  commit
410
- pending={busy === "bulk-approve"}
411
- disabled={busy !== null || toApprove.length === 0}
1020
+ pending={isQueued("bulk-approve")}
1021
+ disabled={busy || toApprove.length === 0}
412
1022
  onClick={() => void approveSelected()}
413
1023
  >
414
1024
  <Check className="size-4" aria-hidden />
@@ -419,8 +1029,8 @@ export function TranslationReviewPanel({
419
1029
  variant="ghost"
420
1030
  size="sm"
421
1031
  commit
422
- pending={busy === "bulk-reset"}
423
- disabled={busy !== null || toReset.length === 0}
1032
+ pending={isQueued("bulk-reset")}
1033
+ disabled={busy || toReset.length === 0}
424
1034
  onClick={() => void resetSelected()}
425
1035
  >
426
1036
  <RotateCcw className="size-4" aria-hidden />
@@ -430,73 +1040,11 @@ export function TranslationReviewPanel({
430
1040
  </BulkActionBar>
431
1041
  )}
432
1042
 
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
- />
1043
+ {groups && groups.length > 0 ? (
1044
+ <div className="space-y-3">{groups.map(groupSection)}</div>
1045
+ ) : (
1046
+ table(visible)
1047
+ )}
500
1048
  </div>
501
1049
  );
502
1050
  }