@byline/admin 3.13.2 → 3.14.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.
@@ -13,12 +13,16 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, content
13
13
  const { t } = useTranslation('byline-admin');
14
14
  const [items, setItems] = useState([]);
15
15
  useEffect(()=>{
16
- Array.isArray(defaultValue) ? setItems(defaultValue.map((item)=>({
16
+ const storeValue = getFieldValue(path);
17
+ const source = Array.isArray(storeValue) ? storeValue : defaultValue;
18
+ Array.isArray(source) ? setItems(source.map((item)=>({
17
19
  id: item && 'object' == typeof item && 'id' in item ? String(item.id) : item && 'object' == typeof item && '_id' in item ? String(item._id) : crypto.randomUUID(),
18
20
  data: item
19
21
  }))) : setItems([]);
20
22
  }, [
21
- defaultValue
23
+ defaultValue,
24
+ getFieldValue,
25
+ path
22
26
  ]);
23
27
  const handleDragEnd = ({ moveFromIndex, moveToIndex })=>{
24
28
  setItems((prev)=>moveItem(prev, moveFromIndex, moveToIndex));
@@ -25,12 +25,16 @@ const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
25
25
  selectedBlockName
26
26
  ]);
27
27
  useEffect(()=>{
28
- Array.isArray(defaultValue) ? setItems(defaultValue.map((item)=>({
28
+ const storeValue = getFieldValue(path);
29
+ const source = Array.isArray(storeValue) ? storeValue : defaultValue;
30
+ Array.isArray(source) ? setItems(source.map((item)=>({
29
31
  id: item && 'object' == typeof item && '_id' in item ? String(item._id) : crypto.randomUUID(),
30
32
  data: item
31
33
  }))) : setItems([]);
32
34
  }, [
33
- defaultValue
35
+ defaultValue,
36
+ getFieldValue,
37
+ path
34
38
  ]);
35
39
  const handleDragEnd = ({ moveFromIndex, moveToIndex })=>{
36
40
  setItems((prev)=>moveItem(prev, moveFromIndex, moveToIndex));
@@ -12,6 +12,7 @@ import { ImageField } from "./image/image-field.js";
12
12
  import { LocaleBadge } from "./locale-badge.js";
13
13
  import { NumericalField } from "./numerical/numerical-field.js";
14
14
  import { RelationField } from "./relation/relation-field.js";
15
+ import { RelationManyField } from "./relation/relation-many-field.js";
15
16
  import { SelectField } from "./select/select-field.js";
16
17
  import { TextField } from "./text/text-field.js";
17
18
  import { TextAreaField } from "./text-area/text-area-field.js";
@@ -152,6 +153,15 @@ const FieldRenderer = ({ field, defaultValue, basePath, disableSorting, hideLabe
152
153
  collectionPath: collectionPath
153
154
  });
154
155
  case 'relation':
156
+ if (field.hasMany) return /*#__PURE__*/ jsx(RelationManyField, {
157
+ field: hideLabel ? {
158
+ ...field,
159
+ label: void 0
160
+ } : field,
161
+ defaultValue: defaultValue,
162
+ path: path,
163
+ id: htmlId
164
+ });
155
165
  return /*#__PURE__*/ jsx(RelationField, {
156
166
  field: hideLabel ? {
157
167
  ...field,
@@ -8,6 +8,21 @@ const relation_field_module = {
8
8
  errorText: "error-text-jHQh34",
9
9
  tile: "tile-Y3_yre",
10
10
  actions: "actions-Nov8hS",
11
- mono: "mono-y8Xo6b"
11
+ mono: "mono-y8Xo6b",
12
+ many: "many-ohEmr7",
13
+ "many-empty": "many-empty-T0OJd9",
14
+ manyEmpty: "many-empty-T0OJd9",
15
+ "many-tile": "many-tile-jNifSf",
16
+ manyTile: "many-tile-jNifSf",
17
+ "many-tile-dragging": "many-tile-dragging-AUDjxQ",
18
+ manyTileDragging: "many-tile-dragging-AUDjxQ",
19
+ "many-grip": "many-grip-i7LGT9",
20
+ manyGrip: "many-grip-i7LGT9",
21
+ "many-body": "many-body-e8JKV8",
22
+ manyBody: "many-body-e8JKV8",
23
+ "many-remove": "many-remove-fuuKiO",
24
+ manyRemove: "many-remove-fuuKiO",
25
+ "many-add": "many-add-c5uQ7B",
26
+ manyAdd: "many-add-c5uQ7B"
12
27
  };
13
28
  export default relation_field_module;
@@ -60,3 +60,67 @@
60
60
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
61
61
  }
62
62
 
63
+ :is(.many-ohEmr7, .byline-field-relation-many) {
64
+ gap: var(--spacing-4);
65
+ flex-direction: column;
66
+ margin-top: .25rem;
67
+ display: flex;
68
+ }
69
+
70
+ :is(.many-empty-T0OJd9, .byline-field-relation-many-empty) {
71
+ color: var(--gray-400);
72
+ font-size: var(--font-size-xs);
73
+ margin: .25rem 0;
74
+ }
75
+
76
+ :is(.many-tile-jNifSf, .byline-field-relation-many-tile) {
77
+ align-items: center;
78
+ gap: var(--spacing-8);
79
+ padding: var(--spacing-4) var(--spacing-8);
80
+ border: var(--border-width-thin) var(--border-style-solid) var(--primary-500);
81
+ border-radius: var(--border-radius-md);
82
+ background-color: var(--surface-100, transparent);
83
+ color: var(--gray-200);
84
+ font-size: var(--font-size-xs);
85
+ display: flex;
86
+ }
87
+
88
+ :is(.many-tile-dragging-AUDjxQ, .byline-field-relation-many-tile-dragging) {
89
+ opacity: .85;
90
+ box-shadow: var(--shadow-md, 0 4px 12px #0003);
91
+ }
92
+
93
+ :is(.many-grip-i7LGT9, .byline-field-relation-many-grip) {
94
+ color: var(--gray-400);
95
+ cursor: grab;
96
+ touch-action: none;
97
+ background: none;
98
+ border: none;
99
+ flex-shrink: 0;
100
+ align-items: center;
101
+ padding: 0;
102
+ display: inline-flex;
103
+ }
104
+
105
+ .many-grip-i7LGT9:active {
106
+ cursor: grabbing;
107
+ }
108
+
109
+ .byline-field-relation-many-grip:active {
110
+ cursor: grabbing;
111
+ }
112
+
113
+ :is(.many-body-e8JKV8, .byline-field-relation-many-body) {
114
+ flex: auto;
115
+ min-width: 0;
116
+ }
117
+
118
+ :is(.many-remove-fuuKiO, .byline-field-relation-many-remove) {
119
+ flex-shrink: 0;
120
+ }
121
+
122
+ :is(.many-add-c5uQ7B, .byline-field-relation-many-add) {
123
+ margin-top: .25rem;
124
+ display: flex;
125
+ }
126
+
@@ -0,0 +1,16 @@
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
+ import type { RelationField as FieldType, RelatedDocumentValue } from '@byline/core';
9
+ interface RelationManyFieldProps {
10
+ field: FieldType;
11
+ defaultValue?: RelatedDocumentValue[] | null;
12
+ id?: string;
13
+ path?: string;
14
+ }
15
+ export declare const RelationManyField: ({ field, defaultValue, id, path }: RelationManyFieldProps) => import("react").JSX.Element;
16
+ export {};
@@ -0,0 +1,193 @@
1
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { getCollectionAdminConfig, getCollectionDefinition } from "@byline/core";
4
+ import { useTranslation } from "@byline/i18n/react";
5
+ import { Button, CloseIcon, DraggableSortable, ErrorText, GripperVerticalIcon, IconButton, Label, PlusIcon, moveItem, useSortable } from "@byline/ui/react";
6
+ import classnames from "classnames";
7
+ import { useFieldError, useFieldValue, useFormContext } from "../../forms/form-context.js";
8
+ import relation_field_module from "./relation-field.module.js";
9
+ import { RelationPicker } from "./relation-picker.js";
10
+ import { RelationSummary } from "./relation-summary.js";
11
+ const RelationManyTile = ({ id, children, onRemove, removeAriaLabel })=>{
12
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
13
+ id,
14
+ transition: {
15
+ duration: 250,
16
+ easing: 'cubic-bezier(0, 0.2, 0.2, 1)'
17
+ }
18
+ });
19
+ const style = {
20
+ transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : void 0,
21
+ transition,
22
+ zIndex: isDragging ? 10 : 'auto'
23
+ };
24
+ return /*#__PURE__*/ jsxs("div", {
25
+ ref: setNodeRef,
26
+ style: style,
27
+ className: classnames('byline-field-relation-many-tile', relation_field_module["many-tile"], isDragging && [
28
+ 'byline-field-relation-many-tile-dragging',
29
+ relation_field_module["many-tile-dragging"]
30
+ ]),
31
+ children: [
32
+ /*#__PURE__*/ jsx("button", {
33
+ type: "button",
34
+ className: classnames('byline-field-relation-many-grip', relation_field_module["many-grip"]),
35
+ ...attributes,
36
+ ...listeners,
37
+ children: /*#__PURE__*/ jsx(GripperVerticalIcon, {})
38
+ }),
39
+ /*#__PURE__*/ jsx("div", {
40
+ className: classnames('byline-field-relation-many-body', relation_field_module["many-body"]),
41
+ children: children
42
+ }),
43
+ /*#__PURE__*/ jsx(IconButton, {
44
+ type: "button",
45
+ size: "xs",
46
+ intent: "noeffect",
47
+ "aria-label": removeAriaLabel,
48
+ className: classnames('byline-field-relation-many-remove', relation_field_module["many-remove"]),
49
+ onClick: onRemove,
50
+ children: /*#__PURE__*/ jsx(CloseIcon, {
51
+ width: "14px",
52
+ height: "14px"
53
+ })
54
+ })
55
+ ]
56
+ });
57
+ };
58
+ const RelationManyField = ({ field, defaultValue, id, path })=>{
59
+ const fieldPath = path ?? field.name;
60
+ const htmlId = id ?? fieldPath;
61
+ const { t } = useTranslation('byline-admin');
62
+ const fieldError = useFieldError(fieldPath);
63
+ const { getFieldValue, setFieldValue } = useFormContext();
64
+ const targetDef = getCollectionDefinition(field.targetCollection);
65
+ const targetAdminConfig = getCollectionAdminConfig(field.targetCollection);
66
+ const isUnknown = null == targetDef;
67
+ const targetLabel = targetDef?.labels.singular ?? field.targetCollection;
68
+ const [pickerOpen, setPickerOpen] = useState(false);
69
+ const [pickedRecords, setPickedRecords] = useState({});
70
+ const liveValue = useFieldValue(fieldPath);
71
+ const items = Array.isArray(liveValue) ? liveValue : Array.isArray(defaultValue) ? defaultValue : [];
72
+ const currentArray = ()=>{
73
+ const v = getFieldValue(fieldPath);
74
+ if (Array.isArray(v)) return v;
75
+ return Array.isArray(defaultValue) ? defaultValue : [];
76
+ };
77
+ const handleAdd = (selection)=>{
78
+ setPickerOpen(false);
79
+ const current = currentArray();
80
+ if (current.some((v)=>v.targetDocumentId === selection.targetDocumentId)) return;
81
+ if (selection.record) setPickedRecords((prev)=>({
82
+ ...prev,
83
+ [selection.targetDocumentId]: selection.record
84
+ }));
85
+ setFieldValue(fieldPath, [
86
+ ...current,
87
+ {
88
+ targetDocumentId: selection.targetDocumentId,
89
+ targetCollectionId: selection.targetCollectionId
90
+ }
91
+ ]);
92
+ };
93
+ const handleRemove = (targetDocumentId)=>{
94
+ setFieldValue(fieldPath, currentArray().filter((v)=>v.targetDocumentId !== targetDocumentId));
95
+ };
96
+ const handleDragEnd = ({ moveFromIndex, moveToIndex })=>{
97
+ if (moveFromIndex === moveToIndex) return;
98
+ setFieldValue(fieldPath, moveItem(currentArray(), moveFromIndex, moveToIndex));
99
+ };
100
+ return /*#__PURE__*/ jsxs("div", {
101
+ className: `byline-field-relation ${field.name}`,
102
+ children: [
103
+ /*#__PURE__*/ jsx("div", {
104
+ className: classnames('byline-field-relation-header', relation_field_module.header),
105
+ children: /*#__PURE__*/ jsx(Label, {
106
+ id: `${htmlId}-label`,
107
+ htmlFor: htmlId,
108
+ label: field.label ?? field.name,
109
+ required: !field.optional
110
+ })
111
+ }),
112
+ field.helpText && /*#__PURE__*/ jsx("div", {
113
+ className: classnames('byline-field-relation-help', relation_field_module.help),
114
+ children: field.helpText
115
+ }),
116
+ isUnknown ? /*#__PURE__*/ jsxs("div", {
117
+ className: classnames('byline-field-relation-error-tile', relation_field_module["error-tile"]),
118
+ children: [
119
+ /*#__PURE__*/ jsx("span", {
120
+ children: t('fields.relation.unknownError', {
121
+ name: field.name,
122
+ target: field.targetCollection
123
+ })
124
+ }),
125
+ /*#__PURE__*/ jsx("span", {
126
+ className: classnames('byline-field-relation-error-text', relation_field_module["error-text"]),
127
+ children: t('fields.relation.unknownHint')
128
+ })
129
+ ]
130
+ }) : /*#__PURE__*/ jsxs(Fragment, {
131
+ children: [
132
+ 0 === items.length ? /*#__PURE__*/ jsx("p", {
133
+ className: classnames('byline-field-relation-many-empty', relation_field_module["many-empty"]),
134
+ children: t('fields.relation.manyEmpty', {
135
+ label: targetDef.labels.plural
136
+ })
137
+ }) : /*#__PURE__*/ jsx(DraggableSortable, {
138
+ ids: items.map((v)=>v.targetDocumentId),
139
+ onDragEnd: handleDragEnd,
140
+ className: classnames('byline-field-relation-many', relation_field_module.many),
141
+ children: items.map((value)=>/*#__PURE__*/ jsx(RelationManyTile, {
142
+ id: value.targetDocumentId,
143
+ onRemove: ()=>handleRemove(value.targetDocumentId),
144
+ removeAriaLabel: t('fields.relation.removeAriaLabel', {
145
+ label: targetLabel
146
+ }),
147
+ children: /*#__PURE__*/ jsx(RelationSummary, {
148
+ targetDefinition: targetDef,
149
+ targetAdminConfig: targetAdminConfig,
150
+ displayField: field.displayField,
151
+ value: value,
152
+ cachedRecord: pickedRecords[value.targetDocumentId] ?? null
153
+ })
154
+ }, value.targetDocumentId))
155
+ }),
156
+ fieldError && /*#__PURE__*/ jsx(ErrorText, {
157
+ id: `${field.name}-error`,
158
+ text: fieldError
159
+ }),
160
+ /*#__PURE__*/ jsx("div", {
161
+ className: classnames('byline-field-relation-many-add', relation_field_module["many-add"]),
162
+ children: /*#__PURE__*/ jsxs(Button, {
163
+ id: htmlId,
164
+ size: "xs",
165
+ variant: "outlined",
166
+ intent: "noeffect",
167
+ type: "button",
168
+ onClick: ()=>setPickerOpen(true),
169
+ children: [
170
+ /*#__PURE__*/ jsx(PlusIcon, {
171
+ width: "14px",
172
+ height: "14px"
173
+ }),
174
+ t('fields.relation.addButton', {
175
+ label: targetLabel
176
+ })
177
+ ]
178
+ })
179
+ }),
180
+ /*#__PURE__*/ jsx(RelationPicker, {
181
+ targetCollectionPath: field.targetCollection,
182
+ targetDefinition: targetDef,
183
+ displayField: field.displayField,
184
+ isOpen: pickerOpen,
185
+ onSelect: handleAdd,
186
+ onDismiss: ()=>setPickerOpen(false)
187
+ })
188
+ ]
189
+ })
190
+ ]
191
+ });
192
+ };
193
+ export { RelationManyField };
@@ -33,3 +33,16 @@ export declare function getThemeMode(): ThemeMode;
33
33
  * override so the document follows `prefers-color-scheme` again.
34
34
  */
35
35
  export declare function setThemeMode(mode: ThemeMode): void;
36
+ /**
37
+ * Re-assert the stored theme on the live document without changing it.
38
+ *
39
+ * Byline admin shares the host's theme contract (the `theme` key + the
40
+ * `.dark`/`.light` class on `<html>`), so the surrounding host owns the
41
+ * before-first-paint application. But a host theme provider can re-apply
42
+ * its own (possibly stale, possibly forced) theme on navigation and
43
+ * clobber the admin user's stored choice. Calling this when the admin
44
+ * shell mounts re-applies the stored preference from the single source of
45
+ * truth — `localStorage` — making the admin area self-correcting
46
+ * regardless of how the host manages theme. No-op on the server.
47
+ */
48
+ export declare function applyStoredTheme(): void;
@@ -27,4 +27,9 @@ function setThemeMode(mode) {
27
27
  applyEffectiveTheme(mode);
28
28
  }
29
29
  }
30
- export { getThemeMode, setThemeMode };
30
+ function applyStoredTheme() {
31
+ if ("u" < typeof window) return;
32
+ const mode = getThemeMode();
33
+ applyEffectiveTheme('system' === mode ? systemPrefersDark() ? 'dark' : 'light' : mode);
34
+ }
35
+ export { applyStoredTheme, getThemeMode, setThemeMode };
package/dist/react.d.ts CHANGED
@@ -46,6 +46,7 @@ export * from './fields/local-date-time.js';
46
46
  export * from './fields/locale-badge.js';
47
47
  export * from './fields/numerical/numerical-field.js';
48
48
  export * from './fields/relation/relation-field.js';
49
+ export * from './fields/relation/relation-many-field.js';
49
50
  export * from './fields/relation/relation-picker.js';
50
51
  export * from './fields/select/select-field.js';
51
52
  export * from './fields/sortable-item.js';
package/dist/react.js CHANGED
@@ -17,6 +17,7 @@ export * from "./fields/local-date-time.js";
17
17
  export * from "./fields/locale-badge.js";
18
18
  export * from "./fields/numerical/numerical-field.js";
19
19
  export * from "./fields/relation/relation-field.js";
20
+ export * from "./fields/relation/relation-many-field.js";
20
21
  export * from "./fields/relation/relation-picker.js";
21
22
  export * from "./fields/select/select-field.js";
22
23
  export * from "./fields/sortable-item.js";
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": "3.13.2",
5
+ "version": "3.14.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -122,6 +122,11 @@
122
122
  "import": "./dist/modules/admin-account/components/container.js",
123
123
  "require": "./dist/modules/admin-account/components/container.js"
124
124
  },
125
+ "./admin-account/components/theme": {
126
+ "types": "./dist/modules/admin-account/components/theme.d.ts",
127
+ "import": "./dist/modules/admin-account/components/theme.js",
128
+ "require": "./dist/modules/admin-account/components/theme.js"
129
+ },
125
130
  "./admin-account/components/update": {
126
131
  "types": "./dist/modules/admin-account/components/update.d.ts",
127
132
  "import": "./dist/modules/admin-account/components/update.js",
@@ -151,10 +156,10 @@
151
156
  "uuid": "^14.0.0",
152
157
  "zod": "^4.4.3",
153
158
  "zod-form-data": "^3.0.1",
154
- "@byline/auth": "3.13.2",
155
- "@byline/ui": "3.13.2",
156
- "@byline/core": "3.13.2",
157
- "@byline/i18n": "3.13.2"
159
+ "@byline/auth": "3.14.0",
160
+ "@byline/ui": "3.14.0",
161
+ "@byline/core": "3.14.0",
162
+ "@byline/i18n": "3.14.0"
158
163
  },
159
164
  "peerDependencies": {
160
165
  "react": "^19.0.0",
@@ -47,9 +47,17 @@ export const ArrayField = ({
47
47
  const [items, setItems] = useState<{ id: string; data: any }[]>([])
48
48
 
49
49
  useEffect(() => {
50
- if (Array.isArray(defaultValue)) {
50
+ // Prefer the live form-store value over `defaultValue` when seeding the
51
+ // rendered list. The store lives in FormProvider — above the tab layout —
52
+ // so it survives this field unmounting/remounting as the editor switches
53
+ // tabs; `defaultValue` is only the initial seed and is empty in create
54
+ // mode. Re-seeding from `defaultValue` on remount would drop items the
55
+ // editor had already added on an earlier visit to this tab.
56
+ const storeValue = getFieldValue(path)
57
+ const source = Array.isArray(storeValue) ? storeValue : defaultValue
58
+ if (Array.isArray(source)) {
51
59
  setItems(
52
- defaultValue.map((item: any) => ({
60
+ source.map((item: any) => ({
53
61
  id:
54
62
  item && typeof item === 'object' && 'id' in item
55
63
  ? String((item as { id: string }).id)
@@ -62,7 +70,7 @@ export const ArrayField = ({
62
70
  } else {
63
71
  setItems([])
64
72
  }
65
- }, [defaultValue])
73
+ }, [defaultValue, getFieldValue, path])
66
74
 
67
75
  const handleDragEnd = ({
68
76
  moveFromIndex,
@@ -73,9 +73,17 @@ export const BlocksField = ({
73
73
  }, [availableBlocks, selectedBlockName])
74
74
 
75
75
  useEffect(() => {
76
- if (Array.isArray(defaultValue)) {
76
+ // Prefer the live form-store value over `defaultValue` when seeding the
77
+ // rendered list. The store lives in FormProvider — above the tab layout —
78
+ // so it survives this field unmounting/remounting as the editor switches
79
+ // tabs; `defaultValue` is only the initial seed and is empty in create
80
+ // mode. Re-seeding from `defaultValue` on remount would drop blocks the
81
+ // editor had already added on an earlier visit to this tab.
82
+ const storeValue = getFieldValue(path)
83
+ const source = Array.isArray(storeValue) ? storeValue : defaultValue
84
+ if (Array.isArray(source)) {
77
85
  setItems(
78
- defaultValue.map((item: any) => ({
86
+ source.map((item: any) => ({
79
87
  id:
80
88
  item && typeof item === 'object' && '_id' in item
81
89
  ? String((item as { _id: string })._id)
@@ -86,7 +94,7 @@ export const BlocksField = ({
86
94
  } else {
87
95
  setItems([])
88
96
  }
89
- }, [defaultValue])
97
+ }, [defaultValue, getFieldValue, path])
90
98
 
91
99
  const handleDragEnd = ({
92
100
  moveFromIndex,
@@ -28,6 +28,7 @@ import { ImageField } from './image/image-field'
28
28
  import { LocaleBadge } from './locale-badge'
29
29
  import { NumericalField } from './numerical/numerical-field'
30
30
  import { RelationField } from './relation/relation-field'
31
+ import { RelationManyField } from './relation/relation-many-field'
31
32
  import { SelectField } from './select/select-field'
32
33
  import { TextField } from './text/text-field'
33
34
  import { TextAreaField } from './text-area/text-area-field'
@@ -223,6 +224,16 @@ export const FieldRenderer = ({
223
224
  />
224
225
  )
225
226
  case 'relation':
227
+ if (field.hasMany) {
228
+ return (
229
+ <RelationManyField
230
+ field={hideLabel ? { ...field, label: undefined } : field}
231
+ defaultValue={defaultValue}
232
+ path={path}
233
+ id={htmlId}
234
+ />
235
+ )
236
+ }
226
237
  return (
227
238
  <RelationField
228
239
  field={hideLabel ? { ...field, label: undefined } : field}
@@ -81,3 +81,84 @@
81
81
  :global(.byline-field-relation-mono) {
82
82
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
83
83
  }
84
+
85
+ /* ---------------------------------------------------------------------------
86
+ * hasMany — ordered list of relation tiles with drag-reorder + remove.
87
+ *
88
+ * Override handles:
89
+ * .byline-field-relation-many — vertical list of tiles
90
+ * .byline-field-relation-many-empty — empty-state hint
91
+ * .byline-field-relation-many-tile — one row: grip + summary + remove
92
+ * .byline-field-relation-many-grip — drag handle
93
+ * .byline-field-relation-many-body — flexible summary cell
94
+ * .byline-field-relation-many-add — add-item button row
95
+ * --------------------------------------------------------------------------- */
96
+
97
+ .many,
98
+ :global(.byline-field-relation-many) {
99
+ display: flex;
100
+ flex-direction: column;
101
+ gap: var(--spacing-4);
102
+ margin-top: 0.25rem;
103
+ }
104
+
105
+ .many-empty,
106
+ :global(.byline-field-relation-many-empty) {
107
+ margin: 0.25rem 0;
108
+ color: var(--gray-400);
109
+ font-size: var(--font-size-xs);
110
+ }
111
+
112
+ .many-tile,
113
+ :global(.byline-field-relation-many-tile) {
114
+ display: flex;
115
+ align-items: center;
116
+ gap: var(--spacing-8);
117
+ padding: var(--spacing-4) var(--spacing-8);
118
+ border: var(--border-width-thin) var(--border-style-solid) var(--primary-500);
119
+ border-radius: var(--border-radius-md);
120
+ background-color: var(--surface-100, transparent);
121
+ color: var(--gray-200);
122
+ font-size: var(--font-size-xs);
123
+ }
124
+
125
+ .many-tile-dragging,
126
+ :global(.byline-field-relation-many-tile-dragging) {
127
+ opacity: 0.85;
128
+ box-shadow: var(--shadow-md, 0 4px 12px rgb(0 0 0 / 0.2));
129
+ }
130
+
131
+ .many-grip,
132
+ :global(.byline-field-relation-many-grip) {
133
+ display: inline-flex;
134
+ align-items: center;
135
+ flex-shrink: 0;
136
+ padding: 0;
137
+ border: none;
138
+ background: none;
139
+ color: var(--gray-400);
140
+ cursor: grab;
141
+ touch-action: none;
142
+ }
143
+
144
+ .many-grip:active,
145
+ :global(.byline-field-relation-many-grip:active) {
146
+ cursor: grabbing;
147
+ }
148
+
149
+ .many-body,
150
+ :global(.byline-field-relation-many-body) {
151
+ flex: 1 1 auto;
152
+ min-width: 0;
153
+ }
154
+
155
+ .many-remove,
156
+ :global(.byline-field-relation-many-remove) {
157
+ flex-shrink: 0;
158
+ }
159
+
160
+ .many-add,
161
+ :global(.byline-field-relation-many-add) {
162
+ display: flex;
163
+ margin-top: 0.25rem;
164
+ }
@@ -0,0 +1,274 @@
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 { type ReactNode, useState } from 'react'
10
+
11
+ import type {
12
+ CollectionAdminConfig,
13
+ CollectionDefinition,
14
+ RelationField as FieldType,
15
+ RelatedDocumentValue,
16
+ } from '@byline/core'
17
+ import { getCollectionAdminConfig, getCollectionDefinition } from '@byline/core'
18
+ import { useTranslation } from '@byline/i18n/react'
19
+ import {
20
+ Button,
21
+ CloseIcon,
22
+ DraggableSortable,
23
+ ErrorText,
24
+ GripperVerticalIcon,
25
+ IconButton,
26
+ Label,
27
+ moveItem,
28
+ PlusIcon,
29
+ useSortable,
30
+ } from '@byline/ui/react'
31
+ import cx from 'classnames'
32
+
33
+ import { useFieldError, useFieldValue, useFormContext } from '../../forms/form-context'
34
+ import styles from './relation-field.module.css'
35
+ import { RelationPicker } from './relation-picker'
36
+ import { RelationSummary } from './relation-summary'
37
+
38
+ // A single stored item. On the edit path the loader's populate pass attaches
39
+ // `_resolved` / `document`; freshly-picked items are bare refs. Both flatten
40
+ // the same way (storage ignores the envelope keys), so we never strip them.
41
+ type IncomingRelationValue = RelatedDocumentValue & {
42
+ _resolved?: boolean
43
+ _cycle?: boolean
44
+ document?: Record<string, any>
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // RelationManyField — `type: 'relation'` with `hasMany: true`.
49
+ //
50
+ // An ordered list of summary tiles: drag to reorder, ✕ to remove, "+ Add" to
51
+ // open the standard picker and append. The value is an array of relation
52
+ // envelopes; every mutation writes the whole array back via `setFieldValue`,
53
+ // which emits a coalesced `field.set` patch — relations carry no stable `_id`,
54
+ // so the granular `array.*` patches (which key on `_id`) don't apply, and a
55
+ // whole-array set is both correct and simplest for these small lists.
56
+ // ---------------------------------------------------------------------------
57
+
58
+ interface RelationManyTileProps {
59
+ id: string
60
+ children: ReactNode
61
+ onRemove: () => void
62
+ removeAriaLabel: string
63
+ }
64
+
65
+ const RelationManyTile = ({ id, children, onRemove, removeAriaLabel }: RelationManyTileProps) => {
66
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
67
+ id,
68
+ transition: { duration: 250, easing: 'cubic-bezier(0, 0.2, 0.2, 1)' },
69
+ })
70
+
71
+ const style = {
72
+ transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
73
+ transition,
74
+ zIndex: isDragging ? 10 : 'auto',
75
+ }
76
+
77
+ return (
78
+ <div
79
+ ref={setNodeRef}
80
+ style={style}
81
+ className={cx(
82
+ 'byline-field-relation-many-tile',
83
+ styles['many-tile'],
84
+ isDragging && ['byline-field-relation-many-tile-dragging', styles['many-tile-dragging']]
85
+ )}
86
+ >
87
+ <button
88
+ type="button"
89
+ className={cx('byline-field-relation-many-grip', styles['many-grip'])}
90
+ {...attributes}
91
+ {...listeners}
92
+ >
93
+ <GripperVerticalIcon />
94
+ </button>
95
+ <div className={cx('byline-field-relation-many-body', styles['many-body'])}>{children}</div>
96
+ <IconButton
97
+ type="button"
98
+ size="xs"
99
+ intent="noeffect"
100
+ aria-label={removeAriaLabel}
101
+ className={cx('byline-field-relation-many-remove', styles['many-remove'])}
102
+ onClick={onRemove}
103
+ >
104
+ <CloseIcon width="14px" height="14px" />
105
+ </IconButton>
106
+ </div>
107
+ )
108
+ }
109
+
110
+ interface RelationManyFieldProps {
111
+ field: FieldType
112
+ defaultValue?: RelatedDocumentValue[] | null
113
+ id?: string
114
+ path?: string
115
+ }
116
+
117
+ export const RelationManyField = ({ field, defaultValue, id, path }: RelationManyFieldProps) => {
118
+ const fieldPath = path ?? field.name
119
+ const htmlId = id ?? fieldPath
120
+ const { t } = useTranslation('byline-admin')
121
+ const fieldError = useFieldError(fieldPath)
122
+ const { getFieldValue, setFieldValue } = useFormContext()
123
+
124
+ const targetDef: CollectionDefinition | null = getCollectionDefinition(field.targetCollection)
125
+ const targetAdminConfig: CollectionAdminConfig | null = getCollectionAdminConfig(
126
+ field.targetCollection
127
+ )
128
+ const isUnknown = targetDef == null
129
+ const targetLabel = targetDef?.labels.singular ?? field.targetCollection
130
+
131
+ const [pickerOpen, setPickerOpen] = useState(false)
132
+ // Records handed back by the picker, keyed by target id — lets a freshly
133
+ // added tile render real display data (title / thumbnail) without a refetch.
134
+ const [pickedRecords, setPickedRecords] = useState<Record<string, Record<string, any>>>({})
135
+
136
+ // Subscribe to the live array so reorders / adds / removes re-render.
137
+ const liveValue = useFieldValue<IncomingRelationValue[] | undefined>(fieldPath)
138
+ const items: IncomingRelationValue[] = Array.isArray(liveValue)
139
+ ? liveValue
140
+ : Array.isArray(defaultValue)
141
+ ? (defaultValue as IncomingRelationValue[])
142
+ : []
143
+
144
+ const currentArray = (): IncomingRelationValue[] => {
145
+ const v = getFieldValue(fieldPath)
146
+ if (Array.isArray(v)) return v as IncomingRelationValue[]
147
+ return Array.isArray(defaultValue) ? (defaultValue as IncomingRelationValue[]) : []
148
+ }
149
+
150
+ const handleAdd = (selection: {
151
+ targetDocumentId: string
152
+ targetCollectionId: string
153
+ record?: Record<string, any>
154
+ }) => {
155
+ setPickerOpen(false)
156
+ const current = currentArray()
157
+ // Dedup — a target may appear at most once.
158
+ if (current.some((v) => v.targetDocumentId === selection.targetDocumentId)) return
159
+ if (selection.record) {
160
+ setPickedRecords((prev) => ({ ...prev, [selection.targetDocumentId]: selection.record! }))
161
+ }
162
+ setFieldValue(fieldPath, [
163
+ ...current,
164
+ {
165
+ targetDocumentId: selection.targetDocumentId,
166
+ targetCollectionId: selection.targetCollectionId,
167
+ },
168
+ ])
169
+ }
170
+
171
+ const handleRemove = (targetDocumentId: string) => {
172
+ setFieldValue(
173
+ fieldPath,
174
+ currentArray().filter((v) => v.targetDocumentId !== targetDocumentId)
175
+ )
176
+ }
177
+
178
+ const handleDragEnd = ({
179
+ moveFromIndex,
180
+ moveToIndex,
181
+ }: {
182
+ moveFromIndex: number
183
+ moveToIndex: number
184
+ }) => {
185
+ if (moveFromIndex === moveToIndex) return
186
+ setFieldValue(fieldPath, moveItem(currentArray(), moveFromIndex, moveToIndex))
187
+ }
188
+
189
+ return (
190
+ <div className={`byline-field-relation ${field.name}`}>
191
+ <div className={cx('byline-field-relation-header', styles.header)}>
192
+ <Label
193
+ id={`${htmlId}-label`}
194
+ htmlFor={htmlId}
195
+ label={field.label ?? field.name}
196
+ required={!field.optional}
197
+ />
198
+ </div>
199
+ {field.helpText && (
200
+ <div className={cx('byline-field-relation-help', styles.help)}>{field.helpText}</div>
201
+ )}
202
+
203
+ {isUnknown ? (
204
+ <div className={cx('byline-field-relation-error-tile', styles['error-tile'])}>
205
+ <span>
206
+ {t('fields.relation.unknownError', {
207
+ name: field.name,
208
+ target: field.targetCollection,
209
+ })}
210
+ </span>
211
+ <span className={cx('byline-field-relation-error-text', styles['error-text'])}>
212
+ {t('fields.relation.unknownHint')}
213
+ </span>
214
+ </div>
215
+ ) : (
216
+ <>
217
+ {items.length === 0 ? (
218
+ <p className={cx('byline-field-relation-many-empty', styles['many-empty'])}>
219
+ {t('fields.relation.manyEmpty', { label: targetDef.labels.plural })}
220
+ </p>
221
+ ) : (
222
+ <DraggableSortable
223
+ ids={items.map((v) => v.targetDocumentId)}
224
+ onDragEnd={handleDragEnd}
225
+ className={cx('byline-field-relation-many', styles.many)}
226
+ >
227
+ {items.map((value) => (
228
+ <RelationManyTile
229
+ key={value.targetDocumentId}
230
+ id={value.targetDocumentId}
231
+ onRemove={() => handleRemove(value.targetDocumentId)}
232
+ removeAriaLabel={t('fields.relation.removeAriaLabel', { label: targetLabel })}
233
+ >
234
+ <RelationSummary
235
+ targetDefinition={targetDef}
236
+ targetAdminConfig={targetAdminConfig}
237
+ displayField={field.displayField}
238
+ value={value}
239
+ cachedRecord={pickedRecords[value.targetDocumentId] ?? null}
240
+ />
241
+ </RelationManyTile>
242
+ ))}
243
+ </DraggableSortable>
244
+ )}
245
+
246
+ {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
247
+
248
+ <div className={cx('byline-field-relation-many-add', styles['many-add'])}>
249
+ <Button
250
+ id={htmlId}
251
+ size="xs"
252
+ variant="outlined"
253
+ intent="noeffect"
254
+ type="button"
255
+ onClick={() => setPickerOpen(true)}
256
+ >
257
+ <PlusIcon width="14px" height="14px" />
258
+ {t('fields.relation.addButton', { label: targetLabel })}
259
+ </Button>
260
+ </div>
261
+
262
+ <RelationPicker
263
+ targetCollectionPath={field.targetCollection}
264
+ targetDefinition={targetDef}
265
+ displayField={field.displayField}
266
+ isOpen={pickerOpen}
267
+ onSelect={handleAdd}
268
+ onDismiss={() => setPickerOpen(false)}
269
+ />
270
+ </>
271
+ )}
272
+ </div>
273
+ )
274
+ }
@@ -70,3 +70,21 @@ export function setThemeMode(mode: ThemeMode): void {
70
70
  applyEffectiveTheme(mode)
71
71
  }
72
72
  }
73
+
74
+ /**
75
+ * Re-assert the stored theme on the live document without changing it.
76
+ *
77
+ * Byline admin shares the host's theme contract (the `theme` key + the
78
+ * `.dark`/`.light` class on `<html>`), so the surrounding host owns the
79
+ * before-first-paint application. But a host theme provider can re-apply
80
+ * its own (possibly stale, possibly forced) theme on navigation and
81
+ * clobber the admin user's stored choice. Calling this when the admin
82
+ * shell mounts re-applies the stored preference from the single source of
83
+ * truth — `localStorage` — making the admin area self-correcting
84
+ * regardless of how the host manages theme. No-op on the server.
85
+ */
86
+ export function applyStoredTheme(): void {
87
+ if (typeof window === 'undefined') return
88
+ const mode = getThemeMode()
89
+ applyEffectiveTheme(mode === 'system' ? (systemPrefersDark() ? 'dark' : 'light') : mode)
90
+ }
package/src/react.ts CHANGED
@@ -49,6 +49,7 @@ export * from './fields/local-date-time.js'
49
49
  export * from './fields/locale-badge.js'
50
50
  export * from './fields/numerical/numerical-field.js'
51
51
  export * from './fields/relation/relation-field.js'
52
+ export * from './fields/relation/relation-many-field.js'
52
53
  export * from './fields/relation/relation-picker.js'
53
54
  export * from './fields/select/select-field.js'
54
55
  export * from './fields/sortable-item.js'