@focus-reactive/payload-plugin-translator 0.9.1 → 0.10.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 (76) hide show
  1. package/README.md +25 -0
  2. package/dist/client/entities/translation/api/mutations/useQueueDocumentTranslation.d.ts +1 -1
  3. package/dist/client/entities/translation/ui/AutoTranslateMarker/AutoTranslateMarker.d.ts +3 -4
  4. package/dist/client/entities/translation/ui/AutoTranslateMarker/AutoTranslateMarker.js +8 -12
  5. package/dist/client/entities/translation/ui/AutoTranslateMarker/styles.module.scss +3 -12
  6. package/dist/client/features/collection-translation-form/model/schema.d.ts +3 -3
  7. package/dist/client/features/collection-translation-form/model/schema.js +8 -1
  8. package/dist/client/features/collection-translation-form/ui/CollectionTranslationForm.d.ts +3 -1
  9. package/dist/client/features/collection-translation-form/ui/CollectionTranslationForm.js +36 -6
  10. package/dist/client/features/translate-document-form/model/schema.d.ts +3 -3
  11. package/dist/client/features/translate-document-form/model/schema.js +8 -1
  12. package/dist/client/features/translate-document-form/ui/DocumentTranslationForm.d.ts +3 -1
  13. package/dist/client/features/translate-document-form/ui/DocumentTranslationForm.js +36 -6
  14. package/dist/client/shared/lib/forms/pruneSourceFromTarget.d.ts +7 -0
  15. package/dist/client/shared/lib/forms/pruneSourceFromTarget.js +14 -0
  16. package/dist/client/shared/ui/InfoPopover/InfoPopover.d.ts +21 -0
  17. package/dist/client/shared/ui/InfoPopover/InfoPopover.js +45 -0
  18. package/dist/client/shared/ui/InfoPopover/index.d.ts +1 -0
  19. package/dist/client/shared/ui/InfoPopover/index.js +3 -0
  20. package/dist/client/shared/ui/InfoPopover/styles.module.scss +49 -0
  21. package/dist/client/shared/ui/MultiSelect/MultiSelect.d.ts +40 -0
  22. package/dist/client/shared/ui/MultiSelect/MultiSelect.js +129 -0
  23. package/dist/client/shared/ui/MultiSelect/index.d.ts +2 -0
  24. package/dist/client/shared/ui/MultiSelect/index.js +3 -0
  25. package/dist/client/shared/ui/MultiSelect/multiSelectModel.d.ts +25 -0
  26. package/dist/client/shared/ui/MultiSelect/multiSelectModel.js +41 -0
  27. package/dist/client/shared/ui/MultiSelect/styles.module.scss +126 -0
  28. package/dist/client/shared/ui/form/FormCheckboxPublish/FormCheckboxPublish.js +4 -7
  29. package/dist/client/shared/ui/form/FormMultiSelect/FormMultiSelect.d.ts +22 -0
  30. package/dist/client/shared/ui/form/FormMultiSelect/FormMultiSelect.js +70 -0
  31. package/dist/client/shared/ui/form/FormMultiSelect/index.d.ts +1 -0
  32. package/dist/client/shared/ui/form/FormMultiSelect/index.js +3 -0
  33. package/dist/client/shared/ui/form/FormSelectStrategy/FormSelectStrategy.js +4 -7
  34. package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.d.ts +3 -1
  35. package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.export.d.ts +3 -1
  36. package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.export.js +5 -2
  37. package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.js +8 -3
  38. package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.d.ts +2 -0
  39. package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.js +2 -1
  40. package/dist/client/widgets/translate-document/ui/TranslateDocument.d.ts +3 -1
  41. package/dist/client/widgets/translate-document/ui/TranslateDocument.export.d.ts +3 -1
  42. package/dist/client/widgets/translate-document/ui/TranslateDocument.export.js +5 -2
  43. package/dist/client/widgets/translate-document/ui/TranslateDocument.js +7 -3
  44. package/dist/client/widgets/translate-document/ui/TranslateDocument.server.d.ts +2 -0
  45. package/dist/client/widgets/translate-document/ui/TranslateDocument.server.js +2 -1
  46. package/dist/composition/levels/collectionLevel.js +1 -1
  47. package/dist/composition/levels/documentLevel.js +1 -1
  48. package/dist/core/kernel/field-traversal/index.d.ts +1 -0
  49. package/dist/core/kernel/field-traversal/index.js +1 -0
  50. package/dist/core/kernel/field-traversal/projectFieldLike.d.ts +32 -0
  51. package/dist/core/kernel/field-traversal/projectFieldLike.js +60 -0
  52. package/dist/index.d.ts +1 -0
  53. package/dist/plugin.d.ts +10 -0
  54. package/dist/plugin.js +11 -11
  55. package/dist/server/features/enqueue-translation/handler.js +28 -8
  56. package/dist/server/features/enqueue-translation/model.d.ts +3 -3
  57. package/dist/server/features/enqueue-translation/model.js +6 -1
  58. package/dist/server/features/enqueue-translation/resolveTargetLocales.d.ts +29 -0
  59. package/dist/server/features/enqueue-translation/resolveTargetLocales.js +41 -0
  60. package/dist/server/features/translate-field/resolveFieldSubtree.d.ts +3 -3
  61. package/dist/server/modules/auto-translate/index.d.ts +2 -0
  62. package/dist/server/modules/auto-translate/index.js +2 -0
  63. package/dist/server/modules/translation-levels/PluginConfigBuilder.d.ts +2 -0
  64. package/dist/server/modules/translation-levels/PluginConfigBuilder.js +2 -0
  65. package/dist/server/modules/translation-levels/types.d.ts +4 -0
  66. package/dist/types/CollectionSchemaMap.d.ts +7 -4
  67. package/dist/types/CollectionSchemaMap.js +4 -2
  68. package/dist/types/TargetSelection.d.ts +10 -0
  69. package/dist/types/TargetSelection.js +11 -0
  70. package/package.json +1 -1
  71. package/dist/client/entities/translation/api/mutations/useQueueDocumentTranslations.d.ts +0 -8
  72. package/dist/client/entities/translation/api/mutations/useQueueDocumentTranslations.js +0 -20
  73. package/dist/client/shared/ui/form/FormSelectLocale/FormSelectLocale.d.ts +0 -11
  74. package/dist/client/shared/ui/form/FormSelectLocale/FormSelectLocale.js +0 -19
  75. package/dist/client/shared/ui/form/FormSelectLocale/index.d.ts +0 -1
  76. package/dist/client/shared/ui/form/FormSelectLocale/index.js +0 -3
package/README.md CHANGED
@@ -16,6 +16,7 @@ It works at three levels — translate the **document** you're editing, **bulk-t
16
16
  - **Deep translation** — every localized leaf field at any nesting level (groups, arrays, blocks, tabs).
17
17
  - **Rich text** — full Lexical translation, preserving formatting and structure.
18
18
  - **Three surfaces** — a per-document popup, a bulk-collection dashboard, and a per-field control, toggled via `levels`.
19
+ - **Single or multi target** — translate into one locale, or pick several at once, via `targetSelection`.
19
20
  - **Async or sync** — queue-based background jobs (Payload Jobs) by default, or run inline.
20
21
  - **Pluggable providers** — OpenAI built in, or implement your own.
21
22
  - **Strategies** — overwrite everything or skip locales that already have content.
@@ -143,6 +144,7 @@ Allowed on **`text`, `textarea`, and `richText`** fields (a compile error on oth
143
144
  | `levels` | `TranslationLevel[]` | No | `[documentLevel(), collectionLevel()]` | Which surfaces to enable — see [Translation surfaces](#translation-surfaces-levels). |
144
145
  | `provenance` | `boolean \| { slug?: string }` | No | `false` (disabled) | Opt in to recording a provenance record per translation. _Since v0.7.0._ See [Provenance](#provenance-opt-in) below. |
145
146
  | `lifecycle` | `{ onQueued?, onCompleted?, onFailed? }` | No | `undefined` | Server-side callbacks fired around each task. _Since v0.7.0._ See [Lifecycle callbacks](#lifecycle-callbacks). |
147
+ | `targetSelection` | `'single' \| 'multi'` | No | `'single'` | Let an editor pick several target locales in one run. _Since v0.10.0._ See [Target-language selection](#target-language-selection) below. |
146
148
 
147
149
  ```typescript
148
150
  translatorPlugin({
@@ -153,6 +155,29 @@ translatorPlugin({
153
155
  });
154
156
  ```
155
157
 
158
+ ### Target-language selection
159
+
160
+ _Since v0.10.0._
161
+
162
+ By default the translation forms (per-document panel and bulk dashboard) translate into **one**
163
+ target locale per run. Set `targetSelection: 'multi'` to let an editor pick **several** target
164
+ locales at once — the "To" field becomes a compact multi-select and the run fans out one translation
165
+ per _(document × target locale)_.
166
+
167
+ ```typescript
168
+ translatorPlugin({
169
+ collections: [Posts, Pages],
170
+ translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
171
+ runner: createPayloadJobsRunner(),
172
+ targetSelection: "multi", // default is "single" (one target per run — unchanged)
173
+ });
174
+ ```
175
+
176
+ Fully backward-compatible and opt-in: the default `'single'` keeps today's behaviour exactly, and
177
+ there is no schema or migration. Unknown or duplicate target locales are ignored, and the source
178
+ locale is never translated into itself. The `/enqueue` endpoint accepts `target_lng` as either a
179
+ single string or an array of strings, so existing API callers keep working.
180
+
156
181
  ### Provenance (opt-in)
157
182
 
158
183
  _Since v0.7.0._
@@ -1,7 +1,7 @@
1
1
  type TranslationStrategy = "overwrite" | "skip_existing";
2
2
  type Variables = {
3
3
  source_lng: string;
4
- target_lng: string;
4
+ target_lng: string | string[];
5
5
  collection_slug: string;
6
6
  collection_id: Array<string | number>;
7
7
  select_all?: boolean;
@@ -5,10 +5,9 @@ type AutoTranslateMarkerProps = {
5
5
  sourceLocale: string;
6
6
  };
7
7
  /**
8
- * A quiet, off-to-the-side marker in the translation popups' header (document + collection): a single
9
- * muted icon that a collection is opted into auto-translate. The detail lives in the tooltip, not the
10
- * layout — this is ambient config the editor rarely needs, so it stays out of the popup's main vertical
11
- * flow (title / Translate / Status). Rendered only when auto-translate is enabled.
8
+ * A quiet, off-to-the-side marker in the translation popups' header that a collection is opted into
9
+ * auto-translate. The detail lives behind the icon (tap/click), not the layout — ambient config the
10
+ * editor rarely needs. Rendered only when auto-translate is enabled.
12
11
  */
13
12
  export declare function AutoTranslateMarker({ targets, sourceLocale }: AutoTranslateMarkerProps): import("react/jsx-runtime").JSX.Element;
14
13
  export {};
@@ -1,15 +1,17 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { AutoTranslateIcon } from "../../../../shared/lib/assets/icons/AutoTranslateIcon";
4
- import Tooltip from "../../../../shared/ui/Tooltip";
4
+ import InfoPopover from "../../../../shared/ui/InfoPopover";
5
5
  import styles from "./styles.module.scss";
6
6
  /**
7
- * A quiet, off-to-the-side marker in the translation popups' header (document + collection): a single
8
- * muted icon that a collection is opted into auto-translate. The detail lives in the tooltip, not the
9
- * layout — this is ambient config the editor rarely needs, so it stays out of the popup's main vertical
10
- * flow (title / Translate / Status). Rendered only when auto-translate is enabled.
7
+ * A quiet, off-to-the-side marker in the translation popups' header that a collection is opted into
8
+ * auto-translate. The detail lives behind the icon (tap/click), not the layout — ambient config the
9
+ * editor rarely needs. Rendered only when auto-translate is enabled.
11
10
  */ export function AutoTranslateMarker({ targets, sourceLocale }) {
12
- return /*#__PURE__*/ _jsx(Tooltip, {
11
+ return /*#__PURE__*/ _jsx(InfoPopover, {
12
+ label: "Auto-translate enabled",
13
+ className: styles.marker,
14
+ icon: /*#__PURE__*/ _jsx(AutoTranslateIcon, {}),
13
15
  content: /*#__PURE__*/ _jsxs("span", {
14
16
  className: styles.tip,
15
17
  children: [
@@ -21,12 +23,6 @@ import styles from "./styles.module.scss";
21
23
  targets.join(" · "),
22
24
  "."
23
25
  ]
24
- }),
25
- children: /*#__PURE__*/ _jsx("span", {
26
- className: styles.marker,
27
- tabIndex: 0,
28
- "aria-label": "Auto-translate enabled",
29
- children: /*#__PURE__*/ _jsx(AutoTranslateIcon, {})
30
26
  })
31
27
  });
32
28
  }
@@ -1,24 +1,15 @@
1
- // A muted, low-emphasis header marker — no border, no fill; it sits at the edge of the title row and
2
- // carries all detail in its tooltip, so it never competes with the popup's main flow.
1
+ // A muted, low-emphasis header marker at the edge of the title row; the detail lives behind the icon,
2
+ // so it never competes with the popup's main flow. (Button reset + focus come from InfoPopover.)
3
3
  .marker {
4
- display: inline-flex;
5
- align-items: center;
6
4
  flex-shrink: 0;
7
5
  color: var(--theme-elevation-400, #a5a5a5);
8
6
  cursor: help;
9
- outline: none;
10
7
  }
11
8
 
12
9
  .marker:hover {
13
10
  color: var(--theme-elevation-600, #6d6d6d);
14
11
  }
15
12
 
16
- .marker:focus-visible {
17
- outline: 2px solid var(--theme-elevation-400, #a5a5a5);
18
- outline-offset: 2px;
19
- border-radius: 3px;
20
- }
21
-
22
13
  .tip {
23
14
  display: block;
24
15
  max-width: 15rem;
@@ -27,4 +18,4 @@
27
18
 
28
19
  .tip code {
29
20
  font-family: var(--font-mono, ui-monospace, monospace);
30
- }
21
+ }
@@ -1,20 +1,20 @@
1
1
  import { z } from "zod";
2
2
  export declare const validationSchema: z.ZodObject<{
3
3
  source_lng: z.ZodString;
4
- target_lng: z.ZodString;
4
+ target_lng: z.ZodEffects<z.ZodUnion<[z.ZodString, z.ZodArray<z.ZodString, "many">]>, string | string[], string | string[]>;
5
5
  collection_slug: z.ZodString;
6
6
  strategy: z.ZodEnum<["overwrite", "skip_existing"]>;
7
7
  publish_on_translation: z.ZodDefault<z.ZodBoolean>;
8
8
  }, "strip", z.ZodTypeAny, {
9
9
  collection_slug: string;
10
10
  source_lng: string;
11
- target_lng: string;
11
+ target_lng: string | string[];
12
12
  strategy: "overwrite" | "skip_existing";
13
13
  publish_on_translation: boolean;
14
14
  }, {
15
15
  collection_slug: string;
16
16
  source_lng: string;
17
- target_lng: string;
17
+ target_lng: string | string[];
18
18
  strategy: "overwrite" | "skip_existing";
19
19
  publish_on_translation?: boolean | undefined;
20
20
  }>;
@@ -2,7 +2,14 @@ import { z } from "zod";
2
2
  import { FORM_FIELDS } from "./constants";
3
3
  export const validationSchema = z.object({
4
4
  [FORM_FIELDS.SOURCE_LNG]: z.string().nonempty(""),
5
- [FORM_FIELDS.TARGET_LNG]: z.string().nonempty(""),
5
+ // Single mode binds a string, multi mode a string[]. Either empty shape ("" / []) is rejected with
6
+ // one human-readable message, so a target must always be chosen before submit (AC7).
7
+ [FORM_FIELDS.TARGET_LNG]: z.union([
8
+ z.string(),
9
+ z.array(z.string())
10
+ ]).refine((value)=>value.length > 0, {
11
+ message: "Select at least one language"
12
+ }),
6
13
  [FORM_FIELDS.HIDDEN_COLLECTION_SLUG]: z.string().nonempty(),
7
14
  [FORM_FIELDS.STRATEGY]: z.enum([
8
15
  "overwrite",
@@ -1,10 +1,12 @@
1
1
  import type { UseFormReturn } from "react-hook-form";
2
+ import type { TargetSelectionMode } from "../../../../types/TargetSelection";
2
3
  import type { FormValues } from "../model/schema";
3
4
  type CollectionTranslationFormProps = {
4
5
  form: UseFormReturn<FormValues>;
5
6
  onSubmit: (values: FormValues) => Promise<void>;
6
7
  selectedCount: number;
7
8
  hasDrafts: boolean;
9
+ targetSelection: TargetSelectionMode;
8
10
  };
9
- export declare function CollectionTranslationForm({ form, onSubmit, selectedCount, hasDrafts, }: CollectionTranslationFormProps): import("react/jsx-runtime").JSX.Element;
11
+ export declare function CollectionTranslationForm({ form, onSubmit, selectedCount, hasDrafts, targetSelection, }: CollectionTranslationFormProps): import("react/jsx-runtime").JSX.Element;
10
12
  export {};
@@ -1,13 +1,34 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo } from "react";
2
3
  import { FormProvider } from "react-hook-form";
3
4
  import { SendIcon } from "../../../shared/lib/assets/icons/SendIcon";
5
+ import { pruneSourceFromTarget } from "../../../shared/lib/forms/pruneSourceFromTarget";
6
+ import { useLocaleOptions } from "../../../shared/lib/payload/hooks/useLocaleOptions";
4
7
  import Button from "../../../shared/ui/Button";
5
- import FormSelectLocale from "../../../shared/ui/form/FormSelectLocale";
8
+ import FormSelect from "../../../shared/ui/form/FormSelect";
9
+ import FormMultiSelect from "../../../shared/ui/form/FormMultiSelect";
6
10
  import { FormSelectStrategy } from "../../../shared/ui/form/FormSelectStrategy";
7
11
  import { FormCheckboxPublish } from "../../../shared/ui/form/FormCheckboxPublish";
8
12
  import { FORM_FIELDS } from "../model/constants";
9
13
  import styles from "./styles.module.scss";
10
- export function CollectionTranslationForm({ form, onSubmit, selectedCount, hasDrafts }) {
14
+ export function CollectionTranslationForm({ form, onSubmit, selectedCount, hasDrafts, targetSelection }) {
15
+ const localeOptions = useLocaleOptions();
16
+ const source = form.watch(FORM_FIELDS.SOURCE_LNG);
17
+ const targetOptions = useMemo(()=>localeOptions.filter((option)=>option.value !== source), [
18
+ localeOptions,
19
+ source
20
+ ]);
21
+ useEffect(()=>{
22
+ const pruned = pruneSourceFromTarget(form.getValues(FORM_FIELDS.TARGET_LNG), source);
23
+ if (pruned !== null) {
24
+ form.setValue(FORM_FIELDS.TARGET_LNG, pruned, {
25
+ shouldValidate: false
26
+ });
27
+ }
28
+ }, [
29
+ source,
30
+ form
31
+ ]);
11
32
  return /*#__PURE__*/ _jsx(FormProvider, {
12
33
  ...form,
13
34
  children: /*#__PURE__*/ _jsxs("fieldset", {
@@ -16,15 +37,24 @@ export function CollectionTranslationForm({ form, onSubmit, selectedCount, hasDr
16
37
  /*#__PURE__*/ _jsxs("div", {
17
38
  className: styles.row,
18
39
  children: [
19
- /*#__PURE__*/ _jsx(FormSelectLocale, {
40
+ /*#__PURE__*/ _jsx(FormSelect, {
20
41
  size: "sm",
21
42
  label: "From",
22
- name: FORM_FIELDS.SOURCE_LNG
43
+ name: FORM_FIELDS.SOURCE_LNG,
44
+ options: localeOptions,
45
+ placeholder: "-"
23
46
  }),
24
- /*#__PURE__*/ _jsx(FormSelectLocale, {
47
+ targetSelection === "multi" ? /*#__PURE__*/ _jsx(FormMultiSelect, {
25
48
  size: "sm",
26
49
  label: "To",
27
- name: FORM_FIELDS.TARGET_LNG
50
+ name: FORM_FIELDS.TARGET_LNG,
51
+ options: targetOptions
52
+ }) : /*#__PURE__*/ _jsx(FormSelect, {
53
+ size: "sm",
54
+ label: "To",
55
+ name: FORM_FIELDS.TARGET_LNG,
56
+ options: targetOptions,
57
+ placeholder: "-"
28
58
  })
29
59
  ]
30
60
  }),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  export declare const validationSchema: z.ZodObject<{
3
3
  source_lng: z.ZodString;
4
- target_lng: z.ZodString;
4
+ target_lng: z.ZodEffects<z.ZodUnion<[z.ZodString, z.ZodArray<z.ZodString, "many">]>, string | string[], string | string[]>;
5
5
  collection_id: z.ZodString;
6
6
  collection_slug: z.ZodString;
7
7
  strategy: z.ZodDefault<z.ZodEnum<["overwrite", "skip_existing"]>>;
@@ -10,14 +10,14 @@ export declare const validationSchema: z.ZodObject<{
10
10
  collection_slug: string;
11
11
  collection_id: string;
12
12
  source_lng: string;
13
- target_lng: string;
13
+ target_lng: string | string[];
14
14
  strategy: "overwrite" | "skip_existing";
15
15
  publish_on_translation: boolean;
16
16
  }, {
17
17
  collection_slug: string;
18
18
  collection_id: string;
19
19
  source_lng: string;
20
- target_lng: string;
20
+ target_lng: string | string[];
21
21
  strategy?: "overwrite" | "skip_existing" | undefined;
22
22
  publish_on_translation?: boolean | undefined;
23
23
  }>;
@@ -2,7 +2,14 @@ import { z } from "zod";
2
2
  import { FORM_FIELDS } from "./constants";
3
3
  export const validationSchema = z.object({
4
4
  [FORM_FIELDS.SOURCE_LNG]: z.string().nonempty(),
5
- [FORM_FIELDS.TARGET_LNG]: z.string().nonempty(),
5
+ // Single mode binds a string, multi mode a string[]. Either empty shape ("" / []) is rejected with
6
+ // one human-readable message, so a target must always be chosen before submit (AC7).
7
+ [FORM_FIELDS.TARGET_LNG]: z.union([
8
+ z.string(),
9
+ z.array(z.string())
10
+ ]).refine((value)=>value.length > 0, {
11
+ message: "Select at least one language"
12
+ }),
6
13
  [FORM_FIELDS.HIDDEN_COLLECTION_ID]: z.string().nonempty(),
7
14
  [FORM_FIELDS.HIDDEN_COLLECTION_SLUG]: z.string().nonempty(),
8
15
  [FORM_FIELDS.STRATEGY]: z.enum([
@@ -1,9 +1,11 @@
1
1
  import type { UseFormReturn } from "react-hook-form";
2
+ import type { TargetSelectionMode } from "../../../../types/TargetSelection";
2
3
  import type { FormValues } from "../model/schema";
3
4
  type DocumentTranslationFormProps = {
4
5
  form: UseFormReturn<FormValues>;
5
6
  onSubmit: (values: FormValues) => Promise<void>;
6
7
  hasDrafts?: boolean;
8
+ targetSelection: TargetSelectionMode;
7
9
  };
8
- export declare function DocumentTranslationForm({ form, onSubmit, hasDrafts, }: DocumentTranslationFormProps): import("react/jsx-runtime").JSX.Element;
10
+ export declare function DocumentTranslationForm({ form, onSubmit, hasDrafts, targetSelection, }: DocumentTranslationFormProps): import("react/jsx-runtime").JSX.Element;
9
11
  export {};
@@ -1,13 +1,34 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo } from "react";
2
3
  import { FormProvider } from "react-hook-form";
3
4
  import { SendIcon } from "../../../shared/lib/assets/icons/SendIcon";
5
+ import { pruneSourceFromTarget } from "../../../shared/lib/forms/pruneSourceFromTarget";
6
+ import { useLocaleOptions } from "../../../shared/lib/payload/hooks/useLocaleOptions";
4
7
  import Button from "../../../shared/ui/Button";
5
- import FormSelectLocale from "../../../shared/ui/form/FormSelectLocale";
8
+ import FormSelect from "../../../shared/ui/form/FormSelect";
9
+ import FormMultiSelect from "../../../shared/ui/form/FormMultiSelect";
6
10
  import { FormSelectStrategy } from "../../../shared/ui/form/FormSelectStrategy";
7
11
  import { FormCheckboxPublish } from "../../../shared/ui/form/FormCheckboxPublish";
8
12
  import { FORM_FIELDS } from "../model/constants";
9
13
  import styles from "./styles.module.scss";
10
- export function DocumentTranslationForm({ form, onSubmit, hasDrafts }) {
14
+ export function DocumentTranslationForm({ form, onSubmit, hasDrafts, targetSelection }) {
15
+ const localeOptions = useLocaleOptions();
16
+ const source = form.watch(FORM_FIELDS.SOURCE_LNG);
17
+ const targetOptions = useMemo(()=>localeOptions.filter((option)=>option.value !== source), [
18
+ localeOptions,
19
+ source
20
+ ]);
21
+ useEffect(()=>{
22
+ const pruned = pruneSourceFromTarget(form.getValues(FORM_FIELDS.TARGET_LNG), source);
23
+ if (pruned !== null) {
24
+ form.setValue(FORM_FIELDS.TARGET_LNG, pruned, {
25
+ shouldValidate: false
26
+ });
27
+ }
28
+ }, [
29
+ source,
30
+ form
31
+ ]);
11
32
  return /*#__PURE__*/ _jsx(FormProvider, {
12
33
  ...form,
13
34
  children: /*#__PURE__*/ _jsxs("fieldset", {
@@ -16,15 +37,24 @@ export function DocumentTranslationForm({ form, onSubmit, hasDrafts }) {
16
37
  /*#__PURE__*/ _jsxs("div", {
17
38
  className: styles.row,
18
39
  children: [
19
- /*#__PURE__*/ _jsx(FormSelectLocale, {
40
+ /*#__PURE__*/ _jsx(FormSelect, {
20
41
  size: "sm",
21
42
  label: "From",
22
- name: FORM_FIELDS.SOURCE_LNG
43
+ name: FORM_FIELDS.SOURCE_LNG,
44
+ options: localeOptions,
45
+ placeholder: "-"
23
46
  }),
24
- /*#__PURE__*/ _jsx(FormSelectLocale, {
47
+ targetSelection === "multi" ? /*#__PURE__*/ _jsx(FormMultiSelect, {
25
48
  size: "sm",
26
49
  label: "To",
27
- name: FORM_FIELDS.TARGET_LNG
50
+ name: FORM_FIELDS.TARGET_LNG,
51
+ options: targetOptions
52
+ }) : /*#__PURE__*/ _jsx(FormSelect, {
53
+ size: "sm",
54
+ label: "To",
55
+ name: FORM_FIELDS.TARGET_LNG,
56
+ options: targetOptions,
57
+ placeholder: "-"
28
58
  })
29
59
  ]
30
60
  }),
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Remove the source locale from a target-field value so a target can never equal the source. Handles
3
+ * both the single (`string`) and multi (`string[]`) target shapes. Returns the pruned value, or `null`
4
+ * when nothing needed pruning — the caller then skips the form write to avoid a redundant update (and
5
+ * the re-render / validation it would trigger).
6
+ */
7
+ export declare function pruneSourceFromTarget(value: string | string[], source: string): string | string[] | null;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Remove the source locale from a target-field value so a target can never equal the source. Handles
3
+ * both the single (`string`) and multi (`string[]`) target shapes. Returns the pruned value, or `null`
4
+ * when nothing needed pruning — the caller then skips the form write to avoid a redundant update (and
5
+ * the re-render / validation it would trigger).
6
+ */ export function pruneSourceFromTarget(value, source) {
7
+ if (Array.isArray(value)) {
8
+ const filtered = value.filter((locale)=>locale !== source);
9
+ return filtered.length === value.length ? null : filtered;
10
+ }
11
+ return value === source ? "" : null;
12
+ }
13
+
14
+ //# sourceMappingURL=pruneSourceFromTarget.js.map
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from "react";
2
+ type InfoPopoverProps = {
3
+ /** The detail shown when the icon is activated. */
4
+ content: ReactNode;
5
+ /** Accessible name for the trigger button (the popover has no visible label). */
6
+ label: string;
7
+ /** Trigger glyph; defaults to a question mark. */
8
+ icon?: ReactNode;
9
+ side?: "top" | "bottom" | "left" | "right";
10
+ sideOffset?: number;
11
+ /** Extra class on the trigger button (icon slot styling). */
12
+ className?: string;
13
+ };
14
+ /**
15
+ * A click/tap info affordance: an icon button that toggles a small popover with the detail. Replaces
16
+ * hover-only tooltips for help text so it works on touch, is keyboard-operable, and never opens itself
17
+ * when a surrounding popover autofocuses it (a Popover, unlike a Tooltip, does not open on focus). For
18
+ * naming an icon *control*, use a Tooltip instead — this is for disclosing information.
19
+ */
20
+ export default function InfoPopover({ content, label, icon, side, sideOffset, className, }: InfoPopoverProps): import("react/jsx-runtime").JSX.Element;
21
+ export {};
@@ -0,0 +1,45 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import * as Popover from "@radix-ui/react-popover";
5
+ import { QuestionCircleIcon } from "../../lib/assets/icons/QuestionCircleIcon";
6
+ import styles from "./styles.module.scss";
7
+ /**
8
+ * A click/tap info affordance: an icon button that toggles a small popover with the detail. Replaces
9
+ * hover-only tooltips for help text so it works on touch, is keyboard-operable, and never opens itself
10
+ * when a surrounding popover autofocuses it (a Popover, unlike a Tooltip, does not open on focus). For
11
+ * naming an icon *control*, use a Tooltip instead — this is for disclosing information.
12
+ */ export default function InfoPopover({ content, label, icon, side = "top", sideOffset = 6, className }) {
13
+ const [open, setOpen] = useState(false);
14
+ return /*#__PURE__*/ _jsxs(Popover.Root, {
15
+ open: open,
16
+ onOpenChange: setOpen,
17
+ children: [
18
+ /*#__PURE__*/ _jsx(Popover.Trigger, {
19
+ asChild: true,
20
+ children: /*#__PURE__*/ _jsx("button", {
21
+ type: "button",
22
+ className: `${styles.trigger} ${className ?? ""}`,
23
+ "aria-label": label,
24
+ children: icon ?? /*#__PURE__*/ _jsx(QuestionCircleIcon, {})
25
+ })
26
+ }),
27
+ /*#__PURE__*/ _jsx(Popover.Portal, {
28
+ children: /*#__PURE__*/ _jsxs(Popover.Content, {
29
+ className: styles.content,
30
+ side: side,
31
+ sideOffset: sideOffset,
32
+ collisionPadding: 12,
33
+ children: [
34
+ content,
35
+ /*#__PURE__*/ _jsx(Popover.Arrow, {
36
+ className: styles.arrow
37
+ })
38
+ ]
39
+ })
40
+ })
41
+ ]
42
+ });
43
+ }
44
+
45
+ //# sourceMappingURL=InfoPopover.js.map
@@ -0,0 +1 @@
1
+ export { default } from "./InfoPopover";
@@ -0,0 +1,3 @@
1
+ export { default } from "./InfoPopover";
2
+
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,49 @@
1
+ .trigger {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ padding: 0;
6
+ border: none;
7
+ background: none;
8
+ color: inherit;
9
+ line-height: 0;
10
+ // Cursor is chosen per usage (marker → pointer, `?` help icons → help) so it never loses to
11
+ // CSS-module source order.
12
+
13
+ &:focus-visible {
14
+ outline: 2px solid var(--theme-elevation-400);
15
+ outline-offset: 2px;
16
+ border-radius: 3px;
17
+ }
18
+ }
19
+
20
+ // Same bubble as the Tooltip, so help text looks identical whether hovered (elsewhere) or tapped here.
21
+ .content {
22
+ max-width: 280px;
23
+ padding: 0.5rem 0.75rem;
24
+ border: 1px solid var(--theme-elevation-150);
25
+ border-radius: var(--style-radius-s);
26
+ background: var(--theme-elevation-50);
27
+ color: var(--theme-elevation-900);
28
+ font-size: 0.8125rem;
29
+ line-height: 1.4;
30
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
31
+ z-index: 9999;
32
+ animation: fadeIn 150ms ease-out;
33
+ }
34
+
35
+ .arrow {
36
+ fill: var(--theme-elevation-50);
37
+ }
38
+
39
+ @keyframes fadeIn {
40
+ from {
41
+ opacity: 0;
42
+ transform: translateY(2px);
43
+ }
44
+
45
+ to {
46
+ opacity: 1;
47
+ transform: translateY(0);
48
+ }
49
+ }
@@ -0,0 +1,40 @@
1
+ import type { ComponentPropsWithRef } from "react";
2
+ import type { MultiSelectOption } from "./multiSelectModel";
3
+ export type { MultiSelectOption } from "./multiSelectModel";
4
+ /**
5
+ * Props mirror the single `Select` (`ComponentPropsWithRef<"button">` + the same custom `$`-prefixed
6
+ * knobs), except `value`/`onChange` are omitted from the button props and redefined for a multi
7
+ * selection (a native `<button>` has a `string` value + a form `onChange`, incompatible with these).
8
+ */
9
+ type MultiSelectProps = Omit<ComponentPropsWithRef<"button">, "value" | "onChange" | "onBlur"> & {
10
+ /** Selected option values (controlled). */
11
+ value: string[];
12
+ /** Next selection whenever the user toggles an option or "select all". */
13
+ onChange: (value: string[]) => void;
14
+ /**
15
+ * Fired when the popover CLOSES (the field is "done"), not when the trigger loses focus — opening
16
+ * the popover moves focus into it, and firing blur then would mark the field touched before the
17
+ * user has picked anything (premature `onTouched` validation).
18
+ */
19
+ onBlur?: () => void;
20
+ options: MultiSelectOption[];
21
+ /** Shown on the trigger when nothing is selected. */
22
+ placeholder?: string;
23
+ hasError?: boolean;
24
+ $size?: "sm" | "md" | "lg";
25
+ /** Stretch to fill the container (default). Set `false` to size to content. */
26
+ $fullWidth?: boolean;
27
+ /** Label for the "select all" row. */
28
+ selectAllLabel?: string;
29
+ /** Show a filter input in the popover once the option count exceeds this. */
30
+ filterThreshold?: number;
31
+ };
32
+ /**
33
+ * A controlled multi-select whose collapsed trigger is a `<button>` styled identically to the single
34
+ * `Select` (same border, chevron, focus ring, error + disabled states, size ramp). The multiplicity
35
+ * lives in a checkbox popover with a "select all" row and a progressive filter — the trigger shows a
36
+ * compact summary (`de, fr +2` / `All (N)`) and never grows with the selection. All decision logic
37
+ * lives in `./multiSelectModel`; the popover width tracks the trigger and grows to its content.
38
+ */
39
+ declare const MultiSelect: import("react").ForwardRefExoticComponent<Omit<MultiSelectProps, "ref"> & import("react").RefAttributes<HTMLButtonElement>>;
40
+ export default MultiSelect;