@eifi1/ui-kit 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/README.md +83 -4
  2. package/dist/chart.d.ts +13 -1
  3. package/dist/components/account-chips.d.ts +153 -0
  4. package/dist/components/account-chips.js +135 -0
  5. package/dist/components/account-chips.js.map +1 -0
  6. package/dist/components/account-settings-labels.d.ts +10 -2
  7. package/dist/components/account-settings-labels.js +3 -0
  8. package/dist/components/account-settings-labels.js.map +1 -1
  9. package/dist/components/amount-input.d.ts +13 -1
  10. package/dist/components/button-group.d.ts +13 -1
  11. package/dist/components/calculator.d.ts +13 -1
  12. package/dist/components/chip.d.ts +25 -1
  13. package/dist/components/chip.js +143 -6
  14. package/dist/components/chip.js.map +1 -1
  15. package/dist/components/confirm-dialog.d.ts +42 -1
  16. package/dist/components/confirm-dialog.js +36 -5
  17. package/dist/components/confirm-dialog.js.map +1 -1
  18. package/dist/components/copy-button.d.ts +13 -1
  19. package/dist/components/danger-confirm.d.ts +55 -4
  20. package/dist/components/danger-confirm.js +67 -30
  21. package/dist/components/danger-confirm.js.map +1 -1
  22. package/dist/components/data-table-cells.d.ts +1 -1
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table.d.ts +1 -1
  26. package/dist/components/data-table.js +2 -0
  27. package/dist/components/data-table.js.map +1 -1
  28. package/dist/components/facing-pair.d.ts +13 -1
  29. package/dist/components/file-button.d.ts +13 -1
  30. package/dist/components/file-dropzone.d.ts +13 -1
  31. package/dist/components/floating-panel.d.ts +4 -1
  32. package/dist/components/floating-panel.js +15 -1
  33. package/dist/components/floating-panel.js.map +1 -1
  34. package/dist/components/form-actions.d.ts +13 -1
  35. package/dist/components/form-actions.js +3 -0
  36. package/dist/components/form-actions.js.map +1 -1
  37. package/dist/components/legal.d.ts +84 -0
  38. package/dist/components/legal.js +55 -0
  39. package/dist/components/legal.js.map +1 -0
  40. package/dist/components/money-field.d.ts +13 -1
  41. package/dist/components/number-field.d.ts +14 -2
  42. package/dist/components/number-field.js +23 -22
  43. package/dist/components/number-field.js.map +1 -1
  44. package/dist/components/number-input.d.ts +13 -1
  45. package/dist/components/numpad-sheet.d.ts +13 -1
  46. package/dist/components/passkeys-setting.d.ts +27 -1
  47. package/dist/components/passkeys-setting.js +31 -5
  48. package/dist/components/passkeys-setting.js.map +1 -1
  49. package/dist/components/progress-bar.d.ts +15 -1
  50. package/dist/components/progress-bar.js +8 -1
  51. package/dist/components/progress-bar.js.map +1 -1
  52. package/dist/components/reauth-dialog.d.ts +90 -0
  53. package/dist/components/reauth-dialog.js +158 -0
  54. package/dist/components/reauth-dialog.js.map +1 -0
  55. package/dist/components/series-chart.d.ts +13 -1
  56. package/dist/components/server-wake.d.ts +60 -0
  57. package/dist/components/server-wake.js +79 -0
  58. package/dist/components/server-wake.js.map +1 -0
  59. package/dist/components/settings-fields.d.ts +13 -1
  60. package/dist/components/share-card.d.ts +64 -0
  61. package/dist/components/share-card.js +350 -0
  62. package/dist/components/share-card.js.map +1 -0
  63. package/dist/components/status-dot.d.ts +2 -0
  64. package/dist/components/text-link.d.ts +13 -1
  65. package/dist/components/time-input.d.ts +13 -1
  66. package/dist/components/toast.d.ts +10 -1
  67. package/dist/components/toast.js +42 -2
  68. package/dist/components/toast.js.map +1 -1
  69. package/dist/components/toggle-group.js +5 -2
  70. package/dist/components/toggle-group.js.map +1 -1
  71. package/dist/components/translation-review-editor.d.ts +56 -0
  72. package/dist/components/translation-review-editor.js +159 -0
  73. package/dist/components/translation-review-editor.js.map +1 -0
  74. package/dist/components/translation-review-labels.d.ts +92 -0
  75. package/dist/components/translation-review-labels.js +67 -0
  76. package/dist/components/translation-review-labels.js.map +1 -0
  77. package/dist/components/translation-review-parts.d.ts +97 -0
  78. package/dist/components/translation-review-parts.js +163 -0
  79. package/dist/components/translation-review-parts.js.map +1 -0
  80. package/dist/components/translation-review.d.ts +94 -0
  81. package/dist/components/translation-review.js +344 -0
  82. package/dist/components/translation-review.js.map +1 -0
  83. package/dist/components/ui.d.ts +13 -1
  84. package/dist/components/ui.js +43 -28
  85. package/dist/components/ui.js.map +1 -1
  86. package/dist/components/use-table-state.d.ts +1 -1
  87. package/dist/components/user-avatar.d.ts +2 -0
  88. package/dist/components/write-lock.d.ts +68 -0
  89. package/dist/components/write-lock.js +33 -0
  90. package/dist/components/write-lock.js.map +1 -0
  91. package/dist/{data-table-CpkWUJBY.d.ts → data-table-3mi2R3so.d.ts} +19 -1
  92. package/dist/data-table.d.ts +1 -1
  93. package/dist/hooks/use-file-drop.d.ts +13 -1
  94. package/dist/hooks/use-overlay-history.js +16 -0
  95. package/dist/hooks/use-overlay-history.js.map +1 -1
  96. package/dist/i18n/defaults.d.ts +13 -1
  97. package/dist/i18n/defaults.js +15 -1
  98. package/dist/i18n/defaults.js.map +1 -1
  99. package/dist/i18n/german.d.ts +81 -0
  100. package/dist/i18n/{locales/de.js → german.js} +110 -9
  101. package/dist/i18n/german.js.map +1 -0
  102. package/dist/i18n/kit-labels.d.ts +13 -1
  103. package/dist/i18n/kit-labels.js +5 -0
  104. package/dist/i18n/kit-labels.js.map +1 -1
  105. package/dist/i18n/languages.d.ts +243 -0
  106. package/dist/i18n/languages.js +70 -0
  107. package/dist/i18n/languages.js.map +1 -0
  108. package/dist/i18n/locales/de-CH.d.ts +26 -8
  109. package/dist/i18n/locales/de-CH.js +7 -3
  110. package/dist/i18n/locales/de-CH.js.map +1 -1
  111. package/dist/i18n/locales/en.d.ts +13 -1
  112. package/dist/i18n/locales/en.js +8 -0
  113. package/dist/i18n/locales/en.js.map +1 -1
  114. package/dist/i18n/locales/es.d.ts +13 -1
  115. package/dist/i18n/locales/es.js +127 -27
  116. package/dist/i18n/locales/es.js.map +1 -1
  117. package/dist/i18n/locales/fr.d.ts +13 -1
  118. package/dist/i18n/locales/fr.js +101 -1
  119. package/dist/i18n/locales/fr.js.map +1 -1
  120. package/dist/i18n/locales/hu.d.ts +13 -1
  121. package/dist/i18n/locales/hu.js +105 -1
  122. package/dist/i18n/locales/hu.js.map +1 -1
  123. package/dist/i18n/locales/it.d.ts +13 -1
  124. package/dist/i18n/locales/it.js +134 -34
  125. package/dist/i18n/locales/it.js.map +1 -1
  126. package/dist/i18n/locales/zh.d.ts +13 -1
  127. package/dist/i18n/locales/zh.js +101 -1
  128. package/dist/i18n/locales/zh.js.map +1 -1
  129. package/dist/i18n/review.d.ts +102 -0
  130. package/dist/i18n/review.js +158 -0
  131. package/dist/i18n/review.js.map +1 -0
  132. package/dist/i18n/swiss.d.ts +3 -3
  133. package/dist/i18n/swiss.js.map +1 -1
  134. package/dist/index.d.ts +17 -4
  135. package/dist/index.js +97 -1
  136. package/dist/index.js.map +1 -1
  137. package/dist/{kit-labels-C4uB_zmg.d.ts → kit-labels-D1J2_jyD.d.ts} +236 -9
  138. package/dist/lib/floating-stack.d.ts +31 -0
  139. package/dist/lib/floating-stack.js +42 -0
  140. package/dist/lib/floating-stack.js.map +1 -0
  141. package/dist/lib/server-wake.d.ts +146 -0
  142. package/dist/lib/server-wake.js +122 -0
  143. package/dist/lib/server-wake.js.map +1 -0
  144. package/dist/lib/translation-review.d.ts +232 -0
  145. package/dist/lib/translation-review.js +194 -0
  146. package/dist/lib/translation-review.js.map +1 -0
  147. package/dist/rhf/fields.d.ts +13 -1
  148. package/dist/rhf/form.d.ts +13 -1
  149. package/dist/rhf.d.ts +13 -1
  150. package/dist/search/global-search.d.ts +3 -1
  151. package/dist/search/global-search.js.map +1 -1
  152. package/dist/shell/app-shell.d.ts +13 -1
  153. package/dist/shell/top-bar-brand.d.ts +13 -1
  154. package/dist/shell/topbar-action-menu.d.ts +2 -0
  155. package/dist/shell.d.ts +13 -1
  156. package/dist/wizard/stepper-nav.d.ts +13 -1
  157. package/dist/wizard.d.ts +13 -1
  158. package/package.json +1 -1
  159. package/src/components/account-chips.tsx +302 -0
  160. package/src/components/account-settings-labels.ts +16 -2
  161. package/src/components/chip.tsx +239 -6
  162. package/src/components/confirm-dialog.tsx +82 -7
  163. package/src/components/danger-confirm.tsx +118 -33
  164. package/src/components/data-table.tsx +20 -0
  165. package/src/components/floating-panel.tsx +19 -1
  166. package/src/components/form-actions.tsx +22 -3
  167. package/src/components/legal.tsx +159 -0
  168. package/src/components/number-field.tsx +32 -26
  169. package/src/components/passkeys-setting.tsx +68 -7
  170. package/src/components/progress-bar.tsx +21 -1
  171. package/src/components/reauth-dialog.tsx +273 -0
  172. package/src/components/server-wake.tsx +156 -0
  173. package/src/components/share-card.tsx +615 -0
  174. package/src/components/toast.tsx +72 -2
  175. package/src/components/toggle-group.tsx +5 -2
  176. package/src/components/translation-review-editor.tsx +261 -0
  177. package/src/components/translation-review-labels.ts +152 -0
  178. package/src/components/translation-review-parts.tsx +269 -0
  179. package/src/components/translation-review.tsx +502 -0
  180. package/src/components/ui.tsx +89 -32
  181. package/src/components/write-lock.tsx +96 -0
  182. package/src/hooks/use-overlay-history.ts +51 -0
  183. package/src/i18n/defaults.ts +14 -0
  184. package/src/i18n/{locales/de.ts → german.ts} +120 -13
  185. package/src/i18n/kit-labels.tsx +29 -2
  186. package/src/i18n/languages.ts +285 -0
  187. package/src/i18n/locales/de-CH.ts +17 -8
  188. package/src/i18n/locales/en.ts +8 -0
  189. package/src/i18n/locales/es.ts +128 -26
  190. package/src/i18n/locales/fr.ts +103 -0
  191. package/src/i18n/locales/hu.ts +107 -0
  192. package/src/i18n/locales/it.ts +135 -33
  193. package/src/i18n/locales/zh.ts +102 -0
  194. package/src/i18n/review.ts +193 -0
  195. package/src/i18n/swiss.ts +3 -3
  196. package/src/index.ts +86 -1
  197. package/src/lib/floating-stack.ts +66 -0
  198. package/src/lib/server-wake.ts +291 -0
  199. package/src/lib/translation-review.ts +465 -0
  200. package/src/search/global-search.tsx +3 -1
  201. package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
  202. package/dist/i18n/locales/de-CH-informal.js +0 -8
  203. package/dist/i18n/locales/de-CH-informal.js.map +0 -1
  204. package/dist/i18n/locales/de-informal.d.ts +0 -82
  205. package/dist/i18n/locales/de-informal.js +0 -37
  206. package/dist/i18n/locales/de-informal.js.map +0 -1
  207. package/dist/i18n/locales/de.d.ts +0 -66
  208. package/dist/i18n/locales/de.js.map +0 -1
  209. package/src/i18n/locales/de-CH-informal.ts +0 -10
  210. package/src/i18n/locales/de-informal.ts +0 -61
@@ -135,9 +135,33 @@ type Kind = "default" | "success" | "error" | "warning" | "info" | "loading";
135
135
 
136
136
  const privately = (node: ReactNode) => <span data-private="">{node}</span>;
137
137
 
138
+ /**
139
+ * The toast's id, written onto its `<li>` as a class — the one per-toast attribute
140
+ * sonner passes through untouched besides `testId`, which is the app's (sonner keeps the
141
+ * id in React state only). `<Toaster dismissOnMiddleClick>` reads it back to dismiss
142
+ * the toast under the pointer, close button or not. The type is kept (`s`/`n`) because
143
+ * sonner matches ids with `===`, and the value is URI-encoded so any string id is one
144
+ * whitespace-free class token.
145
+ */
146
+ const ID_CLASS = "kit-toast-id-";
147
+ const idClass = (id: ToastId) =>
148
+ `${ID_CLASS}${typeof id === "number" ? "n" : "s"}-${encodeURIComponent(String(id))}`;
149
+
150
+ /** The id {@link idClass} stamped on a toast element, or null for a toast the kit did
151
+ * not make (an app still calling sonner directly). */
152
+ function readToastId(el: Element): ToastId | null {
153
+ for (const token of el.classList) {
154
+ if (!token.startsWith(ID_CLASS)) continue;
155
+ const kind = token.charAt(ID_CLASS.length);
156
+ const raw = decodeURIComponent(token.slice(ID_CLASS.length + 2));
157
+ return kind === "n" ? Number(raw) : raw;
158
+ }
159
+ return null;
160
+ }
161
+
138
162
  function toSonnerData(opts: ToastOptions | undefined, id: ToastId): SonnerData {
139
- const { redact, description, duration, action, ...rest } = opts ?? {};
140
- const data: SonnerData = { ...rest, id };
163
+ const { redact, description, duration, action, className, ...rest } = opts ?? {};
164
+ const data: SonnerData = { ...rest, id, className: className ? `${idClass(id)} ${className}` : idClass(id) };
141
165
  if (description !== undefined) data.description = redact ? privately(description) : description;
142
166
  if (action) data.action = action;
143
167
  const resolved = duration ?? (action ? TOAST_ACTION_DURATION : undefined);
@@ -312,6 +336,15 @@ export interface ToasterProps {
312
336
  hotkey?: string[];
313
337
  gap?: number;
314
338
  dir?: "ltr" | "rtl" | "auto";
339
+ /**
340
+ * A middle click on a toast dismisses it (0.18.0) — the gesture that closes a tab,
341
+ * which is what keksdose's users reached for (dev #583). By id, so it works on a
342
+ * toast with `closeButton: false` too; it counts as the user closing it, so
343
+ * `onDismiss` runs. Not on a link or a button inside the toast — a middle click on a
344
+ * link still opens it in a new tab — and not on a `dismissible: false` toast.
345
+ * Default true.
346
+ */
347
+ dismissOnMiddleClick?: boolean;
315
348
  className?: string;
316
349
  style?: CSSProperties;
317
350
  }
@@ -385,6 +418,7 @@ export function Toaster({
385
418
  swipeDirections = ALL_DIRECTIONS,
386
419
  offset,
387
420
  mobileOffset,
421
+ dismissOnMiddleClick = true,
388
422
  className,
389
423
  style,
390
424
  ...rest
@@ -409,6 +443,42 @@ export function Toaster({
409
443
  };
410
444
  }, [labels]);
411
445
 
446
+ useEffect(() => {
447
+ if (!dismissOnMiddleClick || !mod) return;
448
+ // On the document rather than the toaster: sonner owns the `<ol>`, and a listener
449
+ // there would have to be re-attached whenever it remounts it.
450
+ const toastUnder = (e: globalThis.MouseEvent) => {
451
+ if (e.button !== 1 || !(e.target instanceof Element)) return null;
452
+ const li = e.target.closest<HTMLElement>("[data-sonner-toast]");
453
+ if (!li || li.dataset.dismissible === "false") return null;
454
+ // A link keeps its middle click (open in a new tab); a button or a field inside
455
+ // the toast is the app's, not a background to click.
456
+ const control = e.target.closest("a[href], button, input, select, textarea, [role='button'], [role='link']");
457
+ if (control && li.contains(control)) return null;
458
+ return li;
459
+ };
460
+ // `mousedown`: the middle button starts autoscroll (Windows) before any click.
461
+ const onDown = (e: globalThis.MouseEvent) => {
462
+ if (toastUnder(e)) e.preventDefault();
463
+ };
464
+ const onAux = (e: globalThis.MouseEvent) => {
465
+ const li = toastUnder(e);
466
+ if (!li) return;
467
+ // No paste of the primary selection on Linux, no other handler's idea of it.
468
+ e.preventDefault();
469
+ const id = readToastId(li);
470
+ if (id !== null) mod.toast.dismiss(id);
471
+ // A toast made by sonner directly carries no id: its close button, if any.
472
+ else li.querySelector<HTMLButtonElement>("[data-close-button]")?.click();
473
+ };
474
+ document.addEventListener("mousedown", onDown);
475
+ document.addEventListener("auxclick", onAux);
476
+ return () => {
477
+ document.removeEventListener("mousedown", onDown);
478
+ document.removeEventListener("auxclick", onAux);
479
+ };
480
+ }, [dismissOnMiddleClick, mod]);
481
+
412
482
  const documentTheme = useSyncExternalStore(subscribeDocumentTheme, readDocumentTheme, () => "light" as const);
413
483
  const phone = useMediaQuery(PHONE_QUERY, false);
414
484
  const resolved: ToastPosition = position ?? (phone ? "bottom-center" : "top-center");
@@ -340,8 +340,11 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
340
340
  ? "bg-[var(--bg-inverse)] text-[var(--text-inverse)]"
341
341
  : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
342
342
  // No hover fill on a group that cannot be changed — a segment that
343
- // lights up under the pointer is an offer, and there is none here.
344
- disabled && "cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent",
343
+ // lights up under the pointer is an offer, and there is none here. Not on
344
+ // the chosen segment: its fill IS its state, and its inverse text would
345
+ // vanish on a transparent hover.
346
+ disabled && "cursor-not-allowed",
347
+ disabled && !active && "hover:bg-transparent dark:hover:bg-transparent",
345
348
  optionClassName,
346
349
  opt.className,
347
350
  )}
@@ -0,0 +1,261 @@
1
+ import { useState } from "react";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { useKitLabels } from "../i18n/kit-labels";
5
+ import { cn } from "../lib/cn";
6
+ import { useKitFormat } from "../lib/format";
7
+ import { placeholderTokens, reviewWrite } from "../lib/translation-review";
8
+ import type {
9
+ TranslationReviewKey,
10
+ TranslationReviewWrite,
11
+ TranslationRow,
12
+ TranslationVerdict,
13
+ } from "../lib/translation-review";
14
+ import { AlertBanner } from "./alert-banner";
15
+ import { Caption, SectionLabel } from "./text";
16
+ import { DEFAULT_TRANSLATION_REVIEW_LABELS } from "./translation-review-labels";
17
+ import type { TranslationReviewLabels } from "./translation-review-labels";
18
+ import { Button, Textarea } from "./ui";
19
+
20
+ /** A callback may return a promise: resolve and the editor closes, reject and it stays
21
+ * open with the error. */
22
+ type MaybePromise = void | Promise<unknown>;
23
+
24
+ export interface TranslationReviewEditorProps {
25
+ row: TranslationRow;
26
+ /** The reference language's own name ("English", "Deutsch") — the app's choice of
27
+ * reference, so the app's word for it. */
28
+ referenceLabel: ReactNode;
29
+ /** The reviewed language's own name ("Français"). */
30
+ localeLabel: ReactNode;
31
+ /** Store a verdict. Left out (or `readOnly`): the editor shows the row and its last
32
+ * verdict, with no fields and no actions. */
33
+ onSave?: (write: TranslationReviewWrite) => MaybePromise;
34
+ /** Clear the verdict — back to unreviewed. Left out: no reset button. */
35
+ onClear?: (key: TranslationReviewKey) => MaybePromise;
36
+ /** The Cancel button — and called after a save or a reset resolves, so the panel
37
+ * that opened the editor closes it. */
38
+ onClose?: () => void;
39
+ /** Look, do not touch: a locale the viewer may read but not review. */
40
+ readOnly?: boolean;
41
+ /** How the verdict's date reads. Default: the kit's `formatDate`, medium, in the
42
+ * provider's locale. */
43
+ formatDate?: (iso: string) => string;
44
+ /** Turns a rejection into words. Default {@link TranslationReviewLabels.failed}. */
45
+ formatError?: (error: unknown) => ReactNode;
46
+ className?: string;
47
+ labels?: Partial<TranslationReviewLabels>;
48
+ }
49
+
50
+ /** The wording a verdict was given on, under the line that says it changed. */
51
+ function Then({ label, text }: { label: string; text: string }) {
52
+ return (
53
+ <div className="mt-1.5 min-w-0">
54
+ <span className="text-xs font-medium">{label}</span>
55
+ <p className="whitespace-pre-line break-words text-[var(--text-secondary)]">{text}</p>
56
+ </div>
57
+ );
58
+ }
59
+
60
+ /**
61
+ * One string, opened: the reference beside the translation, what changed since the last
62
+ * verdict, what a machine can see is wrong (the placeholders), and the two things a
63
+ * reviewer can say about it — keksdose's and kastlan's `translation-review-editor.tsx`.
64
+ *
65
+ * "Better wording" opens on the CURRENT text rather than empty, because the usual
66
+ * correction is a word or an ending, and retyping a sentence to change one word is how
67
+ * new mistakes get in. It is sent only with "Needs a change" — approving says the text is
68
+ * fine as it stands. A "Needs a change" with neither a note nor a different wording tells
69
+ * whoever fixes it nothing, so it takes one of the two.
70
+ *
71
+ * A MISSING string (kastlan: the locale lacks the key) cannot be approved — there is
72
+ * nothing to approve. Its field starts empty and is the translation to add; the verdict
73
+ * is NEEDS_CHANGE on the empty text, which the export hands a developer as the string to
74
+ * add.
75
+ *
76
+ * Every action waits for its callback: the buttons hold while the promise is out, a
77
+ * resolution closes the editor through `onClose`, and a rejection keeps it open with what
78
+ * was typed and the error under the buttons.
79
+ */
80
+ export function TranslationReviewEditor({
81
+ row,
82
+ referenceLabel,
83
+ localeLabel,
84
+ onSave,
85
+ onClear,
86
+ onClose,
87
+ readOnly = false,
88
+ formatDate: formatDateProp,
89
+ formatError,
90
+ className,
91
+ labels: labelsProp,
92
+ }: TranslationReviewEditorProps) {
93
+ const labels = useKitLabels("translationReview", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);
94
+ const { formatDate } = useKitFormat();
95
+ const review = row.review;
96
+ const missing = row.text === "";
97
+ const editable = !readOnly && onSave !== undefined;
98
+ const [suggestion, setSuggestion] = useState(review?.suggestion ?? row.text);
99
+ const [note, setNote] = useState(review?.note ?? "");
100
+ const [busy, setBusy] = useState<"approve" | "flag" | "clear" | null>(null);
101
+ const [failure, setFailure] = useState<ReactNode>(null);
102
+
103
+ const canFlag = note.trim() !== "" || suggestion.trim() !== row.text.trim();
104
+ const changed = row.status === "changed" ? review : null;
105
+ const date = (iso: string) => (formatDateProp ? formatDateProp(iso) : formatDate(iso, "medium"));
106
+
107
+ const run = async (kind: "approve" | "flag" | "clear", action: () => MaybePromise) => {
108
+ setBusy(kind);
109
+ setFailure(null);
110
+ try {
111
+ await action();
112
+ onClose?.();
113
+ } catch (caught) {
114
+ setFailure(formatError ? formatError(caught) : labels.failed);
115
+ } finally {
116
+ setBusy(null);
117
+ }
118
+ };
119
+
120
+ const save = (verdict: TranslationVerdict) => {
121
+ if (!onSave) return;
122
+ const trimmedNote = note.trim() || null;
123
+ // Approving says the text is right as it stands: there is no wording to suggest.
124
+ const sent = verdict === "APPROVED" ? null : suggestion.trim() || null;
125
+ void run(verdict === "APPROVED" ? "approve" : "flag", () =>
126
+ onSave(reviewWrite(row, verdict, trimmedNote, sent)),
127
+ );
128
+ };
129
+
130
+ return (
131
+ <div className={cn("min-w-0 space-y-3 text-sm", className)}>
132
+ <code className="block break-all text-xs text-[var(--text-muted)]">{row.key}</code>
133
+
134
+ <div className="grid gap-3 md:grid-cols-2">
135
+ <div className="min-w-0">
136
+ <SectionLabel as="span" size="xs">
137
+ {referenceLabel}
138
+ </SectionLabel>
139
+ <p className="mt-1 whitespace-pre-line break-words text-[var(--text-secondary)]">{row.reference}</p>
140
+ </div>
141
+ <div className="min-w-0">
142
+ <SectionLabel as="span" size="xs">
143
+ {localeLabel}
144
+ </SectionLabel>
145
+ {missing ? (
146
+ <p className="mt-1 italic text-[var(--text-muted)]">{labels.missingText}</p>
147
+ ) : (
148
+ <p lang={row.locale} className="mt-1 whitespace-pre-line break-words">
149
+ {row.text}
150
+ </p>
151
+ )}
152
+ </div>
153
+ </div>
154
+
155
+ {changed && (
156
+ <AlertBanner tone="warning" variant="inline" size="sm" block live={false}>
157
+ <div className="min-w-0">
158
+ <p>{labels.changedSince}</p>
159
+ {changed.text !== row.text && <Then label={labels.reviewedWording} text={changed.text || "—"} />}
160
+ {changed.referenceText != null && changed.referenceText !== row.reference && (
161
+ <Then label={labels.reviewedReference} text={changed.referenceText} />
162
+ )}
163
+ </div>
164
+ </AlertBanner>
165
+ )}
166
+
167
+ {row.placeholderMismatch && (
168
+ <AlertBanner tone="danger" variant="inline" size="sm" block live={false}>
169
+ <span className="min-w-0 break-words">
170
+ {labels.placeholderMismatch(
171
+ placeholderTokens(row.reference).join(" ") || "—",
172
+ placeholderTokens(row.text).join(" ") || "—",
173
+ )}
174
+ </span>
175
+ </AlertBanner>
176
+ )}
177
+
178
+ {review && (
179
+ <Caption>
180
+ {(review.verdict === "APPROVED" ? labels.lastApproved : labels.lastFlagged)(
181
+ review.reviewerName ?? labels.erasedReviewer,
182
+ date(review.reviewedAt),
183
+ )}
184
+ </Caption>
185
+ )}
186
+
187
+ {/* Read-only: what the reviewer said, as text — the fields are for saying it. */}
188
+ {!editable && review?.verdict === "NEEDS_CHANGE" && (review.suggestion || review.note) && (
189
+ <div className="space-y-2">
190
+ {review.suggestion && <Then label={missing ? labels.translation : labels.suggestion} text={review.suggestion} />}
191
+ {review.note && <Then label={labels.note} text={review.note} />}
192
+ </div>
193
+ )}
194
+
195
+ {editable && (
196
+ <>
197
+ <Textarea
198
+ label={missing ? labels.translation : labels.suggestion}
199
+ lang={row.locale}
200
+ rows={Math.min(8, Math.max(2, Math.ceil(Math.max(row.text.length, row.reference.length) / 80)))}
201
+ value={suggestion}
202
+ disabled={busy !== null}
203
+ onChange={(e) => setSuggestion(e.target.value)}
204
+ />
205
+ <Textarea
206
+ label={labels.note}
207
+ rows={2}
208
+ value={note}
209
+ placeholder={labels.notePlaceholder}
210
+ disabled={busy !== null}
211
+ onChange={(e) => setNote(e.target.value)}
212
+ />
213
+ <div className="flex flex-wrap gap-2">
214
+ {!missing && (
215
+ <Button
216
+ variant="primary"
217
+ commit
218
+ pending={busy === "approve"}
219
+ disabled={busy !== null}
220
+ onClick={() => save("APPROVED")}
221
+ >
222
+ {labels.approve}
223
+ </Button>
224
+ )}
225
+ <Button
226
+ variant={missing ? "primary" : "danger"}
227
+ commit
228
+ pending={busy === "flag"}
229
+ disabled={busy !== null || !canFlag}
230
+ onClick={() => save("NEEDS_CHANGE")}
231
+ >
232
+ {missing ? labels.suggest : labels.flag}
233
+ </Button>
234
+ {review && onClear && (
235
+ <Button
236
+ variant="ghost"
237
+ commit
238
+ pending={busy === "clear"}
239
+ disabled={busy !== null}
240
+ onClick={() => void run("clear", () => onClear({ locale: row.locale, key: row.key }))}
241
+ >
242
+ {labels.reset}
243
+ </Button>
244
+ )}
245
+ {onClose && (
246
+ <Button variant="ghost" disabled={busy !== null} onClick={onClose}>
247
+ {labels.cancel}
248
+ </Button>
249
+ )}
250
+ </div>
251
+ </>
252
+ )}
253
+
254
+ {failure && (
255
+ <AlertBanner tone="danger" size="sm" role="alert">
256
+ {failure}
257
+ </AlertBanner>
258
+ )}
259
+ </div>
260
+ );
261
+ }
@@ -0,0 +1,152 @@
1
+ import type { ReviewStatus } from "../lib/translation-review";
2
+
3
+ /**
4
+ * The `translationReview` namespace: every word of the translation-review parts
5
+ * (`ReviewStatusChip`, `TranslationProgress`, `TranslationLocaleTabs`,
6
+ * `TranslationExportButton`, `TranslationReviewEditor`, `TranslationReviewPanel`).
7
+ *
8
+ * Its own module, like `data-table-labels.ts`: `kit-labels.tsx` and `defaults.ts` import
9
+ * it, and neither has to load the components to get the words. The English is keksdose's
10
+ * and kastlan's own (`translation_review.*`, `translations:*`), British.
11
+ */
12
+ export interface TranslationReviewLabels {
13
+ /** The five statuses — the chip, the filter, the progress legend. */
14
+ statusMissing: string;
15
+ statusUnreviewed: string;
16
+ statusChanged: string;
17
+ statusNeedsChange: string;
18
+ statusApproved: string;
19
+ /** Accessible name of the status filter. */
20
+ statusFilter: string;
21
+ /** The status filter's first option. */
22
+ all: string;
23
+ /** A status filter option with its count: "Unreviewed · 12". */
24
+ filterCount: (label: string, count: number) => string;
25
+ /** The source filter's label — shown only when the rows come from several sources
26
+ * (kastlan's screen texts and documents). */
27
+ source: string;
28
+ allSources: string;
29
+ /** The namespace filter's label and its "every one" option. */
30
+ namespace: string;
31
+ allNamespaces: string;
32
+ search: string;
33
+ /** The placeholder filter, with how many rows it would show. */
34
+ placeholdersOnly: (count: number) => string;
35
+ /** The chip on a row whose placeholders differ from the reference's. */
36
+ placeholderChip: string;
37
+ /** The key column, and the key's caption in the editor. */
38
+ key: string;
39
+ statusColumn: string;
40
+ /** No row matches the filters. */
41
+ empty: string;
42
+ /** In place of the text of a string the locale lacks. */
43
+ missingText: string;
44
+ /** The progress bar's label. */
45
+ progress: (approved: number, total: number) => string;
46
+ /** A locale tab's badge. */
47
+ localeProgress: (approved: number, total: number) => string;
48
+ /** Accessible name of the locale tabs. */
49
+ locales: string;
50
+ approve: string;
51
+ /** Send a string back: "Needs a change". */
52
+ flag: string;
53
+ /** "Needs a change" for a MISSING string: the suggestion is the translation to add. */
54
+ suggest: string;
55
+ /** Clear a verdict: back to unreviewed. */
56
+ reset: string;
57
+ /** The editor's way out without saving. */
58
+ cancel: string;
59
+ approveSelected: string;
60
+ resetSelected: string;
61
+ /** Selects every row the filters show — the phone's way into the bulk actions, where
62
+ * the table has no checkboxes. */
63
+ selectShown: string;
64
+ /** Over the old wording of a row that changed since its verdict. */
65
+ changedSince: string;
66
+ reviewedWording: string;
67
+ reviewedReference: string;
68
+ /** The placeholders differ: the reference's and the text's, each a list. */
69
+ placeholderMismatch: (reference: string, text: string) => string;
70
+ lastApproved: (name: string, date: string) => string;
71
+ lastFlagged: (name: string, date: string) => string;
72
+ /** The reviewer's name once the account is gone — what `name` above becomes. */
73
+ erasedReviewer: string;
74
+ /** The editor's wording field, opened on the current text. */
75
+ suggestion: string;
76
+ /** The same field for a missing string. */
77
+ translation: string;
78
+ note: string;
79
+ notePlaceholder: string;
80
+ /** The viewer may read this locale but not review it. */
81
+ readOnly: string;
82
+ /** The reviewer is limited to some areas — `areas` is their names, joined. */
83
+ scope: (areas: string) => string;
84
+ exportCorrections: (count: number) => string;
85
+ /** A callback rejected and no `formatError` says better. */
86
+ failed: string;
87
+ }
88
+
89
+ export const DEFAULT_TRANSLATION_REVIEW_LABELS: TranslationReviewLabels = {
90
+ statusMissing: "Missing",
91
+ statusUnreviewed: "Unreviewed",
92
+ statusChanged: "Changed since review",
93
+ statusNeedsChange: "Needs a change",
94
+ statusApproved: "Approved",
95
+ statusFilter: "Status",
96
+ all: "All",
97
+ filterCount: (label, count) => `${label} · ${count}`,
98
+ source: "Texts",
99
+ allSources: "All texts",
100
+ namespace: "Area",
101
+ allNamespaces: "All areas",
102
+ search: "Search keys and text",
103
+ placeholdersOnly: (count) => `Only placeholder problems (${count})`,
104
+ placeholderChip: "Placeholders",
105
+ key: "Key",
106
+ statusColumn: "Status",
107
+ empty: "No strings match these filters.",
108
+ missingText: "Missing in this language",
109
+ progress: (approved, total) => `${approved} of ${total} approved`,
110
+ localeProgress: (approved, total) => `${approved}/${total}`,
111
+ locales: "Languages",
112
+ approve: "Approve",
113
+ flag: "Needs a change",
114
+ suggest: "Suggest translation",
115
+ reset: "Mark unreviewed",
116
+ cancel: "Cancel",
117
+ approveSelected: "Approve selected",
118
+ resetSelected: "Mark selected unreviewed",
119
+ selectShown: "Select all shown",
120
+ changedSince: "This string changed after it was reviewed. Please read it again.",
121
+ reviewedWording: "Wording when reviewed",
122
+ reviewedReference: "Reference when reviewed",
123
+ placeholderMismatch: (reference, text) =>
124
+ `The placeholders differ from the reference — reference: ${reference}; this text: ${text}. The app fills these in, so they must stay exactly as they are.`,
125
+ lastApproved: (name, date) => `Approved by ${name} on ${date}`,
126
+ lastFlagged: (name, date) => `Marked as needing a change by ${name} on ${date}`,
127
+ erasedReviewer: "a deleted account",
128
+ suggestion: "Better wording",
129
+ translation: "Translation",
130
+ note: "Note (optional)",
131
+ notePlaceholder: "What is wrong, or what to keep in mind",
132
+ readOnly: "You can read this language but not review it.",
133
+ scope: (areas) => `Your review is limited to: ${areas}.`,
134
+ exportCorrections: (count) => `Export corrections (${count})`,
135
+ failed: "That did not work. Please try again.",
136
+ };
137
+
138
+ /** A status's word, from the namespace's five. */
139
+ export function reviewStatusLabel(labels: TranslationReviewLabels, status: ReviewStatus): string {
140
+ switch (status) {
141
+ case "missing":
142
+ return labels.statusMissing;
143
+ case "unreviewed":
144
+ return labels.statusUnreviewed;
145
+ case "changed":
146
+ return labels.statusChanged;
147
+ case "needs_change":
148
+ return labels.statusNeedsChange;
149
+ case "approved":
150
+ return labels.statusApproved;
151
+ }
152
+ }