@eifi1/ui-kit 0.25.0 → 0.26.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 (53) hide show
  1. package/README.md +3 -3
  2. package/dist/components/data-table.js +3 -2
  3. package/dist/components/data-table.js.map +1 -1
  4. package/dist/components/swipeable-row.d.ts +5 -1
  5. package/dist/components/swipeable-row.js.map +1 -1
  6. package/dist/components/translation-review-labels.d.ts +4 -0
  7. package/dist/components/translation-review-labels.js +2 -1
  8. package/dist/components/translation-review-labels.js.map +1 -1
  9. package/dist/components/translation-review.d.ts +67 -7
  10. package/dist/components/translation-review.js +115 -29
  11. package/dist/components/translation-review.js.map +1 -1
  12. package/dist/hooks/use-search-param-state.d.ts +1 -1
  13. package/dist/hooks/use-search-param-state.js +8 -1
  14. package/dist/hooks/use-search-param-state.js.map +1 -1
  15. package/dist/i18n/german.js +1 -0
  16. package/dist/i18n/german.js.map +1 -1
  17. package/dist/i18n/locales/en.js +1 -0
  18. package/dist/i18n/locales/en.js.map +1 -1
  19. package/dist/i18n/locales/es.js +1 -0
  20. package/dist/i18n/locales/es.js.map +1 -1
  21. package/dist/i18n/locales/fr.js +1 -0
  22. package/dist/i18n/locales/fr.js.map +1 -1
  23. package/dist/i18n/locales/hu.js +1 -0
  24. package/dist/i18n/locales/hu.js.map +1 -1
  25. package/dist/i18n/locales/it.js +1 -0
  26. package/dist/i18n/locales/it.js.map +1 -1
  27. package/dist/i18n/locales/zh.js +1 -0
  28. package/dist/i18n/locales/zh.js.map +1 -1
  29. package/dist/i18n/review.js +1 -0
  30. package/dist/i18n/review.js.map +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.js +7 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/lib/translation-review.d.ts +76 -1
  35. package/dist/lib/translation-review.js +30 -0
  36. package/dist/lib/translation-review.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/components/data-table.tsx +9 -2
  39. package/src/components/swipeable-row.tsx +5 -1
  40. package/src/components/translation-review-labels.ts +5 -0
  41. package/src/components/translation-review.tsx +275 -48
  42. package/src/hooks/use-search-param-state.ts +19 -4
  43. package/src/i18n/german.ts +1 -0
  44. package/src/i18n/locales/en.ts +1 -0
  45. package/src/i18n/locales/es.ts +1 -0
  46. package/src/i18n/locales/fr.ts +1 -0
  47. package/src/i18n/locales/hu.ts +1 -0
  48. package/src/i18n/locales/it.ts +1 -0
  49. package/src/i18n/locales/zh.ts +1 -0
  50. package/src/i18n/review.ts +1 -0
  51. package/src/index.ts +6 -0
  52. package/src/lib/translation-review.ts +109 -0
  53. package/tokens.css +4 -0
@@ -1,4 +1,4 @@
1
- import { useId, useMemo, useRef, useState } from "react";
1
+ import { useEffect, useId, useMemo, useRef, useState } from "react";
2
2
  import type { ReactNode } from "react";
3
3
  import { Check, ListChecks, PencilLine, RotateCcw } from "lucide-react";
4
4
 
@@ -7,6 +7,7 @@ import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
7
7
  import { cn } from "../lib/cn";
8
8
  import {
9
9
  DEFAULT_TRANSLATION_REVIEW_FILTER,
10
+ DEFAULT_TRANSLATION_REVIEW_SWIPE,
10
11
  REVIEW_STATUSES,
11
12
  filterTranslationRows,
12
13
  groupTranslationRows,
@@ -14,12 +15,15 @@ import {
14
15
  reviewUndo,
15
16
  reviewWrite,
16
17
  summariseRows,
18
+ translationReviewSwipePlan,
17
19
  unreviewedRows,
18
20
  } from "../lib/translation-review";
19
21
  import type {
20
22
  ReviewStatus,
21
23
  TranslationReviewFilter,
22
24
  TranslationReviewKey,
25
+ TranslationReviewSwipeAction,
26
+ TranslationReviewSwipeBinding,
23
27
  TranslationReviewWrite,
24
28
  TranslationRow,
25
29
  TranslationRowGroup,
@@ -33,6 +37,7 @@ import type { DataTableColumn, DataTableProps, MobileSwipeActions, SortState } f
33
37
  import { Disclosure } from "./disclosure";
34
38
  import { SearchField } from "./search-field";
35
39
  import { toast } from "./toast";
40
+ import type { SwipeAction } from "./swipeable-row";
36
41
  import { ToggleGroup } from "./toggle-group";
37
42
  import { TranslationReviewEditor } from "./translation-review-editor";
38
43
  import { DEFAULT_TRANSLATION_REVIEW_LABELS, reviewStatusLabel } from "./translation-review-labels";
@@ -101,7 +106,8 @@ export interface TranslationReviewSaveInfo {
101
106
  /** The open editor, a row's approve button, a swipe on a phone card, a group's
102
107
  * "Approve unreviewed", the selection's bulk bar, or an Undo pressed on the toast. */
103
108
  origin: TranslationReviewOrigin;
104
- /** The panel shows its own toast for this write (an approval's Undo toast). */
109
+ /** The panel shows its own toast for this write (an approval's Undo toast, or a
110
+ * swiped `clear`'s). */
105
111
  toasted: boolean;
106
112
  }
107
113
 
@@ -142,6 +148,11 @@ export interface TranslationReviewPanelProps {
142
148
  /**
143
149
  * Controlled filters — for an app that keeps them in the URL (keksdose's `?status=`,
144
150
  * `?ns=`, `?q=`). Whatever is left out is the panel's own. Pair with `onFilterChange`.
151
+ *
152
+ * A field passed here is the app's, and the control shows what the app says: one the app
153
+ * passes but does not write back from `onFilterChange` is a control that does nothing.
154
+ * A development build warns about that once per field (0.25.1, Kurvenschmiede); a field
155
+ * left out entirely is fine — the panel keeps it.
145
156
  */
146
157
  filter?: Partial<TranslationReviewFilter>;
147
158
  /** Every filter, on every change — write them in ONE update: `useSearchParamsState`'s
@@ -176,8 +187,31 @@ export interface TranslationReviewPanelProps {
176
187
  *
177
188
  * Off by default, so no existing page gains a gesture on the bump. Phones only; the
178
189
  * desktop table keeps its approve button.
190
+ *
191
+ * A BINDING instead of `true` (keksdose — Marcel's live #377 rework, 2026-10-03: *"Add
192
+ * the swipe options to be shown also to the settings /settings#interaction area where
193
+ * the other swipe options are defined"*): keksdose lets the user bind every list's
194
+ * swipes in Settings → Interaction, per side a primary action at the first threshold and
195
+ * a secondary one at a longer drag, and a fixed mapping is one its settings cannot
196
+ * reach. So each logical side takes an ordered ladder of
197
+ * {@link TranslationReviewSwipeAction}s — index 0 at the first threshold, index 1 at the
198
+ * longer drag, as {@link SwipeableRow} stages them:
199
+ *
200
+ * swipe={{ end: ["approve", "clear"], start: ["edit"] }}
201
+ *
202
+ * `approve` (with Undo), `edit` (the editor, cursor in the wording — "Needs a change"),
203
+ * `clear` (the verdict taken back, with Undo, which stores it again; only with
204
+ * `onClear`). An action a row cannot offer drops out and the ladder closes up behind it
205
+ * ({@link translationReviewSwipePlan}): `approve` on an approved or missing string,
206
+ * `clear` on a row with no verdict. Every rule above still holds for every binding —
207
+ * no side swipes in a `readOnly` locale, under a write lock, with the editor open or
208
+ * the row's write out.
209
+ *
210
+ * `true` is {@link DEFAULT_TRANSLATION_REVIEW_SWIPE} — `{ end: ["approve"], start:
211
+ * ["edit"] }`, 0.25's mapping exactly — and `false` or nothing is no swipe. A binding
212
+ * turns the Undo toast on by default, as `true` does.
179
213
  */
180
- swipe?: boolean;
214
+ swipe?: boolean | TranslationReviewSwipeBinding;
181
215
  /**
182
216
  * Answer every approval with the kit's Undo toast (`toast.undo`): a row's button, a
183
217
  * swipe, the editor's Approve, a group's and the selection's bulk approve. Undo stores
@@ -213,11 +247,42 @@ export interface TranslationReviewPanelProps {
213
247
  * rows, and paging one area does not move another — rather than one pager cutting
214
248
  * through the headers. `storageKey` keeps persisting the ungrouped table only.
215
249
  *
216
- * Groups fold (each on its own, remembered while the panel lives). On a phone they
217
- * start folded when there are several — the headers are then the area list, and the
218
- * reviewer opens the one to work through; on a wide screen they start open.
250
+ * Groups fold (each on its own, remembered while the panel lives), and a folded group's
251
+ * rows are not rendered at all — only its header. Which groups start open is
252
+ * {@link defaultGroupsOpen}: by default, on a phone they start folded when there are
253
+ * several — the headers are then the area list, and the reviewer opens the one to work
254
+ * through — and on a wide screen they start open while that is cheap (see there).
219
255
  */
220
256
  groupBy?: TranslationReviewGroupBy;
257
+ /**
258
+ * Which groups start open under {@link groupBy} (0.25.1). A group the reviewer opens or
259
+ * folds keeps that; this is what every other group follows.
260
+ *
261
+ * - `"auto"` (default): on a phone, open when there is one group and folded when there
262
+ * are several, as in 0.25.0. On a wide screen, open while opening every group renders
263
+ * at most 100 rows (each group shows up to `pageSize` of them; the budget is one page
264
+ * where `pageSize` is larger) — and folded above that, the headers then being the area
265
+ * list, as on a phone.
266
+ * - `true`: every group starts open, on a phone too.
267
+ * - `false`: every group starts folded, on a wide screen too.
268
+ *
269
+ * WHY A BUDGET OF ROWS (keksdose, measured on 0.25.0): a wide screen opened EVERY group.
270
+ * keksdose's /translations has 122 areas and ~4700 strings — 122 tables and 2053 rows at
271
+ * once, ~46 000 elements, 5.2 s before the panel appeared and 2.5 s for a filter click
272
+ * to paint, against 50 rows and 0.34 s ungrouped; its page test went from 4 s to 146 s.
273
+ * kastlan (~31 areas, ~1300 rows open) and Kurvenschmiede (28, ~700) had the same page.
274
+ * What costs is the rows on screen, so the rule counts them rather than the groups:
275
+ * 100 rows is two of the panel's default pages — any one or two areas still open as
276
+ * before, however long, and so do a dozen small ones, or a search whose hits fall in
277
+ * many areas — while a whole catalogue starts as its list of areas: keksdose's shape
278
+ * in jsdom, 122 headers and ~2100 elements instead of ~44 000 (the ungrouped panel has
279
+ * ~1200). The rule follows the filters: narrow them and the groups open.
280
+ *
281
+ * A boolean rather than a controlled open state: the panel already keeps each group's
282
+ * own state, and an app that only wants "all open" or "all folded" should not have to
283
+ * keep a record of 122 flags to say it.
284
+ */
285
+ defaultGroupsOpen?: boolean | "auto";
221
286
  /** A group's name. Default: `sourceLabels` for `groupBy="source"`, else the key. */
222
287
  groupLabel?: (group: string) => string;
223
288
  /** The level of the group headings. Default `h2`: the panel is a page's body, under
@@ -231,6 +296,108 @@ export interface TranslationReviewPanelProps {
231
296
  * means the same here as in the table. */
232
297
  const MD_UP = "(min-width: 768px)";
233
298
 
299
+ /**
300
+ * How many rows the groups may render at once on a wide screen before they start folded
301
+ * ({@link TranslationReviewPanelProps.defaultGroupsOpen}): two of the default 50-row pages.
302
+ * A row costs about twenty elements in either layout, so this is the cost of two pages of
303
+ * the ungrouped table — not of 2053 rows in 122 tables.
304
+ */
305
+ const GROUPS_OPEN_ROW_BUDGET = 100;
306
+
307
+ /**
308
+ * Would opening every group stay within the budget? Each group renders up to `pageSize`
309
+ * rows (it pages on its own); the budget is never less than one page, so a single group —
310
+ * which renders at most one — always starts open, as on a phone.
311
+ */
312
+ function groupsFitOpen(groups: readonly TranslationRowGroup[], pageSize: number): boolean {
313
+ const budget = Math.max(GROUPS_OPEN_ROW_BUDGET, pageSize);
314
+ let rendered = 0;
315
+ for (const group of groups) {
316
+ rendered += Math.min(group.rows.length, pageSize);
317
+ if (rendered > budget) return false;
318
+ }
319
+ return true;
320
+ }
321
+
322
+ /**
323
+ * The kit's dev switch, as NumberInput's money guard and `lib/logger.ts` read it: a
324
+ * development build — the consumer's bundler makes `import.meta.env.DEV` false in a
325
+ * production one, and the branch is dead code there — and never under vitest, where every
326
+ * consumer suite would print it; a test opts in with `vi.stubEnv("VITEST", "")`. Read per
327
+ * call, and optional-chained: `import.meta.env` is a Vite injection, absent under Node.
328
+ */
329
+ function devWarnings(): boolean {
330
+ return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
331
+ }
332
+
333
+ type FilterField = keyof TranslationReviewFilter;
334
+
335
+ /** How long after the last filter change the app's `filter` is looked at again: well past
336
+ * a router's transition, so a URL that is still being written is not mistaken for one
337
+ * that never will be. */
338
+ const FILTER_ECHO_MS = 1500;
339
+
340
+ /**
341
+ * The development warning for a controlled filter that does not follow (Kurvenschmiede,
342
+ * 0.25.1). A field the app passes in `filter` is the app's: the panel shows the app's
343
+ * value, whatever the reviewer picked. An app that passes a field but does not write the
344
+ * reported value back — its `onFilterChange` hands on only some fields, `filter` is built
345
+ * as `{ ...DEFAULT_TRANSLATION_REVIEW_FILTER, ...fromTheUrl }`, a URL parser rejects what
346
+ * its serialiser wrote, or there is no `onFilterChange` at all — has a control that
347
+ * silently does nothing: the box unticks itself, the select jumps back.
348
+ *
349
+ * So, after the panel reports a change of a field the app controls, it looks at the
350
+ * app's `filter` again once the change has had time to land ({@link FILTER_ECHO_MS}, read
351
+ * from the last COMMITTED props, so a router transition still rendering does not count),
352
+ * and warns — once per field — when that field still holds the value it had before.
353
+ *
354
+ * Quiet by construction for an app that passes no `filter`, and for a field the app LEAVES
355
+ * OUT: that field is the panel's own and works (keksdose keeps the placeholder switch so,
356
+ * on purpose). Quiet too when the app moved the field somewhere else itself. Development
357
+ * builds only (see {@link devWarnings}): in production and under vitest nothing is
358
+ * recorded and no timer is set.
359
+ */
360
+ function useFilterEchoCheck(filterProp: Partial<TranslationReviewFilter> | undefined) {
361
+ const committed = useRef(filterProp);
362
+ useEffect(() => {
363
+ committed.current = filterProp;
364
+ });
365
+ // Per field: what the app's filter said when the reviewer first changed it, and what
366
+ // the panel reported last.
367
+ const pending = useRef(new Map<FilterField, { from: unknown; to: unknown }>());
368
+ const warned = useRef(new Set<FilterField>());
369
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
370
+ useEffect(() => () => clearTimeout(timer.current), []);
371
+
372
+ return (next: TranslationReviewFilter) => {
373
+ if (filterProp === undefined || !devWarnings()) return;
374
+ for (const field of Object.keys(next) as FilterField[]) {
375
+ const from = filterProp[field];
376
+ if (from === undefined || warned.current.has(field)) continue;
377
+ const seen = pending.current.get(field);
378
+ if (seen) seen.to = next[field];
379
+ else if (next[field] !== from) pending.current.set(field, { from, to: next[field] });
380
+ }
381
+ if (pending.current.size === 0) return;
382
+ clearTimeout(timer.current);
383
+ timer.current = setTimeout(() => {
384
+ const now = committed.current;
385
+ for (const [field, { from, to }] of pending.current) {
386
+ if (to === from || now?.[field] !== from || warned.current.has(field)) continue;
387
+ warned.current.add(field);
388
+ console.warn(
389
+ `[ui-kit] TranslationReviewPanel: the "${field}" filter was changed to ${JSON.stringify(to)}, but the ` +
390
+ `\`filter\` prop still says ${JSON.stringify(from)}, so the control does nothing. A field passed in ` +
391
+ `\`filter\` is the app's: write back every field \`onFilterChange\` reports, in one update ` +
392
+ `(useSearchParamsState's setter takes the object as it is), or leave the field out of \`filter\` and ` +
393
+ `the panel keeps it. (Development builds only; once per field.)`,
394
+ );
395
+ }
396
+ pending.current.clear();
397
+ }, FILTER_ECHO_MS);
398
+ };
399
+ }
400
+
234
401
  /** The row's text — or, for a missing string, the line that says so. */
235
402
  function TextCell({ row, labels, clamp }: { row: TranslationRow; labels: TranslationReviewLabels; clamp?: boolean }) {
236
403
  return row.text === "" ? (
@@ -286,6 +453,7 @@ export function TranslationReviewPanel({
286
453
  groupBy,
287
454
  groupLabel,
288
455
  groupHeadingAs = "h2",
456
+ defaultGroupsOpen = "auto",
289
457
  className,
290
458
  labels: labelsProp,
291
459
  }: TranslationReviewPanelProps) {
@@ -296,7 +464,9 @@ export function TranslationReviewPanel({
296
464
  const baseId = useId();
297
465
  const rootRef = useRef<HTMLDivElement>(null);
298
466
  const editable = !readOnly && onSave !== undefined;
299
- const undoOn = undoProp ?? (swipe || groupBy !== undefined);
467
+ const binding: TranslationReviewSwipeBinding | null =
468
+ swipe === true ? DEFAULT_TRANSLATION_REVIEW_SWIPE : swipe || null;
469
+ const undoOn = undoProp ?? (binding !== null || groupBy !== undefined);
300
470
 
301
471
  const [ownFilter, setOwnFilter] = useState<TranslationReviewFilter>(DEFAULT_TRANSLATION_REVIEW_FILTER);
302
472
  // The app's fields over the panel's own; a field the app leaves `undefined` is the
@@ -305,6 +475,7 @@ export function TranslationReviewPanel({
305
475
  for (const [name, value] of Object.entries(filterProp ?? {})) {
306
476
  if (value !== undefined) Object.assign(filter, { [name]: value });
307
477
  }
478
+ const echoFilter = useFilterEchoCheck(filterProp);
308
479
  const [openId, setOpenId] = useState<string | null>(null);
309
480
  // The row whose editor a swipe opened, to be written in: its wording field takes focus.
310
481
  const [focusId, setFocusId] = useState<string | null>(null);
@@ -352,11 +523,21 @@ export function TranslationReviewPanel({
352
523
  }, [visible, groupBy]);
353
524
  const groupName = (key: string) =>
354
525
  groupLabel?.(key) ?? (groupBy === "source" ? sourceLabels?.[key] : undefined) ?? (key || "—");
355
- // Only what the reviewer chose; a group nobody has touched follows the default.
526
+ // Only what the reviewer chose; a group nobody has touched follows the default, which
527
+ // follows the filters (see `defaultGroupsOpen`).
356
528
  const [groupOpen, setGroupOpen] = useState<Readonly<Record<string, boolean>>>({});
357
- const groupOpenByDefault = !(phone && (groups?.length ?? 0) > 1);
529
+ const groupOpenByDefault =
530
+ defaultGroupsOpen !== "auto"
531
+ ? defaultGroupsOpen
532
+ : groups !== null && (phone ? groups.length <= 1 : groupsFitOpen(groups, pageSize));
358
533
  const isGroupOpen = (key: string) => groupOpen[key] ?? groupOpenByDefault;
359
534
  const setGroupOpenFor = (key: string, open: boolean) => setGroupOpen((prev) => ({ ...prev, [key]: open }));
535
+ /** A group the reviewer works in — an editor opened in it — stays open when the default
536
+ * turns (a search cleared, the rows grown past the budget): folding it would drop the
537
+ * editor and whatever was typed in it. */
538
+ const keepGroupOpen = (key: string | undefined) => {
539
+ if (key !== undefined) setGroupOpen((prev) => (key in prev ? prev : { ...prev, [key]: true }));
540
+ };
360
541
  // The group whose large batch is waiting for a yes — and, until its confirm button
361
542
  // has mounted and taken focus, the group that asked.
362
543
  const [confirming, setConfirming] = useState<string | null>(null);
@@ -368,6 +549,7 @@ export function TranslationReviewPanel({
368
549
  const next = { ...filter, ...patch };
369
550
  setOwnFilter(next);
370
551
  onFilterChange?.(next);
552
+ echoFilter(next);
371
553
  // A selection the new filter hides would be acted on unseen.
372
554
  setSelected(new Set());
373
555
  };
@@ -395,10 +577,12 @@ export function TranslationReviewPanel({
395
577
  };
396
578
 
397
579
  /** "String approved — Undo", once `targets` (as they were BEFORE the write) are
398
- * approved. Undo goes through the same queue as every write. */
399
- const sayApproved = (targets: readonly TranslationRow[], description?: ReactNode) => {
580
+ * approved — or, after a swiped `clear`, "String marked unreviewed — Undo". Either way
581
+ * Undo puts back what the rows said before ({@link reviewUndo}), through the same queue
582
+ * as every write. */
583
+ const sayDone = (targets: readonly TranslationRow[], description?: ReactNode, cleared = false) => {
400
584
  const back = reviewUndo(targets);
401
- const message = labels.approvedToast(targets.length);
585
+ const message = (cleared ? labels.clearedToast : labels.approvedToast)(targets.length);
402
586
  const save = onSave;
403
587
  const clear = onClear;
404
588
  // Taking back the approval of a row that had no verdict is a clear: without
@@ -427,16 +611,27 @@ export function TranslationReviewPanel({
427
611
  if (!onSave || targets.length === 0) return false;
428
612
  const save = onSave;
429
613
  const ok = await run(key, () => save(targets.map((r) => reviewWrite(r, "APPROVED")), { origin, toasted: undoOn }));
430
- if (ok && undoOn) sayApproved(targets, description);
614
+ if (ok && undoOn) sayDone(targets, description);
431
615
  return ok;
432
616
  };
433
617
 
618
+ /** A swipe bound to `clear`: the row's verdict taken back, answered like an approval —
619
+ * the Undo toast stores the verdict again. Under the row's own key, so it queues behind
620
+ * (and never overlaps) an approval of the same row. */
621
+ const clearRow = async (row: TranslationRow) => {
622
+ if (!onClear || row.review === null) return;
623
+ const clear = onClear;
624
+ const keys = [{ locale: row.locale, key: row.key }];
625
+ const ok = await run(`row:${row.id}`, () => clear(keys, { origin: "swipe", toasted: undoOn }));
626
+ if (ok && undoOn) sayDone([row], row.key, true);
627
+ };
628
+
434
629
  /** The editor's own save: it waits, closes and shows its errors itself, so it does
435
630
  * not go through the queue — but an approval there is answered like any other. */
436
631
  const saveFromEditor = async (row: TranslationRow, write: TranslationReviewWrite) => {
437
632
  const toasted = undoOn && write.verdict === "APPROVED";
438
633
  await onSave?.([write], { origin: "editor", toasted });
439
- if (toasted) sayApproved([row], row.key);
634
+ if (toasted) sayDone([row], row.key);
440
635
  };
441
636
 
442
637
  /**
@@ -562,41 +757,65 @@ export function TranslationReviewPanel({
562
757
  });
563
758
  }
564
759
 
565
- /**
566
- * A phone card's swipes: Approve toward the end, "Needs a change" toward the start.
567
- * None where the row must not be acted on — the same conditions under which its
568
- * buttons are locked or absent.
569
- */
570
- const swipeActions = (r: TranslationRow): MobileSwipeActions | null => {
571
- if (!editable || lock.locked || isQueued(`row:${r.id}`)) return null;
760
+ /** One bound swipe action on one row, as {@link SwipeableRow} draws and commits it. */
761
+ const swipeAction = (r: TranslationRow, id: TranslationReviewSwipeAction, groupKey?: string): SwipeAction => {
572
762
  const missing = r.text === "";
573
- return {
574
- end:
575
- missing || r.status === "approved"
576
- ? []
577
- : [
578
- {
579
- label: labels.approve,
580
- icon: <Check className="size-4" aria-hidden />,
581
- onCommit: () => void approve(`row:${r.id}`, [r], "swipe", r.key),
582
- className: "bg-[var(--success)]",
583
- armedClassName: "bg-[var(--success)]",
584
- },
585
- ],
586
- start: [
587
- {
763
+ switch (id) {
764
+ case "approve":
765
+ return {
766
+ label: labels.approve,
767
+ icon: <Check className="size-4" aria-hidden />,
768
+ onCommit: () => void approve(`row:${r.id}`, [r], "swipe", r.key),
769
+ // Text on the fill in its own contrast colour: the fills are pastels in dark
770
+ // mode, where SwipeableRow's default white text was unreadable (0.26).
771
+ className: "bg-[var(--success)] text-[var(--success-contrast)]",
772
+ armedClassName: "bg-[var(--success)] text-[var(--success-contrast)]",
773
+ };
774
+ case "edit":
775
+ return {
588
776
  label: missing ? labels.suggest : labels.flag,
589
777
  icon: <PencilLine className="size-4" aria-hidden />,
590
778
  // Opens the editor rather than sending the string back: "needs a change"
591
779
  // with neither a note nor a better wording tells nobody anything.
592
780
  onCommit: () => {
781
+ keepGroupOpen(groupKey);
593
782
  setOpenId(r.id);
594
783
  setFocusId(r.id);
595
784
  },
596
- className: missing ? "bg-[var(--brand)]" : "bg-[var(--danger)]",
597
- armedClassName: missing ? "bg-[var(--brand-hover)]" : "bg-[var(--danger-hover)]",
598
- },
599
- ],
785
+ className: missing
786
+ ? "bg-[var(--brand)] text-[var(--brand-contrast)]"
787
+ : "bg-[var(--danger)] text-[var(--danger-contrast)]",
788
+ armedClassName: missing
789
+ ? "bg-[var(--brand-hover)] text-[var(--brand-contrast)]"
790
+ : "bg-[var(--danger-hover)] text-[var(--danger-contrast)]",
791
+ };
792
+ case "clear":
793
+ return {
794
+ label: labels.reset,
795
+ icon: <RotateCcw className="size-4" aria-hidden />,
796
+ onCommit: () => void clearRow(r),
797
+ // A step back, not a verdict: neither the approval's green nor the send-back's red.
798
+ // The surface colour as text: white on the grey in light mode, the dark page on
799
+ // the light grey in dark mode.
800
+ className: "bg-[var(--text-muted)] text-[var(--bg-surface)]",
801
+ armedClassName: "bg-[var(--text-secondary)] text-[var(--bg-surface)]",
802
+ };
803
+ }
804
+ };
805
+
806
+ /**
807
+ * A phone card's swipes: the binding ({@link TranslationReviewPanelProps.swipe}) resolved
808
+ * against the row, each side's ladder closed up over what the row cannot offer. None
809
+ * where the row must not be acted on — the same conditions under which its buttons are
810
+ * locked or absent.
811
+ */
812
+ const swipeActions = (r: TranslationRow, groupKey?: string): MobileSwipeActions | null => {
813
+ if (!binding || !editable || lock.locked || isQueued(`row:${r.id}`)) return null;
814
+ const plan = translationReviewSwipePlan(binding, r, { canClear: onClear !== undefined });
815
+ if (plan.start.length === 0 && plan.end.length === 0) return null;
816
+ return {
817
+ start: plan.start.map((id) => swipeAction(r, id, groupKey)),
818
+ end: plan.end.map((id) => swipeAction(r, id, groupKey)),
600
819
  };
601
820
  };
602
821
 
@@ -622,7 +841,7 @@ export function TranslationReviewPanel({
622
841
  };
623
842
  };
624
843
 
625
- const table = (list: TranslationRow[], group?: { name: string }) => (
844
+ const table = (list: TranslationRow[], group?: { key: string; name: string }) => (
626
845
  <DataTable<TranslationRow>
627
846
  rows={list}
628
847
  rowKey={(r) => r.id}
@@ -643,6 +862,7 @@ export function TranslationReviewPanel({
643
862
  : { storageKey })}
644
863
  isExpanded={(r) => r.id === openId}
645
864
  onRowClick={(r) => {
865
+ if (openId !== r.id) keepGroupOpen(group?.key);
646
866
  setFocusId(null);
647
867
  setOpenId((open) => (open === r.id ? null : r.id));
648
868
  }}
@@ -681,7 +901,7 @@ export function TranslationReviewPanel({
681
901
  </div>
682
902
  </div>
683
903
  )}
684
- mobileSwipeActions={swipe ? swipeActions : undefined}
904
+ mobileSwipeActions={binding ? (r) => swipeActions(r, group?.key) : undefined}
685
905
  selection={selectionFor(list)}
686
906
  />
687
907
  );
@@ -692,6 +912,13 @@ export function TranslationReviewPanel({
692
912
  const questionId = `${baseId}-confirm-${index}`;
693
913
  const targets = editable ? unreviewedRows(g.rows) : [];
694
914
  const key = `group:${g.key}`;
915
+ const open = isGroupOpen(g.key);
916
+ // A folded group renders its header and nothing else. Its body is handed over only
917
+ // while it is open, or when the REVIEWER folded it — so the fold animates over the
918
+ // rows it hides. A group folded by the default gets none: when the default turns (a
919
+ // search cleared over 122 areas), the groups that close would otherwise render their
920
+ // grown tables for the length of the fold.
921
+ const body = open || groupOpen[g.key] === false;
695
922
  return (
696
923
  <Disclosure
697
924
  key={g.key}
@@ -699,10 +926,10 @@ export function TranslationReviewPanel({
699
926
  headingAs={groupHeadingAs}
700
927
  title={<span id={titleId}>{name}</span>}
701
928
  hint={labels.groupCount(g.summary.unreviewed, g.summary.total)}
702
- open={isGroupOpen(g.key)}
703
- onOpenChange={(open) => {
704
- setGroupOpenFor(g.key, open);
705
- if (!open) setConfirming((c) => (c === g.key ? null : c));
929
+ open={open}
930
+ onOpenChange={(next) => {
931
+ setGroupOpenFor(g.key, next);
932
+ if (!next) setConfirming((c) => (c === g.key ? null : c));
706
933
  }}
707
934
  trailing={
708
935
  targets.length > 0 ? (
@@ -723,7 +950,7 @@ export function TranslationReviewPanel({
723
950
  }
724
951
  bodyClassName="space-y-0 px-0 pb-0 border-t border-[var(--border)]"
725
952
  >
726
- {confirming === g.key && targets.length > 0 && (
953
+ {body && confirming === g.key && targets.length > 0 && (
727
954
  <div className="border-b border-[var(--border)] p-3">
728
955
  <AlertBanner tone="warning" size="sm">
729
956
  <div className="min-w-0 space-y-2">
@@ -764,7 +991,7 @@ export function TranslationReviewPanel({
764
991
  </AlertBanner>
765
992
  </div>
766
993
  )}
767
- {table(g.rows, { name })}
994
+ {body && table(g.rows, { key: g.key, name })}
768
995
  </Disclosure>
769
996
  );
770
997
  };
@@ -136,9 +136,24 @@ const queuedByLocation = new WeakMap<Location, QueuedQuery>();
136
136
  */
137
137
  type DataRouterProbe = { router?: { state?: { navigation?: { state?: string } } } } | null;
138
138
  const NO_DATA_ROUTER = createContext<DataRouterProbe>(null);
139
- const DataRouterContext: Context<DataRouterProbe> =
140
- (ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??
141
- NO_DATA_ROUTER;
139
+ const DataRouterContext: Context<DataRouterProbe> = readDataRouterContext();
140
+
141
+ /**
142
+ * A static member read (so a bundler still tree-shakes the namespace), inside a `try`:
143
+ * a test's `vi.mock("react-router", () => ({ … }))` without the original module throws
144
+ * on ANY export it does not define, and it threw here at import (keksdose, 0.25.0) —
145
+ * the hook then runs without the refinement, as on a router without the context.
146
+ */
147
+ function readDataRouterContext(): Context<DataRouterProbe> {
148
+ try {
149
+ return (
150
+ (ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??
151
+ NO_DATA_ROUTER
152
+ );
153
+ } catch {
154
+ return NO_DATA_ROUTER;
155
+ }
156
+ }
142
157
 
143
158
  /**
144
159
  * One write: `change` edits the queued query in place and says whether a param it
@@ -295,7 +310,7 @@ export type SearchParamsUpdate<V> = Partial<V> | ((prev: V) => Partial<V>);
295
310
  * The value object keeps its identity while its params do not change, so it can sit in
296
311
  * a dependency array. Requires a react-router Router.
297
312
  */
298
- export function useSearchParamsState<V extends Record<string, unknown>>(
313
+ export function useSearchParamsState<V extends object>(
299
314
  fields: SearchParamFields<V>,
300
315
  ): [V, (next: SearchParamsUpdate<V>) => void] {
301
316
  const [params, write] = useQueryWriter();
@@ -790,6 +790,7 @@ export function germanLabels(numberLocale: string): UiKitLabels {
790
790
  exportCorrections: (count) => `Korrekturen exportieren (${n(count)})`,
791
791
  failed: "Das hat nicht geklappt. Bitte erneut versuchen.",
792
792
  approvedToast: (count) => (count === 1 ? "Text bestätigt" : `${n(count)} Texte bestätigt`),
793
+ clearedToast: (count) => (count === 1 ? "Text als ungeprüft markiert" : `${n(count)} Texte als ungeprüft markiert`),
793
794
  groupCount: (unreviewed, total) => `${n(unreviewed)} ungeprüft / ${n(total)}`,
794
795
  approveGroup: (count) => `Ungeprüfte bestätigen (${n(count)})`,
795
796
  // The group's name quoted, as `columnMapper.roleOf`'s column: it is often a key
@@ -127,6 +127,7 @@ export function uiKitLabelsEn(numberLocale = "en-US"): UiKitLabels {
127
127
  localeProgress: (approved, total) => `${n(approved)}/${n(total)}`,
128
128
  exportCorrections: (count) => `Export corrections (${n(count)})`,
129
129
  approvedToast: (count) => (count === 1 ? "String approved" : `${n(count)} strings approved`),
130
+ clearedToast: (count) => (count === 1 ? "String marked unreviewed" : `${n(count)} strings marked unreviewed`),
130
131
  groupCount: (unreviewed, total) => `${n(unreviewed)} unreviewed / ${n(total)}`,
131
132
  approveGroup: (count) => `Approve unreviewed (${n(count)})`,
132
133
  confirmGroup: (count, group) =>
@@ -770,6 +770,7 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
770
770
  exportCorrections: (count) => `Exportar correcciones (${n(count)})`,
771
771
  failed: "No ha funcionado. Inténtelo de nuevo.",
772
772
  approvedToast: (count) => (count === 1 ? "Texto aprobado" : `${n(count)} textos aprobados`),
773
+ clearedToast: (count) => (count === 1 ? "Texto marcado como sin revisar" : `${n(count)} textos marcados como sin revisar`),
773
774
  // "sin revisar" does not agree with the count, so one form serves both.
774
775
  groupCount: (unreviewed, total) => `${n(unreviewed)} sin revisar / ${n(total)}`,
775
776
  approveGroup: (count) => `Aprobar los sin revisar (${n(count)})`,
@@ -787,6 +787,7 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
787
787
  exportCorrections: (count) => `Exporter les corrections (${n(count)})`,
788
788
  failed: "Cela n’a pas fonctionné. Veuillez réessayer.",
789
789
  approvedToast: (count) => (count < 2 ? "Texte approuvé" : `${n(count)} textes approuvés`),
790
+ clearedToast: (count) => (count < 2 ? "Texte marqué comme non relu" : `${n(count)} textes marqués comme non relus`),
790
791
  groupCount: (unreviewed, total) =>
791
792
  `${n(unreviewed)} non ${plural(unreviewed, "relu", "relus")} / ${n(total)}`,
792
793
  approveGroup: (count) => `Approuver les non relus (${n(count)})`,
@@ -774,6 +774,7 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
774
774
  failed: "Ez nem sikerült. Kérjük, próbálja újra.",
775
775
  // No plural after a numeral (convention 2).
776
776
  approvedToast: (count) => (count === 1 ? "Szöveg jóváhagyva" : `${n(count)} szöveg jóváhagyva`),
777
+ clearedToast: (count) => (count === 1 ? "Szöveg ellenőrizetlenként megjelölve" : `${n(count)} szöveg ellenőrizetlenként megjelölve`),
777
778
  groupCount: (unreviewed, total) => `${n(unreviewed)} ellenőrizetlen / ${n(total)}`,
778
779
  approveGroup: (count) => `Ellenőrizetlenek jóváhagyása (${n(count)})`,
779
780
  // Opens with the quoted name, as `filePicker`'s refusals: no case ending ("-ban/-ben")
@@ -774,6 +774,7 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
774
774
  exportCorrections: (count) => `Esporta le correzioni (${n(count)})`,
775
775
  failed: "Non ha funzionato. Riprovi.",
776
776
  approvedToast: (count) => (count === 1 ? "Testo approvato" : `${n(count)} testi approvati`),
777
+ clearedToast: (count) => (count === 1 ? "Testo segnato come non rivisto" : `${n(count)} testi segnati come non rivisti`),
777
778
  groupCount: (unreviewed, total) =>
778
779
  `${n(unreviewed)} ${plural(unreviewed, "non rivisto", "non rivisti")} / ${n(total)}`,
779
780
  approveGroup: (count) => `Approva i non rivisti (${n(count)})`,
@@ -749,6 +749,7 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
749
749
  exportCorrections: (count) => `导出更正(${n(count)})`,
750
750
  failed: "操作未成功,请重试。",
751
751
  approvedToast: (count) => (count === 1 ? "文本已通过" : `已通过 ${n(count)} 条文本`),
752
+ clearedToast: (count) => (count === 1 ? "文本已标记为未审校" : `已将 ${n(count)} 条文本标记为未审校`),
752
753
  groupCount: (unreviewed, total) => `未审校 ${n(unreviewed)} / ${n(total)}`,
753
754
  approveGroup: (count) => `通过未审校项(${n(count)})`,
754
755
  confirmGroup: (count, group) =>
@@ -194,6 +194,7 @@ export const KIT_LABEL_SAMPLES: Readonly<Record<string, readonly (readonly unkno
194
194
  "translationReview.scope": [["{{areas}}"]],
195
195
  "translationReview.exportCorrections": [[0], [1], [3]],
196
196
  "translationReview.approvedToast": one,
197
+ "translationReview.clearedToast": one,
197
198
  "translationReview.groupCount": [
198
199
  [1, 12],
199
200
  [3, 12],
package/src/index.ts CHANGED
@@ -395,6 +395,10 @@ export {
395
395
  groupTranslationRows,
396
396
  unreviewedRows,
397
397
  reviewUndo,
398
+ // 0.26: swipe bindings for an app's Settings → Interaction (keksdose #377 rework).
399
+ TRANSLATION_REVIEW_SWIPE_ACTIONS,
400
+ DEFAULT_TRANSLATION_REVIEW_SWIPE,
401
+ translationReviewSwipePlan,
398
402
  } from "./lib/translation-review";
399
403
  export type {
400
404
  TranslationVerdict,
@@ -411,6 +415,8 @@ export type {
411
415
  TranslationCorrection,
412
416
  TranslationRowGroup,
413
417
  TranslationReviewUndo,
418
+ TranslationReviewSwipeAction,
419
+ TranslationReviewSwipeBinding,
414
420
  } from "./lib/translation-review";
415
421
  export {
416
422
  TranslationReviewPanel,