@byline/admin 2.6.1 → 3.0.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 (27) hide show
  1. package/dist/forms/available-locales-reconcile.d.ts +30 -0
  2. package/dist/forms/available-locales-reconcile.js +15 -0
  3. package/dist/forms/available-locales-reconcile.test.node.d.ts +8 -0
  4. package/dist/forms/available-locales-widget.d.ts +29 -0
  5. package/dist/forms/available-locales-widget.js +68 -0
  6. package/dist/forms/available-locales-widget.module.js +8 -0
  7. package/dist/forms/available-locales-widget_module.css +18 -0
  8. package/dist/forms/form-context.d.ts +10 -0
  9. package/dist/forms/form-context.js +54 -2
  10. package/dist/forms/form-renderer.d.ts +8 -1
  11. package/dist/forms/form-renderer.js +12 -4
  12. package/dist/react.d.ts +1 -0
  13. package/dist/react.js +1 -0
  14. package/dist/widgets/source-locale-badge/source-locale-badge.d.ts +30 -0
  15. package/dist/widgets/source-locale-badge/source-locale-badge.js +13 -0
  16. package/dist/widgets/source-locale-badge/source-locale-badge.module.js +5 -0
  17. package/dist/widgets/source-locale-badge/source-locale-badge_module.css +10 -0
  18. package/package.json +5 -5
  19. package/src/forms/available-locales-reconcile.test.node.ts +51 -0
  20. package/src/forms/available-locales-reconcile.ts +37 -0
  21. package/src/forms/available-locales-widget.module.css +39 -0
  22. package/src/forms/available-locales-widget.tsx +119 -0
  23. package/src/forms/form-context.tsx +83 -0
  24. package/src/forms/form-renderer.tsx +29 -1
  25. package/src/react.ts +1 -0
  26. package/src/widgets/source-locale-badge/source-locale-badge.module.css +17 -0
  27. package/src/widgets/source-locale-badge/source-locale-badge.tsx +45 -0
@@ -0,0 +1,30 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ /**
9
+ * Pure reconciliation logic for the available-locales widget, kept React- and
10
+ * CSS-free so it can be unit-tested in the node-mode admin suite (importing the
11
+ * `.tsx` widget would drag in JSX + a CSS-module import the node runner can't
12
+ * transform).
13
+ */
14
+ /** Checkbox intent + interactivity for one reconciled locale row. */
15
+ export interface ReconciledLocaleState {
16
+ intent: 'success' | 'warning' | 'noeffect';
17
+ disabled: boolean;
18
+ }
19
+ /**
20
+ * Reconcile a locale's editorial state (`checked` — in the stored advertised
21
+ * set) against the ledger fact (`complete` — in `_availableVersionLocales`)
22
+ * into a checkbox intent + disabled flag. The reconciliation is expressed
23
+ * entirely through colour/interactivity, with no per-row text:
24
+ *
25
+ * complete → `success` (green), enabled — toggle on/off
26
+ * not complete, advertised → `warning` (amber), enabled — advertising an
27
+ * incomplete locale; the editor can uncheck to resolve the over-advert
28
+ * not complete, not advert → `noeffect` (gray), disabled — nothing to do
29
+ */
30
+ export declare function reconcileLocaleState(checked: boolean, complete: boolean): ReconciledLocaleState;
@@ -0,0 +1,15 @@
1
+ function reconcileLocaleState(checked, complete) {
2
+ if (complete) return {
3
+ intent: 'success',
4
+ disabled: false
5
+ };
6
+ if (checked) return {
7
+ intent: 'warning',
8
+ disabled: false
9
+ };
10
+ return {
11
+ intent: 'noeffect',
12
+ disabled: true
13
+ };
14
+ }
15
+ export { reconcileLocaleState };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,29 @@
1
+ export { type ReconciledLocaleState, reconcileLocaleState } from './available-locales-reconcile';
2
+ /** A content locale to render a checkbox for. */
3
+ export interface AvailableLocalesWidgetLocale {
4
+ code: string;
5
+ label: string;
6
+ }
7
+ export interface AvailableLocalesWidgetProps {
8
+ /** All configured content locales — one checkbox each (code + display label). */
9
+ contentLocales: ReadonlyArray<AvailableLocalesWidgetLocale>;
10
+ /**
11
+ * The saved version's ledger-complete locale set (`_availableVersionLocales`,
12
+ * read-only structural fact). Drives the per-row intent. Empty until the read
13
+ * surface supplies it (Slice 6) — in which case every row renders neutral.
14
+ */
15
+ availableVersionLocales: readonly string[];
16
+ }
17
+ /**
18
+ * System-managed `availableLocales` widget — the editorial "advertise these
19
+ * content locales" control. Renders one checkbox per content locale in the
20
+ * sidebar, directly below the path widget. The checked state reflects the
21
+ * stored advertised set (`useSystemAvailableLocales`); each row's intent
22
+ * reconciles that against the structural ledger fact (`availableVersionLocales`)
23
+ * via {@link reconcileLocaleState}. Opt-in: nothing is advertised until the
24
+ * editor checks a (green) locale.
25
+ *
26
+ * Stable override handles: `.byline-form-available-locales`,
27
+ * `.byline-form-available-locales-list`.
28
+ */
29
+ export declare const AvailableLocalesWidget: ({ contentLocales, availableVersionLocales, }: AvailableLocalesWidgetProps) => import("react").JSX.Element | null;
@@ -0,0 +1,68 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useMemo } from "react";
4
+ import { useTranslation } from "@byline/i18n/react";
5
+ import { Checkbox, Label } from "@byline/ui/react";
6
+ import classnames from "classnames";
7
+ import { reconcileLocaleState } from "./available-locales-reconcile.js";
8
+ import available_locales_widget_module from "./available-locales-widget.module.js";
9
+ import { useFormContext, useSystemAvailableLocales } from "./form-context.js";
10
+ const AvailableLocalesWidget = ({ contentLocales, availableVersionLocales })=>{
11
+ const { t } = useTranslation('byline-admin');
12
+ const { setSystemAvailableLocales } = useFormContext();
13
+ const advertised = useSystemAvailableLocales();
14
+ const advertisedSet = useMemo(()=>new Set(advertised), [
15
+ advertised
16
+ ]);
17
+ const ledgerSet = useMemo(()=>new Set(availableVersionLocales), [
18
+ availableVersionLocales
19
+ ]);
20
+ const toggle = useCallback((code, checked)=>{
21
+ const next = new Set(advertised);
22
+ if (checked) next.add(code);
23
+ else next.delete(code);
24
+ setSystemAvailableLocales([
25
+ ...next
26
+ ]);
27
+ }, [
28
+ advertised,
29
+ setSystemAvailableLocales
30
+ ]);
31
+ if (0 === contentLocales.length) return null;
32
+ return /*#__PURE__*/ jsxs("div", {
33
+ className: classnames('byline-form-available-locales', available_locales_widget_module.container),
34
+ children: [
35
+ /*#__PURE__*/ jsx(Label, {
36
+ id: "available-locales",
37
+ htmlFor: "available-locales-group",
38
+ label: t('availableLocalesWidget.label')
39
+ }),
40
+ /*#__PURE__*/ jsx("div", {
41
+ id: "available-locales-group",
42
+ className: classnames('byline-form-available-locales-list', available_locales_widget_module.list),
43
+ role: "group",
44
+ "aria-labelledby": "label-for-available-locales",
45
+ "aria-describedby": "available-locales-description",
46
+ children: contentLocales.map(({ code, label })=>{
47
+ const checked = advertisedSet.has(code);
48
+ const { intent, disabled } = reconcileLocaleState(checked, ledgerSet.has(code));
49
+ return /*#__PURE__*/ jsx(Checkbox, {
50
+ id: `available-locale-${code}`,
51
+ name: `__availableLocale_${code}__`,
52
+ label: label,
53
+ intent: intent,
54
+ checked: checked,
55
+ disabled: disabled,
56
+ onCheckedChange: (value)=>toggle(code, true === value)
57
+ }, code);
58
+ })
59
+ }),
60
+ /*#__PURE__*/ jsx("span", {
61
+ id: "available-locales-description",
62
+ className: classnames('byline-form-available-locales-sr-only', available_locales_widget_module["sr-only"]),
63
+ children: t("availableLocalesWidget.srDescription")
64
+ })
65
+ ]
66
+ });
67
+ };
68
+ export { AvailableLocalesWidget, reconcileLocaleState };
@@ -0,0 +1,8 @@
1
+ import "./available-locales-widget_module.css";
2
+ const available_locales_widget_module = {
3
+ container: "container-exFiHE",
4
+ list: "list-q2z1qA",
5
+ "sr-only": "sr-only-duiY99",
6
+ srOnly: "sr-only-duiY99"
7
+ };
8
+ export default available_locales_widget_module;
@@ -0,0 +1,18 @@
1
+ :is(.container-exFiHE, .byline-form-available-locales), :is(.list-q2z1qA, .byline-form-available-locales-list) {
2
+ gap: var(--spacing-8);
3
+ flex-direction: column;
4
+ display: flex;
5
+ }
6
+
7
+ :is(.sr-only-duiY99, .byline-form-available-locales-sr-only) {
8
+ clip: rect(0, 0, 0, 0);
9
+ white-space: nowrap;
10
+ border: 0;
11
+ width: 1px;
12
+ height: 1px;
13
+ margin: -1px;
14
+ padding: 0;
15
+ position: absolute;
16
+ overflow: hidden;
17
+ }
18
+
@@ -28,6 +28,7 @@ type FieldListener = (value: any) => void;
28
28
  type ErrorsListener = (errors: FormError[]) => void;
29
29
  type MetaListener = () => void;
30
30
  type SystemPathListener = (value: string | null) => void;
31
+ type SystemAvailableLocalesListener = (value: string[]) => void;
31
32
  type FieldUploadingListener = (uploading: boolean) => void;
32
33
  interface FormContextType {
33
34
  setFieldValue: (name: string, value: any) => void;
@@ -61,6 +62,9 @@ interface FormContextType {
61
62
  getSystemPath: () => string | null;
62
63
  setSystemPath: (value: string | null) => void;
63
64
  subscribeSystemPath: (listener: SystemPathListener) => () => void;
65
+ getSystemAvailableLocales: () => string[];
66
+ setSystemAvailableLocales: (value: string[]) => void;
67
+ subscribeSystemAvailableLocales: (listener: SystemAvailableLocalesListener) => () => void;
64
68
  }
65
69
  export declare const useFormContext: () => FormContextType;
66
70
  export declare const FormProvider: ({ children, initialData, }: {
@@ -72,6 +76,12 @@ export declare const FormProvider: ({ children, initialData, }: {
72
76
  * Returns the current value (or `null` when no override is set).
73
77
  */
74
78
  export declare const useSystemPath: () => string | null;
79
+ /**
80
+ * Subscribe to the system `availableLocales` slot edited by the
81
+ * available-locales widget. Returns the current advertised set (or `[]` when
82
+ * nothing is advertised / not yet surfaced).
83
+ */
84
+ export declare const useSystemAvailableLocales: () => string[];
75
85
  export declare const useFormStore: () => FormContextType;
76
86
  export declare const useFieldError: (name: string) => string | undefined;
77
87
  export declare const useFormMeta: () => {
@@ -3,6 +3,16 @@ import { jsx } from "react/jsx-runtime";
3
3
  import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
4
4
  import { normalizeHooks } from "@byline/core";
5
5
  import { get, set as external_lodash_es_set } from "lodash-es";
6
+ const sameLocaleSet = (a, b)=>{
7
+ if (a.length !== b.length) return false;
8
+ const sa = [
9
+ ...a
10
+ ].sort();
11
+ const sb = [
12
+ ...b
13
+ ].sort();
14
+ return sa.every((v, i)=>v === sb[i]);
15
+ };
6
16
  const FormContext = /*#__PURE__*/ createContext(null);
7
17
  const useFormContext = ()=>{
8
18
  const context = useContext(FormContext);
@@ -24,6 +34,13 @@ const FormProvider = ({ children, initialData = {} })=>{
24
34
  const systemPathRef = useRef('string' == typeof initialData?.path && initialData.path.length > 0 ? initialData.path : null);
25
35
  const initialSystemPath = useRef(systemPathRef.current);
26
36
  const systemPathListeners = useRef(new Set());
37
+ const systemAvailableLocalesRef = useRef(Array.isArray(initialData?.availableLocales) ? [
38
+ ...initialData.availableLocales
39
+ ] : []);
40
+ const initialSystemAvailableLocales = useRef([
41
+ ...systemAvailableLocalesRef.current
42
+ ]);
43
+ const systemAvailableLocalesListeners = useRef(new Set());
27
44
  const subscribeField = useCallback((name, listener)=>{
28
45
  if (!fieldListeners.current.has(name)) fieldListeners.current.set(name, new Set());
29
46
  fieldListeners.current.get(name)?.add(listener);
@@ -134,6 +151,9 @@ const FormProvider = ({ children, initialData = {} })=>{
134
151
  dirtyFields.current.clear();
135
152
  patchesRef.current = [];
136
153
  initialSystemPath.current = systemPathRef.current;
154
+ initialSystemAvailableLocales.current = [
155
+ ...systemAvailableLocalesRef.current
156
+ ];
137
157
  notifyMetaListeners();
138
158
  }, [
139
159
  notifyMetaListeners
@@ -157,6 +177,27 @@ const FormProvider = ({ children, initialData = {} })=>{
157
177
  systemPathListeners.current.delete(listener);
158
178
  };
159
179
  }, []);
180
+ const getSystemAvailableLocales = useCallback(()=>systemAvailableLocalesRef.current, []);
181
+ const setSystemAvailableLocales = useCallback((value)=>{
182
+ const next = [
183
+ ...value
184
+ ];
185
+ systemAvailableLocalesRef.current = next;
186
+ if (sameLocaleSet(next, initialSystemAvailableLocales.current)) dirtyFields.current.delete('__systemAvailableLocales__');
187
+ else dirtyFields.current.add('__systemAvailableLocales__');
188
+ systemAvailableLocalesListeners.current.forEach((listener)=>{
189
+ listener(next);
190
+ });
191
+ notifyMetaListeners();
192
+ }, [
193
+ notifyMetaListeners
194
+ ]);
195
+ const subscribeSystemAvailableLocales = useCallback((listener)=>{
196
+ systemAvailableLocalesListeners.current.add(listener);
197
+ return ()=>{
198
+ systemAvailableLocalesListeners.current.delete(listener);
199
+ };
200
+ }, []);
160
201
  const addPendingUpload = useCallback((fieldPath, upload)=>{
161
202
  const existing = pendingUploadsRef.current.get(fieldPath);
162
203
  if (existing) URL.revokeObjectURL(existing.previewUrl);
@@ -378,7 +419,10 @@ const FormProvider = ({ children, initialData = {} })=>{
378
419
  subscribeFieldUploading,
379
420
  getSystemPath,
380
421
  setSystemPath,
381
- subscribeSystemPath
422
+ subscribeSystemPath,
423
+ getSystemAvailableLocales,
424
+ setSystemAvailableLocales,
425
+ subscribeSystemAvailableLocales
382
426
  },
383
427
  children: children
384
428
  });
@@ -391,6 +435,14 @@ const useSystemPath = ()=>{
391
435
  ]);
392
436
  return value;
393
437
  };
438
+ const useSystemAvailableLocales = ()=>{
439
+ const { getSystemAvailableLocales, subscribeSystemAvailableLocales } = useFormContext();
440
+ const [value, setValue] = useState(()=>getSystemAvailableLocales());
441
+ useEffect(()=>subscribeSystemAvailableLocales((next)=>setValue(next)), [
442
+ subscribeSystemAvailableLocales
443
+ ]);
444
+ return value;
445
+ };
394
446
  const useFormStore = ()=>useFormContext();
395
447
  const useFieldError = (name)=>{
396
448
  const { getErrors, subscribeErrors } = useFormContext();
@@ -463,4 +515,4 @@ const useIsFieldUploading = (fieldPath)=>{
463
515
  ]);
464
516
  return uploading;
465
517
  };
466
- export { FormProvider, useFieldError, useFieldValue, useFormContext, useFormMeta, useFormStore, useIsDirty, useIsFieldUploading, useSystemPath };
518
+ export { FormProvider, useFieldError, useFieldValue, useFormContext, useFormMeta, useFormStore, useIsDirty, useIsFieldUploading, useSystemAvailableLocales, useSystemPath };
@@ -66,6 +66,13 @@ export interface FormRendererProps {
66
66
  * present the path widget renders in the sidebar.
67
67
  */
68
68
  useAsPath?: string;
69
+ /**
70
+ * Opts the available-locales widget into the sidebar (below the path
71
+ * widget). Sourced from `CollectionDefinition.advertiseLocales` by the
72
+ * caller. When true, one checkbox per content locale renders, reconciled
73
+ * against the document's `_availableVersionLocales` ledger fact.
74
+ */
75
+ advertiseLocales?: boolean;
69
76
  headingLabel?: string;
70
77
  headerSlot?: ReactNode;
71
78
  /** Collection path forwarded to upload-capable fields (e.g. `'media'`). */
@@ -95,4 +102,4 @@ export interface FormRendererProps {
95
102
  */
96
103
  useNavigationGuard?: UseNavigationGuard;
97
104
  }
98
- export declare const FormRenderer: ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings, }: FormRendererProps) => import("react").JSX.Element;
105
+ export declare const FormRenderer: ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, advertiseLocales, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings, }: FormRendererProps) => import("react").JSX.Element;
@@ -10,6 +10,7 @@ import { LocalDateTime } from "../fields/local-date-time.js";
10
10
  import { AdminGroup } from "../presentation/group.js";
11
11
  import { AdminRow } from "../presentation/row.js";
12
12
  import { AdminTabs } from "../presentation/tabs.js";
13
+ import { AvailableLocalesWidget } from "./available-locales-widget.js";
13
14
  import { DocumentActions } from "./document-actions.js";
14
15
  import { FormProvider, useFieldValue, useFormContext } from "./form-context.js";
15
16
  import form_renderer_module from "./form-renderer.module.js";
@@ -139,8 +140,8 @@ function computeStatusTransitions(currentStatus, workflowStatuses, nextStatus) {
139
140
  isTerminal: false
140
141
  };
141
142
  }
142
- const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale = 'en', useNavigationGuard: useNavigationGuardProp, restoreWarnings, _activeTabBySet, _onTabChange })=>{
143
- const { getFieldValues, runFieldHooks, validateForm, errors: initialErrors, hasChanges: hasChangesFn, resetHasChanges, getPatches, getSystemPath, subscribeErrors, subscribeMeta, setFieldValue, setFieldError, getPendingUploads, clearPendingUploads, setFieldUploading } = useFormContext();
143
+ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, advertiseLocales, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale = 'en', useNavigationGuard: useNavigationGuardProp, restoreWarnings, _activeTabBySet, _onTabChange })=>{
144
+ const { getFieldValues, runFieldHooks, validateForm, errors: initialErrors, hasChanges: hasChangesFn, resetHasChanges, getPatches, getSystemPath, getSystemAvailableLocales, subscribeErrors, subscribeMeta, setFieldValue, setFieldError, getPendingUploads, clearPendingUploads, setFieldUploading } = useFormContext();
144
145
  const { t } = useTranslation('byline-admin');
145
146
  const [errors, setErrors] = useState(initialErrors);
146
147
  const [hasChanges, setHasChanges] = useState(hasChangesFn());
@@ -302,11 +303,13 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
302
303
  const data = getFieldValues();
303
304
  const patches = getPatches();
304
305
  const systemPath = getSystemPath();
306
+ const systemAvailableLocales = advertiseLocales ? getSystemAvailableLocales() : void 0;
305
307
  if (onSubmit && 'function' == typeof onSubmit) {
306
308
  onSubmit({
307
309
  data,
308
310
  patches,
309
- systemPath
311
+ systemPath,
312
+ systemAvailableLocales
310
313
  });
311
314
  resetHasChanges();
312
315
  }
@@ -503,6 +506,10 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
503
506
  activeLocale: contentLocale,
504
507
  mode: mode
505
508
  }),
509
+ advertiseLocales && /*#__PURE__*/ jsx(AvailableLocalesWidget, {
510
+ contentLocales: contentLocales ?? [],
511
+ availableVersionLocales: initialData?._availableVersionLocales ?? []
512
+ }),
506
513
  (layout.sidebar ?? []).map((name)=>renderItem(name))
507
514
  ]
508
515
  })
@@ -554,7 +561,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
554
561
  ]
555
562
  });
556
563
  };
557
- const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings })=>{
564
+ const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, advertiseLocales, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings })=>{
558
565
  const savedTabsRef = useRef({});
559
566
  return /*#__PURE__*/ jsx(FormProvider, {
560
567
  initialData: initialData,
@@ -576,6 +583,7 @@ const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpu
576
583
  adminConfig: adminConfig,
577
584
  useAsTitle: useAsTitle,
578
585
  useAsPath: useAsPath,
586
+ advertiseLocales: advertiseLocales,
579
587
  headingLabel: headingLabel,
580
588
  headerSlot: headerSlot,
581
589
  collectionPath: collectionPath,
package/dist/react.d.ts CHANGED
@@ -62,5 +62,6 @@ export * from './presentation/group.js';
62
62
  export * from './presentation/row.js';
63
63
  export * from './presentation/tabs.js';
64
64
  export * from './widgets/diff-viewer/diff-modal.js';
65
+ export * from './widgets/source-locale-badge/source-locale-badge.js';
65
66
  export * from './widgets/status-badge/status-badge.js';
66
67
  export type { BylineFieldServices, CollectionListDoc, CollectionListParams, CollectionListResponse, GetCollectionDocumentsFn, UploadedFileResult, UploadFieldFn, } from './fields/field-services-types.js';
package/dist/react.js CHANGED
@@ -33,4 +33,5 @@ export * from "./presentation/group.js";
33
33
  export * from "./presentation/row.js";
34
34
  export * from "./presentation/tabs.js";
35
35
  export * from "./widgets/diff-viewer/diff-modal.js";
36
+ export * from "./widgets/source-locale-badge/source-locale-badge.js";
36
37
  export * from "./widgets/status-badge/status-badge.js";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export interface SourceLocaleBadgeProps {
9
+ /** The document's content source locale code (e.g. `en`, `fr`). */
10
+ locale: string;
11
+ className?: string;
12
+ }
13
+ /**
14
+ * Small neutral badge indicating a document's content **source locale** — the
15
+ * locale it was authored in (its anchor: fallback floor, path locale,
16
+ * completeness yardstick). Rendered next to the document title in the edit and
17
+ * list views. Mirrors the localized-field {@link LocaleBadge} in spirit but uses
18
+ * the shared {@link Badge} with the `noeffect` (neutral) intent.
19
+ *
20
+ * NOTE: currently rendered for *every* document so the anchor is visible during
21
+ * development. The intended end state is to show it only when `locale` differs
22
+ * from the system's current default content locale (a normal single-default
23
+ * install then shows nothing). See docs/DEFAULT-LOCALE-SWITCHING.md (Slice 6).
24
+ *
25
+ * Stable override handle: `.byline-source-locale-badge`.
26
+ */
27
+ export declare const SourceLocaleBadge: {
28
+ ({ locale, className }: SourceLocaleBadgeProps): import("react").JSX.Element;
29
+ displayName: string;
30
+ };
@@ -0,0 +1,13 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import { Badge } from "@byline/ui/react";
3
+ import classnames from "classnames";
4
+ import source_locale_badge_module from "./source-locale-badge.module.js";
5
+ const SourceLocaleBadge = ({ locale, className })=>/*#__PURE__*/ jsx(Badge, {
6
+ intent: "noeffect",
7
+ render: /*#__PURE__*/ jsx("span", {}),
8
+ title: `Primary content language: ${locale.toUpperCase()}`,
9
+ className: classnames('byline-source-locale-badge', source_locale_badge_module.badge, className),
10
+ children: locale.toUpperCase()
11
+ });
12
+ SourceLocaleBadge.displayName = 'SourceLocaleBadge';
13
+ export { SourceLocaleBadge };
@@ -0,0 +1,5 @@
1
+ import "./source-locale-badge_module.css";
2
+ const source_locale_badge_module = {
3
+ badge: "badge-agHrzy"
4
+ };
5
+ export default source_locale_badge_module;
@@ -0,0 +1,10 @@
1
+ :is(.badge-agHrzy, .byline-source-locale-badge) {
2
+ font-size: .65rem;
3
+ font-weight: var(--font-weight-semibold);
4
+ letter-spacing: var(--letter-spacing-wide);
5
+ vertical-align: middle;
6
+ margin-left: .5rem;
7
+ padding: 0 .375rem;
8
+ line-height: 1.5;
9
+ }
10
+
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/admin",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "2.6.1",
5
+ "version": "3.0.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -147,10 +147,10 @@
147
147
  "uuid": "^14.0.0",
148
148
  "zod": "^4.4.3",
149
149
  "zod-form-data": "^3.0.1",
150
- "@byline/core": "2.6.1",
151
- "@byline/auth": "2.6.1",
152
- "@byline/ui": "2.6.1",
153
- "@byline/i18n": "2.6.1"
150
+ "@byline/core": "3.0.0",
151
+ "@byline/i18n": "3.0.0",
152
+ "@byline/auth": "3.0.0",
153
+ "@byline/ui": "3.0.0"
154
154
  },
155
155
  "peerDependencies": {
156
156
  "react": "^19.0.0",
@@ -0,0 +1,51 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest'
10
+
11
+ import { reconcileLocaleState } from './available-locales-reconcile.js'
12
+
13
+ /**
14
+ * The widget's reconciliation is expressed purely as Checkbox intent +
15
+ * disabled state — no per-row text. `reconcileLocaleState` is the pure heart
16
+ * of that mapping; the four cells below are the full truth table from
17
+ * docs/AVAILABLE-LOCALES.md.
18
+ */
19
+ describe('reconcileLocaleState', () => {
20
+ it('ledger-complete + advertised → green, enabled (advertised & complete)', () => {
21
+ expect(reconcileLocaleState(true, true)).toEqual({ intent: 'success', disabled: false })
22
+ })
23
+
24
+ it('ledger-complete + not advertised → green, enabled (invitation to advertise)', () => {
25
+ expect(reconcileLocaleState(false, true)).toEqual({ intent: 'success', disabled: false })
26
+ })
27
+
28
+ it('not complete + advertised → amber, enabled (advertising an incomplete locale)', () => {
29
+ // The ⚠ row: surfaced as `warning` and kept enabled so the editor can
30
+ // uncheck to resolve the over-advertisement.
31
+ expect(reconcileLocaleState(true, false)).toEqual({ intent: 'warning', disabled: false })
32
+ })
33
+
34
+ it('not complete + not advertised → gray, disabled (nothing to advertise)', () => {
35
+ expect(reconcileLocaleState(false, false)).toEqual({ intent: 'noeffect', disabled: true })
36
+ })
37
+
38
+ it('completeness drives green regardless of advertised state', () => {
39
+ // The ledger fact wins the colour when complete; only the disabled/amber
40
+ // distinction depends on the advertised flag.
41
+ expect(reconcileLocaleState(true, true).intent).toBe('success')
42
+ expect(reconcileLocaleState(false, true).intent).toBe('success')
43
+ })
44
+
45
+ it('only the not-complete + not-advertised cell is non-interactive', () => {
46
+ expect(reconcileLocaleState(true, true).disabled).toBe(false)
47
+ expect(reconcileLocaleState(false, true).disabled).toBe(false)
48
+ expect(reconcileLocaleState(true, false).disabled).toBe(false)
49
+ expect(reconcileLocaleState(false, false).disabled).toBe(true)
50
+ })
51
+ })
@@ -0,0 +1,37 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Pure reconciliation logic for the available-locales widget, kept React- and
11
+ * CSS-free so it can be unit-tested in the node-mode admin suite (importing the
12
+ * `.tsx` widget would drag in JSX + a CSS-module import the node runner can't
13
+ * transform).
14
+ */
15
+
16
+ /** Checkbox intent + interactivity for one reconciled locale row. */
17
+ export interface ReconciledLocaleState {
18
+ intent: 'success' | 'warning' | 'noeffect'
19
+ disabled: boolean
20
+ }
21
+
22
+ /**
23
+ * Reconcile a locale's editorial state (`checked` — in the stored advertised
24
+ * set) against the ledger fact (`complete` — in `_availableVersionLocales`)
25
+ * into a checkbox intent + disabled flag. The reconciliation is expressed
26
+ * entirely through colour/interactivity, with no per-row text:
27
+ *
28
+ * complete → `success` (green), enabled — toggle on/off
29
+ * not complete, advertised → `warning` (amber), enabled — advertising an
30
+ * incomplete locale; the editor can uncheck to resolve the over-advert
31
+ * not complete, not advert → `noeffect` (gray), disabled — nothing to do
32
+ */
33
+ export function reconcileLocaleState(checked: boolean, complete: boolean): ReconciledLocaleState {
34
+ if (complete) return { intent: 'success', disabled: false }
35
+ if (checked) return { intent: 'warning', disabled: false }
36
+ return { intent: 'noeffect', disabled: true }
37
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * AvailableLocalesWidget — system-managed `availableLocales` form widget.
3
+ *
4
+ * The editorial "advertise these locales" checkbox list, rendered in the
5
+ * sidebar below the path widget. Per-row reconciliation against the ledger is
6
+ * conveyed by Checkbox `intent` (green/amber/gray) — no per-row text.
7
+ *
8
+ * Override handles:
9
+ * .byline-form-available-locales — wrapper div
10
+ * .byline-form-available-locales-list — checkbox group
11
+ * .byline-form-available-locales-sr-only — visually-hidden screen-reader hint
12
+ */
13
+
14
+ .container,
15
+ :global(.byline-form-available-locales) {
16
+ display: flex;
17
+ flex-direction: column;
18
+ gap: var(--spacing-8);
19
+ }
20
+
21
+ .list,
22
+ :global(.byline-form-available-locales-list) {
23
+ display: flex;
24
+ flex-direction: column;
25
+ gap: var(--spacing-8);
26
+ }
27
+
28
+ .sr-only,
29
+ :global(.byline-form-available-locales-sr-only) {
30
+ position: absolute;
31
+ width: 1px;
32
+ height: 1px;
33
+ padding: 0;
34
+ margin: -1px;
35
+ overflow: hidden;
36
+ clip: rect(0, 0, 0, 0);
37
+ white-space: nowrap;
38
+ border: 0;
39
+ }
@@ -0,0 +1,119 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { useCallback, useMemo } from 'react'
12
+
13
+ import { useTranslation } from '@byline/i18n/react'
14
+ import { Checkbox, Label } from '@byline/ui/react'
15
+ import cx from 'classnames'
16
+
17
+ import { reconcileLocaleState } from './available-locales-reconcile'
18
+ import styles from './available-locales-widget.module.css'
19
+ import { useFormContext, useSystemAvailableLocales } from './form-context'
20
+
21
+ export { type ReconciledLocaleState, reconcileLocaleState } from './available-locales-reconcile'
22
+
23
+ /** A content locale to render a checkbox for. */
24
+ export interface AvailableLocalesWidgetLocale {
25
+ code: string
26
+ label: string
27
+ }
28
+
29
+ export interface AvailableLocalesWidgetProps {
30
+ /** All configured content locales — one checkbox each (code + display label). */
31
+ contentLocales: ReadonlyArray<AvailableLocalesWidgetLocale>
32
+ /**
33
+ * The saved version's ledger-complete locale set (`_availableVersionLocales`,
34
+ * read-only structural fact). Drives the per-row intent. Empty until the read
35
+ * surface supplies it (Slice 6) — in which case every row renders neutral.
36
+ */
37
+ availableVersionLocales: readonly string[]
38
+ }
39
+
40
+ /**
41
+ * System-managed `availableLocales` widget — the editorial "advertise these
42
+ * content locales" control. Renders one checkbox per content locale in the
43
+ * sidebar, directly below the path widget. The checked state reflects the
44
+ * stored advertised set (`useSystemAvailableLocales`); each row's intent
45
+ * reconciles that against the structural ledger fact (`availableVersionLocales`)
46
+ * via {@link reconcileLocaleState}. Opt-in: nothing is advertised until the
47
+ * editor checks a (green) locale.
48
+ *
49
+ * Stable override handles: `.byline-form-available-locales`,
50
+ * `.byline-form-available-locales-list`.
51
+ */
52
+ export const AvailableLocalesWidget = ({
53
+ contentLocales,
54
+ availableVersionLocales,
55
+ }: AvailableLocalesWidgetProps) => {
56
+ const { t } = useTranslation('byline-admin')
57
+ const { setSystemAvailableLocales } = useFormContext()
58
+ const advertised = useSystemAvailableLocales()
59
+
60
+ const advertisedSet = useMemo(() => new Set(advertised), [advertised])
61
+ const ledgerSet = useMemo(() => new Set(availableVersionLocales), [availableVersionLocales])
62
+
63
+ const toggle = useCallback(
64
+ (code: string, checked: boolean) => {
65
+ const next = new Set(advertised)
66
+ if (checked) {
67
+ next.add(code)
68
+ } else {
69
+ next.delete(code)
70
+ }
71
+ setSystemAvailableLocales([...next])
72
+ },
73
+ [advertised, setSystemAvailableLocales]
74
+ )
75
+
76
+ if (contentLocales.length === 0) {
77
+ return null
78
+ }
79
+
80
+ return (
81
+ <div className={cx('byline-form-available-locales', styles.container)}>
82
+ <Label
83
+ id="available-locales"
84
+ htmlFor="available-locales-group"
85
+ label={t('availableLocalesWidget.label')}
86
+ />
87
+ <div
88
+ id="available-locales-group"
89
+ className={cx('byline-form-available-locales-list', styles.list)}
90
+ role="group"
91
+ aria-labelledby="label-for-available-locales"
92
+ aria-describedby="available-locales-description"
93
+ >
94
+ {contentLocales.map(({ code, label }) => {
95
+ const checked = advertisedSet.has(code)
96
+ const { intent, disabled } = reconcileLocaleState(checked, ledgerSet.has(code))
97
+ return (
98
+ <Checkbox
99
+ key={code}
100
+ id={`available-locale-${code}`}
101
+ name={`__availableLocale_${code}__`}
102
+ label={label}
103
+ intent={intent}
104
+ checked={checked}
105
+ disabled={disabled}
106
+ onCheckedChange={(value) => toggle(code, value === true)}
107
+ />
108
+ )
109
+ })}
110
+ </div>
111
+ <span
112
+ id="available-locales-description"
113
+ className={cx('byline-form-available-locales-sr-only', styles['sr-only'])}
114
+ >
115
+ {t('availableLocalesWidget.srDescription')}
116
+ </span>
117
+ </div>
118
+ )
119
+ }
@@ -38,8 +38,21 @@ type FieldListener = (value: any) => void
38
38
  type ErrorsListener = (errors: FormError[]) => void
39
39
  type MetaListener = () => void
40
40
  type SystemPathListener = (value: string | null) => void
41
+ type SystemAvailableLocalesListener = (value: string[]) => void
41
42
  type FieldUploadingListener = (uploading: boolean) => void
42
43
 
44
+ /**
45
+ * Order-insensitive set equality for the advertised-locale slot. The slot
46
+ * holds an array, so a fresh array reference is never `===` its initial — dirty
47
+ * tracking must compare membership, not identity.
48
+ */
49
+ const sameLocaleSet = (a: string[], b: string[]): boolean => {
50
+ if (a.length !== b.length) return false
51
+ const sa = [...a].sort()
52
+ const sb = [...b].sort()
53
+ return sa.every((v, i) => v === sb[i])
54
+ }
55
+
43
56
  interface FormContextType {
44
57
  setFieldValue: (name: string, value: any) => void
45
58
  setFieldStore: (name: string, value: any) => void
@@ -80,6 +93,13 @@ interface FormContextType {
80
93
  getSystemPath: () => string | null
81
94
  setSystemPath: (value: string | null) => void
82
95
  subscribeSystemPath: (listener: SystemPathListener) => () => void
96
+ // System-managed `availableLocales` slot (the editorial advertised-locale
97
+ // set, persisted in `byline_document_available_locales`), edited by the
98
+ // available-locales widget. Holds the full set; the value is sent verbatim
99
+ // to the server. Document-grain and sticky, like the path slot above.
100
+ getSystemAvailableLocales: () => string[]
101
+ setSystemAvailableLocales: (value: string[]) => void
102
+ subscribeSystemAvailableLocales: (listener: SystemAvailableLocalesListener) => () => void
83
103
  }
84
104
 
85
105
  const FormContext = createContext<FormContextType | null>(null)
@@ -125,6 +145,16 @@ export const FormProvider = ({
125
145
  const initialSystemPath = useRef<string | null>(systemPathRef.current)
126
146
  const systemPathListeners = useRef<Set<SystemPathListener>>(new Set())
127
147
 
148
+ // System available-locales slot — initialised from the loaded version's
149
+ // top-level `availableLocales` (edit mode) or `[]` (create mode / not yet
150
+ // surfaced). Edits via `setSystemAvailableLocales` mark the form dirty so
151
+ // the Save button enables. Stored as a defensive copy.
152
+ const systemAvailableLocalesRef = useRef<string[]>(
153
+ Array.isArray(initialData?.availableLocales) ? [...initialData.availableLocales] : []
154
+ )
155
+ const initialSystemAvailableLocales = useRef<string[]>([...systemAvailableLocalesRef.current])
156
+ const systemAvailableLocalesListeners = useRef<Set<SystemAvailableLocalesListener>>(new Set())
157
+
128
158
  const subscribeField = useCallback((name: string, listener: FieldListener) => {
129
159
  if (!fieldListeners.current.has(name)) {
130
160
  fieldListeners.current.set(name, new Set())
@@ -267,6 +297,7 @@ export const FormProvider = ({
267
297
  dirtyFields.current.clear()
268
298
  patchesRef.current = []
269
299
  initialSystemPath.current = systemPathRef.current
300
+ initialSystemAvailableLocales.current = [...systemAvailableLocalesRef.current]
270
301
  notifyMetaListeners()
271
302
  }, [notifyMetaListeners])
272
303
 
@@ -303,6 +334,39 @@ export const FormProvider = ({
303
334
  }
304
335
  }, [])
305
336
 
337
+ // -------------------------------------------------------------------------
338
+ // System available-locales slot
339
+ // -------------------------------------------------------------------------
340
+
341
+ const getSystemAvailableLocales = useCallback(() => systemAvailableLocalesRef.current, [])
342
+
343
+ const setSystemAvailableLocales = useCallback(
344
+ (value: string[]) => {
345
+ const next = [...value]
346
+ systemAvailableLocalesRef.current = next
347
+ if (!sameLocaleSet(next, initialSystemAvailableLocales.current)) {
348
+ dirtyFields.current.add('__systemAvailableLocales__')
349
+ } else {
350
+ dirtyFields.current.delete('__systemAvailableLocales__')
351
+ }
352
+ systemAvailableLocalesListeners.current.forEach((listener) => {
353
+ listener(next)
354
+ })
355
+ notifyMetaListeners()
356
+ },
357
+ [notifyMetaListeners]
358
+ )
359
+
360
+ const subscribeSystemAvailableLocales = useCallback(
361
+ (listener: SystemAvailableLocalesListener) => {
362
+ systemAvailableLocalesListeners.current.add(listener)
363
+ return () => {
364
+ systemAvailableLocalesListeners.current.delete(listener)
365
+ }
366
+ },
367
+ []
368
+ )
369
+
306
370
  // ---------------------------------------------------------------------------
307
371
  // Pending uploads (deferred until save)
308
372
  // ---------------------------------------------------------------------------
@@ -591,6 +655,9 @@ export const FormProvider = ({
591
655
  getSystemPath,
592
656
  setSystemPath,
593
657
  subscribeSystemPath,
658
+ getSystemAvailableLocales,
659
+ setSystemAvailableLocales,
660
+ subscribeSystemAvailableLocales,
594
661
  }}
595
662
  >
596
663
  {children}
@@ -613,6 +680,22 @@ export const useSystemPath = (): string | null => {
613
680
  return value
614
681
  }
615
682
 
683
+ /**
684
+ * Subscribe to the system `availableLocales` slot edited by the
685
+ * available-locales widget. Returns the current advertised set (or `[]` when
686
+ * nothing is advertised / not yet surfaced).
687
+ */
688
+ export const useSystemAvailableLocales = (): string[] => {
689
+ const { getSystemAvailableLocales, subscribeSystemAvailableLocales } = useFormContext()
690
+ const [value, setValue] = useState<string[]>(() => getSystemAvailableLocales())
691
+
692
+ useEffect(() => {
693
+ return subscribeSystemAvailableLocales((next) => setValue(next))
694
+ }, [subscribeSystemAvailableLocales])
695
+
696
+ return value
697
+ }
698
+
616
699
  export const useFormStore = () => {
617
700
  return useFormContext()
618
701
  }
@@ -28,6 +28,7 @@ import { LocalDateTime } from '../fields/local-date-time'
28
28
  import { AdminGroup } from '../presentation/group'
29
29
  import { AdminRow } from '../presentation/row'
30
30
  import { AdminTabs } from '../presentation/tabs'
31
+ import { AvailableLocalesWidget } from './available-locales-widget'
31
32
  import { DocumentActions, type DocumentActionsLocaleOption } from './document-actions'
32
33
  import { FormProvider, useFieldValue, useFormContext } from './form-context'
33
34
  import styles from './form-renderer.module.css'
@@ -91,6 +92,13 @@ export interface FormRendererProps {
91
92
  * present the path widget renders in the sidebar.
92
93
  */
93
94
  useAsPath?: string
95
+ /**
96
+ * Opts the available-locales widget into the sidebar (below the path
97
+ * widget). Sourced from `CollectionDefinition.advertiseLocales` by the
98
+ * caller. When true, one checkbox per content locale renders, reconciled
99
+ * against the document's `_availableVersionLocales` ledger fact.
100
+ */
101
+ advertiseLocales?: boolean
94
102
  headingLabel?: string
95
103
  headerSlot?: ReactNode
96
104
  /** Collection path forwarded to upload-capable fields (e.g. `'media'`). */
@@ -299,6 +307,7 @@ const FormContent = ({
299
307
  adminConfig,
300
308
  useAsTitle,
301
309
  useAsPath,
310
+ advertiseLocales,
302
311
  headingLabel,
303
312
  headerSlot,
304
313
  collectionPath,
@@ -323,6 +332,7 @@ const FormContent = ({
323
332
  resetHasChanges,
324
333
  getPatches,
325
334
  getSystemPath,
335
+ getSystemAvailableLocales,
326
336
  subscribeErrors,
327
337
  subscribeMeta,
328
338
  setFieldValue,
@@ -560,9 +570,13 @@ const FormContent = ({
560
570
  const data = getFieldValues()
561
571
  const patches = getPatches()
562
572
  const systemPath = getSystemPath()
573
+ // Only emit the advertised-locale set for collections that opted into the
574
+ // widget — otherwise leave it undefined so the write path never touches
575
+ // `byline_document_available_locales` for non-advertising collections.
576
+ const systemAvailableLocales = advertiseLocales ? getSystemAvailableLocales() : undefined
563
577
 
564
578
  if (onSubmit && typeof onSubmit === 'function') {
565
- onSubmit({ data, patches, systemPath })
579
+ onSubmit({ data, patches, systemPath, systemAvailableLocales })
566
580
  resetHasChanges()
567
581
  }
568
582
  })()
@@ -658,6 +672,10 @@ const FormContent = ({
658
672
  <form noValidate onSubmit={handleSubmit} className={cx('byline-form', styles.form)}>
659
673
  <div className={cx('byline-form-heading-row', styles['heading-row'])}>
660
674
  <h1 className={cx('byline-form-heading', styles.heading)}>{heading}</h1>
675
+ {/* Source-locale anchor indicator removed pending heading-layout work.
676
+ To re-enable: render `<SourceLocaleBadge locale={sourceLocale} />`
677
+ here from `initialData.sourceLocale` (mismatch-only is the intended
678
+ end state). See docs/DEFAULT-LOCALE-SWITCHING.md (Slice 6). */}
661
679
  {headerSlot}
662
680
  </div>
663
681
  <div className={cx('byline-form-status-bar', styles['status-bar'])}>
@@ -786,6 +804,14 @@ const FormContent = ({
786
804
  mode={mode}
787
805
  />
788
806
  )}
807
+ {advertiseLocales && (
808
+ <AvailableLocalesWidget
809
+ contentLocales={contentLocales ?? []}
810
+ availableVersionLocales={
811
+ (initialData?._availableVersionLocales as string[] | undefined) ?? []
812
+ }
813
+ />
814
+ )}
789
815
  {(layout.sidebar ?? []).map((name) => renderItem(name))}
790
816
  </div>
791
817
  </div>
@@ -837,6 +863,7 @@ export const FormRenderer = ({
837
863
  adminConfig,
838
864
  useAsTitle,
839
865
  useAsPath,
866
+ advertiseLocales,
840
867
  headingLabel,
841
868
  headerSlot,
842
869
  collectionPath,
@@ -873,6 +900,7 @@ export const FormRenderer = ({
873
900
  adminConfig={adminConfig}
874
901
  useAsTitle={useAsTitle}
875
902
  useAsPath={useAsPath}
903
+ advertiseLocales={advertiseLocales}
876
904
  headingLabel={headingLabel}
877
905
  headerSlot={headerSlot}
878
906
  collectionPath={collectionPath}
package/src/react.ts CHANGED
@@ -71,6 +71,7 @@ export * from './presentation/row.js'
71
71
  export * from './presentation/tabs.js'
72
72
  // Collection-editor-shared widgets.
73
73
  export * from './widgets/diff-viewer/diff-modal.js'
74
+ export * from './widgets/source-locale-badge/source-locale-badge.js'
74
75
  export * from './widgets/status-badge/status-badge.js'
75
76
  // Field-side service contract types.
76
77
  export type {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * SourceLocaleBadge — compact neutral pill marking a document's content source
3
+ * locale, shown next to the document title in edit and list views.
4
+ *
5
+ * Override handle: `.byline-source-locale-badge`
6
+ */
7
+
8
+ .badge,
9
+ :global(.byline-source-locale-badge) {
10
+ margin-left: 0.5rem;
11
+ padding: 0 0.375rem;
12
+ font-size: 0.65rem;
13
+ font-weight: var(--font-weight-semibold);
14
+ line-height: 1.5;
15
+ letter-spacing: var(--letter-spacing-wide);
16
+ vertical-align: middle;
17
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { Badge } from '@byline/ui/react'
10
+ import cx from 'classnames'
11
+
12
+ import styles from './source-locale-badge.module.css'
13
+
14
+ export interface SourceLocaleBadgeProps {
15
+ /** The document's content source locale code (e.g. `en`, `fr`). */
16
+ locale: string
17
+ className?: string
18
+ }
19
+
20
+ /**
21
+ * Small neutral badge indicating a document's content **source locale** — the
22
+ * locale it was authored in (its anchor: fallback floor, path locale,
23
+ * completeness yardstick). Rendered next to the document title in the edit and
24
+ * list views. Mirrors the localized-field {@link LocaleBadge} in spirit but uses
25
+ * the shared {@link Badge} with the `noeffect` (neutral) intent.
26
+ *
27
+ * NOTE: currently rendered for *every* document so the anchor is visible during
28
+ * development. The intended end state is to show it only when `locale` differs
29
+ * from the system's current default content locale (a normal single-default
30
+ * install then shows nothing). See docs/DEFAULT-LOCALE-SWITCHING.md (Slice 6).
31
+ *
32
+ * Stable override handle: `.byline-source-locale-badge`.
33
+ */
34
+ export const SourceLocaleBadge = ({ locale, className }: SourceLocaleBadgeProps) => (
35
+ <Badge
36
+ intent="noeffect"
37
+ render={<span />}
38
+ title={`Primary content language: ${locale.toUpperCase()}`}
39
+ className={cx('byline-source-locale-badge', styles.badge, className)}
40
+ >
41
+ {locale.toUpperCase()}
42
+ </Badge>
43
+ )
44
+
45
+ SourceLocaleBadge.displayName = 'SourceLocaleBadge'