@eifi1/ui-kit 0.18.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 (119) hide show
  1. package/README.md +60 -6
  2. package/dist/chart.d.ts +4 -1
  3. package/dist/components/amount-input.d.ts +4 -1
  4. package/dist/components/button-group.d.ts +4 -1
  5. package/dist/components/calculator.d.ts +4 -1
  6. package/dist/components/copy-button.d.ts +4 -1
  7. package/dist/components/facing-pair.d.ts +4 -1
  8. package/dist/components/file-button.d.ts +4 -1
  9. package/dist/components/file-dropzone.d.ts +4 -1
  10. package/dist/components/floating-panel.d.ts +4 -1
  11. package/dist/components/floating-panel.js +15 -1
  12. package/dist/components/floating-panel.js.map +1 -1
  13. package/dist/components/form-actions.d.ts +4 -1
  14. package/dist/components/legal.d.ts +84 -0
  15. package/dist/components/legal.js +55 -0
  16. package/dist/components/legal.js.map +1 -0
  17. package/dist/components/money-field.d.ts +4 -1
  18. package/dist/components/number-field.d.ts +4 -1
  19. package/dist/components/number-input.d.ts +4 -1
  20. package/dist/components/numpad-sheet.d.ts +4 -1
  21. package/dist/components/series-chart.d.ts +4 -1
  22. package/dist/components/server-wake.d.ts +4 -2
  23. package/dist/components/server-wake.js +23 -2
  24. package/dist/components/server-wake.js.map +1 -1
  25. package/dist/components/settings-fields.d.ts +4 -1
  26. package/dist/components/share-card.d.ts +4 -1
  27. package/dist/components/text-link.d.ts +4 -1
  28. package/dist/components/time-input.d.ts +4 -1
  29. package/dist/components/translation-review-editor.d.ts +56 -0
  30. package/dist/components/translation-review-editor.js +159 -0
  31. package/dist/components/translation-review-editor.js.map +1 -0
  32. package/dist/components/translation-review-labels.d.ts +92 -0
  33. package/dist/components/translation-review-labels.js +67 -0
  34. package/dist/components/translation-review-labels.js.map +1 -0
  35. package/dist/components/translation-review-parts.d.ts +97 -0
  36. package/dist/components/translation-review-parts.js +163 -0
  37. package/dist/components/translation-review-parts.js.map +1 -0
  38. package/dist/components/translation-review.d.ts +94 -0
  39. package/dist/components/translation-review.js +344 -0
  40. package/dist/components/translation-review.js.map +1 -0
  41. package/dist/components/ui.d.ts +4 -1
  42. package/dist/hooks/use-file-drop.d.ts +4 -1
  43. package/dist/i18n/defaults.d.ts +4 -1
  44. package/dist/i18n/defaults.js +5 -1
  45. package/dist/i18n/defaults.js.map +1 -1
  46. package/dist/i18n/german.d.ts +4 -1
  47. package/dist/i18n/german.js +51 -0
  48. package/dist/i18n/german.js.map +1 -1
  49. package/dist/i18n/kit-labels.d.ts +4 -1
  50. package/dist/i18n/kit-labels.js.map +1 -1
  51. package/dist/i18n/languages.d.ts +243 -0
  52. package/dist/i18n/languages.js +70 -0
  53. package/dist/i18n/languages.js.map +1 -0
  54. package/dist/i18n/locales/de-CH.d.ts +4 -1
  55. package/dist/i18n/locales/en.d.ts +4 -1
  56. package/dist/i18n/locales/en.js +8 -0
  57. package/dist/i18n/locales/en.js.map +1 -1
  58. package/dist/i18n/locales/es.d.ts +4 -1
  59. package/dist/i18n/locales/es.js +83 -33
  60. package/dist/i18n/locales/es.js.map +1 -1
  61. package/dist/i18n/locales/fr.d.ts +4 -1
  62. package/dist/i18n/locales/fr.js +50 -0
  63. package/dist/i18n/locales/fr.js.map +1 -1
  64. package/dist/i18n/locales/hu.d.ts +4 -1
  65. package/dist/i18n/locales/hu.js +51 -0
  66. package/dist/i18n/locales/hu.js.map +1 -1
  67. package/dist/i18n/locales/it.d.ts +4 -1
  68. package/dist/i18n/locales/it.js +90 -40
  69. package/dist/i18n/locales/it.js.map +1 -1
  70. package/dist/i18n/locales/zh.d.ts +4 -1
  71. package/dist/i18n/locales/zh.js +51 -0
  72. package/dist/i18n/locales/zh.js.map +1 -1
  73. package/dist/i18n/review.d.ts +102 -0
  74. package/dist/i18n/review.js +158 -0
  75. package/dist/i18n/review.js.map +1 -0
  76. package/dist/index.d.ts +9 -1
  77. package/dist/index.js +64 -0
  78. package/dist/index.js.map +1 -1
  79. package/dist/{kit-labels-CPbsjlXr.d.ts → kit-labels-D1J2_jyD.d.ts} +7 -0
  80. package/dist/lib/floating-stack.d.ts +31 -0
  81. package/dist/lib/floating-stack.js +42 -0
  82. package/dist/lib/floating-stack.js.map +1 -0
  83. package/dist/lib/server-wake.d.ts +24 -8
  84. package/dist/lib/server-wake.js +6 -5
  85. package/dist/lib/server-wake.js.map +1 -1
  86. package/dist/lib/translation-review.d.ts +232 -0
  87. package/dist/lib/translation-review.js +194 -0
  88. package/dist/lib/translation-review.js.map +1 -0
  89. package/dist/rhf/fields.d.ts +4 -1
  90. package/dist/rhf/form.d.ts +4 -1
  91. package/dist/rhf.d.ts +4 -1
  92. package/dist/shell/app-shell.d.ts +4 -1
  93. package/dist/shell/top-bar-brand.d.ts +4 -1
  94. package/dist/shell.d.ts +4 -1
  95. package/dist/wizard/stepper-nav.d.ts +4 -1
  96. package/dist/wizard.d.ts +4 -1
  97. package/package.json +1 -1
  98. package/src/components/floating-panel.tsx +19 -1
  99. package/src/components/legal.tsx +159 -0
  100. package/src/components/server-wake.tsx +37 -5
  101. package/src/components/translation-review-editor.tsx +261 -0
  102. package/src/components/translation-review-labels.ts +152 -0
  103. package/src/components/translation-review-parts.tsx +269 -0
  104. package/src/components/translation-review.tsx +502 -0
  105. package/src/i18n/defaults.ts +4 -0
  106. package/src/i18n/german.ts +52 -0
  107. package/src/i18n/kit-labels.tsx +7 -0
  108. package/src/i18n/languages.ts +285 -0
  109. package/src/i18n/locales/en.ts +8 -0
  110. package/src/i18n/locales/es.ts +84 -33
  111. package/src/i18n/locales/fr.ts +51 -0
  112. package/src/i18n/locales/hu.ts +52 -0
  113. package/src/i18n/locales/it.ts +91 -40
  114. package/src/i18n/locales/zh.ts +52 -0
  115. package/src/i18n/review.ts +193 -0
  116. package/src/index.ts +63 -0
  117. package/src/lib/floating-stack.ts +66 -0
  118. package/src/lib/server-wake.ts +34 -13
  119. package/src/lib/translation-review.ts +465 -0
@@ -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
+ }
@@ -0,0 +1,269 @@
1
+ import { useMemo } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { Download } from "lucide-react";
4
+
5
+ import { useKitLabels } from "../i18n/kit-labels";
6
+ import { useKitFormat } from "../lib/format";
7
+ import { summariseRows, translationCorrections } from "../lib/translation-review";
8
+ import type {
9
+ ReviewStatus,
10
+ TranslationCorrection,
11
+ TranslationRow,
12
+ TranslationSummary,
13
+ } from "../lib/translation-review";
14
+ import { Chip } from "./chip";
15
+ import type { ChipShape, ChipSize, ChipTone, ChipVariant } from "./chip";
16
+ import { ProgressBar } from "./progress-bar";
17
+ import type { ProgressBarSegment } from "./progress-bar";
18
+ import { DEFAULT_TRANSLATION_REVIEW_LABELS, reviewStatusLabel } from "./translation-review-labels";
19
+ import type { TranslationReviewLabels } from "./translation-review-labels";
20
+ import { Button, Tabs } from "./ui";
21
+
22
+ /**
23
+ * The small parts of the translation review, each usable on its own: the status
24
+ * vocabulary as a chip, one locale's progress, the locale tabs and the export. The page
25
+ * body that puts them together is `TranslationReviewPanel` (translation-review.tsx).
26
+ */
27
+
28
+ // ── ReviewStatusChip ──────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * The tone of each status: nothing yet is neutral, a gap is orange (kastlan's choice — a
32
+ * hue, not a verdict), a change since review is amber, sent back is red, done is green.
33
+ */
34
+ export const REVIEW_STATUS_TONES: Readonly<Record<ReviewStatus, ChipTone>> = {
35
+ missing: "orange",
36
+ unreviewed: "neutral",
37
+ changed: "warning",
38
+ needs_change: "danger",
39
+ approved: "success",
40
+ };
41
+
42
+ export interface ReviewStatusChipProps {
43
+ status: ReviewStatus;
44
+ /** Over {@link REVIEW_STATUS_TONES}. */
45
+ tone?: ChipTone;
46
+ /** Over the label. */
47
+ children?: ReactNode;
48
+ /** Default `sm` — a chip in a table cell. */
49
+ size?: ChipSize;
50
+ variant?: ChipVariant;
51
+ shape?: ChipShape;
52
+ caps?: boolean;
53
+ className?: string;
54
+ /** Prop > `<UiKitProvider labels={{ translationReview }}>` > English. */
55
+ labels?: Partial<TranslationReviewLabels>;
56
+ [key: `data-${string}`]: string | number | boolean | undefined;
57
+ }
58
+
59
+ /** One row's status as a chip, in the kit's words and tones. Carries `data-status`. */
60
+ export function ReviewStatusChip({
61
+ status,
62
+ tone,
63
+ children,
64
+ size = "sm",
65
+ variant,
66
+ shape,
67
+ caps,
68
+ className,
69
+ labels: labelsProp,
70
+ ...rest
71
+ }: ReviewStatusChipProps) {
72
+ const labels = useKitLabels("translationReview", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);
73
+ return (
74
+ <Chip
75
+ {...rest}
76
+ data-status={status}
77
+ tone={tone ?? REVIEW_STATUS_TONES[status]}
78
+ size={size}
79
+ variant={variant}
80
+ shape={shape}
81
+ caps={caps}
82
+ className={className}
83
+ >
84
+ {children ?? reviewStatusLabel(labels, status)}
85
+ </Chip>
86
+ );
87
+ }
88
+
89
+ // ── TranslationProgress ───────────────────────────────────────────────────────
90
+
91
+ export interface TranslationProgressProps {
92
+ /** One locale's rows — or their {@link summariseRows} when the app has only counts. */
93
+ rows?: readonly TranslationRow[];
94
+ summary?: TranslationSummary;
95
+ /** The list under the bar naming each part with its count. Default true. */
96
+ legend?: boolean;
97
+ className?: string;
98
+ labels?: Partial<TranslationReviewLabels>;
99
+ }
100
+
101
+ /**
102
+ * "120 of 3859 approved", and the bar split by status: approved, sent back, changed since
103
+ * review, missing — in the chips' colours. What is left of the track is unreviewed. The
104
+ * legend counts rows rather than percentages: "12 need a change" is the next hour's work,
105
+ * "0.3 %" is not.
106
+ */
107
+ export function TranslationProgress({
108
+ rows,
109
+ summary: summaryProp,
110
+ legend = true,
111
+ className,
112
+ labels: labelsProp,
113
+ }: TranslationProgressProps) {
114
+ const labels = useKitLabels("translationReview", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);
115
+ const { formatNumber } = useKitFormat();
116
+ const summary = useMemo(() => summaryProp ?? summariseRows(rows ?? []), [summaryProp, rows]);
117
+ const part = (status: ReviewStatus, tone: ProgressBarSegment["tone"], color?: string): ProgressBarSegment => ({
118
+ key: status,
119
+ value: summary[status],
120
+ tone,
121
+ color,
122
+ label: reviewStatusLabel(labels, status),
123
+ // A status with no rows stays in the legend ("Needs a change 0" is news too) but out
124
+ // of the bar, where it would only be a gap.
125
+ legendOnly: summary[status] === 0 ? true : undefined,
126
+ });
127
+ const segments = [
128
+ part("approved", "success"),
129
+ part("needs_change", "danger"),
130
+ part("changed", "warning"),
131
+ // The chip's orange, which is a hue and not a ProgressBar tone. Only where there is
132
+ // a gap: an app whose locales are complete never hears of "missing".
133
+ ...(summary.missing > 0 ? [part("missing", undefined, "var(--hue-orange)")] : []),
134
+ ];
135
+ return (
136
+ <ProgressBar
137
+ className={className}
138
+ label={labels.progress(summary.approved, summary.total)}
139
+ max={Math.max(summary.total, 1)}
140
+ segments={segments}
141
+ legend={legend}
142
+ formatValue={(value) => formatNumber(value)}
143
+ />
144
+ );
145
+ }
146
+
147
+ // ── TranslationLocaleTabs ─────────────────────────────────────────────────────
148
+
149
+ export interface TranslationLocaleTab {
150
+ /** The locale code. */
151
+ value: string;
152
+ /** Its name, in its own language ("Français"). */
153
+ label: ReactNode;
154
+ /** The locale's rows, for the badge — built with the same `areas` as the panel's, so
155
+ * a scoped reviewer's tab counts what they can see. */
156
+ rows: readonly TranslationRow[];
157
+ }
158
+
159
+ export interface TranslationLocaleTabsProps {
160
+ /** The locales the viewer may see, in the app's order — the server's answer, never a
161
+ * guess from a role name. */
162
+ locales: readonly TranslationLocaleTab[];
163
+ active: string;
164
+ onChange: (locale: string) => void;
165
+ className?: string;
166
+ labels?: Partial<TranslationReviewLabels>;
167
+ }
168
+
169
+ /**
170
+ * One tab per locale with its approved/total, wrapping on a phone. Nothing for a single
171
+ * locale: a strip with one tab is a heading that looks clickable.
172
+ */
173
+ export function TranslationLocaleTabs({ locales, active, onChange, className, labels: labelsProp }: TranslationLocaleTabsProps) {
174
+ const labels = useKitLabels("translationReview", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);
175
+ if (locales.length < 2) return null;
176
+ return (
177
+ <Tabs
178
+ aria-label={labels.locales}
179
+ wrap
180
+ className={className}
181
+ tabs={locales.map((locale) => {
182
+ const summary = summariseRows(locale.rows);
183
+ return {
184
+ id: locale.value,
185
+ label: locale.label,
186
+ name: typeof locale.label === "string" ? locale.label : locale.value,
187
+ badge: (
188
+ // The tab's own colour, dimmed — a fixed muted grey vanished on the selected
189
+ // tab's brand fill (390px browser check).
190
+ <span className="text-xs tabular-nums opacity-75">
191
+ {labels.localeProgress(summary.approved, summary.total)}
192
+ </span>
193
+ ),
194
+ };
195
+ })}
196
+ active={active}
197
+ onChange={onChange}
198
+ />
199
+ );
200
+ }
201
+
202
+ // ── TranslationExportButton ───────────────────────────────────────────────────
203
+
204
+ export interface TranslationExportButtonProps {
205
+ /** Every row the export covers — usually every locale's, not only the open one. */
206
+ rows: readonly TranslationRow[];
207
+ /** Listed in the file. Default: the locales the rows come from. */
208
+ locales?: readonly string[];
209
+ /** Default `translation-corrections-<date>.json`. */
210
+ fileName?: string;
211
+ /**
212
+ * Take the export instead of downloading it — the entries, and the file as a ready
213
+ * JSON Blob. Left out, the kit downloads the Blob as `fileName`.
214
+ */
215
+ onExport?: (corrections: TranslationCorrection[], file: Blob) => void;
216
+ className?: string;
217
+ labels?: Partial<TranslationReviewLabels>;
218
+ }
219
+
220
+ /**
221
+ * "Export corrections (n)": every string sent back, as the JSON file keksdose and kastlan
222
+ * hand a developer — `{ exported_at, locales, corrections }`. Corrections are not applied
223
+ * on the page: the strings are source code. Disabled while there is nothing to export.
224
+ */
225
+ export function TranslationExportButton({
226
+ rows,
227
+ locales,
228
+ fileName,
229
+ onExport,
230
+ className,
231
+ labels: labelsProp,
232
+ }: TranslationExportButtonProps) {
233
+ const labels = useKitLabels("translationReview", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);
234
+ const corrections = useMemo(() => translationCorrections(rows), [rows]);
235
+ const run = () => {
236
+ const now = new Date();
237
+ const file = new Blob(
238
+ [
239
+ JSON.stringify(
240
+ {
241
+ exported_at: now.toISOString(),
242
+ locales: locales ?? [...new Set(rows.map((r) => r.locale))],
243
+ corrections,
244
+ },
245
+ null,
246
+ 2,
247
+ ),
248
+ ],
249
+ { type: "application/json" },
250
+ );
251
+ if (onExport) {
252
+ onExport(corrections, file);
253
+ return;
254
+ }
255
+ const url = URL.createObjectURL(file);
256
+ const a = document.createElement("a");
257
+ a.href = url;
258
+ a.download = fileName ?? `translation-corrections-${now.toISOString().slice(0, 10)}.json`;
259
+ a.click();
260
+ // After the click has handed the URL to the download, not before.
261
+ setTimeout(() => URL.revokeObjectURL(url), 0);
262
+ };
263
+ return (
264
+ <Button variant="secondary" disabled={corrections.length === 0} onClick={run} className={className}>
265
+ <Download className="size-4" aria-hidden />
266
+ {labels.exportCorrections(corrections.length)}
267
+ </Button>
268
+ );
269
+ }