@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.
- package/dist/components/translation-review.d.ts +40 -4
- package/dist/components/translation-review.js +67 -10
- package/dist/components/translation-review.js.map +1 -1
- package/dist/hooks/use-search-param-state.d.ts +1 -1
- package/dist/hooks/use-search-param-state.js +8 -1
- package/dist/hooks/use-search-param-state.js.map +1 -1
- package/package.json +1 -1
- package/src/components/translation-review.tsx +175 -15
- package/src/hooks/use-search-param-state.ts +19 -4
|
@@ -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)
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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 =
|
|
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
|
|
397
|
-
onOpenChange: (
|
|
398
|
-
setGroupOpenFor(g.key,
|
|
399
|
-
if (!
|
|
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
|
|
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 =
|
|
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.
|
|
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)
|
|
217
|
-
*
|
|
218
|
-
*
|
|
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 =
|
|
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={
|
|
703
|
-
onOpenChange={(
|
|
704
|
-
setGroupOpenFor(g.key,
|
|
705
|
-
if (!
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
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();
|