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