@eifi1/ui-kit 0.24.1 → 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 +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 +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,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 { 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,7 @@ 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)";
35
43
  function TextCell({ row, labels, clamp }) {
36
44
  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
45
  }
@@ -53,21 +61,37 @@ function TranslationReviewPanel({
53
61
  storageKey,
54
62
  formatDate,
55
63
  formatError,
64
+ swipe = false,
65
+ undo: undoProp,
66
+ groupBy,
67
+ groupLabel,
68
+ groupHeadingAs = "h2",
56
69
  className,
57
70
  labels: labelsProp
58
71
  }) {
59
72
  const labels = useKitLabels("translationReview", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);
60
73
  const locale = useKitLocale();
74
+ const lock = useWriteLock();
75
+ const phone = !useMediaQuery(MD_UP, true);
76
+ const baseId = useId();
77
+ const rootRef = useRef(null);
61
78
  const editable = !readOnly && onSave !== void 0;
79
+ const undoOn = undoProp ?? (swipe || groupBy !== void 0);
62
80
  const [ownFilter, setOwnFilter] = useState(DEFAULT_TRANSLATION_REVIEW_FILTER);
63
81
  const filter = { ...ownFilter };
64
82
  for (const [name, value] of Object.entries(filterProp ?? {})) {
65
83
  if (value !== void 0) Object.assign(filter, { [name]: value });
66
84
  }
67
85
  const [openId, setOpenId] = useState(null);
86
+ const [focusId, setFocusId] = useState(null);
68
87
  const [selected, setSelected] = useState(() => /* @__PURE__ */ new Set());
69
- const [busy, setBusy] = useState(null);
88
+ const [queued, setQueued] = useState([]);
89
+ const claimed = useRef(/* @__PURE__ */ new Set());
90
+ const tail = useRef(Promise.resolve());
91
+ const undoCount = useRef(0);
70
92
  const [failure, setFailure] = useState(null);
93
+ const busy = queued.length > 0;
94
+ const isQueued = (key) => queued.includes(key);
71
95
  const rows = useMemo(
72
96
  () => areas ? rowsProp.filter((row) => keyInAreas(row.key, areas, inArea)) : [...rowsProp],
73
97
  [rowsProp, areas, inArea]
@@ -86,39 +110,113 @@ function TranslationReviewPanel({
86
110
  () => filterTranslationRows(rows, { status, source, namespace, query, placeholdersOnly }),
87
111
  [rows, status, source, namespace, query, placeholdersOnly]
88
112
  );
113
+ const groups = useMemo(() => {
114
+ if (groupBy === void 0) return null;
115
+ const of = groupBy === "namespace" ? (r) => r.namespace : groupBy === "source" ? (r) => r.source : groupBy;
116
+ return groupTranslationRows(visible, of);
117
+ }, [visible, groupBy]);
118
+ const groupName = (key) => groupLabel?.(key) ?? (groupBy === "source" ? sourceLabels?.[key] : void 0) ?? (key || "\u2014");
119
+ const [groupOpen, setGroupOpen] = useState({});
120
+ const groupOpenByDefault = !(phone && (groups?.length ?? 0) > 1);
121
+ const isGroupOpen = (key) => groupOpen[key] ?? groupOpenByDefault;
122
+ const setGroupOpenFor = (key, open) => setGroupOpen((prev) => ({ ...prev, [key]: open }));
123
+ const [confirming, setConfirming] = useState(null);
124
+ const confirmFocus = useRef(null);
125
+ const [groupSorts, setGroupSorts] = useState([]);
89
126
  const setFilter = (patch) => {
90
127
  const next = { ...filter, ...patch };
91
128
  setOwnFilter(next);
92
129
  onFilterChange?.(next);
93
130
  setSelected(/* @__PURE__ */ new Set());
94
131
  };
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);
132
+ const run = (key, action) => {
133
+ if (claimed.current.has(key)) return Promise.resolve(false);
134
+ claimed.current.add(key);
135
+ setQueued((q) => [...q, key]);
136
+ const step = tail.current.then(async () => {
137
+ setFailure(null);
138
+ try {
139
+ await action();
140
+ return true;
141
+ } catch (caught) {
142
+ setFailure(formatError ? formatError(caught) : labels.failed);
143
+ return false;
144
+ } finally {
145
+ claimed.current.delete(key);
146
+ setQueued((q) => q.filter((k) => k !== key));
147
+ }
148
+ });
149
+ tail.current = step;
150
+ return step;
151
+ };
152
+ const sayApproved = (targets, description) => {
153
+ const back = reviewUndo(targets);
154
+ const message = labels.approvedToast(targets.length);
155
+ const save = onSave;
156
+ const clear = onClear;
157
+ if (!save || back.clears.length > 0 && !clear) {
158
+ toast.success(message, { description });
159
+ return;
160
+ }
161
+ const key = `undo:${++undoCount.current}`;
162
+ toast.undo(message, {
163
+ description,
164
+ onUndo: () => void run(key, async () => {
165
+ if (back.writes.length > 0) await save(back.writes, { origin: "undo", toasted: false });
166
+ if (back.clears.length > 0) await clear?.(back.clears, { origin: "undo", toasted: false });
167
+ })
168
+ });
169
+ };
170
+ const approve = async (key, targets, origin, description) => {
171
+ if (!onSave || targets.length === 0) return false;
172
+ const save = onSave;
173
+ const ok = await run(key, () => save(targets.map((r) => reviewWrite(r, "APPROVED")), { origin, toasted: undoOn }));
174
+ if (ok && undoOn) sayApproved(targets, description);
175
+ return ok;
176
+ };
177
+ const saveFromEditor = async (row, write) => {
178
+ const toasted = undoOn && write.verdict === "APPROVED";
179
+ await onSave?.([write], { origin: "editor", toasted });
180
+ if (toasted) sayApproved([row], row.key);
181
+ };
182
+ const keepFocusAt = (groupKey) => {
183
+ const check = () => {
184
+ const root = rootRef.current;
185
+ const active = document.activeElement;
186
+ if (!root || active && active !== document.body && active.isConnected) return;
187
+ const sections = [...root.querySelectorAll("[data-review-group]")];
188
+ const section = sections.find((el) => el.dataset.reviewGroup === groupKey) ?? sections[0];
189
+ section?.querySelector("[data-disclosure-trigger]")?.focus();
190
+ };
191
+ requestAnimationFrame(() => requestAnimationFrame(check));
192
+ };
193
+ const approveGroup = async (group, confirmed) => {
194
+ const targets = unreviewedRows(group.rows);
195
+ if (targets.length === 0) return;
196
+ if (!confirmed && targets.length > pageSize) {
197
+ setGroupOpenFor(group.key, true);
198
+ setConfirming(group.key);
199
+ confirmFocus.current = group.key;
200
+ return;
106
201
  }
202
+ const ok = await approve(`group:${group.key}`, targets, "group", groupName(group.key));
203
+ if (ok) setConfirming((key) => key === group.key ? null : key);
204
+ keepFocusAt(group.key);
107
205
  };
108
206
  const visibleIds = new Set(visible.map((r) => r.id));
109
207
  const selectedRows = visible.filter((r) => selected.has(r.id));
110
- const allSelected = visible.length > 0 && selectedRows.length === visible.length;
208
+ const shownRows = groups ? groups.filter((g) => isGroupOpen(g.key)).flatMap((g) => g.rows) : visible;
209
+ const allShownSelected = shownRows.length > 0 && shownRows.every((r) => selected.has(r.id));
111
210
  const toApprove = selectedRows.filter((r) => r.text !== "" && r.status !== "approved");
112
211
  const toReset = selectedRows.filter((r) => r.review !== null);
113
212
  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
- }
213
+ if (await approve("bulk-approve", toApprove, "selection")) setSelected(/* @__PURE__ */ new Set());
118
214
  };
119
215
  const resetSelected = async () => {
120
216
  if (!onClear || toReset.length === 0) return;
121
- if (await run("bulk-reset", () => onClear(toReset.map((r) => ({ locale: r.locale, key: r.key }))))) {
217
+ const clear = onClear;
218
+ const keys = toReset.map((r) => ({ locale: r.locale, key: r.key }));
219
+ if (await run("bulk-reset", () => clear(keys, { origin: "selection", toasted: false }))) {
122
220
  setSelected(/* @__PURE__ */ new Set());
123
221
  }
124
222
  };
@@ -155,8 +253,7 @@ function TranslationReviewPanel({
155
253
  sortBy: (r) => REVIEW_STATUSES.indexOf(r.status)
156
254
  }
157
255
  ];
158
- if (editable && onSave) {
159
- const save = onSave;
256
+ if (editable) {
160
257
  columns.push({
161
258
  key: "approve",
162
259
  header: /* @__PURE__ */ jsx("span", { className: "sr-only", children: labels.approve }),
@@ -168,18 +265,201 @@ function TranslationReviewPanel({
168
265
  tone: "muted",
169
266
  commit: true,
170
267
  label: labels.approve,
171
- pending: busy === `row:${r.id}`,
172
- disabled: busy !== null,
268
+ pending: isQueued(`row:${r.id}`),
269
+ disabled: busy,
173
270
  onClick: (event) => {
174
271
  event.stopPropagation();
175
- void run(`row:${r.id}`, () => save([reviewWrite(r, "APPROVED")]));
272
+ void approve(`row:${r.id}`, [r], "row", r.key);
176
273
  },
177
274
  children: /* @__PURE__ */ jsx(Check, {})
178
275
  }
179
276
  )
180
277
  });
181
278
  }
182
- return /* @__PURE__ */ jsxs("div", { className: cn("min-w-0 space-y-4", className), children: [
279
+ const swipeActions = (r) => {
280
+ if (!editable || lock.locked || isQueued(`row:${r.id}`)) return null;
281
+ const missing = r.text === "";
282
+ return {
283
+ end: missing || r.status === "approved" ? [] : [
284
+ {
285
+ label: labels.approve,
286
+ icon: /* @__PURE__ */ jsx(Check, { className: "size-4", "aria-hidden": true }),
287
+ onCommit: () => void approve(`row:${r.id}`, [r], "swipe", r.key),
288
+ className: "bg-[var(--success)]",
289
+ armedClassName: "bg-[var(--success)]"
290
+ }
291
+ ],
292
+ start: [
293
+ {
294
+ label: missing ? labels.suggest : labels.flag,
295
+ icon: /* @__PURE__ */ jsx(PencilLine, { className: "size-4", "aria-hidden": true }),
296
+ // Opens the editor rather than sending the string back: "needs a change"
297
+ // with neither a note nor a better wording tells nobody anything.
298
+ onCommit: () => {
299
+ setOpenId(r.id);
300
+ setFocusId(r.id);
301
+ },
302
+ className: missing ? "bg-[var(--brand)]" : "bg-[var(--danger)]",
303
+ armedClassName: missing ? "bg-[var(--brand-hover)]" : "bg-[var(--danger-hover)]"
304
+ }
305
+ ]
306
+ };
307
+ };
308
+ const toggleIn = (prev, many, checked) => {
309
+ const next = new Set([...prev].filter((id) => visibleIds.has(id)));
310
+ for (const r of many) {
311
+ if (checked) next.add(r.id);
312
+ else next.delete(r.id);
313
+ }
314
+ return next;
315
+ };
316
+ const selectionFor = (list) => {
317
+ if (!editable) return void 0;
318
+ const picked = list.filter((r) => selected.has(r.id)).length;
319
+ return {
320
+ isSelected: (r) => selected.has(r.id),
321
+ onToggle: (r, checked) => setSelected((prev) => toggleIn(prev, [r], checked)),
322
+ onToggleMany: (many, checked) => setSelected((prev) => toggleIn(prev, many, checked)),
323
+ allSelected: list.length > 0 && picked === list.length,
324
+ someSelected: picked > 0 && picked < list.length,
325
+ onToggleAll: (checked) => setSelected((prev) => toggleIn(prev, list, checked))
326
+ };
327
+ };
328
+ const table = (list, group) => /* @__PURE__ */ jsx(
329
+ DataTable,
330
+ {
331
+ rows: list,
332
+ rowKey: (r) => r.id,
333
+ columns,
334
+ empty: labels.empty,
335
+ defaultPageSize: pageSize,
336
+ ...group ? {
337
+ // Inside the group's own card: no frame in a frame, no column rail per group,
338
+ // no scroller per group, and the group's name as the table's.
339
+ frame: false,
340
+ chrome: "minimal",
341
+ maxBodyHeight: "none",
342
+ sorts: groupSorts,
343
+ onSortsChange: setGroupSorts,
344
+ labels: { table: group.name }
345
+ } : { storageKey },
346
+ isExpanded: (r) => r.id === openId,
347
+ onRowClick: (r) => {
348
+ setFocusId(null);
349
+ setOpenId((open) => open === r.id ? null : r.id);
350
+ },
351
+ expandedRow: (r) => /* @__PURE__ */ jsx(
352
+ TranslationReviewEditor,
353
+ {
354
+ row: r,
355
+ referenceLabel,
356
+ localeLabel,
357
+ readOnly: !editable,
358
+ focusWording: focusId === r.id,
359
+ onSave: onSave ? (write) => saveFromEditor(r, write) : void 0,
360
+ onClear: onClear ? (key) => onClear([key], { origin: "editor", toasted: false }) : void 0,
361
+ onClose: () => {
362
+ setOpenId(null);
363
+ setFocusId(null);
364
+ },
365
+ formatDate,
366
+ formatError,
367
+ labels: labelsProp
368
+ },
369
+ r.id
370
+ ),
371
+ mobileCard: (r) => /* @__PURE__ */ jsxs("div", { className: "min-w-0 space-y-1", children: [
372
+ /* @__PURE__ */ jsx("code", { className: "block break-all text-xs text-[var(--text-muted)]", children: r.key }),
373
+ /* @__PURE__ */ jsx("div", { className: "text-sm text-[var(--text-primary)]", children: /* @__PURE__ */ jsx(TextCell, { row: r, labels, clamp: true }) }),
374
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-1", children: [
375
+ /* @__PURE__ */ jsx(ReviewStatusChip, { status: r.status, labels: labelsProp }),
376
+ r.placeholderMismatch && /* @__PURE__ */ jsx(Chip, { size: "sm", tone: "danger", variant: "outline", children: labels.placeholderChip })
377
+ ] })
378
+ ] }),
379
+ mobileSwipeActions: swipe ? swipeActions : void 0,
380
+ selection: selectionFor(list)
381
+ }
382
+ );
383
+ const groupSection = (g, index) => {
384
+ const name = groupName(g.key);
385
+ const titleId = `${baseId}-group-${index}`;
386
+ const questionId = `${baseId}-confirm-${index}`;
387
+ const targets = editable ? unreviewedRows(g.rows) : [];
388
+ const key = `group:${g.key}`;
389
+ return /* @__PURE__ */ jsxs(
390
+ Disclosure,
391
+ {
392
+ "data-review-group": g.key,
393
+ headingAs: groupHeadingAs,
394
+ title: /* @__PURE__ */ jsx("span", { id: titleId, children: name }),
395
+ hint: labels.groupCount(g.summary.unreviewed, g.summary.total),
396
+ open: isGroupOpen(g.key),
397
+ onOpenChange: (open) => {
398
+ setGroupOpenFor(g.key, open);
399
+ if (!open) setConfirming((c) => c === g.key ? null : c);
400
+ },
401
+ trailing: targets.length > 0 ? /* @__PURE__ */ jsxs(
402
+ Button,
403
+ {
404
+ variant: "secondary",
405
+ size: "sm",
406
+ commit: true,
407
+ "aria-describedby": titleId,
408
+ pending: isQueued(key) && confirming !== g.key,
409
+ disabled: busy,
410
+ onClick: () => void approveGroup(g, false),
411
+ children: [
412
+ /* @__PURE__ */ jsx(Check, { className: "size-4", "aria-hidden": true }),
413
+ labels.approveGroup(targets.length)
414
+ ]
415
+ }
416
+ ) : void 0,
417
+ bodyClassName: "space-y-0 px-0 pb-0 border-t border-[var(--border)]",
418
+ children: [
419
+ 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: [
420
+ /* @__PURE__ */ jsx("p", { id: questionId, children: labels.confirmGroup(targets.length, name) }),
421
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2", children: [
422
+ /* @__PURE__ */ jsx(
423
+ Button,
424
+ {
425
+ ref: (el) => {
426
+ if (el && confirmFocus.current === g.key) {
427
+ confirmFocus.current = null;
428
+ el.focus();
429
+ }
430
+ },
431
+ variant: "primary",
432
+ size: "sm",
433
+ commit: true,
434
+ "aria-describedby": questionId,
435
+ pending: isQueued(key),
436
+ disabled: busy,
437
+ onClick: () => void approveGroup(g, true),
438
+ children: labels.approveGroup(targets.length)
439
+ }
440
+ ),
441
+ /* @__PURE__ */ jsx(
442
+ Button,
443
+ {
444
+ variant: "ghost",
445
+ size: "sm",
446
+ disabled: isQueued(key),
447
+ onClick: () => {
448
+ setConfirming(null);
449
+ keepFocusAt(g.key);
450
+ },
451
+ children: labels.cancel
452
+ }
453
+ )
454
+ ] })
455
+ ] }) }) }),
456
+ table(g.rows, { name })
457
+ ]
458
+ },
459
+ g.key
460
+ );
461
+ };
462
+ return /* @__PURE__ */ jsxs("div", { ref: rootRef, className: cn("min-w-0 space-y-4", className), children: [
183
463
  scopeText && /* @__PURE__ */ jsx(AlertBanner, { tone: "info", variant: "inline", size: "sm", block: true, live: false, children: labels.scope(scopeText) }),
184
464
  readOnly && /* @__PURE__ */ jsx(AlertBanner, { tone: "info", variant: "inline", size: "sm", block: true, live: false, children: labels.readOnly }),
185
465
  progress && /* @__PURE__ */ jsx(TranslationProgress, { summary, labels: labelsProp }),
@@ -224,15 +504,15 @@ function TranslationReviewPanel({
224
504
  }
225
505
  )
226
506
  ] }),
227
- editable && visible.length > 0 && // The phone's table has no checkboxes; this is its way into the bulk actions.
507
+ editable && shownRows.length > 0 && // The phone's table has no checkboxes; this is its way into the bulk actions.
228
508
  /* @__PURE__ */ jsxs(
229
509
  Button,
230
510
  {
231
511
  variant: "ghost",
232
512
  size: "sm",
233
513
  className: "md:hidden",
234
- disabled: allSelected,
235
- onClick: () => setSelected(new Set(visible.map((r) => r.id))),
514
+ disabled: allShownSelected,
515
+ onClick: () => setSelected((prev) => toggleIn(prev, shownRows, true)),
236
516
  children: [
237
517
  /* @__PURE__ */ jsx(ListChecks, { className: "size-4", "aria-hidden": true }),
238
518
  labels.selectShown
@@ -247,8 +527,8 @@ function TranslationReviewPanel({
247
527
  variant: "primary",
248
528
  size: "sm",
249
529
  commit: true,
250
- pending: busy === "bulk-approve",
251
- disabled: busy !== null || toApprove.length === 0,
530
+ pending: isQueued("bulk-approve"),
531
+ disabled: busy || toApprove.length === 0,
252
532
  onClick: () => void approveSelected(),
253
533
  children: [
254
534
  /* @__PURE__ */ jsx(Check, { className: "size-4", "aria-hidden": true }),
@@ -262,8 +542,8 @@ function TranslationReviewPanel({
262
542
  variant: "ghost",
263
543
  size: "sm",
264
544
  commit: true,
265
- pending: busy === "bulk-reset",
266
- disabled: busy !== null || toReset.length === 0,
545
+ pending: isQueued("bulk-reset"),
546
+ disabled: busy || toReset.length === 0,
267
547
  onClick: () => void resetSelected(),
268
548
  children: [
269
549
  /* @__PURE__ */ jsx(RotateCcw, { className: "size-4", "aria-hidden": true }),
@@ -272,63 +552,7 @@ function TranslationReviewPanel({
272
552
  }
273
553
  )
274
554
  ] }),
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
- )
555
+ groups && groups.length > 0 ? /* @__PURE__ */ jsx("div", { className: "space-y-3", children: groups.map(groupSection) }) : table(visible)
332
556
  ] });
333
557
  }
334
558
  export {