@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,28 +1,35 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { useMemo, useState } from "react";
4
- import { Check, ListChecks, RotateCcw } from "lucide-react";
3
+ import { useEffect, useId, useMemo, useRef, useState } from "react";
4
+ import { Check, ListChecks, PencilLine, RotateCcw } from "lucide-react";
5
+ import { useMediaQuery } from "../hooks/use-media-query.js";
5
6
  import { useKitLabels, useKitLocale } from "../i18n/kit-labels.js";
6
7
  import { cn } from "../lib/cn.js";
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
- summariseRows
16
+ summariseRows,
17
+ unreviewedRows
14
18
  } from "../lib/translation-review.js";
15
19
  import { AlertBanner } from "./alert-banner.js";
16
20
  import { BulkActionBar } from "./bulk-action-bar.js";
17
21
  import { Checkbox } from "./checkbox.js";
18
22
  import { Chip } from "./chip.js";
19
23
  import { DataTable } from "./data-table.js";
24
+ import { Disclosure } from "./disclosure.js";
20
25
  import { SearchField } from "./search-field.js";
26
+ import { toast } from "./toast.js";
21
27
  import { ToggleGroup } from "./toggle-group.js";
22
28
  import { TranslationReviewEditor } from "./translation-review-editor.js";
23
29
  import { DEFAULT_TRANSLATION_REVIEW_LABELS, reviewStatusLabel } from "./translation-review-labels.js";
24
30
  import { ReviewStatusChip, TranslationProgress } from "./translation-review-parts.js";
25
31
  import { Button, IconButton, Select } from "./ui.js";
32
+ import { useWriteLock } from "./write-lock.js";
26
33
  import {
27
34
  REVIEW_STATUS_TONES,
28
35
  ReviewStatusChip as ReviewStatusChip2,
@@ -32,6 +39,54 @@ import {
32
39
  } from "./translation-review-parts.js";
33
40
  import { TranslationReviewEditor as TranslationReviewEditor2 } from "./translation-review-editor.js";
34
41
  import { DEFAULT_TRANSLATION_REVIEW_LABELS as DEFAULT_TRANSLATION_REVIEW_LABELS2 } from "./translation-review-labels.js";
42
+ const MD_UP = "(min-width: 768px)";
43
+ const GROUPS_OPEN_ROW_BUDGET = 100;
44
+ function groupsFitOpen(groups, pageSize) {
45
+ const budget = Math.max(GROUPS_OPEN_ROW_BUDGET, pageSize);
46
+ let rendered = 0;
47
+ for (const group of groups) {
48
+ rendered += Math.min(group.rows.length, pageSize);
49
+ if (rendered > budget) return false;
50
+ }
51
+ return true;
52
+ }
53
+ function devWarnings() {
54
+ return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
55
+ }
56
+ const FILTER_ECHO_MS = 1500;
57
+ function useFilterEchoCheck(filterProp) {
58
+ const committed = useRef(filterProp);
59
+ useEffect(() => {
60
+ committed.current = filterProp;
61
+ });
62
+ const pending = useRef(/* @__PURE__ */ new Map());
63
+ const warned = useRef(/* @__PURE__ */ new Set());
64
+ const timer = useRef(void 0);
65
+ useEffect(() => () => clearTimeout(timer.current), []);
66
+ return (next) => {
67
+ if (filterProp === void 0 || !devWarnings()) return;
68
+ for (const field of Object.keys(next)) {
69
+ const from = filterProp[field];
70
+ if (from === void 0 || warned.current.has(field)) continue;
71
+ const seen = pending.current.get(field);
72
+ if (seen) seen.to = next[field];
73
+ else if (next[field] !== from) pending.current.set(field, { from, to: next[field] });
74
+ }
75
+ if (pending.current.size === 0) return;
76
+ clearTimeout(timer.current);
77
+ timer.current = setTimeout(() => {
78
+ const now = committed.current;
79
+ for (const [field, { from, to }] of pending.current) {
80
+ if (to === from || now?.[field] !== from || warned.current.has(field)) continue;
81
+ warned.current.add(field);
82
+ console.warn(
83
+ `[ui-kit] TranslationReviewPanel: the "${field}" filter was changed to ${JSON.stringify(to)}, but the \`filter\` prop still says ${JSON.stringify(from)}, so the control does nothing. A field passed in \`filter\` is the app's: write back every field \`onFilterChange\` reports, in one update (useSearchParamsState's setter takes the object as it is), or leave the field out of \`filter\` and the panel keeps it. (Development builds only; once per field.)`
84
+ );
85
+ }
86
+ pending.current.clear();
87
+ }, FILTER_ECHO_MS);
88
+ };
89
+ }
35
90
  function TextCell({ row, labels, clamp }) {
36
91
  return row.text === "" ? /* @__PURE__ */ jsx("span", { className: "italic text-[var(--text-muted)]", children: labels.missingText }) : /* @__PURE__ */ jsx("span", { lang: row.locale, className: cn("whitespace-pre-line break-words", clamp && "line-clamp-3"), children: row.text });
37
92
  }
@@ -53,21 +108,39 @@ function TranslationReviewPanel({
53
108
  storageKey,
54
109
  formatDate,
55
110
  formatError,
111
+ swipe = false,
112
+ undo: undoProp,
113
+ groupBy,
114
+ groupLabel,
115
+ groupHeadingAs = "h2",
116
+ defaultGroupsOpen = "auto",
56
117
  className,
57
118
  labels: labelsProp
58
119
  }) {
59
120
  const labels = useKitLabels("translationReview", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);
60
121
  const locale = useKitLocale();
122
+ const lock = useWriteLock();
123
+ const phone = !useMediaQuery(MD_UP, true);
124
+ const baseId = useId();
125
+ const rootRef = useRef(null);
61
126
  const editable = !readOnly && onSave !== void 0;
127
+ const undoOn = undoProp ?? (swipe || groupBy !== void 0);
62
128
  const [ownFilter, setOwnFilter] = useState(DEFAULT_TRANSLATION_REVIEW_FILTER);
63
129
  const filter = { ...ownFilter };
64
130
  for (const [name, value] of Object.entries(filterProp ?? {})) {
65
131
  if (value !== void 0) Object.assign(filter, { [name]: value });
66
132
  }
133
+ const echoFilter = useFilterEchoCheck(filterProp);
67
134
  const [openId, setOpenId] = useState(null);
135
+ const [focusId, setFocusId] = useState(null);
68
136
  const [selected, setSelected] = useState(() => /* @__PURE__ */ new Set());
69
- const [busy, setBusy] = useState(null);
137
+ const [queued, setQueued] = useState([]);
138
+ const claimed = useRef(/* @__PURE__ */ new Set());
139
+ const tail = useRef(Promise.resolve());
140
+ const undoCount = useRef(0);
70
141
  const [failure, setFailure] = useState(null);
142
+ const busy = queued.length > 0;
143
+ const isQueued = (key) => queued.includes(key);
71
144
  const rows = useMemo(
72
145
  () => areas ? rowsProp.filter((row) => keyInAreas(row.key, areas, inArea)) : [...rowsProp],
73
146
  [rowsProp, areas, inArea]
@@ -86,39 +159,117 @@ function TranslationReviewPanel({
86
159
  () => filterTranslationRows(rows, { status, source, namespace, query, placeholdersOnly }),
87
160
  [rows, status, source, namespace, query, placeholdersOnly]
88
161
  );
162
+ const groups = useMemo(() => {
163
+ if (groupBy === void 0) return null;
164
+ const of = groupBy === "namespace" ? (r) => r.namespace : groupBy === "source" ? (r) => r.source : groupBy;
165
+ return groupTranslationRows(visible, of);
166
+ }, [visible, groupBy]);
167
+ const groupName = (key) => groupLabel?.(key) ?? (groupBy === "source" ? sourceLabels?.[key] : void 0) ?? (key || "\u2014");
168
+ const [groupOpen, setGroupOpen] = useState({});
169
+ const groupOpenByDefault = defaultGroupsOpen !== "auto" ? defaultGroupsOpen : groups !== null && (phone ? groups.length <= 1 : groupsFitOpen(groups, pageSize));
170
+ const isGroupOpen = (key) => groupOpen[key] ?? groupOpenByDefault;
171
+ const setGroupOpenFor = (key, open) => setGroupOpen((prev) => ({ ...prev, [key]: open }));
172
+ const keepGroupOpen = (key) => {
173
+ if (key !== void 0) setGroupOpen((prev) => key in prev ? prev : { ...prev, [key]: true });
174
+ };
175
+ const [confirming, setConfirming] = useState(null);
176
+ const confirmFocus = useRef(null);
177
+ const [groupSorts, setGroupSorts] = useState([]);
89
178
  const setFilter = (patch) => {
90
179
  const next = { ...filter, ...patch };
91
180
  setOwnFilter(next);
92
181
  onFilterChange?.(next);
182
+ echoFilter(next);
93
183
  setSelected(/* @__PURE__ */ new Set());
94
184
  };
95
- const run = async (key, action) => {
96
- setBusy(key);
97
- setFailure(null);
98
- try {
99
- await action();
100
- return true;
101
- } catch (caught) {
102
- setFailure(formatError ? formatError(caught) : labels.failed);
103
- return false;
104
- } finally {
105
- setBusy(null);
185
+ const run = (key, action) => {
186
+ if (claimed.current.has(key)) return Promise.resolve(false);
187
+ claimed.current.add(key);
188
+ setQueued((q) => [...q, key]);
189
+ const step = tail.current.then(async () => {
190
+ setFailure(null);
191
+ try {
192
+ await action();
193
+ return true;
194
+ } catch (caught) {
195
+ setFailure(formatError ? formatError(caught) : labels.failed);
196
+ return false;
197
+ } finally {
198
+ claimed.current.delete(key);
199
+ setQueued((q) => q.filter((k) => k !== key));
200
+ }
201
+ });
202
+ tail.current = step;
203
+ return step;
204
+ };
205
+ const sayApproved = (targets, description) => {
206
+ const back = reviewUndo(targets);
207
+ const message = labels.approvedToast(targets.length);
208
+ const save = onSave;
209
+ const clear = onClear;
210
+ if (!save || back.clears.length > 0 && !clear) {
211
+ toast.success(message, { description });
212
+ return;
106
213
  }
214
+ const key = `undo:${++undoCount.current}`;
215
+ toast.undo(message, {
216
+ description,
217
+ onUndo: () => void run(key, async () => {
218
+ if (back.writes.length > 0) await save(back.writes, { origin: "undo", toasted: false });
219
+ if (back.clears.length > 0) await clear?.(back.clears, { origin: "undo", toasted: false });
220
+ })
221
+ });
222
+ };
223
+ const approve = async (key, targets, origin, description) => {
224
+ if (!onSave || targets.length === 0) return false;
225
+ const save = onSave;
226
+ const ok = await run(key, () => save(targets.map((r) => reviewWrite(r, "APPROVED")), { origin, toasted: undoOn }));
227
+ if (ok && undoOn) sayApproved(targets, description);
228
+ return ok;
229
+ };
230
+ const saveFromEditor = async (row, write) => {
231
+ const toasted = undoOn && write.verdict === "APPROVED";
232
+ await onSave?.([write], { origin: "editor", toasted });
233
+ if (toasted) sayApproved([row], row.key);
234
+ };
235
+ const keepFocusAt = (groupKey) => {
236
+ const check = () => {
237
+ const root = rootRef.current;
238
+ const active = document.activeElement;
239
+ if (!root || active && active !== document.body && active.isConnected) return;
240
+ const sections = [...root.querySelectorAll("[data-review-group]")];
241
+ const section = sections.find((el) => el.dataset.reviewGroup === groupKey) ?? sections[0];
242
+ section?.querySelector("[data-disclosure-trigger]")?.focus();
243
+ };
244
+ requestAnimationFrame(() => requestAnimationFrame(check));
245
+ };
246
+ const approveGroup = async (group, confirmed) => {
247
+ const targets = unreviewedRows(group.rows);
248
+ if (targets.length === 0) return;
249
+ if (!confirmed && targets.length > pageSize) {
250
+ setGroupOpenFor(group.key, true);
251
+ setConfirming(group.key);
252
+ confirmFocus.current = group.key;
253
+ return;
254
+ }
255
+ const ok = await approve(`group:${group.key}`, targets, "group", groupName(group.key));
256
+ if (ok) setConfirming((key) => key === group.key ? null : key);
257
+ keepFocusAt(group.key);
107
258
  };
108
259
  const visibleIds = new Set(visible.map((r) => r.id));
109
260
  const selectedRows = visible.filter((r) => selected.has(r.id));
110
- const allSelected = visible.length > 0 && selectedRows.length === visible.length;
261
+ const shownRows = groups ? groups.filter((g) => isGroupOpen(g.key)).flatMap((g) => g.rows) : visible;
262
+ const allShownSelected = shownRows.length > 0 && shownRows.every((r) => selected.has(r.id));
111
263
  const toApprove = selectedRows.filter((r) => r.text !== "" && r.status !== "approved");
112
264
  const toReset = selectedRows.filter((r) => r.review !== null);
113
265
  const approveSelected = async () => {
114
- if (!onSave || toApprove.length === 0) return;
115
- if (await run("bulk-approve", () => onSave(toApprove.map((r) => reviewWrite(r, "APPROVED"))))) {
116
- setSelected(/* @__PURE__ */ new Set());
117
- }
266
+ if (await approve("bulk-approve", toApprove, "selection")) setSelected(/* @__PURE__ */ new Set());
118
267
  };
119
268
  const resetSelected = async () => {
120
269
  if (!onClear || toReset.length === 0) return;
121
- if (await run("bulk-reset", () => onClear(toReset.map((r) => ({ locale: r.locale, key: r.key }))))) {
270
+ const clear = onClear;
271
+ const keys = toReset.map((r) => ({ locale: r.locale, key: r.key }));
272
+ if (await run("bulk-reset", () => clear(keys, { origin: "selection", toasted: false }))) {
122
273
  setSelected(/* @__PURE__ */ new Set());
123
274
  }
124
275
  };
@@ -155,8 +306,7 @@ function TranslationReviewPanel({
155
306
  sortBy: (r) => REVIEW_STATUSES.indexOf(r.status)
156
307
  }
157
308
  ];
158
- if (editable && onSave) {
159
- const save = onSave;
309
+ if (editable) {
160
310
  columns.push({
161
311
  key: "approve",
162
312
  header: /* @__PURE__ */ jsx("span", { className: "sr-only", children: labels.approve }),
@@ -168,18 +318,205 @@ function TranslationReviewPanel({
168
318
  tone: "muted",
169
319
  commit: true,
170
320
  label: labels.approve,
171
- pending: busy === `row:${r.id}`,
172
- disabled: busy !== null,
321
+ pending: isQueued(`row:${r.id}`),
322
+ disabled: busy,
173
323
  onClick: (event) => {
174
324
  event.stopPropagation();
175
- void run(`row:${r.id}`, () => save([reviewWrite(r, "APPROVED")]));
325
+ void approve(`row:${r.id}`, [r], "row", r.key);
176
326
  },
177
327
  children: /* @__PURE__ */ jsx(Check, {})
178
328
  }
179
329
  )
180
330
  });
181
331
  }
182
- return /* @__PURE__ */ jsxs("div", { className: cn("min-w-0 space-y-4", className), children: [
332
+ const swipeActions = (r, groupKey) => {
333
+ if (!editable || lock.locked || isQueued(`row:${r.id}`)) return null;
334
+ const missing = r.text === "";
335
+ return {
336
+ end: missing || r.status === "approved" ? [] : [
337
+ {
338
+ label: labels.approve,
339
+ icon: /* @__PURE__ */ jsx(Check, { className: "size-4", "aria-hidden": true }),
340
+ onCommit: () => void approve(`row:${r.id}`, [r], "swipe", r.key),
341
+ className: "bg-[var(--success)]",
342
+ armedClassName: "bg-[var(--success)]"
343
+ }
344
+ ],
345
+ start: [
346
+ {
347
+ label: missing ? labels.suggest : labels.flag,
348
+ icon: /* @__PURE__ */ jsx(PencilLine, { className: "size-4", "aria-hidden": true }),
349
+ // Opens the editor rather than sending the string back: "needs a change"
350
+ // with neither a note nor a better wording tells nobody anything.
351
+ onCommit: () => {
352
+ keepGroupOpen(groupKey);
353
+ setOpenId(r.id);
354
+ setFocusId(r.id);
355
+ },
356
+ className: missing ? "bg-[var(--brand)]" : "bg-[var(--danger)]",
357
+ armedClassName: missing ? "bg-[var(--brand-hover)]" : "bg-[var(--danger-hover)]"
358
+ }
359
+ ]
360
+ };
361
+ };
362
+ const toggleIn = (prev, many, checked) => {
363
+ const next = new Set([...prev].filter((id) => visibleIds.has(id)));
364
+ for (const r of many) {
365
+ if (checked) next.add(r.id);
366
+ else next.delete(r.id);
367
+ }
368
+ return next;
369
+ };
370
+ const selectionFor = (list) => {
371
+ if (!editable) return void 0;
372
+ const picked = list.filter((r) => selected.has(r.id)).length;
373
+ return {
374
+ isSelected: (r) => selected.has(r.id),
375
+ onToggle: (r, checked) => setSelected((prev) => toggleIn(prev, [r], checked)),
376
+ onToggleMany: (many, checked) => setSelected((prev) => toggleIn(prev, many, checked)),
377
+ allSelected: list.length > 0 && picked === list.length,
378
+ someSelected: picked > 0 && picked < list.length,
379
+ onToggleAll: (checked) => setSelected((prev) => toggleIn(prev, list, checked))
380
+ };
381
+ };
382
+ const table = (list, group) => /* @__PURE__ */ jsx(
383
+ DataTable,
384
+ {
385
+ rows: list,
386
+ rowKey: (r) => r.id,
387
+ columns,
388
+ empty: labels.empty,
389
+ defaultPageSize: pageSize,
390
+ ...group ? {
391
+ // Inside the group's own card: no frame in a frame, no column rail per group,
392
+ // no scroller per group, and the group's name as the table's.
393
+ frame: false,
394
+ chrome: "minimal",
395
+ maxBodyHeight: "none",
396
+ sorts: groupSorts,
397
+ onSortsChange: setGroupSorts,
398
+ labels: { table: group.name }
399
+ } : { storageKey },
400
+ isExpanded: (r) => r.id === openId,
401
+ onRowClick: (r) => {
402
+ if (openId !== r.id) keepGroupOpen(group?.key);
403
+ setFocusId(null);
404
+ setOpenId((open) => open === r.id ? null : r.id);
405
+ },
406
+ expandedRow: (r) => /* @__PURE__ */ jsx(
407
+ TranslationReviewEditor,
408
+ {
409
+ row: r,
410
+ referenceLabel,
411
+ localeLabel,
412
+ readOnly: !editable,
413
+ focusWording: focusId === r.id,
414
+ onSave: onSave ? (write) => saveFromEditor(r, write) : void 0,
415
+ onClear: onClear ? (key) => onClear([key], { origin: "editor", toasted: false }) : void 0,
416
+ onClose: () => {
417
+ setOpenId(null);
418
+ setFocusId(null);
419
+ },
420
+ formatDate,
421
+ formatError,
422
+ labels: labelsProp
423
+ },
424
+ r.id
425
+ ),
426
+ mobileCard: (r) => /* @__PURE__ */ jsxs("div", { className: "min-w-0 space-y-1", children: [
427
+ /* @__PURE__ */ jsx("code", { className: "block break-all text-xs text-[var(--text-muted)]", children: r.key }),
428
+ /* @__PURE__ */ jsx("div", { className: "text-sm text-[var(--text-primary)]", children: /* @__PURE__ */ jsx(TextCell, { row: r, labels, clamp: true }) }),
429
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-1", children: [
430
+ /* @__PURE__ */ jsx(ReviewStatusChip, { status: r.status, labels: labelsProp }),
431
+ r.placeholderMismatch && /* @__PURE__ */ jsx(Chip, { size: "sm", tone: "danger", variant: "outline", children: labels.placeholderChip })
432
+ ] })
433
+ ] }),
434
+ mobileSwipeActions: swipe ? (r) => swipeActions(r, group?.key) : void 0,
435
+ selection: selectionFor(list)
436
+ }
437
+ );
438
+ const groupSection = (g, index) => {
439
+ const name = groupName(g.key);
440
+ const titleId = `${baseId}-group-${index}`;
441
+ const questionId = `${baseId}-confirm-${index}`;
442
+ const targets = editable ? unreviewedRows(g.rows) : [];
443
+ const key = `group:${g.key}`;
444
+ const open = isGroupOpen(g.key);
445
+ const body = open || groupOpen[g.key] === false;
446
+ return /* @__PURE__ */ jsxs(
447
+ Disclosure,
448
+ {
449
+ "data-review-group": g.key,
450
+ headingAs: groupHeadingAs,
451
+ title: /* @__PURE__ */ jsx("span", { id: titleId, children: name }),
452
+ hint: labels.groupCount(g.summary.unreviewed, g.summary.total),
453
+ open,
454
+ onOpenChange: (next) => {
455
+ setGroupOpenFor(g.key, next);
456
+ if (!next) setConfirming((c) => c === g.key ? null : c);
457
+ },
458
+ trailing: targets.length > 0 ? /* @__PURE__ */ jsxs(
459
+ Button,
460
+ {
461
+ variant: "secondary",
462
+ size: "sm",
463
+ commit: true,
464
+ "aria-describedby": titleId,
465
+ pending: isQueued(key) && confirming !== g.key,
466
+ disabled: busy,
467
+ onClick: () => void approveGroup(g, false),
468
+ children: [
469
+ /* @__PURE__ */ jsx(Check, { className: "size-4", "aria-hidden": true }),
470
+ labels.approveGroup(targets.length)
471
+ ]
472
+ }
473
+ ) : void 0,
474
+ bodyClassName: "space-y-0 px-0 pb-0 border-t border-[var(--border)]",
475
+ children: [
476
+ body && confirming === g.key && targets.length > 0 && /* @__PURE__ */ jsx("div", { className: "border-b border-[var(--border)] p-3", children: /* @__PURE__ */ jsx(AlertBanner, { tone: "warning", size: "sm", children: /* @__PURE__ */ jsxs("div", { className: "min-w-0 space-y-2", children: [
477
+ /* @__PURE__ */ jsx("p", { id: questionId, children: labels.confirmGroup(targets.length, name) }),
478
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2", children: [
479
+ /* @__PURE__ */ jsx(
480
+ Button,
481
+ {
482
+ ref: (el) => {
483
+ if (el && confirmFocus.current === g.key) {
484
+ confirmFocus.current = null;
485
+ el.focus();
486
+ }
487
+ },
488
+ variant: "primary",
489
+ size: "sm",
490
+ commit: true,
491
+ "aria-describedby": questionId,
492
+ pending: isQueued(key),
493
+ disabled: busy,
494
+ onClick: () => void approveGroup(g, true),
495
+ children: labels.approveGroup(targets.length)
496
+ }
497
+ ),
498
+ /* @__PURE__ */ jsx(
499
+ Button,
500
+ {
501
+ variant: "ghost",
502
+ size: "sm",
503
+ disabled: isQueued(key),
504
+ onClick: () => {
505
+ setConfirming(null);
506
+ keepFocusAt(g.key);
507
+ },
508
+ children: labels.cancel
509
+ }
510
+ )
511
+ ] })
512
+ ] }) }) }),
513
+ body && table(g.rows, { key: g.key, name })
514
+ ]
515
+ },
516
+ g.key
517
+ );
518
+ };
519
+ return /* @__PURE__ */ jsxs("div", { ref: rootRef, className: cn("min-w-0 space-y-4", className), children: [
183
520
  scopeText && /* @__PURE__ */ jsx(AlertBanner, { tone: "info", variant: "inline", size: "sm", block: true, live: false, children: labels.scope(scopeText) }),
184
521
  readOnly && /* @__PURE__ */ jsx(AlertBanner, { tone: "info", variant: "inline", size: "sm", block: true, live: false, children: labels.readOnly }),
185
522
  progress && /* @__PURE__ */ jsx(TranslationProgress, { summary, labels: labelsProp }),
@@ -224,15 +561,15 @@ function TranslationReviewPanel({
224
561
  }
225
562
  )
226
563
  ] }),
227
- editable && visible.length > 0 && // The phone's table has no checkboxes; this is its way into the bulk actions.
564
+ editable && shownRows.length > 0 && // The phone's table has no checkboxes; this is its way into the bulk actions.
228
565
  /* @__PURE__ */ jsxs(
229
566
  Button,
230
567
  {
231
568
  variant: "ghost",
232
569
  size: "sm",
233
570
  className: "md:hidden",
234
- disabled: allSelected,
235
- onClick: () => setSelected(new Set(visible.map((r) => r.id))),
571
+ disabled: allShownSelected,
572
+ onClick: () => setSelected((prev) => toggleIn(prev, shownRows, true)),
236
573
  children: [
237
574
  /* @__PURE__ */ jsx(ListChecks, { className: "size-4", "aria-hidden": true }),
238
575
  labels.selectShown
@@ -247,8 +584,8 @@ function TranslationReviewPanel({
247
584
  variant: "primary",
248
585
  size: "sm",
249
586
  commit: true,
250
- pending: busy === "bulk-approve",
251
- disabled: busy !== null || toApprove.length === 0,
587
+ pending: isQueued("bulk-approve"),
588
+ disabled: busy || toApprove.length === 0,
252
589
  onClick: () => void approveSelected(),
253
590
  children: [
254
591
  /* @__PURE__ */ jsx(Check, { className: "size-4", "aria-hidden": true }),
@@ -262,8 +599,8 @@ function TranslationReviewPanel({
262
599
  variant: "ghost",
263
600
  size: "sm",
264
601
  commit: true,
265
- pending: busy === "bulk-reset",
266
- disabled: busy !== null || toReset.length === 0,
602
+ pending: isQueued("bulk-reset"),
603
+ disabled: busy || toReset.length === 0,
267
604
  onClick: () => void resetSelected(),
268
605
  children: [
269
606
  /* @__PURE__ */ jsx(RotateCcw, { className: "size-4", "aria-hidden": true }),
@@ -272,63 +609,7 @@ function TranslationReviewPanel({
272
609
  }
273
610
  )
274
611
  ] }),
275
- /* @__PURE__ */ jsx(
276
- DataTable,
277
- {
278
- rows: visible,
279
- rowKey: (r) => r.id,
280
- columns,
281
- empty: labels.empty,
282
- defaultPageSize: pageSize,
283
- storageKey,
284
- isExpanded: (r) => r.id === openId,
285
- onRowClick: (r) => setOpenId((open) => open === r.id ? null : r.id),
286
- expandedRow: (r) => /* @__PURE__ */ jsx(
287
- TranslationReviewEditor,
288
- {
289
- row: r,
290
- referenceLabel,
291
- localeLabel,
292
- readOnly: !editable,
293
- onSave: onSave ? (write) => onSave([write]) : void 0,
294
- onClear: onClear ? (key) => onClear([key]) : void 0,
295
- onClose: () => setOpenId(null),
296
- formatDate,
297
- formatError,
298
- labels: labelsProp
299
- },
300
- r.id
301
- ),
302
- mobileCard: (r) => /* @__PURE__ */ jsxs("div", { className: "min-w-0 space-y-1", children: [
303
- /* @__PURE__ */ jsx("code", { className: "block break-all text-xs text-[var(--text-muted)]", children: r.key }),
304
- /* @__PURE__ */ jsx("div", { className: "text-sm text-[var(--text-primary)]", children: /* @__PURE__ */ jsx(TextCell, { row: r, labels, clamp: true }) }),
305
- /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-1", children: [
306
- /* @__PURE__ */ jsx(ReviewStatusChip, { status: r.status, labels: labelsProp }),
307
- r.placeholderMismatch && /* @__PURE__ */ jsx(Chip, { size: "sm", tone: "danger", variant: "outline", children: labels.placeholderChip })
308
- ] })
309
- ] }),
310
- selection: editable ? {
311
- isSelected: (r) => selected.has(r.id),
312
- onToggle: (r, checked) => setSelected((prev) => {
313
- const next = new Set([...prev].filter((id) => visibleIds.has(id)));
314
- if (checked) next.add(r.id);
315
- else next.delete(r.id);
316
- return next;
317
- }),
318
- onToggleMany: (many, checked) => setSelected((prev) => {
319
- const next = new Set([...prev].filter((id) => visibleIds.has(id)));
320
- for (const r of many) {
321
- if (checked) next.add(r.id);
322
- else next.delete(r.id);
323
- }
324
- return next;
325
- }),
326
- allSelected,
327
- someSelected: selectedRows.length > 0 && !allSelected,
328
- onToggleAll: (checked) => setSelected(checked ? new Set(visible.map((r) => r.id)) : /* @__PURE__ */ new Set())
329
- } : void 0
330
- }
331
- )
612
+ groups && groups.length > 0 ? /* @__PURE__ */ jsx("div", { className: "space-y-3", children: groups.map(groupSection) }) : table(visible)
332
613
  ] });
333
614
  }
334
615
  export {