@eifi1/ui-kit 0.25.0 → 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.
@@ -85,6 +85,11 @@ interface TranslationReviewPanelProps {
85
85
  /**
86
86
  * Controlled filters — for an app that keeps them in the URL (keksdose's `?status=`,
87
87
  * `?ns=`, `?q=`). Whatever is left out is the panel's own. Pair with `onFilterChange`.
88
+ *
89
+ * A field passed here is the app's, and the control shows what the app says: one the app
90
+ * passes but does not write back from `onFilterChange` is a control that does nothing.
91
+ * A development build warns about that once per field (0.25.1, Kurvenschmiede); a field
92
+ * left out entirely is fine — the panel keeps it.
88
93
  */
89
94
  filter?: Partial<TranslationReviewFilter>;
90
95
  /** Every filter, on every change — write them in ONE update: `useSearchParamsState`'s
@@ -156,11 +161,42 @@ interface TranslationReviewPanelProps {
156
161
  * rows, and paging one area does not move another — rather than one pager cutting
157
162
  * through the headers. `storageKey` keeps persisting the ungrouped table only.
158
163
  *
159
- * Groups fold (each on its own, remembered while the panel lives). On a phone they
160
- * start folded when there are several — the headers are then the area list, and the
161
- * reviewer opens the one to work through; on a wide screen they start open.
164
+ * Groups fold (each on its own, remembered while the panel lives), and a folded group's
165
+ * rows are not rendered at all — only its header. Which groups start open is
166
+ * {@link defaultGroupsOpen}: by default, on a phone they start folded when there are
167
+ * several — the headers are then the area list, and the reviewer opens the one to work
168
+ * through — and on a wide screen they start open while that is cheap (see there).
162
169
  */
163
170
  groupBy?: TranslationReviewGroupBy;
171
+ /**
172
+ * Which groups start open under {@link groupBy} (0.25.1). A group the reviewer opens or
173
+ * folds keeps that; this is what every other group follows.
174
+ *
175
+ * - `"auto"` (default): on a phone, open when there is one group and folded when there
176
+ * are several, as in 0.25.0. On a wide screen, open while opening every group renders
177
+ * at most 100 rows (each group shows up to `pageSize` of them; the budget is one page
178
+ * where `pageSize` is larger) — and folded above that, the headers then being the area
179
+ * list, as on a phone.
180
+ * - `true`: every group starts open, on a phone too.
181
+ * - `false`: every group starts folded, on a wide screen too.
182
+ *
183
+ * WHY A BUDGET OF ROWS (keksdose, measured on 0.25.0): a wide screen opened EVERY group.
184
+ * keksdose's /translations has 122 areas and ~4700 strings — 122 tables and 2053 rows at
185
+ * once, ~46 000 elements, 5.2 s before the panel appeared and 2.5 s for a filter click
186
+ * to paint, against 50 rows and 0.34 s ungrouped; its page test went from 4 s to 146 s.
187
+ * kastlan (~31 areas, ~1300 rows open) and Kurvenschmiede (28, ~700) had the same page.
188
+ * What costs is the rows on screen, so the rule counts them rather than the groups:
189
+ * 100 rows is two of the panel's default pages — any one or two areas still open as
190
+ * before, however long, and so do a dozen small ones, or a search whose hits fall in
191
+ * many areas — while a whole catalogue starts as its list of areas: keksdose's shape
192
+ * in jsdom, 122 headers and ~2100 elements instead of ~44 000 (the ungrouped panel has
193
+ * ~1200). The rule follows the filters: narrow them and the groups open.
194
+ *
195
+ * A boolean rather than a controlled open state: the panel already keeps each group's
196
+ * own state, and an app that only wants "all open" or "all folded" should not have to
197
+ * keep a record of 122 flags to say it.
198
+ */
199
+ defaultGroupsOpen?: boolean | "auto";
164
200
  /** A group's name. Default: `sourceLabels` for `groupBy="source"`, else the key. */
165
201
  groupLabel?: (group: string) => string;
166
202
  /** The level of the group headings. Default `h2`: the panel is a page's body, under
@@ -190,6 +226,6 @@ interface TranslationReviewPanelProps {
190
226
  * approval it takes back. A swipe on the next card while one is out is queued rather than
191
227
  * refused — a reviewer swiping down a list is faster than the server.
192
228
  */
193
- declare function TranslationReviewPanel({ rows: rowsProp, localeLabel, referenceLabel, onSave, onClear, readOnly, areas, areaLabels, inArea, sourceLabels, filter: filterProp, onFilterChange, progress, pageSize, storageKey, formatDate, formatError, swipe, undo: undoProp, groupBy, groupLabel, groupHeadingAs, className, labels: labelsProp, }: TranslationReviewPanelProps): react.JSX.Element;
229
+ declare function TranslationReviewPanel({ rows: rowsProp, localeLabel, referenceLabel, onSave, onClear, readOnly, areas, areaLabels, inArea, sourceLabels, filter: filterProp, onFilterChange, progress, pageSize, storageKey, formatDate, formatError, swipe, undo: undoProp, groupBy, groupLabel, groupHeadingAs, defaultGroupsOpen, className, labels: labelsProp, }: TranslationReviewPanelProps): react.JSX.Element;
194
230
 
195
231
  export { type TranslationReviewGroupBy, TranslationReviewLabels, type TranslationReviewOrigin, TranslationReviewPanel, type TranslationReviewPanelProps, type TranslationReviewSaveInfo };
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { useId, useMemo, useRef, useState } from "react";
3
+ import { useEffect, useId, useMemo, useRef, useState } from "react";
4
4
  import { Check, ListChecks, PencilLine, RotateCcw } from "lucide-react";
5
5
  import { useMediaQuery } from "../hooks/use-media-query.js";
6
6
  import { useKitLabels, useKitLocale } from "../i18n/kit-labels.js";
@@ -40,6 +40,53 @@ import {
40
40
  import { TranslationReviewEditor as TranslationReviewEditor2 } from "./translation-review-editor.js";
41
41
  import { DEFAULT_TRANSLATION_REVIEW_LABELS as DEFAULT_TRANSLATION_REVIEW_LABELS2 } from "./translation-review-labels.js";
42
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
+ }
43
90
  function TextCell({ row, labels, clamp }) {
44
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 });
45
92
  }
@@ -66,6 +113,7 @@ function TranslationReviewPanel({
66
113
  groupBy,
67
114
  groupLabel,
68
115
  groupHeadingAs = "h2",
116
+ defaultGroupsOpen = "auto",
69
117
  className,
70
118
  labels: labelsProp
71
119
  }) {
@@ -82,6 +130,7 @@ function TranslationReviewPanel({
82
130
  for (const [name, value] of Object.entries(filterProp ?? {})) {
83
131
  if (value !== void 0) Object.assign(filter, { [name]: value });
84
132
  }
133
+ const echoFilter = useFilterEchoCheck(filterProp);
85
134
  const [openId, setOpenId] = useState(null);
86
135
  const [focusId, setFocusId] = useState(null);
87
136
  const [selected, setSelected] = useState(() => /* @__PURE__ */ new Set());
@@ -117,9 +166,12 @@ function TranslationReviewPanel({
117
166
  }, [visible, groupBy]);
118
167
  const groupName = (key) => groupLabel?.(key) ?? (groupBy === "source" ? sourceLabels?.[key] : void 0) ?? (key || "\u2014");
119
168
  const [groupOpen, setGroupOpen] = useState({});
120
- const groupOpenByDefault = !(phone && (groups?.length ?? 0) > 1);
169
+ const groupOpenByDefault = defaultGroupsOpen !== "auto" ? defaultGroupsOpen : groups !== null && (phone ? groups.length <= 1 : groupsFitOpen(groups, pageSize));
121
170
  const isGroupOpen = (key) => groupOpen[key] ?? groupOpenByDefault;
122
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
+ };
123
175
  const [confirming, setConfirming] = useState(null);
124
176
  const confirmFocus = useRef(null);
125
177
  const [groupSorts, setGroupSorts] = useState([]);
@@ -127,6 +179,7 @@ function TranslationReviewPanel({
127
179
  const next = { ...filter, ...patch };
128
180
  setOwnFilter(next);
129
181
  onFilterChange?.(next);
182
+ echoFilter(next);
130
183
  setSelected(/* @__PURE__ */ new Set());
131
184
  };
132
185
  const run = (key, action) => {
@@ -276,7 +329,7 @@ function TranslationReviewPanel({
276
329
  )
277
330
  });
278
331
  }
279
- const swipeActions = (r) => {
332
+ const swipeActions = (r, groupKey) => {
280
333
  if (!editable || lock.locked || isQueued(`row:${r.id}`)) return null;
281
334
  const missing = r.text === "";
282
335
  return {
@@ -296,6 +349,7 @@ function TranslationReviewPanel({
296
349
  // Opens the editor rather than sending the string back: "needs a change"
297
350
  // with neither a note nor a better wording tells nobody anything.
298
351
  onCommit: () => {
352
+ keepGroupOpen(groupKey);
299
353
  setOpenId(r.id);
300
354
  setFocusId(r.id);
301
355
  },
@@ -345,6 +399,7 @@ function TranslationReviewPanel({
345
399
  } : { storageKey },
346
400
  isExpanded: (r) => r.id === openId,
347
401
  onRowClick: (r) => {
402
+ if (openId !== r.id) keepGroupOpen(group?.key);
348
403
  setFocusId(null);
349
404
  setOpenId((open) => open === r.id ? null : r.id);
350
405
  },
@@ -376,7 +431,7 @@ function TranslationReviewPanel({
376
431
  r.placeholderMismatch && /* @__PURE__ */ jsx(Chip, { size: "sm", tone: "danger", variant: "outline", children: labels.placeholderChip })
377
432
  ] })
378
433
  ] }),
379
- mobileSwipeActions: swipe ? swipeActions : void 0,
434
+ mobileSwipeActions: swipe ? (r) => swipeActions(r, group?.key) : void 0,
380
435
  selection: selectionFor(list)
381
436
  }
382
437
  );
@@ -386,6 +441,8 @@ function TranslationReviewPanel({
386
441
  const questionId = `${baseId}-confirm-${index}`;
387
442
  const targets = editable ? unreviewedRows(g.rows) : [];
388
443
  const key = `group:${g.key}`;
444
+ const open = isGroupOpen(g.key);
445
+ const body = open || groupOpen[g.key] === false;
389
446
  return /* @__PURE__ */ jsxs(
390
447
  Disclosure,
391
448
  {
@@ -393,10 +450,10 @@ function TranslationReviewPanel({
393
450
  headingAs: groupHeadingAs,
394
451
  title: /* @__PURE__ */ jsx("span", { id: titleId, children: name }),
395
452
  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);
453
+ open,
454
+ onOpenChange: (next) => {
455
+ setGroupOpenFor(g.key, next);
456
+ if (!next) setConfirming((c) => c === g.key ? null : c);
400
457
  },
401
458
  trailing: targets.length > 0 ? /* @__PURE__ */ jsxs(
402
459
  Button,
@@ -416,7 +473,7 @@ function TranslationReviewPanel({
416
473
  ) : void 0,
417
474
  bodyClassName: "space-y-0 px-0 pb-0 border-t border-[var(--border)]",
418
475
  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: [
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: [
420
477
  /* @__PURE__ */ jsx("p", { id: questionId, children: labels.confirmGroup(targets.length, name) }),
421
478
  /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2", children: [
422
479
  /* @__PURE__ */ jsx(
@@ -453,7 +510,7 @@ function TranslationReviewPanel({
453
510
  )
454
511
  ] })
455
512
  ] }) }) }),
456
- table(g.rows, { name })
513
+ body && table(g.rows, { key: g.key, name })
457
514
  ]
458
515
  },
459
516
  g.key
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/translation-review.tsx"],"sourcesContent":["import { useId, useMemo, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { Check, ListChecks, PencilLine, RotateCcw } from \"lucide-react\";\n\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport {\n DEFAULT_TRANSLATION_REVIEW_FILTER,\n REVIEW_STATUSES,\n filterTranslationRows,\n groupTranslationRows,\n keyInAreas,\n reviewUndo,\n reviewWrite,\n summariseRows,\n unreviewedRows,\n} from \"../lib/translation-review\";\nimport type {\n ReviewStatus,\n TranslationReviewFilter,\n TranslationReviewKey,\n TranslationReviewWrite,\n TranslationRow,\n TranslationRowGroup,\n} from \"../lib/translation-review\";\nimport { AlertBanner } from \"./alert-banner\";\nimport { BulkActionBar } from \"./bulk-action-bar\";\nimport { Checkbox } from \"./checkbox\";\nimport { Chip } from \"./chip\";\nimport { DataTable } from \"./data-table\";\nimport type { DataTableColumn, DataTableProps, MobileSwipeActions, SortState } from \"./data-table\";\nimport { Disclosure } from \"./disclosure\";\nimport { SearchField } from \"./search-field\";\nimport { toast } from \"./toast\";\nimport { ToggleGroup } from \"./toggle-group\";\nimport { TranslationReviewEditor } from \"./translation-review-editor\";\nimport { DEFAULT_TRANSLATION_REVIEW_LABELS, reviewStatusLabel } from \"./translation-review-labels\";\nimport type { TranslationReviewLabels } from \"./translation-review-labels\";\nimport { ReviewStatusChip, TranslationProgress } from \"./translation-review-parts\";\nimport { Button, IconButton, Select } from \"./ui\";\nimport { useWriteLock } from \"./write-lock\";\n\nexport {\n REVIEW_STATUS_TONES,\n ReviewStatusChip,\n TranslationExportButton,\n TranslationLocaleTabs,\n TranslationProgress,\n} from \"./translation-review-parts\";\nexport type {\n ReviewStatusChipProps,\n TranslationExportButtonProps,\n TranslationLocaleTab,\n TranslationLocaleTabsProps,\n TranslationProgressProps,\n} from \"./translation-review-parts\";\nexport { TranslationReviewEditor } from \"./translation-review-editor\";\nexport type { TranslationReviewEditorProps } from \"./translation-review-editor\";\nexport { DEFAULT_TRANSLATION_REVIEW_LABELS } from \"./translation-review-labels\";\nexport type { TranslationReviewLabels } from \"./translation-review-labels\";\n\n/**\n * An app's translation review page, as kit parts (H1, docs/i18n-harmonization.md):\n * keksdose's /translations, which kastlan was about to build a second time. The app keeps\n * what is its own — where the strings come from, which locale is read against which, who\n * may review what, every request — and hands the kit rows (`translationRows`) and two\n * callbacks. Everything a reviewer touches is here.\n *\n * Presentational — no request, no route — and one LOCALE at a time. Its table is the\n * kit's DataTable, so like every DataTable it renders under the app's Router. The page\n * around it puts\n * {@link TranslationLocaleTabs} above it and {@link TranslationExportButton} in its header,\n * because both span every locale while this panel is the one open.\n *\n * Callbacks may return promises, and the panel waits for them: an editor stays open with\n * its text and the error when a save is refused (a 403 for a key outside the reviewer's\n * areas, a network failure), a bulk action keeps its selection. Chunking a long bulk\n * write to the server's batch limit is the app's; the panel sends one call per action.\n */\n\ntype MaybePromise = void | Promise<unknown>;\n\n/** What asked for a write — see {@link TranslationReviewSaveInfo}. */\nexport type TranslationReviewOrigin = \"editor\" | \"row\" | \"swipe\" | \"group\" | \"selection\" | \"undo\";\n\n/**\n * The second argument of `onSave` and `onClear` (0.25): where the write came from, and\n * whether the panel answers it with a toast of its own.\n *\n * WHY THE APP IS TOLD. keksdose and kastlan toast \"Saved\" from their mutation on every\n * save. With {@link TranslationReviewPanelProps.undo} on, an approval is answered by the\n * kit's Undo toast instead — and a reviewer swiping through a list then read two toasts\n * per card. The app skips its own where `toasted` is true:\n *\n * onSave={(writes, { toasted }) => save.mutateAsync({ items: writes.map(toApiWrite), quiet: toasted })}\n *\n * An app that ignores the argument keeps working; it only says \"Saved\" twice.\n */\nexport interface TranslationReviewSaveInfo {\n /** The open editor, a row's approve button, a swipe on a phone card, a group's\n * \"Approve unreviewed\", the selection's bulk bar, or an Undo pressed on the toast. */\n origin: TranslationReviewOrigin;\n /** The panel shows its own toast for this write (an approval's Undo toast). */\n toasted: boolean;\n}\n\n/** How {@link TranslationReviewPanelProps.groupBy} names a row's group. */\nexport type TranslationReviewGroupBy = \"namespace\" | \"source\" | ((row: TranslationRow) => string);\n\nexport interface TranslationReviewPanelProps {\n /** One locale's rows, from `translationRows`. */\n rows: readonly TranslationRow[];\n /** The reviewed language's own name — the text column's header (\"Français\"). */\n localeLabel: string;\n /** The reference language's own name — the reference column's header. */\n referenceLabel: string;\n /** Store verdicts: one for an editor or a row's approve button, many for a bulk\n * approve. Left out (or `readOnly`): nothing can be changed. The second argument\n * says where the write came from ({@link TranslationReviewSaveInfo}). */\n onSave?: (writes: TranslationReviewWrite[], info: TranslationReviewSaveInfo) => MaybePromise;\n /** Clear verdicts — back to unreviewed. Left out: no reset, and an approval of a row\n * that had no verdict cannot be undone. */\n onClear?: (keys: TranslationReviewKey[], info: TranslationReviewSaveInfo) => MaybePromise;\n /** A locale the viewer may read but not review: the rows and their verdicts, no\n * actions, and a line saying so. */\n readOnly?: boolean;\n /**\n * The areas the reviewer is limited to (keksdose's lawyer: `[\"legal\"]`), as the server\n * answers them; `null` is every area. Rows outside are not shown or counted — build the\n * rows with the same `areas` and they never exist — and a notice names the areas.\n */\n areas?: readonly string[] | null;\n /** An area's name for the notice (\"the legal pages (Imprint, …)\"). Default: the key. */\n areaLabels?: Readonly<Record<string, string>>;\n /** Default `keyInArea`: the key is the area or sits under it (`legal.…`, `legal:…`). */\n inArea?: (key: string, area: string) => boolean;\n /** A source's name for the source filter, which appears once the rows come from more\n * than one (kastlan: `{ screen: \"Screen\", documents: \"PDFs and emails\" }`). Default:\n * the source itself. */\n sourceLabels?: Readonly<Record<string, string>>;\n /**\n * Controlled filters — for an app that keeps them in the URL (keksdose's `?status=`,\n * `?ns=`, `?q=`). Whatever is left out is the panel's own. Pair with `onFilterChange`.\n */\n filter?: Partial<TranslationReviewFilter>;\n /** Every filter, on every change — write them in ONE update: `useSearchParamsState`'s\n * setter takes this object as it is (`filter={filter} onFilterChange={setFilter}`).\n * Single `useSearchParamState` setters compose since 0.25; before, four in a row\n * clobbered each other (keksdose live #378). */\n onFilterChange?: (filter: TranslationReviewFilter) => void;\n /** The progress bar over the filters. Default true. */\n progress?: boolean;\n /** Rows per page. Default 50. */\n pageSize?: number;\n /** Persists the table's sort and page size (DataTable's `storageKey`). */\n storageKey?: string;\n /** How a verdict's date reads in the editor. */\n formatDate?: (iso: string) => string;\n /** Turns a callback's rejection into words. Default {@link TranslationReviewLabels.failed}. */\n formatError?: (error: unknown) => ReactNode;\n /**\n * Swipe the phone cards (0.25, keksdose live #377 — Marcel reviewing on a phone): toward\n * the reading END to approve, toward the START for \"Needs a change\", which OPENS the\n * editor with the cursor in the wording rather than sending the string back blind — a\n * verdict without a note or a better wording tells whoever fixes it nothing. In a\n * right-to-left page the two sides mirror with the reading direction.\n *\n * DataTable's swipe sides (`mobileSwipeActions`) on {@link SwipeableRow}: the same\n * gesture, the same feedback, and the same keyboard path — every swipe is also a real\n * button, read out and reachable by Tab, so the gesture is a shortcut and never the only\n * way. A row has no swipe while the editor under it is open, while its own write is out,\n * under a {@link WriteLockProvider} lock, in a `readOnly` locale, and toward \"Approve\"\n * where there is nothing to approve (a missing string, an approved one). A reviewer\n * limited to `areas` never sees a row outside them, so never swipes one.\n *\n * Off by default, so no existing page gains a gesture on the bump. Phones only; the\n * desktop table keeps its approve button.\n */\n swipe?: boolean;\n /**\n * Answer every approval with the kit's Undo toast (`toast.undo`): a row's button, a\n * swipe, the editor's Approve, a group's and the selection's bulk approve. Undo stores\n * again what the rows said before ({@link reviewUndo}) — or clears them, for rows that\n * had no verdict, which needs `onClear` (without it the toast reports and offers\n * nothing). A batch has ONE Undo.\n *\n * Default: on with `swipe` or `groupBy` — the two ways to approve in one movement or many\n * rows at once bring their way back — and off otherwise, so an existing page does not\n * gain a toast on the bump. Needs the app's `<Toaster>`. Tell the app's own \"Saved\"\n * toast apart with the `toasted` flag ({@link TranslationReviewSaveInfo}).\n */\n undo?: boolean;\n /**\n * Group the strings under a header per area (0.25, keksdose live #377: *\"grouping to the\n * translation review entries for faster reviewing\"*): `\"namespace\"` — the row's\n * `namespace`, which `translationRows` derives from the key (`budget`, `kit.dataTable`\n * with keksdose's `namespaceOf`) — `\"source\"`, or a function of the row.\n *\n * Each group is a section with a heading ({@link groupHeadingAs}): its name, \"n\n * unreviewed / total\", and \"Approve unreviewed (n)\", which approves the group's rows\n * nobody has given a verdict ({@link unreviewedRows}) under the same rules as one\n * approval — write lock, `readOnly`, one write at a time — with one Undo for the batch.\n * A batch larger than `pageSize` asks first, inside the opened group: more than a page\n * is more than the reviewer can have had on screen.\n *\n * WHAT IT MEANS FOR THE FILTERS, THE SORT AND THE PAGES. The filters narrow the rows\n * first, then the rows are grouped: a header counts what the filters show in it (so\n * \"Approve unreviewed\" never takes a row the reviewer filtered away), and an area with\n * nothing left is not shown. Groups stand in the order their first string has in the\n * reference; a column sort orders the rows INSIDE every group alike. Each group pages\n * on its own, `pageSize` rows at a time — a header then always stands over its own\n * rows, and paging one area does not move another — rather than one pager cutting\n * through the headers. `storageKey` keeps persisting the ungrouped table only.\n *\n * Groups fold (each on its own, remembered while the panel lives). On a phone they\n * start folded when there are several — the headers are then the area list, and the\n * reviewer opens the one to work through; on a wide screen they start open.\n */\n groupBy?: TranslationReviewGroupBy;\n /** A group's name. Default: `sourceLabels` for `groupBy=\"source\"`, else the key. */\n groupLabel?: (group: string) => string;\n /** The level of the group headings. Default `h2`: the panel is a page's body, under\n * the page's `h1`. */\n groupHeadingAs?: \"h2\" | \"h3\" | \"h4\";\n className?: string;\n labels?: Partial<TranslationReviewLabels>;\n}\n\n/** The wide screen's query — the same one DataTable switches its layout on, so \"a phone\"\n * means the same here as in the table. */\nconst MD_UP = \"(min-width: 768px)\";\n\n/** The row's text — or, for a missing string, the line that says so. */\nfunction TextCell({ row, labels, clamp }: { row: TranslationRow; labels: TranslationReviewLabels; clamp?: boolean }) {\n return row.text === \"\" ? (\n <span className=\"italic text-[var(--text-muted)]\">{labels.missingText}</span>\n ) : (\n <span lang={row.locale} className={cn(\"whitespace-pre-line break-words\", clamp && \"line-clamp-3\")}>\n {row.text}\n </span>\n );\n}\n\n/**\n * The page body for one locale: progress, filters (status, source, namespace, search,\n * placeholder problems), the strings with an editor that unfolds under each, and the\n * bulk actions over a selection.\n *\n * The status filter is the work queue in order — what is not there, what nobody has\n * read, what changed after it was read, what was sent back, what is done — with each\n * count on it. \"Missing\" is offered only where something is.\n *\n * Bulk approve takes the selected rows that are not approved yet and have a text:\n * approving an absent string says nothing, and re-approving one says it again. On a\n * phone the table has no checkboxes, so \"Select all shown\" selects what the filters\n * show — filter to `Unreviewed` in one area, read, select, approve. With `groupBy`, a\n * group's own \"Approve unreviewed\" is the shorter way, and with `swipe` a card is approved\n * by a swipe.\n *\n * The panel's writes go out ONE AT A TIME, in the order they were asked for: two writes on\n * one locale's verdicts can answer out of order, and an Undo must not overtake the\n * approval it takes back. A swipe on the next card while one is out is queued rather than\n * refused — a reviewer swiping down a list is faster than the server.\n */\nexport function TranslationReviewPanel({\n rows: rowsProp,\n localeLabel,\n referenceLabel,\n onSave,\n onClear,\n readOnly = false,\n areas,\n areaLabels,\n inArea,\n sourceLabels,\n filter: filterProp,\n onFilterChange,\n progress = true,\n pageSize = 50,\n storageKey,\n formatDate,\n formatError,\n swipe = false,\n undo: undoProp,\n groupBy,\n groupLabel,\n groupHeadingAs = \"h2\",\n className,\n labels: labelsProp,\n}: TranslationReviewPanelProps) {\n const labels = useKitLabels(\"translationReview\", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);\n const locale = useKitLocale();\n const lock = useWriteLock();\n const phone = !useMediaQuery(MD_UP, true);\n const baseId = useId();\n const rootRef = useRef<HTMLDivElement>(null);\n const editable = !readOnly && onSave !== undefined;\n const undoOn = undoProp ?? (swipe || groupBy !== undefined);\n\n const [ownFilter, setOwnFilter] = useState<TranslationReviewFilter>(DEFAULT_TRANSLATION_REVIEW_FILTER);\n // The app's fields over the panel's own; a field the app leaves `undefined` is the\n // panel's, not a blank.\n const filter: TranslationReviewFilter = { ...ownFilter };\n for (const [name, value] of Object.entries(filterProp ?? {})) {\n if (value !== undefined) Object.assign(filter, { [name]: value });\n }\n const [openId, setOpenId] = useState<string | null>(null);\n // The row whose editor a swipe opened, to be written in: its wording field takes focus.\n const [focusId, setFocusId] = useState<string | null>(null);\n const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set());\n // The writes asked for and not yet answered, in order: the first is the one out. The\n // editor keeps its own, for its own buttons.\n const [queued, setQueued] = useState<readonly string[]>([]);\n const claimed = useRef(new Set<string>());\n const tail = useRef<Promise<unknown>>(Promise.resolve());\n const undoCount = useRef(0);\n const [failure, setFailure] = useState<ReactNode>(null);\n const busy = queued.length > 0;\n const isQueued = (key: string) => queued.includes(key);\n\n const rows = useMemo(\n () => (areas ? rowsProp.filter((row) => keyInAreas(row.key, areas, inArea)) : [...rowsProp]),\n [rowsProp, areas, inArea],\n );\n const summary = useMemo(() => summariseRows(rows), [rows]);\n const sources = useMemo(() => [...new Set(rows.map((r) => r.source))], [rows]);\n const source = sources.includes(filter.source) ? filter.source : \"all\";\n const namespaces = useMemo(\n () => [...new Set(rows.filter((r) => source === \"all\" || r.source === source).map((r) => r.namespace))],\n [rows, source],\n );\n const namespace = namespaces.includes(filter.namespace) ? filter.namespace : \"all\";\n const mismatches = useMemo(() => rows.filter((r) => r.placeholderMismatch).length, [rows]);\n // `filter` is rebuilt every render; its fields are what the list depends on.\n const { status, query, placeholdersOnly } = filter;\n const visible = useMemo(\n () => filterTranslationRows(rows, { status, source, namespace, query, placeholdersOnly }),\n [rows, status, source, namespace, query, placeholdersOnly],\n );\n\n // ── Groups ──\n const groups = useMemo(() => {\n if (groupBy === undefined) return null;\n const of =\n groupBy === \"namespace\"\n ? (r: TranslationRow) => r.namespace\n : groupBy === \"source\"\n ? (r: TranslationRow) => r.source\n : groupBy;\n return groupTranslationRows(visible, of);\n }, [visible, groupBy]);\n const groupName = (key: string) =>\n groupLabel?.(key) ?? (groupBy === \"source\" ? sourceLabels?.[key] : undefined) ?? (key || \"—\");\n // Only what the reviewer chose; a group nobody has touched follows the default.\n const [groupOpen, setGroupOpen] = useState<Readonly<Record<string, boolean>>>({});\n const groupOpenByDefault = !(phone && (groups?.length ?? 0) > 1);\n const isGroupOpen = (key: string) => groupOpen[key] ?? groupOpenByDefault;\n const setGroupOpenFor = (key: string, open: boolean) => setGroupOpen((prev) => ({ ...prev, [key]: open }));\n // The group whose large batch is waiting for a yes — and, until its confirm button\n // has mounted and taken focus, the group that asked.\n const [confirming, setConfirming] = useState<string | null>(null);\n const confirmFocus = useRef<string | null>(null);\n // One sort for every group's table, so a header click orders all of them alike.\n const [groupSorts, setGroupSorts] = useState<SortState[]>([]);\n\n const setFilter = (patch: Partial<TranslationReviewFilter>) => {\n const next = { ...filter, ...patch };\n setOwnFilter(next);\n onFilterChange?.(next);\n // A selection the new filter hides would be acted on unseen.\n setSelected(new Set());\n };\n\n /** Queue a write under `key` (one per key at a time); resolves whether it worked. */\n const run = (key: string, action: () => MaybePromise): Promise<boolean> => {\n if (claimed.current.has(key)) return Promise.resolve(false);\n claimed.current.add(key);\n setQueued((q) => [...q, key]);\n const step = tail.current.then(async () => {\n setFailure(null);\n try {\n await action();\n return true;\n } catch (caught) {\n setFailure(formatError ? formatError(caught) : labels.failed);\n return false;\n } finally {\n claimed.current.delete(key);\n setQueued((q) => q.filter((k) => k !== key));\n }\n });\n tail.current = step;\n return step;\n };\n\n /** \"String approved — Undo\", once `targets` (as they were BEFORE the write) are\n * approved. Undo goes through the same queue as every write. */\n const sayApproved = (targets: readonly TranslationRow[], description?: ReactNode) => {\n const back = reviewUndo(targets);\n const message = labels.approvedToast(targets.length);\n const save = onSave;\n const clear = onClear;\n // Taking back the approval of a row that had no verdict is a clear: without\n // `onClear` there is no way to say it, so the toast reports and offers nothing.\n if (!save || (back.clears.length > 0 && !clear)) {\n toast.success(message, { description });\n return;\n }\n const key = `undo:${++undoCount.current}`;\n toast.undo(message, {\n description,\n onUndo: () =>\n void run(key, async () => {\n if (back.writes.length > 0) await save(back.writes, { origin: \"undo\", toasted: false });\n if (back.clears.length > 0) await clear?.(back.clears, { origin: \"undo\", toasted: false });\n }),\n });\n };\n\n const approve = async (\n key: string,\n targets: readonly TranslationRow[],\n origin: TranslationReviewOrigin,\n description?: ReactNode,\n ): Promise<boolean> => {\n if (!onSave || targets.length === 0) return false;\n const save = onSave;\n const ok = await run(key, () => save(targets.map((r) => reviewWrite(r, \"APPROVED\")), { origin, toasted: undoOn }));\n if (ok && undoOn) sayApproved(targets, description);\n return ok;\n };\n\n /** The editor's own save: it waits, closes and shows its errors itself, so it does\n * not go through the queue — but an approval there is answered like any other. */\n const saveFromEditor = async (row: TranslationRow, write: TranslationReviewWrite) => {\n const toasted = undoOn && write.verdict === \"APPROVED\";\n await onSave?.([write], { origin: \"editor\", toasted });\n if (toasted) sayApproved([row], row.key);\n };\n\n /**\n * Focus back into the panel once a group's batch has settled, if it fell out: the\n * button that was pressed goes with the rows it approved (and under the `Unreviewed`\n * filter the whole group can go). Two frames, so the app's new rows have been drawn.\n */\n const keepFocusAt = (groupKey: string) => {\n const check = () => {\n const root = rootRef.current;\n const active = document.activeElement;\n if (!root || (active && active !== document.body && active.isConnected)) return;\n const sections = [...root.querySelectorAll<HTMLElement>(\"[data-review-group]\")];\n const section = sections.find((el) => el.dataset.reviewGroup === groupKey) ?? sections[0];\n section?.querySelector<HTMLElement>(\"[data-disclosure-trigger]\")?.focus();\n };\n requestAnimationFrame(() => requestAnimationFrame(check));\n };\n\n const approveGroup = async (group: TranslationRowGroup, confirmed: boolean) => {\n const targets = unreviewedRows(group.rows);\n if (targets.length === 0) return;\n if (!confirmed && targets.length > pageSize) {\n // Asked inside the opened group, over the rows it is about.\n setGroupOpenFor(group.key, true);\n setConfirming(group.key);\n confirmFocus.current = group.key;\n return;\n }\n const ok = await approve(`group:${group.key}`, targets, \"group\", groupName(group.key));\n if (ok) setConfirming((key) => (key === group.key ? null : key));\n keepFocusAt(group.key);\n };\n\n const visibleIds = new Set(visible.map((r) => r.id));\n const selectedRows = visible.filter((r) => selected.has(r.id));\n // What \"Select all shown\" takes: grouped, the rows of the open groups — a folded\n // group's rows are not shown.\n const shownRows = groups ? groups.filter((g) => isGroupOpen(g.key)).flatMap((g) => g.rows) : visible;\n const allShownSelected = shownRows.length > 0 && shownRows.every((r) => selected.has(r.id));\n const toApprove = selectedRows.filter((r) => r.text !== \"\" && r.status !== \"approved\");\n const toReset = selectedRows.filter((r) => r.review !== null);\n\n const approveSelected = async () => {\n if (await approve(\"bulk-approve\", toApprove, \"selection\")) setSelected(new Set());\n };\n const resetSelected = async () => {\n if (!onClear || toReset.length === 0) return;\n const clear = onClear;\n const keys = toReset.map((r) => ({ locale: r.locale, key: r.key }));\n if (await run(\"bulk-reset\", () => clear(keys, { origin: \"selection\", toasted: false }))) {\n setSelected(new Set());\n }\n };\n\n // The status options: \"missing\" only where something is (or it is the filter).\n const statuses = REVIEW_STATUSES.filter((s) => s !== \"missing\" || summary.missing > 0 || filter.status === s);\n const scopeNames = (areas ?? []).map((area) => areaLabels?.[area] ?? area);\n const scopeText =\n scopeNames.length > 0 ? new Intl.ListFormat(locale, { type: \"conjunction\", style: \"long\" }).format(scopeNames) : \"\";\n\n const columns: DataTableColumn<TranslationRow>[] = [\n {\n key: \"key\",\n header: labels.key,\n cell: (r) => <code className=\"break-all text-xs text-[var(--text-secondary)]\">{r.key}</code>,\n sortBy: (r) => r.key,\n mobileHidden: true,\n },\n {\n key: \"reference\",\n header: referenceLabel,\n cell: (r) => (\n <span className=\"line-clamp-3 whitespace-pre-line break-words text-[var(--text-secondary)]\">{r.reference}</span>\n ),\n mobileHidden: true,\n },\n {\n key: \"text\",\n header: localeLabel,\n cell: (r) => <TextCell row={r} labels={labels} clamp />,\n mobilePrimary: true,\n },\n {\n key: \"status\",\n header: labels.statusColumn,\n cell: (r) => (\n <span className=\"inline-flex flex-wrap gap-1\">\n <ReviewStatusChip status={r.status} labels={labelsProp} />\n {r.placeholderMismatch && (\n <Chip size=\"sm\" tone=\"danger\" variant=\"outline\">\n {labels.placeholderChip}\n </Chip>\n )}\n </span>\n ),\n sortBy: (r) => REVIEW_STATUSES.indexOf(r.status),\n },\n ];\n if (editable) {\n columns.push({\n key: \"approve\",\n header: <span className=\"sr-only\">{labels.approve}</span>,\n cell: (r) =>\n r.status === \"approved\" || r.text === \"\" ? null : (\n <IconButton\n type=\"button\"\n size=\"xs\"\n tone=\"muted\"\n commit\n label={labels.approve}\n pending={isQueued(`row:${r.id}`)}\n disabled={busy}\n onClick={(event) => {\n // The row's own click opens the editor; this one only approves.\n event.stopPropagation();\n void approve(`row:${r.id}`, [r], \"row\", r.key);\n }}\n >\n <Check />\n </IconButton>\n ),\n });\n }\n\n /**\n * A phone card's swipes: Approve toward the end, \"Needs a change\" toward the start.\n * None where the row must not be acted on — the same conditions under which its\n * buttons are locked or absent.\n */\n const swipeActions = (r: TranslationRow): MobileSwipeActions | null => {\n if (!editable || lock.locked || isQueued(`row:${r.id}`)) return null;\n const missing = r.text === \"\";\n return {\n end:\n missing || r.status === \"approved\"\n ? []\n : [\n {\n label: labels.approve,\n icon: <Check className=\"size-4\" aria-hidden />,\n onCommit: () => void approve(`row:${r.id}`, [r], \"swipe\", r.key),\n className: \"bg-[var(--success)]\",\n armedClassName: \"bg-[var(--success)]\",\n },\n ],\n start: [\n {\n label: missing ? labels.suggest : labels.flag,\n icon: <PencilLine className=\"size-4\" aria-hidden />,\n // Opens the editor rather than sending the string back: \"needs a change\"\n // with neither a note nor a better wording tells nobody anything.\n onCommit: () => {\n setOpenId(r.id);\n setFocusId(r.id);\n },\n className: missing ? \"bg-[var(--brand)]\" : \"bg-[var(--danger)]\",\n armedClassName: missing ? \"bg-[var(--brand-hover)]\" : \"bg-[var(--danger-hover)]\",\n },\n ],\n };\n };\n\n const toggleIn = (prev: ReadonlySet<string>, many: readonly TranslationRow[], checked: boolean) => {\n const next = new Set([...prev].filter((id) => visibleIds.has(id)));\n for (const r of many) {\n if (checked) next.add(r.id);\n else next.delete(r.id);\n }\n return next;\n };\n /** The checkbox column of a table over `list` — the whole list, or one group. */\n const selectionFor = (list: readonly TranslationRow[]): DataTableProps<TranslationRow>[\"selection\"] => {\n if (!editable) return undefined;\n const picked = list.filter((r) => selected.has(r.id)).length;\n return {\n isSelected: (r) => selected.has(r.id),\n onToggle: (r, checked) => setSelected((prev) => toggleIn(prev, [r], checked)),\n onToggleMany: (many, checked) => setSelected((prev) => toggleIn(prev, many, checked)),\n allSelected: list.length > 0 && picked === list.length,\n someSelected: picked > 0 && picked < list.length,\n onToggleAll: (checked) => setSelected((prev) => toggleIn(prev, list, checked)),\n };\n };\n\n const table = (list: TranslationRow[], group?: { name: string }) => (\n <DataTable<TranslationRow>\n rows={list}\n rowKey={(r) => r.id}\n columns={columns}\n empty={labels.empty}\n defaultPageSize={pageSize}\n {...(group\n ? {\n // Inside the group's own card: no frame in a frame, no column rail per group,\n // no scroller per group, and the group's name as the table's.\n frame: false,\n chrome: \"minimal\" as const,\n maxBodyHeight: \"none\",\n sorts: groupSorts,\n onSortsChange: setGroupSorts,\n labels: { table: group.name },\n }\n : { storageKey })}\n isExpanded={(r) => r.id === openId}\n onRowClick={(r) => {\n setFocusId(null);\n setOpenId((open) => (open === r.id ? null : r.id));\n }}\n expandedRow={(r) => (\n <TranslationReviewEditor\n key={r.id}\n row={r}\n referenceLabel={referenceLabel}\n localeLabel={localeLabel}\n readOnly={!editable}\n focusWording={focusId === r.id}\n onSave={onSave ? (write) => saveFromEditor(r, write) : undefined}\n onClear={onClear ? (key) => onClear([key], { origin: \"editor\", toasted: false }) : undefined}\n onClose={() => {\n setOpenId(null);\n setFocusId(null);\n }}\n formatDate={formatDate}\n formatError={formatError}\n labels={labelsProp}\n />\n )}\n mobileCard={(r) => (\n <div className=\"min-w-0 space-y-1\">\n <code className=\"block break-all text-xs text-[var(--text-muted)]\">{r.key}</code>\n <div className=\"text-sm text-[var(--text-primary)]\">\n <TextCell row={r} labels={labels} clamp />\n </div>\n <div className=\"flex flex-wrap gap-1\">\n <ReviewStatusChip status={r.status} labels={labelsProp} />\n {r.placeholderMismatch && (\n <Chip size=\"sm\" tone=\"danger\" variant=\"outline\">\n {labels.placeholderChip}\n </Chip>\n )}\n </div>\n </div>\n )}\n mobileSwipeActions={swipe ? swipeActions : undefined}\n selection={selectionFor(list)}\n />\n );\n\n const groupSection = (g: TranslationRowGroup, index: number) => {\n const name = groupName(g.key);\n const titleId = `${baseId}-group-${index}`;\n const questionId = `${baseId}-confirm-${index}`;\n const targets = editable ? unreviewedRows(g.rows) : [];\n const key = `group:${g.key}`;\n return (\n <Disclosure\n key={g.key}\n data-review-group={g.key}\n headingAs={groupHeadingAs}\n title={<span id={titleId}>{name}</span>}\n hint={labels.groupCount(g.summary.unreviewed, g.summary.total)}\n open={isGroupOpen(g.key)}\n onOpenChange={(open) => {\n setGroupOpenFor(g.key, open);\n if (!open) setConfirming((c) => (c === g.key ? null : c));\n }}\n trailing={\n targets.length > 0 ? (\n <Button\n variant=\"secondary\"\n size=\"sm\"\n commit\n // The visible words say what; the group's name, read after them, says where.\n aria-describedby={titleId}\n pending={isQueued(key) && confirming !== g.key}\n disabled={busy}\n onClick={() => void approveGroup(g, false)}\n >\n <Check className=\"size-4\" aria-hidden />\n {labels.approveGroup(targets.length)}\n </Button>\n ) : undefined\n }\n bodyClassName=\"space-y-0 px-0 pb-0 border-t border-[var(--border)]\"\n >\n {confirming === g.key && targets.length > 0 && (\n <div className=\"border-b border-[var(--border)] p-3\">\n <AlertBanner tone=\"warning\" size=\"sm\">\n <div className=\"min-w-0 space-y-2\">\n <p id={questionId}>{labels.confirmGroup(targets.length, name)}</p>\n <div className=\"flex flex-wrap gap-2\">\n <Button\n // Focus lands here when the question appears: the expected answer to a\n // question the reviewer just asked for, and Undo stays behind it.\n ref={(el) => {\n if (el && confirmFocus.current === g.key) {\n confirmFocus.current = null;\n el.focus();\n }\n }}\n variant=\"primary\"\n size=\"sm\"\n commit\n aria-describedby={questionId}\n pending={isQueued(key)}\n disabled={busy}\n onClick={() => void approveGroup(g, true)}\n >\n {labels.approveGroup(targets.length)}\n </Button>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n disabled={isQueued(key)}\n onClick={() => {\n setConfirming(null);\n keepFocusAt(g.key);\n }}\n >\n {labels.cancel}\n </Button>\n </div>\n </div>\n </AlertBanner>\n </div>\n )}\n {table(g.rows, { name })}\n </Disclosure>\n );\n };\n return (\n <div ref={rootRef} className={cn(\"min-w-0 space-y-4\", className)}>\n {scopeText && (\n <AlertBanner tone=\"info\" variant=\"inline\" size=\"sm\" block live={false}>\n {labels.scope(scopeText)}\n </AlertBanner>\n )}\n {readOnly && (\n <AlertBanner tone=\"info\" variant=\"inline\" size=\"sm\" block live={false}>\n {labels.readOnly}\n </AlertBanner>\n )}\n\n {progress && <TranslationProgress summary={summary} labels={labelsProp} />}\n\n <ToggleGroup<ReviewStatus | \"all\">\n aria-label={labels.statusFilter}\n overflow=\"wrap\"\n size=\"sm\"\n value={filter.status}\n onChange={(next) => setFilter({ status: next })}\n options={[\n { value: \"all\", label: labels.filterCount(labels.all, summary.total) },\n ...statuses.map((s) => ({ value: s, label: labels.filterCount(reviewStatusLabel(labels, s), summary[s]) })),\n ]}\n />\n\n <div className=\"flex flex-col gap-3 md:flex-row md:flex-wrap md:items-end\">\n {sources.length > 1 && (\n <div className=\"md:w-48\">\n <Select label={labels.source} value={source} onChange={(e) => setFilter({ source: e.target.value })}>\n <option value=\"all\">{labels.allSources}</option>\n {sources.map((s) => (\n <option key={s} value={s}>\n {sourceLabels?.[s] ?? s}\n </option>\n ))}\n </Select>\n </div>\n )}\n <div className=\"md:w-56\">\n <Select label={labels.namespace} value={namespace} onChange={(e) => setFilter({ namespace: e.target.value })}>\n <option value=\"all\">{labels.allNamespaces}</option>\n {namespaces.map((ns) => (\n <option key={ns} value={ns}>\n {ns}\n </option>\n ))}\n </Select>\n </div>\n <SearchField\n className=\"min-w-0 md:flex-1\"\n label={labels.search}\n value={filter.query}\n onChange={(next) => setFilter({ query: next })}\n />\n <Checkbox\n label={labels.placeholdersOnly(mismatches)}\n checked={filter.placeholdersOnly}\n onCheckedChange={(next) => setFilter({ placeholdersOnly: next })}\n />\n </div>\n\n {editable && shownRows.length > 0 && (\n // The phone's table has no checkboxes; this is its way into the bulk actions.\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"md:hidden\"\n disabled={allShownSelected}\n onClick={() => setSelected((prev) => toggleIn(prev, shownRows, true))}\n >\n <ListChecks className=\"size-4\" aria-hidden />\n {labels.selectShown}\n </Button>\n )}\n\n {failure && (\n <AlertBanner tone=\"danger\" size=\"sm\" role=\"alert\">\n {failure}\n </AlertBanner>\n )}\n\n {editable && (\n <BulkActionBar count={selectedRows.length} onClear={() => setSelected(new Set())}>\n <Button\n variant=\"primary\"\n size=\"sm\"\n commit\n pending={isQueued(\"bulk-approve\")}\n disabled={busy || toApprove.length === 0}\n onClick={() => void approveSelected()}\n >\n <Check className=\"size-4\" aria-hidden />\n {labels.approveSelected}\n </Button>\n {onClear && (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n commit\n pending={isQueued(\"bulk-reset\")}\n disabled={busy || toReset.length === 0}\n onClick={() => void resetSelected()}\n >\n <RotateCcw className=\"size-4\" aria-hidden />\n {labels.resetSelected}\n </Button>\n )}\n </BulkActionBar>\n )}\n\n {groups && groups.length > 0 ? (\n <div className=\"space-y-3\">{groups.map(groupSection)}</div>\n ) : (\n table(visible)\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["ReviewStatusChip","TranslationProgress","TranslationReviewEditor","DEFAULT_TRANSLATION_REVIEW_LABELS"]}
1
+ {"version":3,"sources":["../../src/components/translation-review.tsx"],"sourcesContent":["import { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { Check, ListChecks, PencilLine, RotateCcw } from \"lucide-react\";\n\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport {\n DEFAULT_TRANSLATION_REVIEW_FILTER,\n REVIEW_STATUSES,\n filterTranslationRows,\n groupTranslationRows,\n keyInAreas,\n reviewUndo,\n reviewWrite,\n summariseRows,\n unreviewedRows,\n} from \"../lib/translation-review\";\nimport type {\n ReviewStatus,\n TranslationReviewFilter,\n TranslationReviewKey,\n TranslationReviewWrite,\n TranslationRow,\n TranslationRowGroup,\n} from \"../lib/translation-review\";\nimport { AlertBanner } from \"./alert-banner\";\nimport { BulkActionBar } from \"./bulk-action-bar\";\nimport { Checkbox } from \"./checkbox\";\nimport { Chip } from \"./chip\";\nimport { DataTable } from \"./data-table\";\nimport type { DataTableColumn, DataTableProps, MobileSwipeActions, SortState } from \"./data-table\";\nimport { Disclosure } from \"./disclosure\";\nimport { SearchField } from \"./search-field\";\nimport { toast } from \"./toast\";\nimport { ToggleGroup } from \"./toggle-group\";\nimport { TranslationReviewEditor } from \"./translation-review-editor\";\nimport { DEFAULT_TRANSLATION_REVIEW_LABELS, reviewStatusLabel } from \"./translation-review-labels\";\nimport type { TranslationReviewLabels } from \"./translation-review-labels\";\nimport { ReviewStatusChip, TranslationProgress } from \"./translation-review-parts\";\nimport { Button, IconButton, Select } from \"./ui\";\nimport { useWriteLock } from \"./write-lock\";\n\nexport {\n REVIEW_STATUS_TONES,\n ReviewStatusChip,\n TranslationExportButton,\n TranslationLocaleTabs,\n TranslationProgress,\n} from \"./translation-review-parts\";\nexport type {\n ReviewStatusChipProps,\n TranslationExportButtonProps,\n TranslationLocaleTab,\n TranslationLocaleTabsProps,\n TranslationProgressProps,\n} from \"./translation-review-parts\";\nexport { TranslationReviewEditor } from \"./translation-review-editor\";\nexport type { TranslationReviewEditorProps } from \"./translation-review-editor\";\nexport { DEFAULT_TRANSLATION_REVIEW_LABELS } from \"./translation-review-labels\";\nexport type { TranslationReviewLabels } from \"./translation-review-labels\";\n\n/**\n * An app's translation review page, as kit parts (H1, docs/i18n-harmonization.md):\n * keksdose's /translations, which kastlan was about to build a second time. The app keeps\n * what is its own — where the strings come from, which locale is read against which, who\n * may review what, every request — and hands the kit rows (`translationRows`) and two\n * callbacks. Everything a reviewer touches is here.\n *\n * Presentational — no request, no route — and one LOCALE at a time. Its table is the\n * kit's DataTable, so like every DataTable it renders under the app's Router. The page\n * around it puts\n * {@link TranslationLocaleTabs} above it and {@link TranslationExportButton} in its header,\n * because both span every locale while this panel is the one open.\n *\n * Callbacks may return promises, and the panel waits for them: an editor stays open with\n * its text and the error when a save is refused (a 403 for a key outside the reviewer's\n * areas, a network failure), a bulk action keeps its selection. Chunking a long bulk\n * write to the server's batch limit is the app's; the panel sends one call per action.\n */\n\ntype MaybePromise = void | Promise<unknown>;\n\n/** What asked for a write — see {@link TranslationReviewSaveInfo}. */\nexport type TranslationReviewOrigin = \"editor\" | \"row\" | \"swipe\" | \"group\" | \"selection\" | \"undo\";\n\n/**\n * The second argument of `onSave` and `onClear` (0.25): where the write came from, and\n * whether the panel answers it with a toast of its own.\n *\n * WHY THE APP IS TOLD. keksdose and kastlan toast \"Saved\" from their mutation on every\n * save. With {@link TranslationReviewPanelProps.undo} on, an approval is answered by the\n * kit's Undo toast instead — and a reviewer swiping through a list then read two toasts\n * per card. The app skips its own where `toasted` is true:\n *\n * onSave={(writes, { toasted }) => save.mutateAsync({ items: writes.map(toApiWrite), quiet: toasted })}\n *\n * An app that ignores the argument keeps working; it only says \"Saved\" twice.\n */\nexport interface TranslationReviewSaveInfo {\n /** The open editor, a row's approve button, a swipe on a phone card, a group's\n * \"Approve unreviewed\", the selection's bulk bar, or an Undo pressed on the toast. */\n origin: TranslationReviewOrigin;\n /** The panel shows its own toast for this write (an approval's Undo toast). */\n toasted: boolean;\n}\n\n/** How {@link TranslationReviewPanelProps.groupBy} names a row's group. */\nexport type TranslationReviewGroupBy = \"namespace\" | \"source\" | ((row: TranslationRow) => string);\n\nexport interface TranslationReviewPanelProps {\n /** One locale's rows, from `translationRows`. */\n rows: readonly TranslationRow[];\n /** The reviewed language's own name — the text column's header (\"Français\"). */\n localeLabel: string;\n /** The reference language's own name — the reference column's header. */\n referenceLabel: string;\n /** Store verdicts: one for an editor or a row's approve button, many for a bulk\n * approve. Left out (or `readOnly`): nothing can be changed. The second argument\n * says where the write came from ({@link TranslationReviewSaveInfo}). */\n onSave?: (writes: TranslationReviewWrite[], info: TranslationReviewSaveInfo) => MaybePromise;\n /** Clear verdicts — back to unreviewed. Left out: no reset, and an approval of a row\n * that had no verdict cannot be undone. */\n onClear?: (keys: TranslationReviewKey[], info: TranslationReviewSaveInfo) => MaybePromise;\n /** A locale the viewer may read but not review: the rows and their verdicts, no\n * actions, and a line saying so. */\n readOnly?: boolean;\n /**\n * The areas the reviewer is limited to (keksdose's lawyer: `[\"legal\"]`), as the server\n * answers them; `null` is every area. Rows outside are not shown or counted — build the\n * rows with the same `areas` and they never exist — and a notice names the areas.\n */\n areas?: readonly string[] | null;\n /** An area's name for the notice (\"the legal pages (Imprint, …)\"). Default: the key. */\n areaLabels?: Readonly<Record<string, string>>;\n /** Default `keyInArea`: the key is the area or sits under it (`legal.…`, `legal:…`). */\n inArea?: (key: string, area: string) => boolean;\n /** A source's name for the source filter, which appears once the rows come from more\n * than one (kastlan: `{ screen: \"Screen\", documents: \"PDFs and emails\" }`). Default:\n * the source itself. */\n sourceLabels?: Readonly<Record<string, string>>;\n /**\n * Controlled filters — for an app that keeps them in the URL (keksdose's `?status=`,\n * `?ns=`, `?q=`). Whatever is left out is the panel's own. Pair with `onFilterChange`.\n *\n * A field passed here is the app's, and the control shows what the app says: one the app\n * passes but does not write back from `onFilterChange` is a control that does nothing.\n * A development build warns about that once per field (0.25.1, Kurvenschmiede); a field\n * left out entirely is fine — the panel keeps it.\n */\n filter?: Partial<TranslationReviewFilter>;\n /** Every filter, on every change — write them in ONE update: `useSearchParamsState`'s\n * setter takes this object as it is (`filter={filter} onFilterChange={setFilter}`).\n * Single `useSearchParamState` setters compose since 0.25; before, four in a row\n * clobbered each other (keksdose live #378). */\n onFilterChange?: (filter: TranslationReviewFilter) => void;\n /** The progress bar over the filters. Default true. */\n progress?: boolean;\n /** Rows per page. Default 50. */\n pageSize?: number;\n /** Persists the table's sort and page size (DataTable's `storageKey`). */\n storageKey?: string;\n /** How a verdict's date reads in the editor. */\n formatDate?: (iso: string) => string;\n /** Turns a callback's rejection into words. Default {@link TranslationReviewLabels.failed}. */\n formatError?: (error: unknown) => ReactNode;\n /**\n * Swipe the phone cards (0.25, keksdose live #377 — Marcel reviewing on a phone): toward\n * the reading END to approve, toward the START for \"Needs a change\", which OPENS the\n * editor with the cursor in the wording rather than sending the string back blind — a\n * verdict without a note or a better wording tells whoever fixes it nothing. In a\n * right-to-left page the two sides mirror with the reading direction.\n *\n * DataTable's swipe sides (`mobileSwipeActions`) on {@link SwipeableRow}: the same\n * gesture, the same feedback, and the same keyboard path — every swipe is also a real\n * button, read out and reachable by Tab, so the gesture is a shortcut and never the only\n * way. A row has no swipe while the editor under it is open, while its own write is out,\n * under a {@link WriteLockProvider} lock, in a `readOnly` locale, and toward \"Approve\"\n * where there is nothing to approve (a missing string, an approved one). A reviewer\n * limited to `areas` never sees a row outside them, so never swipes one.\n *\n * Off by default, so no existing page gains a gesture on the bump. Phones only; the\n * desktop table keeps its approve button.\n */\n swipe?: boolean;\n /**\n * Answer every approval with the kit's Undo toast (`toast.undo`): a row's button, a\n * swipe, the editor's Approve, a group's and the selection's bulk approve. Undo stores\n * again what the rows said before ({@link reviewUndo}) — or clears them, for rows that\n * had no verdict, which needs `onClear` (without it the toast reports and offers\n * nothing). A batch has ONE Undo.\n *\n * Default: on with `swipe` or `groupBy` — the two ways to approve in one movement or many\n * rows at once bring their way back — and off otherwise, so an existing page does not\n * gain a toast on the bump. Needs the app's `<Toaster>`. Tell the app's own \"Saved\"\n * toast apart with the `toasted` flag ({@link TranslationReviewSaveInfo}).\n */\n undo?: boolean;\n /**\n * Group the strings under a header per area (0.25, keksdose live #377: *\"grouping to the\n * translation review entries for faster reviewing\"*): `\"namespace\"` — the row's\n * `namespace`, which `translationRows` derives from the key (`budget`, `kit.dataTable`\n * with keksdose's `namespaceOf`) — `\"source\"`, or a function of the row.\n *\n * Each group is a section with a heading ({@link groupHeadingAs}): its name, \"n\n * unreviewed / total\", and \"Approve unreviewed (n)\", which approves the group's rows\n * nobody has given a verdict ({@link unreviewedRows}) under the same rules as one\n * approval — write lock, `readOnly`, one write at a time — with one Undo for the batch.\n * A batch larger than `pageSize` asks first, inside the opened group: more than a page\n * is more than the reviewer can have had on screen.\n *\n * WHAT IT MEANS FOR THE FILTERS, THE SORT AND THE PAGES. The filters narrow the rows\n * first, then the rows are grouped: a header counts what the filters show in it (so\n * \"Approve unreviewed\" never takes a row the reviewer filtered away), and an area with\n * nothing left is not shown. Groups stand in the order their first string has in the\n * reference; a column sort orders the rows INSIDE every group alike. Each group pages\n * on its own, `pageSize` rows at a time — a header then always stands over its own\n * rows, and paging one area does not move another — rather than one pager cutting\n * through the headers. `storageKey` keeps persisting the ungrouped table only.\n *\n * Groups fold (each on its own, remembered while the panel lives), and a folded group's\n * rows are not rendered at all — only its header. Which groups start open is\n * {@link defaultGroupsOpen}: by default, on a phone they start folded when there are\n * several — the headers are then the area list, and the reviewer opens the one to work\n * through — and on a wide screen they start open while that is cheap (see there).\n */\n groupBy?: TranslationReviewGroupBy;\n /**\n * Which groups start open under {@link groupBy} (0.25.1). A group the reviewer opens or\n * folds keeps that; this is what every other group follows.\n *\n * - `\"auto\"` (default): on a phone, open when there is one group and folded when there\n * are several, as in 0.25.0. On a wide screen, open while opening every group renders\n * at most 100 rows (each group shows up to `pageSize` of them; the budget is one page\n * where `pageSize` is larger) — and folded above that, the headers then being the area\n * list, as on a phone.\n * - `true`: every group starts open, on a phone too.\n * - `false`: every group starts folded, on a wide screen too.\n *\n * WHY A BUDGET OF ROWS (keksdose, measured on 0.25.0): a wide screen opened EVERY group.\n * keksdose's /translations has 122 areas and ~4700 strings — 122 tables and 2053 rows at\n * once, ~46 000 elements, 5.2 s before the panel appeared and 2.5 s for a filter click\n * to paint, against 50 rows and 0.34 s ungrouped; its page test went from 4 s to 146 s.\n * kastlan (~31 areas, ~1300 rows open) and Kurvenschmiede (28, ~700) had the same page.\n * What costs is the rows on screen, so the rule counts them rather than the groups:\n * 100 rows is two of the panel's default pages — any one or two areas still open as\n * before, however long, and so do a dozen small ones, or a search whose hits fall in\n * many areas — while a whole catalogue starts as its list of areas: keksdose's shape\n * in jsdom, 122 headers and ~2100 elements instead of ~44 000 (the ungrouped panel has\n * ~1200). The rule follows the filters: narrow them and the groups open.\n *\n * A boolean rather than a controlled open state: the panel already keeps each group's\n * own state, and an app that only wants \"all open\" or \"all folded\" should not have to\n * keep a record of 122 flags to say it.\n */\n defaultGroupsOpen?: boolean | \"auto\";\n /** A group's name. Default: `sourceLabels` for `groupBy=\"source\"`, else the key. */\n groupLabel?: (group: string) => string;\n /** The level of the group headings. Default `h2`: the panel is a page's body, under\n * the page's `h1`. */\n groupHeadingAs?: \"h2\" | \"h3\" | \"h4\";\n className?: string;\n labels?: Partial<TranslationReviewLabels>;\n}\n\n/** The wide screen's query — the same one DataTable switches its layout on, so \"a phone\"\n * means the same here as in the table. */\nconst MD_UP = \"(min-width: 768px)\";\n\n/**\n * How many rows the groups may render at once on a wide screen before they start folded\n * ({@link TranslationReviewPanelProps.defaultGroupsOpen}): two of the default 50-row pages.\n * A row costs about twenty elements in either layout, so this is the cost of two pages of\n * the ungrouped table — not of 2053 rows in 122 tables.\n */\nconst GROUPS_OPEN_ROW_BUDGET = 100;\n\n/**\n * Would opening every group stay within the budget? Each group renders up to `pageSize`\n * rows (it pages on its own); the budget is never less than one page, so a single group —\n * which renders at most one — always starts open, as on a phone.\n */\nfunction groupsFitOpen(groups: readonly TranslationRowGroup[], pageSize: number): boolean {\n const budget = Math.max(GROUPS_OPEN_ROW_BUDGET, pageSize);\n let rendered = 0;\n for (const group of groups) {\n rendered += Math.min(group.rows.length, pageSize);\n if (rendered > budget) return false;\n }\n return true;\n}\n\n/**\n * The kit's dev switch, as NumberInput's money guard and `lib/logger.ts` read it: a\n * development build — the consumer's bundler makes `import.meta.env.DEV` false in a\n * production one, and the branch is dead code there — and never under vitest, where every\n * consumer suite would print it; a test opts in with `vi.stubEnv(\"VITEST\", \"\")`. Read per\n * call, and optional-chained: `import.meta.env` is a Vite injection, absent under Node.\n */\nfunction devWarnings(): boolean {\n return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;\n}\n\ntype FilterField = keyof TranslationReviewFilter;\n\n/** How long after the last filter change the app's `filter` is looked at again: well past\n * a router's transition, so a URL that is still being written is not mistaken for one\n * that never will be. */\nconst FILTER_ECHO_MS = 1500;\n\n/**\n * The development warning for a controlled filter that does not follow (Kurvenschmiede,\n * 0.25.1). A field the app passes in `filter` is the app's: the panel shows the app's\n * value, whatever the reviewer picked. An app that passes a field but does not write the\n * reported value back — its `onFilterChange` hands on only some fields, `filter` is built\n * as `{ ...DEFAULT_TRANSLATION_REVIEW_FILTER, ...fromTheUrl }`, a URL parser rejects what\n * its serialiser wrote, or there is no `onFilterChange` at all — has a control that\n * silently does nothing: the box unticks itself, the select jumps back.\n *\n * So, after the panel reports a change of a field the app controls, it looks at the\n * app's `filter` again once the change has had time to land ({@link FILTER_ECHO_MS}, read\n * from the last COMMITTED props, so a router transition still rendering does not count),\n * and warns — once per field — when that field still holds the value it had before.\n *\n * Quiet by construction for an app that passes no `filter`, and for a field the app LEAVES\n * OUT: that field is the panel's own and works (keksdose keeps the placeholder switch so,\n * on purpose). Quiet too when the app moved the field somewhere else itself. Development\n * builds only (see {@link devWarnings}): in production and under vitest nothing is\n * recorded and no timer is set.\n */\nfunction useFilterEchoCheck(filterProp: Partial<TranslationReviewFilter> | undefined) {\n const committed = useRef(filterProp);\n useEffect(() => {\n committed.current = filterProp;\n });\n // Per field: what the app's filter said when the reviewer first changed it, and what\n // the panel reported last.\n const pending = useRef(new Map<FilterField, { from: unknown; to: unknown }>());\n const warned = useRef(new Set<FilterField>());\n const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(timer.current), []);\n\n return (next: TranslationReviewFilter) => {\n if (filterProp === undefined || !devWarnings()) return;\n for (const field of Object.keys(next) as FilterField[]) {\n const from = filterProp[field];\n if (from === undefined || warned.current.has(field)) continue;\n const seen = pending.current.get(field);\n if (seen) seen.to = next[field];\n else if (next[field] !== from) pending.current.set(field, { from, to: next[field] });\n }\n if (pending.current.size === 0) return;\n clearTimeout(timer.current);\n timer.current = setTimeout(() => {\n const now = committed.current;\n for (const [field, { from, to }] of pending.current) {\n if (to === from || now?.[field] !== from || warned.current.has(field)) continue;\n warned.current.add(field);\n console.warn(\n `[ui-kit] TranslationReviewPanel: the \"${field}\" filter was changed to ${JSON.stringify(to)}, but the ` +\n `\\`filter\\` prop still says ${JSON.stringify(from)}, so the control does nothing. A field passed in ` +\n `\\`filter\\` is the app's: write back every field \\`onFilterChange\\` reports, in one update ` +\n `(useSearchParamsState's setter takes the object as it is), or leave the field out of \\`filter\\` and ` +\n `the panel keeps it. (Development builds only; once per field.)`,\n );\n }\n pending.current.clear();\n }, FILTER_ECHO_MS);\n };\n}\n\n/** The row's text — or, for a missing string, the line that says so. */\nfunction TextCell({ row, labels, clamp }: { row: TranslationRow; labels: TranslationReviewLabels; clamp?: boolean }) {\n return row.text === \"\" ? (\n <span className=\"italic text-[var(--text-muted)]\">{labels.missingText}</span>\n ) : (\n <span lang={row.locale} className={cn(\"whitespace-pre-line break-words\", clamp && \"line-clamp-3\")}>\n {row.text}\n </span>\n );\n}\n\n/**\n * The page body for one locale: progress, filters (status, source, namespace, search,\n * placeholder problems), the strings with an editor that unfolds under each, and the\n * bulk actions over a selection.\n *\n * The status filter is the work queue in order — what is not there, what nobody has\n * read, what changed after it was read, what was sent back, what is done — with each\n * count on it. \"Missing\" is offered only where something is.\n *\n * Bulk approve takes the selected rows that are not approved yet and have a text:\n * approving an absent string says nothing, and re-approving one says it again. On a\n * phone the table has no checkboxes, so \"Select all shown\" selects what the filters\n * show — filter to `Unreviewed` in one area, read, select, approve. With `groupBy`, a\n * group's own \"Approve unreviewed\" is the shorter way, and with `swipe` a card is approved\n * by a swipe.\n *\n * The panel's writes go out ONE AT A TIME, in the order they were asked for: two writes on\n * one locale's verdicts can answer out of order, and an Undo must not overtake the\n * approval it takes back. A swipe on the next card while one is out is queued rather than\n * refused — a reviewer swiping down a list is faster than the server.\n */\nexport function TranslationReviewPanel({\n rows: rowsProp,\n localeLabel,\n referenceLabel,\n onSave,\n onClear,\n readOnly = false,\n areas,\n areaLabels,\n inArea,\n sourceLabels,\n filter: filterProp,\n onFilterChange,\n progress = true,\n pageSize = 50,\n storageKey,\n formatDate,\n formatError,\n swipe = false,\n undo: undoProp,\n groupBy,\n groupLabel,\n groupHeadingAs = \"h2\",\n defaultGroupsOpen = \"auto\",\n className,\n labels: labelsProp,\n}: TranslationReviewPanelProps) {\n const labels = useKitLabels(\"translationReview\", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);\n const locale = useKitLocale();\n const lock = useWriteLock();\n const phone = !useMediaQuery(MD_UP, true);\n const baseId = useId();\n const rootRef = useRef<HTMLDivElement>(null);\n const editable = !readOnly && onSave !== undefined;\n const undoOn = undoProp ?? (swipe || groupBy !== undefined);\n\n const [ownFilter, setOwnFilter] = useState<TranslationReviewFilter>(DEFAULT_TRANSLATION_REVIEW_FILTER);\n // The app's fields over the panel's own; a field the app leaves `undefined` is the\n // panel's, not a blank.\n const filter: TranslationReviewFilter = { ...ownFilter };\n for (const [name, value] of Object.entries(filterProp ?? {})) {\n if (value !== undefined) Object.assign(filter, { [name]: value });\n }\n const echoFilter = useFilterEchoCheck(filterProp);\n const [openId, setOpenId] = useState<string | null>(null);\n // The row whose editor a swipe opened, to be written in: its wording field takes focus.\n const [focusId, setFocusId] = useState<string | null>(null);\n const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set());\n // The writes asked for and not yet answered, in order: the first is the one out. The\n // editor keeps its own, for its own buttons.\n const [queued, setQueued] = useState<readonly string[]>([]);\n const claimed = useRef(new Set<string>());\n const tail = useRef<Promise<unknown>>(Promise.resolve());\n const undoCount = useRef(0);\n const [failure, setFailure] = useState<ReactNode>(null);\n const busy = queued.length > 0;\n const isQueued = (key: string) => queued.includes(key);\n\n const rows = useMemo(\n () => (areas ? rowsProp.filter((row) => keyInAreas(row.key, areas, inArea)) : [...rowsProp]),\n [rowsProp, areas, inArea],\n );\n const summary = useMemo(() => summariseRows(rows), [rows]);\n const sources = useMemo(() => [...new Set(rows.map((r) => r.source))], [rows]);\n const source = sources.includes(filter.source) ? filter.source : \"all\";\n const namespaces = useMemo(\n () => [...new Set(rows.filter((r) => source === \"all\" || r.source === source).map((r) => r.namespace))],\n [rows, source],\n );\n const namespace = namespaces.includes(filter.namespace) ? filter.namespace : \"all\";\n const mismatches = useMemo(() => rows.filter((r) => r.placeholderMismatch).length, [rows]);\n // `filter` is rebuilt every render; its fields are what the list depends on.\n const { status, query, placeholdersOnly } = filter;\n const visible = useMemo(\n () => filterTranslationRows(rows, { status, source, namespace, query, placeholdersOnly }),\n [rows, status, source, namespace, query, placeholdersOnly],\n );\n\n // ── Groups ──\n const groups = useMemo(() => {\n if (groupBy === undefined) return null;\n const of =\n groupBy === \"namespace\"\n ? (r: TranslationRow) => r.namespace\n : groupBy === \"source\"\n ? (r: TranslationRow) => r.source\n : groupBy;\n return groupTranslationRows(visible, of);\n }, [visible, groupBy]);\n const groupName = (key: string) =>\n groupLabel?.(key) ?? (groupBy === \"source\" ? sourceLabels?.[key] : undefined) ?? (key || \"—\");\n // Only what the reviewer chose; a group nobody has touched follows the default, which\n // follows the filters (see `defaultGroupsOpen`).\n const [groupOpen, setGroupOpen] = useState<Readonly<Record<string, boolean>>>({});\n const groupOpenByDefault =\n defaultGroupsOpen !== \"auto\"\n ? defaultGroupsOpen\n : groups !== null && (phone ? groups.length <= 1 : groupsFitOpen(groups, pageSize));\n const isGroupOpen = (key: string) => groupOpen[key] ?? groupOpenByDefault;\n const setGroupOpenFor = (key: string, open: boolean) => setGroupOpen((prev) => ({ ...prev, [key]: open }));\n /** A group the reviewer works in — an editor opened in it — stays open when the default\n * turns (a search cleared, the rows grown past the budget): folding it would drop the\n * editor and whatever was typed in it. */\n const keepGroupOpen = (key: string | undefined) => {\n if (key !== undefined) setGroupOpen((prev) => (key in prev ? prev : { ...prev, [key]: true }));\n };\n // The group whose large batch is waiting for a yes — and, until its confirm button\n // has mounted and taken focus, the group that asked.\n const [confirming, setConfirming] = useState<string | null>(null);\n const confirmFocus = useRef<string | null>(null);\n // One sort for every group's table, so a header click orders all of them alike.\n const [groupSorts, setGroupSorts] = useState<SortState[]>([]);\n\n const setFilter = (patch: Partial<TranslationReviewFilter>) => {\n const next = { ...filter, ...patch };\n setOwnFilter(next);\n onFilterChange?.(next);\n echoFilter(next);\n // A selection the new filter hides would be acted on unseen.\n setSelected(new Set());\n };\n\n /** Queue a write under `key` (one per key at a time); resolves whether it worked. */\n const run = (key: string, action: () => MaybePromise): Promise<boolean> => {\n if (claimed.current.has(key)) return Promise.resolve(false);\n claimed.current.add(key);\n setQueued((q) => [...q, key]);\n const step = tail.current.then(async () => {\n setFailure(null);\n try {\n await action();\n return true;\n } catch (caught) {\n setFailure(formatError ? formatError(caught) : labels.failed);\n return false;\n } finally {\n claimed.current.delete(key);\n setQueued((q) => q.filter((k) => k !== key));\n }\n });\n tail.current = step;\n return step;\n };\n\n /** \"String approved — Undo\", once `targets` (as they were BEFORE the write) are\n * approved. Undo goes through the same queue as every write. */\n const sayApproved = (targets: readonly TranslationRow[], description?: ReactNode) => {\n const back = reviewUndo(targets);\n const message = labels.approvedToast(targets.length);\n const save = onSave;\n const clear = onClear;\n // Taking back the approval of a row that had no verdict is a clear: without\n // `onClear` there is no way to say it, so the toast reports and offers nothing.\n if (!save || (back.clears.length > 0 && !clear)) {\n toast.success(message, { description });\n return;\n }\n const key = `undo:${++undoCount.current}`;\n toast.undo(message, {\n description,\n onUndo: () =>\n void run(key, async () => {\n if (back.writes.length > 0) await save(back.writes, { origin: \"undo\", toasted: false });\n if (back.clears.length > 0) await clear?.(back.clears, { origin: \"undo\", toasted: false });\n }),\n });\n };\n\n const approve = async (\n key: string,\n targets: readonly TranslationRow[],\n origin: TranslationReviewOrigin,\n description?: ReactNode,\n ): Promise<boolean> => {\n if (!onSave || targets.length === 0) return false;\n const save = onSave;\n const ok = await run(key, () => save(targets.map((r) => reviewWrite(r, \"APPROVED\")), { origin, toasted: undoOn }));\n if (ok && undoOn) sayApproved(targets, description);\n return ok;\n };\n\n /** The editor's own save: it waits, closes and shows its errors itself, so it does\n * not go through the queue — but an approval there is answered like any other. */\n const saveFromEditor = async (row: TranslationRow, write: TranslationReviewWrite) => {\n const toasted = undoOn && write.verdict === \"APPROVED\";\n await onSave?.([write], { origin: \"editor\", toasted });\n if (toasted) sayApproved([row], row.key);\n };\n\n /**\n * Focus back into the panel once a group's batch has settled, if it fell out: the\n * button that was pressed goes with the rows it approved (and under the `Unreviewed`\n * filter the whole group can go). Two frames, so the app's new rows have been drawn.\n */\n const keepFocusAt = (groupKey: string) => {\n const check = () => {\n const root = rootRef.current;\n const active = document.activeElement;\n if (!root || (active && active !== document.body && active.isConnected)) return;\n const sections = [...root.querySelectorAll<HTMLElement>(\"[data-review-group]\")];\n const section = sections.find((el) => el.dataset.reviewGroup === groupKey) ?? sections[0];\n section?.querySelector<HTMLElement>(\"[data-disclosure-trigger]\")?.focus();\n };\n requestAnimationFrame(() => requestAnimationFrame(check));\n };\n\n const approveGroup = async (group: TranslationRowGroup, confirmed: boolean) => {\n const targets = unreviewedRows(group.rows);\n if (targets.length === 0) return;\n if (!confirmed && targets.length > pageSize) {\n // Asked inside the opened group, over the rows it is about.\n setGroupOpenFor(group.key, true);\n setConfirming(group.key);\n confirmFocus.current = group.key;\n return;\n }\n const ok = await approve(`group:${group.key}`, targets, \"group\", groupName(group.key));\n if (ok) setConfirming((key) => (key === group.key ? null : key));\n keepFocusAt(group.key);\n };\n\n const visibleIds = new Set(visible.map((r) => r.id));\n const selectedRows = visible.filter((r) => selected.has(r.id));\n // What \"Select all shown\" takes: grouped, the rows of the open groups — a folded\n // group's rows are not shown.\n const shownRows = groups ? groups.filter((g) => isGroupOpen(g.key)).flatMap((g) => g.rows) : visible;\n const allShownSelected = shownRows.length > 0 && shownRows.every((r) => selected.has(r.id));\n const toApprove = selectedRows.filter((r) => r.text !== \"\" && r.status !== \"approved\");\n const toReset = selectedRows.filter((r) => r.review !== null);\n\n const approveSelected = async () => {\n if (await approve(\"bulk-approve\", toApprove, \"selection\")) setSelected(new Set());\n };\n const resetSelected = async () => {\n if (!onClear || toReset.length === 0) return;\n const clear = onClear;\n const keys = toReset.map((r) => ({ locale: r.locale, key: r.key }));\n if (await run(\"bulk-reset\", () => clear(keys, { origin: \"selection\", toasted: false }))) {\n setSelected(new Set());\n }\n };\n\n // The status options: \"missing\" only where something is (or it is the filter).\n const statuses = REVIEW_STATUSES.filter((s) => s !== \"missing\" || summary.missing > 0 || filter.status === s);\n const scopeNames = (areas ?? []).map((area) => areaLabels?.[area] ?? area);\n const scopeText =\n scopeNames.length > 0 ? new Intl.ListFormat(locale, { type: \"conjunction\", style: \"long\" }).format(scopeNames) : \"\";\n\n const columns: DataTableColumn<TranslationRow>[] = [\n {\n key: \"key\",\n header: labels.key,\n cell: (r) => <code className=\"break-all text-xs text-[var(--text-secondary)]\">{r.key}</code>,\n sortBy: (r) => r.key,\n mobileHidden: true,\n },\n {\n key: \"reference\",\n header: referenceLabel,\n cell: (r) => (\n <span className=\"line-clamp-3 whitespace-pre-line break-words text-[var(--text-secondary)]\">{r.reference}</span>\n ),\n mobileHidden: true,\n },\n {\n key: \"text\",\n header: localeLabel,\n cell: (r) => <TextCell row={r} labels={labels} clamp />,\n mobilePrimary: true,\n },\n {\n key: \"status\",\n header: labels.statusColumn,\n cell: (r) => (\n <span className=\"inline-flex flex-wrap gap-1\">\n <ReviewStatusChip status={r.status} labels={labelsProp} />\n {r.placeholderMismatch && (\n <Chip size=\"sm\" tone=\"danger\" variant=\"outline\">\n {labels.placeholderChip}\n </Chip>\n )}\n </span>\n ),\n sortBy: (r) => REVIEW_STATUSES.indexOf(r.status),\n },\n ];\n if (editable) {\n columns.push({\n key: \"approve\",\n header: <span className=\"sr-only\">{labels.approve}</span>,\n cell: (r) =>\n r.status === \"approved\" || r.text === \"\" ? null : (\n <IconButton\n type=\"button\"\n size=\"xs\"\n tone=\"muted\"\n commit\n label={labels.approve}\n pending={isQueued(`row:${r.id}`)}\n disabled={busy}\n onClick={(event) => {\n // The row's own click opens the editor; this one only approves.\n event.stopPropagation();\n void approve(`row:${r.id}`, [r], \"row\", r.key);\n }}\n >\n <Check />\n </IconButton>\n ),\n });\n }\n\n /**\n * A phone card's swipes: Approve toward the end, \"Needs a change\" toward the start.\n * None where the row must not be acted on — the same conditions under which its\n * buttons are locked or absent.\n */\n const swipeActions = (r: TranslationRow, groupKey?: string): MobileSwipeActions | null => {\n if (!editable || lock.locked || isQueued(`row:${r.id}`)) return null;\n const missing = r.text === \"\";\n return {\n end:\n missing || r.status === \"approved\"\n ? []\n : [\n {\n label: labels.approve,\n icon: <Check className=\"size-4\" aria-hidden />,\n onCommit: () => void approve(`row:${r.id}`, [r], \"swipe\", r.key),\n className: \"bg-[var(--success)]\",\n armedClassName: \"bg-[var(--success)]\",\n },\n ],\n start: [\n {\n label: missing ? labels.suggest : labels.flag,\n icon: <PencilLine className=\"size-4\" aria-hidden />,\n // Opens the editor rather than sending the string back: \"needs a change\"\n // with neither a note nor a better wording tells nobody anything.\n onCommit: () => {\n keepGroupOpen(groupKey);\n setOpenId(r.id);\n setFocusId(r.id);\n },\n className: missing ? \"bg-[var(--brand)]\" : \"bg-[var(--danger)]\",\n armedClassName: missing ? \"bg-[var(--brand-hover)]\" : \"bg-[var(--danger-hover)]\",\n },\n ],\n };\n };\n\n const toggleIn = (prev: ReadonlySet<string>, many: readonly TranslationRow[], checked: boolean) => {\n const next = new Set([...prev].filter((id) => visibleIds.has(id)));\n for (const r of many) {\n if (checked) next.add(r.id);\n else next.delete(r.id);\n }\n return next;\n };\n /** The checkbox column of a table over `list` — the whole list, or one group. */\n const selectionFor = (list: readonly TranslationRow[]): DataTableProps<TranslationRow>[\"selection\"] => {\n if (!editable) return undefined;\n const picked = list.filter((r) => selected.has(r.id)).length;\n return {\n isSelected: (r) => selected.has(r.id),\n onToggle: (r, checked) => setSelected((prev) => toggleIn(prev, [r], checked)),\n onToggleMany: (many, checked) => setSelected((prev) => toggleIn(prev, many, checked)),\n allSelected: list.length > 0 && picked === list.length,\n someSelected: picked > 0 && picked < list.length,\n onToggleAll: (checked) => setSelected((prev) => toggleIn(prev, list, checked)),\n };\n };\n\n const table = (list: TranslationRow[], group?: { key: string; name: string }) => (\n <DataTable<TranslationRow>\n rows={list}\n rowKey={(r) => r.id}\n columns={columns}\n empty={labels.empty}\n defaultPageSize={pageSize}\n {...(group\n ? {\n // Inside the group's own card: no frame in a frame, no column rail per group,\n // no scroller per group, and the group's name as the table's.\n frame: false,\n chrome: \"minimal\" as const,\n maxBodyHeight: \"none\",\n sorts: groupSorts,\n onSortsChange: setGroupSorts,\n labels: { table: group.name },\n }\n : { storageKey })}\n isExpanded={(r) => r.id === openId}\n onRowClick={(r) => {\n if (openId !== r.id) keepGroupOpen(group?.key);\n setFocusId(null);\n setOpenId((open) => (open === r.id ? null : r.id));\n }}\n expandedRow={(r) => (\n <TranslationReviewEditor\n key={r.id}\n row={r}\n referenceLabel={referenceLabel}\n localeLabel={localeLabel}\n readOnly={!editable}\n focusWording={focusId === r.id}\n onSave={onSave ? (write) => saveFromEditor(r, write) : undefined}\n onClear={onClear ? (key) => onClear([key], { origin: \"editor\", toasted: false }) : undefined}\n onClose={() => {\n setOpenId(null);\n setFocusId(null);\n }}\n formatDate={formatDate}\n formatError={formatError}\n labels={labelsProp}\n />\n )}\n mobileCard={(r) => (\n <div className=\"min-w-0 space-y-1\">\n <code className=\"block break-all text-xs text-[var(--text-muted)]\">{r.key}</code>\n <div className=\"text-sm text-[var(--text-primary)]\">\n <TextCell row={r} labels={labels} clamp />\n </div>\n <div className=\"flex flex-wrap gap-1\">\n <ReviewStatusChip status={r.status} labels={labelsProp} />\n {r.placeholderMismatch && (\n <Chip size=\"sm\" tone=\"danger\" variant=\"outline\">\n {labels.placeholderChip}\n </Chip>\n )}\n </div>\n </div>\n )}\n mobileSwipeActions={swipe ? (r) => swipeActions(r, group?.key) : undefined}\n selection={selectionFor(list)}\n />\n );\n\n const groupSection = (g: TranslationRowGroup, index: number) => {\n const name = groupName(g.key);\n const titleId = `${baseId}-group-${index}`;\n const questionId = `${baseId}-confirm-${index}`;\n const targets = editable ? unreviewedRows(g.rows) : [];\n const key = `group:${g.key}`;\n const open = isGroupOpen(g.key);\n // A folded group renders its header and nothing else. Its body is handed over only\n // while it is open, or when the REVIEWER folded it — so the fold animates over the\n // rows it hides. A group folded by the default gets none: when the default turns (a\n // search cleared over 122 areas), the groups that close would otherwise render their\n // grown tables for the length of the fold.\n const body = open || groupOpen[g.key] === false;\n return (\n <Disclosure\n key={g.key}\n data-review-group={g.key}\n headingAs={groupHeadingAs}\n title={<span id={titleId}>{name}</span>}\n hint={labels.groupCount(g.summary.unreviewed, g.summary.total)}\n open={open}\n onOpenChange={(next) => {\n setGroupOpenFor(g.key, next);\n if (!next) setConfirming((c) => (c === g.key ? null : c));\n }}\n trailing={\n targets.length > 0 ? (\n <Button\n variant=\"secondary\"\n size=\"sm\"\n commit\n // The visible words say what; the group's name, read after them, says where.\n aria-describedby={titleId}\n pending={isQueued(key) && confirming !== g.key}\n disabled={busy}\n onClick={() => void approveGroup(g, false)}\n >\n <Check className=\"size-4\" aria-hidden />\n {labels.approveGroup(targets.length)}\n </Button>\n ) : undefined\n }\n bodyClassName=\"space-y-0 px-0 pb-0 border-t border-[var(--border)]\"\n >\n {body && confirming === g.key && targets.length > 0 && (\n <div className=\"border-b border-[var(--border)] p-3\">\n <AlertBanner tone=\"warning\" size=\"sm\">\n <div className=\"min-w-0 space-y-2\">\n <p id={questionId}>{labels.confirmGroup(targets.length, name)}</p>\n <div className=\"flex flex-wrap gap-2\">\n <Button\n // Focus lands here when the question appears: the expected answer to a\n // question the reviewer just asked for, and Undo stays behind it.\n ref={(el) => {\n if (el && confirmFocus.current === g.key) {\n confirmFocus.current = null;\n el.focus();\n }\n }}\n variant=\"primary\"\n size=\"sm\"\n commit\n aria-describedby={questionId}\n pending={isQueued(key)}\n disabled={busy}\n onClick={() => void approveGroup(g, true)}\n >\n {labels.approveGroup(targets.length)}\n </Button>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n disabled={isQueued(key)}\n onClick={() => {\n setConfirming(null);\n keepFocusAt(g.key);\n }}\n >\n {labels.cancel}\n </Button>\n </div>\n </div>\n </AlertBanner>\n </div>\n )}\n {body && table(g.rows, { key: g.key, name })}\n </Disclosure>\n );\n };\n return (\n <div ref={rootRef} className={cn(\"min-w-0 space-y-4\", className)}>\n {scopeText && (\n <AlertBanner tone=\"info\" variant=\"inline\" size=\"sm\" block live={false}>\n {labels.scope(scopeText)}\n </AlertBanner>\n )}\n {readOnly && (\n <AlertBanner tone=\"info\" variant=\"inline\" size=\"sm\" block live={false}>\n {labels.readOnly}\n </AlertBanner>\n )}\n\n {progress && <TranslationProgress summary={summary} labels={labelsProp} />}\n\n <ToggleGroup<ReviewStatus | \"all\">\n aria-label={labels.statusFilter}\n overflow=\"wrap\"\n size=\"sm\"\n value={filter.status}\n onChange={(next) => setFilter({ status: next })}\n options={[\n { value: \"all\", label: labels.filterCount(labels.all, summary.total) },\n ...statuses.map((s) => ({ value: s, label: labels.filterCount(reviewStatusLabel(labels, s), summary[s]) })),\n ]}\n />\n\n <div className=\"flex flex-col gap-3 md:flex-row md:flex-wrap md:items-end\">\n {sources.length > 1 && (\n <div className=\"md:w-48\">\n <Select label={labels.source} value={source} onChange={(e) => setFilter({ source: e.target.value })}>\n <option value=\"all\">{labels.allSources}</option>\n {sources.map((s) => (\n <option key={s} value={s}>\n {sourceLabels?.[s] ?? s}\n </option>\n ))}\n </Select>\n </div>\n )}\n <div className=\"md:w-56\">\n <Select label={labels.namespace} value={namespace} onChange={(e) => setFilter({ namespace: e.target.value })}>\n <option value=\"all\">{labels.allNamespaces}</option>\n {namespaces.map((ns) => (\n <option key={ns} value={ns}>\n {ns}\n </option>\n ))}\n </Select>\n </div>\n <SearchField\n className=\"min-w-0 md:flex-1\"\n label={labels.search}\n value={filter.query}\n onChange={(next) => setFilter({ query: next })}\n />\n <Checkbox\n label={labels.placeholdersOnly(mismatches)}\n checked={filter.placeholdersOnly}\n onCheckedChange={(next) => setFilter({ placeholdersOnly: next })}\n />\n </div>\n\n {editable && shownRows.length > 0 && (\n // The phone's table has no checkboxes; this is its way into the bulk actions.\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"md:hidden\"\n disabled={allShownSelected}\n onClick={() => setSelected((prev) => toggleIn(prev, shownRows, true))}\n >\n <ListChecks className=\"size-4\" aria-hidden />\n {labels.selectShown}\n </Button>\n )}\n\n {failure && (\n <AlertBanner tone=\"danger\" size=\"sm\" role=\"alert\">\n {failure}\n </AlertBanner>\n )}\n\n {editable && (\n <BulkActionBar count={selectedRows.length} onClear={() => setSelected(new Set())}>\n <Button\n variant=\"primary\"\n size=\"sm\"\n commit\n pending={isQueued(\"bulk-approve\")}\n disabled={busy || toApprove.length === 0}\n onClick={() => void approveSelected()}\n >\n <Check className=\"size-4\" aria-hidden />\n {labels.approveSelected}\n </Button>\n {onClear && (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n commit\n pending={isQueued(\"bulk-reset\")}\n disabled={busy || toReset.length === 0}\n onClick={() => void resetSelected()}\n >\n <RotateCcw className=\"size-4\" aria-hidden />\n {labels.resetSelected}\n </Button>\n )}\n </BulkActionBar>\n )}\n\n {groups && groups.length > 0 ? (\n <div className=\"space-y-3\">{groups.map(groupSection)}</div>\n ) : (\n table(visible)\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["ReviewStatusChip","TranslationProgress","TranslationReviewEditor","DEFAULT_TRANSLATION_REVIEW_LABELS"]}
@@ -95,7 +95,7 @@ type SearchParamsUpdate<V> = Partial<V> | ((prev: V) => Partial<V>);
95
95
  * The value object keeps its identity while its params do not change, so it can sit in
96
96
  * a dependency array. Requires a react-router Router.
97
97
  */
98
- declare function useSearchParamsState<V extends Record<string, unknown>>(fields: SearchParamFields<V>): [V, (next: SearchParamsUpdate<V>) => void];
98
+ declare function useSearchParamsState<V extends object>(fields: SearchParamFields<V>): [V, (next: SearchParamsUpdate<V>) => void];
99
99
  /**
100
100
  * The active tab in `?tab=` (or `key`), with the default tab as the clean URL, for the
101
101
  * kit's `Tabs`:
@@ -37,7 +37,14 @@ function putParam(params, rule, value) {
37
37
  }
38
38
  const queuedByLocation = /* @__PURE__ */ new WeakMap();
39
39
  const NO_DATA_ROUTER = createContext(null);
40
- const DataRouterContext = ReactRouter.UNSAFE_DataRouterContext ?? NO_DATA_ROUTER;
40
+ const DataRouterContext = readDataRouterContext();
41
+ function readDataRouterContext() {
42
+ try {
43
+ return ReactRouter.UNSAFE_DataRouterContext ?? NO_DATA_ROUTER;
44
+ } catch {
45
+ return NO_DATA_ROUTER;
46
+ }
47
+ }
41
48
  function writeQuery(location, navigate, dataRouter, change) {
42
49
  let queued = queuedByLocation.get(location);
43
50
  if (!queued) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hooks/use-search-param-state.ts"],"sourcesContent":["import { createContext, useContext, useMemo, type Context } from \"react\";\nimport * as ReactRouter from \"react-router\";\nimport { useLocation, useNavigate, useSearchParams, type Location, type NavigateFunction } from \"react-router\";\n\ntype Updater<T> = T | ((prev: T) => T);\n\nexport interface SearchParamStateOptions<T> {\n /**\n * The value of a param that IS present. Return `undefined` (or throw) for a value\n * that does not parse — a hand-edited or stale link — and the default is used.\n * Default: the raw string.\n */\n parse?: (raw: string) => T | undefined;\n /**\n * The param for a value, or `null` to remove it. Default: `String(value)`, and\n * `null` for `null` / `undefined`. A value that serialises the same as the default\n * removes the param too, so the default is always the clean URL.\n */\n serialize?: (value: T) => string | null;\n /** Shorthand for `history: \"replace\"`. */\n replace?: boolean;\n /**\n * What a change does to the browser history:\n * - `\"push\"` (default): a new entry, so Back undoes the change.\n * - `\"replace\"`: the current entry is rewritten — a tab, a sort, a view toggle,\n * which are not steps anyone wants Back to walk through.\n * - `\"replace-on-clear\"`: push when a value is set, replace when it is cleared (the\n * \"open a form, close it\" pattern).\n *\n * Several writes in one tick (one handler) add at most ONE entry — see\n * {@link useSearchParamState}.\n */\n history?: \"push\" | \"replace\" | \"replace-on-clear\";\n}\n\nconst defaultSerialize = (value: unknown): string | null =>\n value === null || value === undefined ? null : String(value);\n\n/** The route state of the current entry, carried over a `replace` so a query rewrite\n * does not drop what a navigation put there (a dialog's opener mark, below). */\nfunction carriedState(state: unknown): Record<string, unknown> | undefined {\n return state && typeof state === \"object\" ? (state as Record<string, unknown>) : undefined;\n}\n\n/* ── One param: read and write ───────────────────────────────────────────── */\n\n/** One param's rules — the single hook's arguments, or one field of the multi-key hook. */\ninterface ParamRule<T> {\n key: string;\n defaultValue: T;\n parse?: (raw: string) => T | undefined;\n serialize: (value: T) => string | null;\n history: \"push\" | \"replace\" | \"replace-on-clear\";\n}\n\nfunction paramRule<T>(key: string, defaultValue: T, options: SearchParamStateOptions<T>): ParamRule<T> {\n return {\n key,\n defaultValue,\n parse: options.parse,\n serialize: options.serialize ?? (defaultSerialize as (value: T) => string | null),\n history: options.history ?? (options.replace ? \"replace\" : \"push\"),\n };\n}\n\n/** An absent or unparseable param reads as the default. */\nfunction readParam<T>(params: URLSearchParams, rule: ParamRule<T>): T {\n const raw = params.get(rule.key);\n if (raw === null) return rule.defaultValue;\n if (!rule.parse) return raw as unknown as T;\n try {\n const parsed = rule.parse(raw);\n return parsed === undefined ? rule.defaultValue : parsed;\n } catch {\n return rule.defaultValue;\n }\n}\n\n/**\n * Writes `value` into `params` — the default removes the param. Returns `null` when\n * the param already says that (a write that changes nothing has no say in the history),\n * otherwise whether this change asks for an entry of its own.\n */\nfunction putParam<T>(params: URLSearchParams, rule: ParamRule<T>, value: T): boolean | null {\n const serialized = rule.serialize(value);\n const next = serialized === rule.serialize(rule.defaultValue) ? null : serialized;\n const current = params.getAll(rule.key);\n if (next === null ? current.length === 0 : current.length === 1 && current[0] === next) return null;\n if (next === null) params.delete(rule.key);\n else params.set(rule.key, next);\n return rule.history === \"push\" || (rule.history === \"replace-on-clear\" && next !== null);\n}\n\n/* ── Writes that compose ─────────────────────────────────────────────────── */\n\n/**\n * The query as the writes made against ONE rendered location leave it — shared by every\n * hook on that router, so a write builds on the one before it instead of on the render.\n *\n * Why this exists (keksdose live #378): react-router's `setSearchParams(updater)` hands\n * its updater `new URLSearchParams(searchParams)` — the params of the RENDER the setter\n * came from, in react-router 7 and 8 alike — not the previous call's result. keksdose\n * wired TranslationReviewPanel's `onFilterChange` to four of these setters (status,\n * source, ns, q); each started from the rendered URL, so the last wrote the old values\n * of the other three back and no filter select had any effect.\n *\n * Keyed by react-router's location object, which is one object per committed location\n * per router: every hook under a router reads the same one, a second router (another\n * test) has its own, and when react-router renders the next location this record is\n * unreachable — nothing queued outlives the navigation it belongs to. Until then a\n * later write builds on it even a tick on (a transition or a data router's loaders still\n * pending), which is what keeps two quick clicks from clobbering each other either. The\n * one cost: a navigation a data router's blocker stops leaves its writes here, and the\n * page's next write carries them again — the blocker asks again.\n */\ninterface QueuedQuery {\n /** The query with every write against this location so far. */\n params: URLSearchParams;\n /** The route state of the entry those writes now sit on, carried over a replace. */\n state: unknown;\n /** A write in the current tick pushed; the tick's later writes rewrite that entry. */\n pushedThisTick: boolean;\n /** The last navigation sent was a push. */\n lastPushed: boolean;\n}\n\nconst queuedByLocation = new WeakMap<Location, QueuedQuery>();\n\n/**\n * The data router's own state, read for ONE question: is a navigation still loading?\n * A data router whose loaders run commits a navigation only when they finish, and a\n * second `navigate` before then cancels the first — so a push followed by a replace\n * in the same tick would land as a replace alone. `UNSAFE_DataRouterContext` is in\n * react-router 6.4 through 8; read off the namespace with a stand-in, a release\n * without it costs only that refinement, not the import.\n */\ntype DataRouterProbe = { router?: { state?: { navigation?: { state?: string } } } } | null;\nconst NO_DATA_ROUTER = createContext<DataRouterProbe>(null);\nconst DataRouterContext: Context<DataRouterProbe> =\n (ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??\n NO_DATA_ROUTER;\n\n/**\n * One write: `change` edits the queued query in place and says whether a param it\n * changed asks for its own entry. The navigation goes out at once — a lone write\n * behaves exactly as before, and a test can assert right after `act` — carrying the\n * whole queued query, so it includes every write before it.\n *\n * History, per tick (the writes one handler makes before a microtask runs):\n * - the FIRST write that asks to push pushes; every later write in the tick REPLACES\n * that entry, so a handler that sets four params adds one entry, and Back undoes\n * all four at once — they were one change;\n * - a write that asks to replace, before any push, rewrites the entry it found;\n * - so mixed modes combine as \"push if any write asked to push\", which keeps both\n * promises: a push-mode param's change is undone by Back, and a replace-mode param\n * never adds an entry of its own (it rides on the one the push made). A replace\n * write ahead of a push stays on the entry it rewrote, as it would one tick apart.\n * - a write that changes nothing has no say, and sends nothing.\n */\nfunction writeQuery(\n location: Location,\n navigate: NavigateFunction,\n dataRouter: DataRouterProbe,\n change: (params: URLSearchParams) => boolean,\n): void {\n let queued = queuedByLocation.get(location);\n if (!queued) {\n queued = {\n params: new URLSearchParams(location.search),\n state: location.state,\n pushedThisTick: false,\n lastPushed: false,\n };\n queuedByLocation.set(location, queued);\n }\n const before = queued.params.toString();\n const wantsPush = change(queued.params);\n const search = queued.params.toString();\n if (search === before) return;\n\n // A data router still loading our push has not written it to the history yet: a\n // replace now would cancel it. Push again — the new navigation takes its place.\n const loading = (dataRouter?.router?.state?.navigation?.state ?? \"idle\") !== \"idle\";\n const push = (queued.lastPushed && loading) || (wantsPush && !queued.pushedThisTick);\n if (push) {\n queued.state = null;\n if (!queued.pushedThisTick) {\n queued.pushedThisTick = true;\n const record = queued;\n queueMicrotask(() => {\n record.pushedThisTick = false;\n });\n }\n }\n queued.lastPushed = push;\n void navigate(`?${search}`, push ? undefined : { replace: true, state: carriedState(queued.state) });\n}\n\n/** The rendered query, and a write that composes with every other one on the router. */\nfunction useQueryWriter(): [URLSearchParams, (change: (params: URLSearchParams) => boolean) => void] {\n const location = useLocation();\n const navigate = useNavigate();\n const dataRouter = useContext(DataRouterContext);\n const params = useMemo(() => new URLSearchParams(location.search), [location.search]);\n return [params, (change) => writeQuery(location, navigate, dataRouter, change)];\n}\n\n/* ── The hooks ───────────────────────────────────────────────────────────── */\n\n/**\n * One URL search param as React state: `const [view, setView] = useSearchParamState(\"view\", \"list\")`.\n *\n * Replaces the hand-rolled `setParams((prev) => { const p = new URLSearchParams(prev);\n * …set/delete…; return p })` blocks that keksdose had in a dozen pages and kastlan ported\n * as its own copy of this hook (shared/hooks/use-search-param-state.ts, both apps). The\n * kit's version takes the DEFAULT as an argument rather than inside `parse`, so the\n * two rules that follow from it cannot be forgotten at a call site:\n *\n * * an absent or unparseable param reads as the default, and\n * * setting the default removes the param — the default view is the clean URL.\n *\n * Every other param in the query string is left as it is.\n *\n * **Setters compose** (0.25, keksdose live #378): setters called in a row — of one hook,\n * or of several hooks under the same router — each build on the write before them, so\n * `setStatus(\"open\"); setNs(\"legal\")` in one handler leaves both in the URL. react-\n * router's own `setSearchParams(updater)` does not: it hands every updater the params of\n * the render, so the last call used to write the others' old values back. The tick's\n * writes add at most one history entry; see `history`. When the params belong\n * together, {@link useSearchParamsState} writes them with one setter. A functional\n * update receives the value as the writes before it left it.\n *\n * Requires a react-router Router.\n */\nexport function useSearchParamState<T = string>(\n key: string,\n defaultValue: T,\n options: SearchParamStateOptions<T> = {},\n): [T, (next: Updater<T>) => void] {\n const [params, write] = useQueryWriter();\n const rule = paramRule(key, defaultValue, options);\n const value = readParam(params, rule);\n\n const setValue = (next: Updater<T>) =>\n write((query) => {\n // Resolved against the queued query, not the render: a second update in the same\n // handler sees the first one's result, as React's own functional updates do.\n const resolved = typeof next === \"function\" ? (next as (prev: T) => T)(readParam(query, rule)) : next;\n return putParam(query, rule, resolved) === true;\n });\n\n return [value, setValue];\n}\n\n/** One field of {@link useSearchParamsState}: its default, and the single hook's options. */\nexport interface SearchParamField<T> extends SearchParamStateOptions<T> {\n /** The value when the param is absent or does not parse; writing it removes the param. */\n default: T;\n /** The param's name in the URL. Default: the field's name (`namespace: { param: \"ns\" }`). */\n param?: string;\n}\n\n/** The fields of {@link useSearchParamsState}, one per key of the value object. */\nexport type SearchParamFields<V> = { [K in keyof V]: SearchParamField<V[K]> };\n\n/** A partial value (fields left out — or `undefined` — stay as they are), or a function\n * of the current value returning one. */\nexport type SearchParamsUpdate<V> = Partial<V> | ((prev: V) => Partial<V>);\n\n/**\n * Several URL search params as ONE state object, with one setter that writes them in\n * one navigation:\n *\n * const [filter, setFilter] = useSearchParamsState({\n * status: { default: \"all\" as ReviewStatus | \"all\", parse: toStatus },\n * namespace: { param: \"ns\", default: \"all\" },\n * query: { param: \"q\", default: \"\", history: \"replace\" },\n * });\n * <TranslationReviewPanel filter={filter} onFilterChange={setFilter} />\n *\n * keksdose live #378: a filter bar reports all its filters on every change, and four\n * single-param setters in a row clobbered each other (see {@link useSearchParamState},\n * whose setters now compose too). This is the shape that never could: one setter, one\n * write, every field's rules in one place.\n *\n * Each field takes what the single hook takes — `parse`, `serialize`, `history` /\n * `replace` — and its `default` (the clean URL, as there) and optional `param` name.\n * The setter takes a partial object or a function of the current one; fields it leaves\n * out, or sets to `undefined`, stay as they are, and keys that are not fields are\n * ignored, so a component's whole filter object can be handed over as it is. Its\n * history is the single hook's rule over the fields that change: one entry, pushed if\n * any of them asks to push, else the current entry rewritten. Setting what the URL\n * already says navigates nowhere.\n *\n * The value object keeps its identity while its params do not change, so it can sit in\n * a dependency array. Requires a react-router Router.\n */\nexport function useSearchParamsState<V extends Record<string, unknown>>(\n fields: SearchParamFields<V>,\n): [V, (next: SearchParamsUpdate<V>) => void] {\n const [params, write] = useQueryWriter();\n const rules = (Object.keys(fields) as Array<keyof V & string>).map((name) => {\n const field = fields[name];\n return [name, paramRule(field.param ?? name, field.default, field)] as const;\n });\n const readAll = (query: URLSearchParams): V =>\n Object.fromEntries(rules.map(([name, rule]) => [name, readParam(query, rule)])) as V;\n\n // The fields' own params, not the whole query: another param changing (a tab, a\n // dialog) leaves this object as it was. `fields` is a fresh literal every render;\n // what it reads is the same while the params and the field names are.\n const ownParams = JSON.stringify(rules.map(([name, rule]) => [name, params.getAll(rule.key)]));\n // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on what the value is read from; see above\n const values = useMemo(() => readAll(params), [ownParams]);\n\n const setValues = (next: SearchParamsUpdate<V>) =>\n write((query) => {\n const patch = typeof next === \"function\" ? next(readAll(query)) : next;\n let push = false;\n for (const [name, rule] of rules) {\n const value = patch[name];\n if (value === undefined) continue;\n if (putParam(query, rule as ParamRule<unknown>, value) === true) push = true;\n }\n return push;\n });\n\n return [values, setValues];\n}\n\n/**\n * The active tab in `?tab=` (or `key`), with the default tab as the clean URL, for the\n * kit's `Tabs`:\n *\n * const [tab, setTab] = useTabParam(\"overview\", { tabs: [\"overview\", \"history\"] });\n * <Tabs tabs={…} active={tab} onChange={setTab} />\n *\n * Switching tabs REPLACES the history entry — a tab is a view of this page, not a\n * place Back should step through. `tabs`, when given, turns a param naming no tab (a\n * renamed tab, an old link) into the default rather than into a blank panel.\n * kastlan's use-tab-param.ts, with the validation it did not have.\n */\nexport function useTabParam<T extends string>(\n defaultTab: T,\n options: { key?: string; tabs?: readonly T[] } = {},\n): [T, (tab: T) => void] {\n const { key = \"tab\", tabs } = options;\n return useSearchParamState<T>(key, defaultTab, {\n parse: (raw) => (!tabs || (tabs as readonly string[]).includes(raw) ? (raw as T) : undefined),\n history: \"replace\",\n });\n}\n\n/** Where a dialog's open state lives: a param's presence (`\"edit\"` → `?edit=1`), or one\n * value of a shared param (`{ key: \"dialog\", value: \"create-lease\" }`). */\nexport type DialogParam = string | { key: string; value: string };\n\n/** The route-state mark an opener leaves on the entry it pushes, so the close knows it\n * may go BACK over it rather than add a second entry. */\nconst OPENED_BY_KIT = \"__uiKitDialogParam\";\n\n/**\n * A dialog's open state in the URL, for `Modal`'s `urlParam` and anything else that\n * opens one: `const [open, setOpen] = useDialogParam({ key: \"dialog\", value: \"create\" })`.\n *\n * * **Opening pushes** an entry, so the platform Back gesture closes the dialog — by\n * leaving the entry — and Forward opens it again, like any other step.\n * * **Closing goes back** over that entry when this hook pushed it, so a closed dialog\n * leaves no dead entry behind. A dialog that arrived OPEN (a deep link, a reload, a\n * plain `<Link>` to it) has nothing of ours to go back over, and its close removes the\n * param in place instead — Back from there leaves the page, as it should.\n *\n * That is why it is not `useSearchParamState`: going back is the only close that\n * leaves the history as it was before the dialog, and only the opener knows whether it\n * may.\n */\nexport function useDialogParam(param: DialogParam): [boolean, (open: boolean) => void] {\n const { key, value } = typeof param === \"string\" ? { key: param, value: \"1\" } : param;\n const [params, setParams] = useSearchParams();\n const location = useLocation();\n const navigate = useNavigate();\n const open = typeof param === \"string\" ? params.has(key) : params.get(key) === value;\n const mark = `${key}=${value}`;\n\n const setOpen = (next: boolean) => {\n if (next === open) return;\n const state = carriedState(location.state);\n if (next) {\n setParams(\n (prev) => {\n const p = new URLSearchParams(prev);\n p.set(key, value);\n return p;\n },\n { state: { ...state, [OPENED_BY_KIT]: mark } },\n );\n return;\n }\n if (state?.[OPENED_BY_KIT] === mark) {\n void navigate(-1);\n return;\n }\n setParams(\n (prev) => {\n const p = new URLSearchParams(prev);\n p.delete(key);\n return p;\n },\n { replace: true, state },\n );\n };\n\n return [open, setOpen];\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/hooks/use-search-param-state.ts"],"sourcesContent":["import { createContext, useContext, useMemo, type Context } from \"react\";\nimport * as ReactRouter from \"react-router\";\nimport { useLocation, useNavigate, useSearchParams, type Location, type NavigateFunction } from \"react-router\";\n\ntype Updater<T> = T | ((prev: T) => T);\n\nexport interface SearchParamStateOptions<T> {\n /**\n * The value of a param that IS present. Return `undefined` (or throw) for a value\n * that does not parse — a hand-edited or stale link — and the default is used.\n * Default: the raw string.\n */\n parse?: (raw: string) => T | undefined;\n /**\n * The param for a value, or `null` to remove it. Default: `String(value)`, and\n * `null` for `null` / `undefined`. A value that serialises the same as the default\n * removes the param too, so the default is always the clean URL.\n */\n serialize?: (value: T) => string | null;\n /** Shorthand for `history: \"replace\"`. */\n replace?: boolean;\n /**\n * What a change does to the browser history:\n * - `\"push\"` (default): a new entry, so Back undoes the change.\n * - `\"replace\"`: the current entry is rewritten — a tab, a sort, a view toggle,\n * which are not steps anyone wants Back to walk through.\n * - `\"replace-on-clear\"`: push when a value is set, replace when it is cleared (the\n * \"open a form, close it\" pattern).\n *\n * Several writes in one tick (one handler) add at most ONE entry — see\n * {@link useSearchParamState}.\n */\n history?: \"push\" | \"replace\" | \"replace-on-clear\";\n}\n\nconst defaultSerialize = (value: unknown): string | null =>\n value === null || value === undefined ? null : String(value);\n\n/** The route state of the current entry, carried over a `replace` so a query rewrite\n * does not drop what a navigation put there (a dialog's opener mark, below). */\nfunction carriedState(state: unknown): Record<string, unknown> | undefined {\n return state && typeof state === \"object\" ? (state as Record<string, unknown>) : undefined;\n}\n\n/* ── One param: read and write ───────────────────────────────────────────── */\n\n/** One param's rules — the single hook's arguments, or one field of the multi-key hook. */\ninterface ParamRule<T> {\n key: string;\n defaultValue: T;\n parse?: (raw: string) => T | undefined;\n serialize: (value: T) => string | null;\n history: \"push\" | \"replace\" | \"replace-on-clear\";\n}\n\nfunction paramRule<T>(key: string, defaultValue: T, options: SearchParamStateOptions<T>): ParamRule<T> {\n return {\n key,\n defaultValue,\n parse: options.parse,\n serialize: options.serialize ?? (defaultSerialize as (value: T) => string | null),\n history: options.history ?? (options.replace ? \"replace\" : \"push\"),\n };\n}\n\n/** An absent or unparseable param reads as the default. */\nfunction readParam<T>(params: URLSearchParams, rule: ParamRule<T>): T {\n const raw = params.get(rule.key);\n if (raw === null) return rule.defaultValue;\n if (!rule.parse) return raw as unknown as T;\n try {\n const parsed = rule.parse(raw);\n return parsed === undefined ? rule.defaultValue : parsed;\n } catch {\n return rule.defaultValue;\n }\n}\n\n/**\n * Writes `value` into `params` — the default removes the param. Returns `null` when\n * the param already says that (a write that changes nothing has no say in the history),\n * otherwise whether this change asks for an entry of its own.\n */\nfunction putParam<T>(params: URLSearchParams, rule: ParamRule<T>, value: T): boolean | null {\n const serialized = rule.serialize(value);\n const next = serialized === rule.serialize(rule.defaultValue) ? null : serialized;\n const current = params.getAll(rule.key);\n if (next === null ? current.length === 0 : current.length === 1 && current[0] === next) return null;\n if (next === null) params.delete(rule.key);\n else params.set(rule.key, next);\n return rule.history === \"push\" || (rule.history === \"replace-on-clear\" && next !== null);\n}\n\n/* ── Writes that compose ─────────────────────────────────────────────────── */\n\n/**\n * The query as the writes made against ONE rendered location leave it — shared by every\n * hook on that router, so a write builds on the one before it instead of on the render.\n *\n * Why this exists (keksdose live #378): react-router's `setSearchParams(updater)` hands\n * its updater `new URLSearchParams(searchParams)` — the params of the RENDER the setter\n * came from, in react-router 7 and 8 alike — not the previous call's result. keksdose\n * wired TranslationReviewPanel's `onFilterChange` to four of these setters (status,\n * source, ns, q); each started from the rendered URL, so the last wrote the old values\n * of the other three back and no filter select had any effect.\n *\n * Keyed by react-router's location object, which is one object per committed location\n * per router: every hook under a router reads the same one, a second router (another\n * test) has its own, and when react-router renders the next location this record is\n * unreachable — nothing queued outlives the navigation it belongs to. Until then a\n * later write builds on it even a tick on (a transition or a data router's loaders still\n * pending), which is what keeps two quick clicks from clobbering each other either. The\n * one cost: a navigation a data router's blocker stops leaves its writes here, and the\n * page's next write carries them again — the blocker asks again.\n */\ninterface QueuedQuery {\n /** The query with every write against this location so far. */\n params: URLSearchParams;\n /** The route state of the entry those writes now sit on, carried over a replace. */\n state: unknown;\n /** A write in the current tick pushed; the tick's later writes rewrite that entry. */\n pushedThisTick: boolean;\n /** The last navigation sent was a push. */\n lastPushed: boolean;\n}\n\nconst queuedByLocation = new WeakMap<Location, QueuedQuery>();\n\n/**\n * The data router's own state, read for ONE question: is a navigation still loading?\n * A data router whose loaders run commits a navigation only when they finish, and a\n * second `navigate` before then cancels the first — so a push followed by a replace\n * in the same tick would land as a replace alone. `UNSAFE_DataRouterContext` is in\n * react-router 6.4 through 8; read off the namespace with a stand-in, a release\n * without it costs only that refinement, not the import.\n */\ntype DataRouterProbe = { router?: { state?: { navigation?: { state?: string } } } } | null;\nconst NO_DATA_ROUTER = createContext<DataRouterProbe>(null);\nconst DataRouterContext: Context<DataRouterProbe> = readDataRouterContext();\n\n/**\n * A static member read (so a bundler still tree-shakes the namespace), inside a `try`:\n * a test's `vi.mock(\"react-router\", () => ({ … }))` without the original module throws\n * on ANY export it does not define, and it threw here at import (keksdose, 0.25.0) —\n * the hook then runs without the refinement, as on a router without the context.\n */\nfunction readDataRouterContext(): Context<DataRouterProbe> {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??\n NO_DATA_ROUTER\n );\n } catch {\n return NO_DATA_ROUTER;\n }\n}\n\n/**\n * One write: `change` edits the queued query in place and says whether a param it\n * changed asks for its own entry. The navigation goes out at once — a lone write\n * behaves exactly as before, and a test can assert right after `act` — carrying the\n * whole queued query, so it includes every write before it.\n *\n * History, per tick (the writes one handler makes before a microtask runs):\n * - the FIRST write that asks to push pushes; every later write in the tick REPLACES\n * that entry, so a handler that sets four params adds one entry, and Back undoes\n * all four at once — they were one change;\n * - a write that asks to replace, before any push, rewrites the entry it found;\n * - so mixed modes combine as \"push if any write asked to push\", which keeps both\n * promises: a push-mode param's change is undone by Back, and a replace-mode param\n * never adds an entry of its own (it rides on the one the push made). A replace\n * write ahead of a push stays on the entry it rewrote, as it would one tick apart.\n * - a write that changes nothing has no say, and sends nothing.\n */\nfunction writeQuery(\n location: Location,\n navigate: NavigateFunction,\n dataRouter: DataRouterProbe,\n change: (params: URLSearchParams) => boolean,\n): void {\n let queued = queuedByLocation.get(location);\n if (!queued) {\n queued = {\n params: new URLSearchParams(location.search),\n state: location.state,\n pushedThisTick: false,\n lastPushed: false,\n };\n queuedByLocation.set(location, queued);\n }\n const before = queued.params.toString();\n const wantsPush = change(queued.params);\n const search = queued.params.toString();\n if (search === before) return;\n\n // A data router still loading our push has not written it to the history yet: a\n // replace now would cancel it. Push again — the new navigation takes its place.\n const loading = (dataRouter?.router?.state?.navigation?.state ?? \"idle\") !== \"idle\";\n const push = (queued.lastPushed && loading) || (wantsPush && !queued.pushedThisTick);\n if (push) {\n queued.state = null;\n if (!queued.pushedThisTick) {\n queued.pushedThisTick = true;\n const record = queued;\n queueMicrotask(() => {\n record.pushedThisTick = false;\n });\n }\n }\n queued.lastPushed = push;\n void navigate(`?${search}`, push ? undefined : { replace: true, state: carriedState(queued.state) });\n}\n\n/** The rendered query, and a write that composes with every other one on the router. */\nfunction useQueryWriter(): [URLSearchParams, (change: (params: URLSearchParams) => boolean) => void] {\n const location = useLocation();\n const navigate = useNavigate();\n const dataRouter = useContext(DataRouterContext);\n const params = useMemo(() => new URLSearchParams(location.search), [location.search]);\n return [params, (change) => writeQuery(location, navigate, dataRouter, change)];\n}\n\n/* ── The hooks ───────────────────────────────────────────────────────────── */\n\n/**\n * One URL search param as React state: `const [view, setView] = useSearchParamState(\"view\", \"list\")`.\n *\n * Replaces the hand-rolled `setParams((prev) => { const p = new URLSearchParams(prev);\n * …set/delete…; return p })` blocks that keksdose had in a dozen pages and kastlan ported\n * as its own copy of this hook (shared/hooks/use-search-param-state.ts, both apps). The\n * kit's version takes the DEFAULT as an argument rather than inside `parse`, so the\n * two rules that follow from it cannot be forgotten at a call site:\n *\n * * an absent or unparseable param reads as the default, and\n * * setting the default removes the param — the default view is the clean URL.\n *\n * Every other param in the query string is left as it is.\n *\n * **Setters compose** (0.25, keksdose live #378): setters called in a row — of one hook,\n * or of several hooks under the same router — each build on the write before them, so\n * `setStatus(\"open\"); setNs(\"legal\")` in one handler leaves both in the URL. react-\n * router's own `setSearchParams(updater)` does not: it hands every updater the params of\n * the render, so the last call used to write the others' old values back. The tick's\n * writes add at most one history entry; see `history`. When the params belong\n * together, {@link useSearchParamsState} writes them with one setter. A functional\n * update receives the value as the writes before it left it.\n *\n * Requires a react-router Router.\n */\nexport function useSearchParamState<T = string>(\n key: string,\n defaultValue: T,\n options: SearchParamStateOptions<T> = {},\n): [T, (next: Updater<T>) => void] {\n const [params, write] = useQueryWriter();\n const rule = paramRule(key, defaultValue, options);\n const value = readParam(params, rule);\n\n const setValue = (next: Updater<T>) =>\n write((query) => {\n // Resolved against the queued query, not the render: a second update in the same\n // handler sees the first one's result, as React's own functional updates do.\n const resolved = typeof next === \"function\" ? (next as (prev: T) => T)(readParam(query, rule)) : next;\n return putParam(query, rule, resolved) === true;\n });\n\n return [value, setValue];\n}\n\n/** One field of {@link useSearchParamsState}: its default, and the single hook's options. */\nexport interface SearchParamField<T> extends SearchParamStateOptions<T> {\n /** The value when the param is absent or does not parse; writing it removes the param. */\n default: T;\n /** The param's name in the URL. Default: the field's name (`namespace: { param: \"ns\" }`). */\n param?: string;\n}\n\n/** The fields of {@link useSearchParamsState}, one per key of the value object. */\nexport type SearchParamFields<V> = { [K in keyof V]: SearchParamField<V[K]> };\n\n/** A partial value (fields left out — or `undefined` — stay as they are), or a function\n * of the current value returning one. */\nexport type SearchParamsUpdate<V> = Partial<V> | ((prev: V) => Partial<V>);\n\n/**\n * Several URL search params as ONE state object, with one setter that writes them in\n * one navigation:\n *\n * const [filter, setFilter] = useSearchParamsState({\n * status: { default: \"all\" as ReviewStatus | \"all\", parse: toStatus },\n * namespace: { param: \"ns\", default: \"all\" },\n * query: { param: \"q\", default: \"\", history: \"replace\" },\n * });\n * <TranslationReviewPanel filter={filter} onFilterChange={setFilter} />\n *\n * keksdose live #378: a filter bar reports all its filters on every change, and four\n * single-param setters in a row clobbered each other (see {@link useSearchParamState},\n * whose setters now compose too). This is the shape that never could: one setter, one\n * write, every field's rules in one place.\n *\n * Each field takes what the single hook takes — `parse`, `serialize`, `history` /\n * `replace` — and its `default` (the clean URL, as there) and optional `param` name.\n * The setter takes a partial object or a function of the current one; fields it leaves\n * out, or sets to `undefined`, stay as they are, and keys that are not fields are\n * ignored, so a component's whole filter object can be handed over as it is. Its\n * history is the single hook's rule over the fields that change: one entry, pushed if\n * any of them asks to push, else the current entry rewritten. Setting what the URL\n * already says navigates nowhere.\n *\n * The value object keeps its identity while its params do not change, so it can sit in\n * a dependency array. Requires a react-router Router.\n */\nexport function useSearchParamsState<V extends object>(\n fields: SearchParamFields<V>,\n): [V, (next: SearchParamsUpdate<V>) => void] {\n const [params, write] = useQueryWriter();\n const rules = (Object.keys(fields) as Array<keyof V & string>).map((name) => {\n const field = fields[name];\n return [name, paramRule(field.param ?? name, field.default, field)] as const;\n });\n const readAll = (query: URLSearchParams): V =>\n Object.fromEntries(rules.map(([name, rule]) => [name, readParam(query, rule)])) as V;\n\n // The fields' own params, not the whole query: another param changing (a tab, a\n // dialog) leaves this object as it was. `fields` is a fresh literal every render;\n // what it reads is the same while the params and the field names are.\n const ownParams = JSON.stringify(rules.map(([name, rule]) => [name, params.getAll(rule.key)]));\n // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on what the value is read from; see above\n const values = useMemo(() => readAll(params), [ownParams]);\n\n const setValues = (next: SearchParamsUpdate<V>) =>\n write((query) => {\n const patch = typeof next === \"function\" ? next(readAll(query)) : next;\n let push = false;\n for (const [name, rule] of rules) {\n const value = patch[name];\n if (value === undefined) continue;\n if (putParam(query, rule as ParamRule<unknown>, value) === true) push = true;\n }\n return push;\n });\n\n return [values, setValues];\n}\n\n/**\n * The active tab in `?tab=` (or `key`), with the default tab as the clean URL, for the\n * kit's `Tabs`:\n *\n * const [tab, setTab] = useTabParam(\"overview\", { tabs: [\"overview\", \"history\"] });\n * <Tabs tabs={…} active={tab} onChange={setTab} />\n *\n * Switching tabs REPLACES the history entry — a tab is a view of this page, not a\n * place Back should step through. `tabs`, when given, turns a param naming no tab (a\n * renamed tab, an old link) into the default rather than into a blank panel.\n * kastlan's use-tab-param.ts, with the validation it did not have.\n */\nexport function useTabParam<T extends string>(\n defaultTab: T,\n options: { key?: string; tabs?: readonly T[] } = {},\n): [T, (tab: T) => void] {\n const { key = \"tab\", tabs } = options;\n return useSearchParamState<T>(key, defaultTab, {\n parse: (raw) => (!tabs || (tabs as readonly string[]).includes(raw) ? (raw as T) : undefined),\n history: \"replace\",\n });\n}\n\n/** Where a dialog's open state lives: a param's presence (`\"edit\"` → `?edit=1`), or one\n * value of a shared param (`{ key: \"dialog\", value: \"create-lease\" }`). */\nexport type DialogParam = string | { key: string; value: string };\n\n/** The route-state mark an opener leaves on the entry it pushes, so the close knows it\n * may go BACK over it rather than add a second entry. */\nconst OPENED_BY_KIT = \"__uiKitDialogParam\";\n\n/**\n * A dialog's open state in the URL, for `Modal`'s `urlParam` and anything else that\n * opens one: `const [open, setOpen] = useDialogParam({ key: \"dialog\", value: \"create\" })`.\n *\n * * **Opening pushes** an entry, so the platform Back gesture closes the dialog — by\n * leaving the entry — and Forward opens it again, like any other step.\n * * **Closing goes back** over that entry when this hook pushed it, so a closed dialog\n * leaves no dead entry behind. A dialog that arrived OPEN (a deep link, a reload, a\n * plain `<Link>` to it) has nothing of ours to go back over, and its close removes the\n * param in place instead — Back from there leaves the page, as it should.\n *\n * That is why it is not `useSearchParamState`: going back is the only close that\n * leaves the history as it was before the dialog, and only the opener knows whether it\n * may.\n */\nexport function useDialogParam(param: DialogParam): [boolean, (open: boolean) => void] {\n const { key, value } = typeof param === \"string\" ? { key: param, value: \"1\" } : param;\n const [params, setParams] = useSearchParams();\n const location = useLocation();\n const navigate = useNavigate();\n const open = typeof param === \"string\" ? params.has(key) : params.get(key) === value;\n const mark = `${key}=${value}`;\n\n const setOpen = (next: boolean) => {\n if (next === open) return;\n const state = carriedState(location.state);\n if (next) {\n setParams(\n (prev) => {\n const p = new URLSearchParams(prev);\n p.set(key, value);\n return p;\n },\n { state: { ...state, [OPENED_BY_KIT]: mark } },\n );\n return;\n }\n if (state?.[OPENED_BY_KIT] === mark) {\n void navigate(-1);\n return;\n }\n setParams(\n (prev) => {\n const p = new URLSearchParams(prev);\n p.delete(key);\n return p;\n },\n { replace: true, state },\n );\n };\n\n return [open, setOpen];\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "type": "module",
5
5
  "description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
6
6
  "_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
@@ -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
 
@@ -142,6 +142,11 @@ export interface TranslationReviewPanelProps {
142
142
  /**
143
143
  * Controlled filters — for an app that keeps them in the URL (keksdose's `?status=`,
144
144
  * `?ns=`, `?q=`). Whatever is left out is the panel's own. Pair with `onFilterChange`.
145
+ *
146
+ * A field passed here is the app's, and the control shows what the app says: one the app
147
+ * passes but does not write back from `onFilterChange` is a control that does nothing.
148
+ * A development build warns about that once per field (0.25.1, Kurvenschmiede); a field
149
+ * left out entirely is fine — the panel keeps it.
145
150
  */
146
151
  filter?: Partial<TranslationReviewFilter>;
147
152
  /** Every filter, on every change — write them in ONE update: `useSearchParamsState`'s
@@ -213,11 +218,42 @@ export interface TranslationReviewPanelProps {
213
218
  * rows, and paging one area does not move another — rather than one pager cutting
214
219
  * through the headers. `storageKey` keeps persisting the ungrouped table only.
215
220
  *
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.
221
+ * Groups fold (each on its own, remembered while the panel lives), and a folded group's
222
+ * rows are not rendered at all — only its header. Which groups start open is
223
+ * {@link defaultGroupsOpen}: by default, on a phone they start folded when there are
224
+ * several — the headers are then the area list, and the reviewer opens the one to work
225
+ * through — and on a wide screen they start open while that is cheap (see there).
219
226
  */
220
227
  groupBy?: TranslationReviewGroupBy;
228
+ /**
229
+ * Which groups start open under {@link groupBy} (0.25.1). A group the reviewer opens or
230
+ * folds keeps that; this is what every other group follows.
231
+ *
232
+ * - `"auto"` (default): on a phone, open when there is one group and folded when there
233
+ * are several, as in 0.25.0. On a wide screen, open while opening every group renders
234
+ * at most 100 rows (each group shows up to `pageSize` of them; the budget is one page
235
+ * where `pageSize` is larger) — and folded above that, the headers then being the area
236
+ * list, as on a phone.
237
+ * - `true`: every group starts open, on a phone too.
238
+ * - `false`: every group starts folded, on a wide screen too.
239
+ *
240
+ * WHY A BUDGET OF ROWS (keksdose, measured on 0.25.0): a wide screen opened EVERY group.
241
+ * keksdose's /translations has 122 areas and ~4700 strings — 122 tables and 2053 rows at
242
+ * once, ~46 000 elements, 5.2 s before the panel appeared and 2.5 s for a filter click
243
+ * to paint, against 50 rows and 0.34 s ungrouped; its page test went from 4 s to 146 s.
244
+ * kastlan (~31 areas, ~1300 rows open) and Kurvenschmiede (28, ~700) had the same page.
245
+ * What costs is the rows on screen, so the rule counts them rather than the groups:
246
+ * 100 rows is two of the panel's default pages — any one or two areas still open as
247
+ * before, however long, and so do a dozen small ones, or a search whose hits fall in
248
+ * many areas — while a whole catalogue starts as its list of areas: keksdose's shape
249
+ * in jsdom, 122 headers and ~2100 elements instead of ~44 000 (the ungrouped panel has
250
+ * ~1200). The rule follows the filters: narrow them and the groups open.
251
+ *
252
+ * A boolean rather than a controlled open state: the panel already keeps each group's
253
+ * own state, and an app that only wants "all open" or "all folded" should not have to
254
+ * keep a record of 122 flags to say it.
255
+ */
256
+ defaultGroupsOpen?: boolean | "auto";
221
257
  /** A group's name. Default: `sourceLabels` for `groupBy="source"`, else the key. */
222
258
  groupLabel?: (group: string) => string;
223
259
  /** The level of the group headings. Default `h2`: the panel is a page's body, under
@@ -231,6 +267,108 @@ export interface TranslationReviewPanelProps {
231
267
  * means the same here as in the table. */
232
268
  const MD_UP = "(min-width: 768px)";
233
269
 
270
+ /**
271
+ * How many rows the groups may render at once on a wide screen before they start folded
272
+ * ({@link TranslationReviewPanelProps.defaultGroupsOpen}): two of the default 50-row pages.
273
+ * A row costs about twenty elements in either layout, so this is the cost of two pages of
274
+ * the ungrouped table — not of 2053 rows in 122 tables.
275
+ */
276
+ const GROUPS_OPEN_ROW_BUDGET = 100;
277
+
278
+ /**
279
+ * Would opening every group stay within the budget? Each group renders up to `pageSize`
280
+ * rows (it pages on its own); the budget is never less than one page, so a single group —
281
+ * which renders at most one — always starts open, as on a phone.
282
+ */
283
+ function groupsFitOpen(groups: readonly TranslationRowGroup[], pageSize: number): boolean {
284
+ const budget = Math.max(GROUPS_OPEN_ROW_BUDGET, pageSize);
285
+ let rendered = 0;
286
+ for (const group of groups) {
287
+ rendered += Math.min(group.rows.length, pageSize);
288
+ if (rendered > budget) return false;
289
+ }
290
+ return true;
291
+ }
292
+
293
+ /**
294
+ * The kit's dev switch, as NumberInput's money guard and `lib/logger.ts` read it: a
295
+ * development build — the consumer's bundler makes `import.meta.env.DEV` false in a
296
+ * production one, and the branch is dead code there — and never under vitest, where every
297
+ * consumer suite would print it; a test opts in with `vi.stubEnv("VITEST", "")`. Read per
298
+ * call, and optional-chained: `import.meta.env` is a Vite injection, absent under Node.
299
+ */
300
+ function devWarnings(): boolean {
301
+ return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
302
+ }
303
+
304
+ type FilterField = keyof TranslationReviewFilter;
305
+
306
+ /** How long after the last filter change the app's `filter` is looked at again: well past
307
+ * a router's transition, so a URL that is still being written is not mistaken for one
308
+ * that never will be. */
309
+ const FILTER_ECHO_MS = 1500;
310
+
311
+ /**
312
+ * The development warning for a controlled filter that does not follow (Kurvenschmiede,
313
+ * 0.25.1). A field the app passes in `filter` is the app's: the panel shows the app's
314
+ * value, whatever the reviewer picked. An app that passes a field but does not write the
315
+ * reported value back — its `onFilterChange` hands on only some fields, `filter` is built
316
+ * as `{ ...DEFAULT_TRANSLATION_REVIEW_FILTER, ...fromTheUrl }`, a URL parser rejects what
317
+ * its serialiser wrote, or there is no `onFilterChange` at all — has a control that
318
+ * silently does nothing: the box unticks itself, the select jumps back.
319
+ *
320
+ * So, after the panel reports a change of a field the app controls, it looks at the
321
+ * app's `filter` again once the change has had time to land ({@link FILTER_ECHO_MS}, read
322
+ * from the last COMMITTED props, so a router transition still rendering does not count),
323
+ * and warns — once per field — when that field still holds the value it had before.
324
+ *
325
+ * Quiet by construction for an app that passes no `filter`, and for a field the app LEAVES
326
+ * OUT: that field is the panel's own and works (keksdose keeps the placeholder switch so,
327
+ * on purpose). Quiet too when the app moved the field somewhere else itself. Development
328
+ * builds only (see {@link devWarnings}): in production and under vitest nothing is
329
+ * recorded and no timer is set.
330
+ */
331
+ function useFilterEchoCheck(filterProp: Partial<TranslationReviewFilter> | undefined) {
332
+ const committed = useRef(filterProp);
333
+ useEffect(() => {
334
+ committed.current = filterProp;
335
+ });
336
+ // Per field: what the app's filter said when the reviewer first changed it, and what
337
+ // the panel reported last.
338
+ const pending = useRef(new Map<FilterField, { from: unknown; to: unknown }>());
339
+ const warned = useRef(new Set<FilterField>());
340
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
341
+ useEffect(() => () => clearTimeout(timer.current), []);
342
+
343
+ return (next: TranslationReviewFilter) => {
344
+ if (filterProp === undefined || !devWarnings()) return;
345
+ for (const field of Object.keys(next) as FilterField[]) {
346
+ const from = filterProp[field];
347
+ if (from === undefined || warned.current.has(field)) continue;
348
+ const seen = pending.current.get(field);
349
+ if (seen) seen.to = next[field];
350
+ else if (next[field] !== from) pending.current.set(field, { from, to: next[field] });
351
+ }
352
+ if (pending.current.size === 0) return;
353
+ clearTimeout(timer.current);
354
+ timer.current = setTimeout(() => {
355
+ const now = committed.current;
356
+ for (const [field, { from, to }] of pending.current) {
357
+ if (to === from || now?.[field] !== from || warned.current.has(field)) continue;
358
+ warned.current.add(field);
359
+ console.warn(
360
+ `[ui-kit] TranslationReviewPanel: the "${field}" filter was changed to ${JSON.stringify(to)}, but the ` +
361
+ `\`filter\` prop still says ${JSON.stringify(from)}, so the control does nothing. A field passed in ` +
362
+ `\`filter\` is the app's: write back every field \`onFilterChange\` reports, in one update ` +
363
+ `(useSearchParamsState's setter takes the object as it is), or leave the field out of \`filter\` and ` +
364
+ `the panel keeps it. (Development builds only; once per field.)`,
365
+ );
366
+ }
367
+ pending.current.clear();
368
+ }, FILTER_ECHO_MS);
369
+ };
370
+ }
371
+
234
372
  /** The row's text — or, for a missing string, the line that says so. */
235
373
  function TextCell({ row, labels, clamp }: { row: TranslationRow; labels: TranslationReviewLabels; clamp?: boolean }) {
236
374
  return row.text === "" ? (
@@ -286,6 +424,7 @@ export function TranslationReviewPanel({
286
424
  groupBy,
287
425
  groupLabel,
288
426
  groupHeadingAs = "h2",
427
+ defaultGroupsOpen = "auto",
289
428
  className,
290
429
  labels: labelsProp,
291
430
  }: TranslationReviewPanelProps) {
@@ -305,6 +444,7 @@ export function TranslationReviewPanel({
305
444
  for (const [name, value] of Object.entries(filterProp ?? {})) {
306
445
  if (value !== undefined) Object.assign(filter, { [name]: value });
307
446
  }
447
+ const echoFilter = useFilterEchoCheck(filterProp);
308
448
  const [openId, setOpenId] = useState<string | null>(null);
309
449
  // The row whose editor a swipe opened, to be written in: its wording field takes focus.
310
450
  const [focusId, setFocusId] = useState<string | null>(null);
@@ -352,11 +492,21 @@ export function TranslationReviewPanel({
352
492
  }, [visible, groupBy]);
353
493
  const groupName = (key: string) =>
354
494
  groupLabel?.(key) ?? (groupBy === "source" ? sourceLabels?.[key] : undefined) ?? (key || "—");
355
- // Only what the reviewer chose; a group nobody has touched follows the default.
495
+ // Only what the reviewer chose; a group nobody has touched follows the default, which
496
+ // follows the filters (see `defaultGroupsOpen`).
356
497
  const [groupOpen, setGroupOpen] = useState<Readonly<Record<string, boolean>>>({});
357
- const groupOpenByDefault = !(phone && (groups?.length ?? 0) > 1);
498
+ const groupOpenByDefault =
499
+ defaultGroupsOpen !== "auto"
500
+ ? defaultGroupsOpen
501
+ : groups !== null && (phone ? groups.length <= 1 : groupsFitOpen(groups, pageSize));
358
502
  const isGroupOpen = (key: string) => groupOpen[key] ?? groupOpenByDefault;
359
503
  const setGroupOpenFor = (key: string, open: boolean) => setGroupOpen((prev) => ({ ...prev, [key]: open }));
504
+ /** A group the reviewer works in — an editor opened in it — stays open when the default
505
+ * turns (a search cleared, the rows grown past the budget): folding it would drop the
506
+ * editor and whatever was typed in it. */
507
+ const keepGroupOpen = (key: string | undefined) => {
508
+ if (key !== undefined) setGroupOpen((prev) => (key in prev ? prev : { ...prev, [key]: true }));
509
+ };
360
510
  // The group whose large batch is waiting for a yes — and, until its confirm button
361
511
  // has mounted and taken focus, the group that asked.
362
512
  const [confirming, setConfirming] = useState<string | null>(null);
@@ -368,6 +518,7 @@ export function TranslationReviewPanel({
368
518
  const next = { ...filter, ...patch };
369
519
  setOwnFilter(next);
370
520
  onFilterChange?.(next);
521
+ echoFilter(next);
371
522
  // A selection the new filter hides would be acted on unseen.
372
523
  setSelected(new Set());
373
524
  };
@@ -567,7 +718,7 @@ export function TranslationReviewPanel({
567
718
  * None where the row must not be acted on — the same conditions under which its
568
719
  * buttons are locked or absent.
569
720
  */
570
- const swipeActions = (r: TranslationRow): MobileSwipeActions | null => {
721
+ const swipeActions = (r: TranslationRow, groupKey?: string): MobileSwipeActions | null => {
571
722
  if (!editable || lock.locked || isQueued(`row:${r.id}`)) return null;
572
723
  const missing = r.text === "";
573
724
  return {
@@ -590,6 +741,7 @@ export function TranslationReviewPanel({
590
741
  // Opens the editor rather than sending the string back: "needs a change"
591
742
  // with neither a note nor a better wording tells nobody anything.
592
743
  onCommit: () => {
744
+ keepGroupOpen(groupKey);
593
745
  setOpenId(r.id);
594
746
  setFocusId(r.id);
595
747
  },
@@ -622,7 +774,7 @@ export function TranslationReviewPanel({
622
774
  };
623
775
  };
624
776
 
625
- const table = (list: TranslationRow[], group?: { name: string }) => (
777
+ const table = (list: TranslationRow[], group?: { key: string; name: string }) => (
626
778
  <DataTable<TranslationRow>
627
779
  rows={list}
628
780
  rowKey={(r) => r.id}
@@ -643,6 +795,7 @@ export function TranslationReviewPanel({
643
795
  : { storageKey })}
644
796
  isExpanded={(r) => r.id === openId}
645
797
  onRowClick={(r) => {
798
+ if (openId !== r.id) keepGroupOpen(group?.key);
646
799
  setFocusId(null);
647
800
  setOpenId((open) => (open === r.id ? null : r.id));
648
801
  }}
@@ -681,7 +834,7 @@ export function TranslationReviewPanel({
681
834
  </div>
682
835
  </div>
683
836
  )}
684
- mobileSwipeActions={swipe ? swipeActions : undefined}
837
+ mobileSwipeActions={swipe ? (r) => swipeActions(r, group?.key) : undefined}
685
838
  selection={selectionFor(list)}
686
839
  />
687
840
  );
@@ -692,6 +845,13 @@ export function TranslationReviewPanel({
692
845
  const questionId = `${baseId}-confirm-${index}`;
693
846
  const targets = editable ? unreviewedRows(g.rows) : [];
694
847
  const key = `group:${g.key}`;
848
+ const open = isGroupOpen(g.key);
849
+ // A folded group renders its header and nothing else. Its body is handed over only
850
+ // while it is open, or when the REVIEWER folded it — so the fold animates over the
851
+ // rows it hides. A group folded by the default gets none: when the default turns (a
852
+ // search cleared over 122 areas), the groups that close would otherwise render their
853
+ // grown tables for the length of the fold.
854
+ const body = open || groupOpen[g.key] === false;
695
855
  return (
696
856
  <Disclosure
697
857
  key={g.key}
@@ -699,10 +859,10 @@ export function TranslationReviewPanel({
699
859
  headingAs={groupHeadingAs}
700
860
  title={<span id={titleId}>{name}</span>}
701
861
  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));
862
+ open={open}
863
+ onOpenChange={(next) => {
864
+ setGroupOpenFor(g.key, next);
865
+ if (!next) setConfirming((c) => (c === g.key ? null : c));
706
866
  }}
707
867
  trailing={
708
868
  targets.length > 0 ? (
@@ -723,7 +883,7 @@ export function TranslationReviewPanel({
723
883
  }
724
884
  bodyClassName="space-y-0 px-0 pb-0 border-t border-[var(--border)]"
725
885
  >
726
- {confirming === g.key && targets.length > 0 && (
886
+ {body && confirming === g.key && targets.length > 0 && (
727
887
  <div className="border-b border-[var(--border)] p-3">
728
888
  <AlertBanner tone="warning" size="sm">
729
889
  <div className="min-w-0 space-y-2">
@@ -764,7 +924,7 @@ export function TranslationReviewPanel({
764
924
  </AlertBanner>
765
925
  </div>
766
926
  )}
767
- {table(g.rows, { name })}
927
+ {body && table(g.rows, { key: g.key, name })}
768
928
  </Disclosure>
769
929
  );
770
930
  };
@@ -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();