@byline/admin 4.1.0 → 4.3.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 (58) hide show
  1. package/dist/fields/array/array-field.d.ts +12 -2
  2. package/dist/fields/array/array-field.js +46 -20
  3. package/dist/fields/array/array-field.module.js +5 -1
  4. package/dist/fields/array/array-field_module.css +23 -0
  5. package/dist/fields/blocks/blocks-field.js +34 -12
  6. package/dist/fields/blocks/blocks-field.module.js +2 -0
  7. package/dist/fields/blocks/blocks-field_module.css +37 -0
  8. package/dist/fields/code/code-editor.d.ts +20 -0
  9. package/dist/fields/code/code-editor.js +239 -0
  10. package/dist/fields/code/code-field.d.ts +21 -0
  11. package/dist/fields/code/code-field.js +124 -0
  12. package/dist/fields/code/code-field.module.js +7 -0
  13. package/dist/fields/code/code-field_module.css +58 -0
  14. package/dist/fields/draggable-context-menu.js +2 -1
  15. package/dist/fields/field-admin.d.ts +15 -0
  16. package/dist/fields/field-admin.js +11 -0
  17. package/dist/fields/field-admin.test.node.d.ts +8 -0
  18. package/dist/fields/field-helpers.js +1 -0
  19. package/dist/fields/field-renderer.d.ts +11 -2
  20. package/dist/fields/field-renderer.js +21 -4
  21. package/dist/fields/group/group-field.d.ts +23 -2
  22. package/dist/fields/group/group-field.js +7 -3
  23. package/dist/fields/relation/relation-picker.js +8 -2
  24. package/dist/fields/select/select-field.js +4 -2
  25. package/dist/fields/select/select-field.module.js +1 -0
  26. package/dist/fields/select/select-field_module.css +4 -0
  27. package/dist/fields/sortable-item.d.ts +6 -0
  28. package/dist/fields/sortable-item.js +44 -25
  29. package/dist/fields/text/text-field_module.css +1 -0
  30. package/dist/fields/text-area/text-area-field_module.css +1 -0
  31. package/dist/forms/form-context.js +1 -1
  32. package/dist/forms/form-renderer.d.ts +1 -1
  33. package/dist/forms/form-renderer.js +3 -1
  34. package/dist/forms/tree-placement-widget.d.ts +1 -1
  35. package/package.json +23 -10
  36. package/src/fields/array/array-field.module.css +30 -0
  37. package/src/fields/array/array-field.tsx +74 -20
  38. package/src/fields/blocks/blocks-field.module.css +53 -0
  39. package/src/fields/blocks/blocks-field.tsx +38 -1
  40. package/src/fields/code/code-editor.tsx +246 -0
  41. package/src/fields/code/code-field.module.css +84 -0
  42. package/src/fields/code/code-field.tsx +191 -0
  43. package/src/fields/draggable-context-menu.tsx +9 -1
  44. package/src/fields/field-admin.test.node.ts +49 -0
  45. package/src/fields/field-admin.ts +39 -0
  46. package/src/fields/field-helpers.ts +1 -0
  47. package/src/fields/field-renderer.tsx +33 -2
  48. package/src/fields/field-services-types.ts +1 -1
  49. package/src/fields/group/group-field.tsx +29 -2
  50. package/src/fields/relation/relation-picker.tsx +11 -0
  51. package/src/fields/select/select-field.module.css +5 -0
  52. package/src/fields/select/select-field.tsx +9 -2
  53. package/src/fields/sortable-item.tsx +115 -34
  54. package/src/fields/text/text-field.module.css +1 -0
  55. package/src/fields/text-area/text-area-field.module.css +1 -0
  56. package/src/forms/form-context.tsx +9 -1
  57. package/src/forms/form-renderer.tsx +3 -1
  58. package/src/forms/tree-placement-widget.tsx +1 -1
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": "4.1.0",
5
+ "version": "4.3.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -149,32 +149,45 @@
149
149
  "src/"
150
150
  ],
151
151
  "dependencies": {
152
- "@tanstack/react-form-start": "^1.33.0",
152
+ "@codemirror/commands": "^6.10.4",
153
+ "@codemirror/lang-css": "^6.3.1",
154
+ "@codemirror/lang-html": "^6.4.11",
155
+ "@codemirror/lang-javascript": "^6.2.5",
156
+ "@codemirror/lang-json": "^6.0.2",
157
+ "@codemirror/lang-markdown": "^6.5.1",
158
+ "@codemirror/lang-python": "^6.2.1",
159
+ "@codemirror/lang-sql": "^6.10.0",
160
+ "@codemirror/lang-yaml": "^6.1.3",
161
+ "@codemirror/language": "^6.12.4",
162
+ "@codemirror/state": "^6.7.1",
163
+ "@codemirror/view": "^6.43.6",
164
+ "@lezer/highlight": "^1.2.3",
165
+ "@tanstack/react-form-start": "^1.33.2",
153
166
  "classnames": "^2.5.1",
154
167
  "jose": "^6.2.3",
155
- "react-diff-viewer-continued": "^4.2.2",
168
+ "react-diff-viewer-continued": "^4.4.0",
156
169
  "uuid": "^14.0.1",
157
170
  "zod": "^4.4.3",
158
- "@byline/core": "4.1.0",
159
- "@byline/ui": "4.1.0",
160
- "@byline/auth": "4.1.0",
161
- "@byline/i18n": "4.1.0"
171
+ "@byline/core": "4.3.0",
172
+ "@byline/auth": "4.3.0",
173
+ "@byline/i18n": "4.3.0",
174
+ "@byline/ui": "4.3.0"
162
175
  },
163
176
  "peerDependencies": {
164
177
  "react": "^19.0.0",
165
178
  "react-dom": "^19.0.0"
166
179
  },
167
180
  "devDependencies": {
168
- "@biomejs/biome": "2.5.2",
181
+ "@biomejs/biome": "2.5.4",
169
182
  "@rsbuild/plugin-react": "^2.1.0",
170
183
  "@rslib/core": "^0.23.2",
171
- "@types/node": "^26.1.0",
184
+ "@types/node": "^26.1.1",
172
185
  "@types/react": "19.2.17",
173
186
  "@types/react-dom": "19.2.3",
174
187
  "react": "^19.2.7",
175
188
  "react-dom": "^19.2.7",
176
189
  "rimraf": "^6.1.3",
177
- "tsx": "^4.23.0",
190
+ "tsx": "^4.23.1",
178
191
  "typescript": "^7.0.2",
179
192
  "typescript-plugin-css-modules": "^5.2.0",
180
193
  "vitest": "^4.1.10"
@@ -5,6 +5,8 @@
5
5
  * .byline-field-array — outer wrapper
6
6
  * .byline-field-array-title — array label heading
7
7
  * .byline-field-array-stack — vertical layout for items + add row
8
+ * .byline-field-array-add-row — add-button row at the bottom
9
+ * .byline-field-array-add-label — clickable text label in the add row
8
10
  * .byline-field-array-card — disable-sorting fallback card per item
9
11
  * .byline-field-array-group-header — sub-group title above grouped fields
10
12
  * .byline-field-array-group-fields — vertical stack of fields inside an item
@@ -46,3 +48,31 @@
46
48
  flex-direction: column;
47
49
  gap: var(--spacing-16);
48
50
  }
51
+
52
+ .add-row,
53
+ :global(.byline-field-array-add-row) {
54
+ display: flex;
55
+ align-items: center;
56
+ font-size: 0.9rem;
57
+ color: var(--muted);
58
+ gap: var(--spacing-8);
59
+ }
60
+
61
+ /* Text-styled companion button for the add row's label — clickable like the
62
+ icon button. Hovering anywhere on the row brightens the label text. */
63
+ .add-label,
64
+ :global(.byline-field-array-add-label) {
65
+ appearance: none;
66
+ background: none;
67
+ border: none;
68
+ padding: 0;
69
+ font: inherit;
70
+ color: inherit;
71
+ cursor: pointer;
72
+ transition: color 120ms ease;
73
+ }
74
+
75
+ .add-row:hover .add-label,
76
+ :global(.byline-field-array-add-row):hover :global(.byline-field-array-add-label) {
77
+ color: var(--text);
78
+ }
@@ -8,20 +8,22 @@
8
8
 
9
9
  import { useEffect, useState } from 'react'
10
10
 
11
- import type { ArrayField as ArrayFieldType, Field } from '@byline/core'
11
+ import type { ArrayField as ArrayFieldType, Field, FieldAdminConfig } from '@byline/core'
12
12
  import { useTranslation } from '@byline/i18n/react'
13
13
  import { DraggableSortable, IconButton, moveItem, PlusIcon } from '@byline/ui/react'
14
14
  import cx from 'classnames'
15
15
 
16
+ import { sliceFieldAdmin } from '../../fields/field-admin'
16
17
  import { defaultScalarForField } from '../../fields/field-helpers'
17
18
  import { FieldRenderer } from '../../fields/field-renderer'
18
- import { SortableItem } from '../../fields/sortable-item'
19
+ import { SortableItem, StaticItem } from '../../fields/sortable-item'
19
20
  import { useFormContext } from '../../forms/form-context'
20
21
  import styles from './array-field.module.css'
21
22
 
22
23
  // ---------------------------------------------------------------------------
23
- // ArrayField — renders `type: 'array'` fields. Children are homogeneous:
24
- // either single value fields or a single group definition. Supports D&D.
24
+ // ArrayField — renders `type: 'array'` fields. Each item renders every child
25
+ // field declared in the array's `fields` (value fields and/or groups, in any
26
+ // combination). Supports D&D.
25
27
  // ---------------------------------------------------------------------------
26
28
 
27
29
  export const ArrayField = ({
@@ -31,6 +33,7 @@ export const ArrayField = ({
31
33
  disableSorting = false,
32
34
  collectionPath,
33
35
  contentLocale,
36
+ fieldAdmin,
34
37
  }: {
35
38
  field: ArrayFieldType
36
39
  defaultValue: any
@@ -49,6 +52,16 @@ export const ArrayField = ({
49
52
  * can render their locale badge.
50
53
  */
51
54
  contentLocale?: string
55
+ /**
56
+ * Admin overrides for the array's child fields, keyed by dotted,
57
+ * index-free schema paths relative to this array ('answer',
58
+ * 'filesGroup.publicationFile'). Schema paths address declarations, so
59
+ * one entry applies to that field in every item. Arrives pre-sliced from
60
+ * the enclosing widget (`FieldRenderer` / `GroupField`); exact-name
61
+ * entries apply to the child, deeper entries are re-sliced and threaded
62
+ * on (see `sliceFieldAdmin`).
63
+ */
64
+ fieldAdmin?: Record<string, FieldAdminConfig>
52
65
  }) => {
53
66
  const { appendPatch, getFieldValue, getFieldValues, setFieldStore } = useFormContext()
54
67
  const { t } = useTranslation('byline-admin')
@@ -207,8 +220,11 @@ export const ArrayField = ({
207
220
  const initial = item[childField.name]
208
221
 
209
222
  if (childField.type === 'group' && childField.fields && childField.fields.length > 0) {
210
- // Group child — render its inner fields with the group's sub-object
223
+ // Group child — render its inner fields with the group's sub-object.
224
+ // fieldAdmin slices one level per structural hop: the group's
225
+ // descendant map first, then each inner field's own slice.
211
226
  const groupData = initial && typeof initial === 'object' ? initial : {}
227
+ const groupAdmin = sliceFieldAdmin(fieldAdmin, childField.name)
212
228
  return (
213
229
  <div
214
230
  key={childField.name}
@@ -228,6 +244,9 @@ export const ArrayField = ({
228
244
  disableSorting={true}
229
245
  collectionPath={collectionPath}
230
246
  contentLocale={contentLocale}
247
+ components={groupAdmin?.[innerField.name]?.components}
248
+ editor={groupAdmin?.[innerField.name]?.editor}
249
+ fieldAdmin={sliceFieldAdmin(groupAdmin, innerField.name)}
231
250
  />
232
251
  ))}
233
252
  </div>
@@ -243,19 +262,34 @@ export const ArrayField = ({
243
262
  disableSorting={true}
244
263
  collectionPath={collectionPath}
245
264
  contentLocale={contentLocale}
265
+ components={fieldAdmin?.[childField.name]?.components}
266
+ editor={fieldAdmin?.[childField.name]?.editor}
267
+ fieldAdmin={sliceFieldAdmin(fieldAdmin, childField.name)}
246
268
  />
247
269
  )
248
270
  })
249
271
 
250
- const label = field.label ?? field.name
272
+ // Per-item label: the array label plus a 1-based position, so items
273
+ // read "Questions 1", "Questions 2" rather than all repeating the bare
274
+ // array label.
275
+ const itemLabel = `${field.label ?? field.name} ${index + 1}`
251
276
 
277
+ // `disableSorting` removes only the drag affordance. Structural editing
278
+ // (add-below / remove / collapse) is always available — StaticItem is
279
+ // the grip-less sibling of SortableItem (see sortable-item.tsx for why
280
+ // it is a separate component rather than a prop).
252
281
  if (disableSorting) {
253
282
  return (
254
- <div key={itemWrapper.id} className={cx('byline-field-array-card', styles.card)}>
283
+ <StaticItem
284
+ key={itemWrapper.id}
285
+ label={itemLabel}
286
+ onAddBelow={() => handleInsertBelow(index)}
287
+ onRemove={() => handleRemoveItem(index)}
288
+ >
255
289
  <div className={cx('byline-field-array-group-fields', styles['group-fields'])}>
256
290
  {innerBody}
257
291
  </div>
258
- </div>
292
+ </StaticItem>
259
293
  )
260
294
  }
261
295
 
@@ -263,7 +297,7 @@ export const ArrayField = ({
263
297
  <SortableItem
264
298
  key={itemWrapper.id}
265
299
  id={itemWrapper.id}
266
- label={label}
300
+ label={itemLabel}
267
301
  onAddBelow={() => handleInsertBelow(index)}
268
302
  onRemove={() => handleRemoveItem(index)}
269
303
  >
@@ -274,14 +308,43 @@ export const ArrayField = ({
274
308
  )
275
309
  }
276
310
 
311
+ // The add-row renders in both branches — structural editing does not
312
+ // depend on sortability.
313
+ const addRow = (
314
+ <div className={cx('byline-field-array-add-row', styles['add-row'])}>
315
+ <IconButton
316
+ onClick={() => {
317
+ void handleAddItem()
318
+ }}
319
+ aria-label={t('fields.array.addItemAriaLabel')}
320
+ >
321
+ <PlusIcon />
322
+ </IconButton>
323
+ {/* Text-styled button so the label is clickable too. Removed from
324
+ the tab order (tabIndex -1) — the IconButton is the single
325
+ keyboard/screen-reader control for this action. */}
326
+ <button
327
+ type="button"
328
+ tabIndex={-1}
329
+ onClick={() => {
330
+ void handleAddItem()
331
+ }}
332
+ className={cx('byline-field-array-add-label', styles['add-label'])}
333
+ >
334
+ {t('fields.array.addItem')}
335
+ </button>
336
+ </div>
337
+ )
338
+
277
339
  return (
278
340
  <div className={`byline-field-array ${field.name}`}>
279
- {!disableSorting && field.label && (
341
+ {field.label && (
280
342
  <h3 className={cx('byline-field-array-title', styles.title)}>{field.label}</h3>
281
343
  )}
282
344
  {disableSorting ? (
283
345
  <div className={cx('byline-field-array-stack', styles.stack)}>
284
346
  {items.map((item, index) => renderItem(item, index))}
347
+ {addRow}
285
348
  </div>
286
349
  ) : (
287
350
  <DraggableSortable
@@ -290,16 +353,7 @@ export const ArrayField = ({
290
353
  className={cx('byline-field-array-stack', styles.stack)}
291
354
  >
292
355
  {items.map((item, index) => renderItem(item, index))}
293
- <span>
294
- <IconButton
295
- onClick={() => {
296
- void handleAddItem()
297
- }}
298
- aria-label={t('fields.array.addItemAriaLabel')}
299
- >
300
- <PlusIcon />
301
- </IconButton>
302
- </span>
356
+ {addRow}
303
357
  </DraggableSortable>
304
358
  )}
305
359
  </div>
@@ -6,6 +6,7 @@
6
6
  * .byline-field-blocks-title — block-list label heading
7
7
  * .byline-field-blocks-stack — vertical stack of blocks + add row
8
8
  * .byline-field-blocks-add-row — add-button row at the bottom
9
+ * .byline-field-blocks-add-label — clickable text label in the add row
9
10
  * .byline-field-blocks-modal-head — picker modal header
10
11
  * .byline-field-blocks-modal-title — picker modal title
11
12
  * .byline-field-blocks-grid — grid of variant cards
@@ -36,9 +37,35 @@
36
37
  :global(.byline-field-blocks-add-row) {
37
38
  display: flex;
38
39
  align-items: center;
40
+ font-size: 0.9rem;
41
+ color: var(--muted);
39
42
  gap: var(--spacing-8);
40
43
  }
41
44
 
45
+ /* Text-styled companion button for the add row's label — clickable like the
46
+ icon button. Hovering anywhere on the row brightens the label text. */
47
+ .add-label,
48
+ :global(.byline-field-blocks-add-label) {
49
+ appearance: none;
50
+ background: none;
51
+ border: none;
52
+ padding: 0;
53
+ font: inherit;
54
+ color: inherit;
55
+ cursor: pointer;
56
+ transition: color 120ms ease;
57
+ }
58
+
59
+ .add-label:disabled,
60
+ :global(.byline-field-blocks-add-label):disabled {
61
+ cursor: default;
62
+ }
63
+
64
+ .add-row:hover .add-label:not(:disabled),
65
+ :global(.byline-field-blocks-add-row):hover :global(.byline-field-blocks-add-label):not(:disabled) {
66
+ color: var(--text);
67
+ }
68
+
42
69
  .modal-head,
43
70
  :global(.byline-field-blocks-modal-head) {
44
71
  padding-top: 1rem;
@@ -54,6 +81,32 @@
54
81
  .modal-content,
55
82
  :global(.byline-field-blocks-card-cursor) {
56
83
  cursor: pointer;
84
+ /* Cap the picker's height and scroll the card grid when a collection
85
+ declares more blocks than fit. Mobile-first: 80% of the (dynamic)
86
+ viewport height, tightening to 70% on tablet and 60% on desktop —
87
+ smaller screens get proportionally more of the picker. `min-height: 0`
88
+ lets this flex child (Modal.Content is a flex column item) actually
89
+ shrink and scroll; `overscroll-behavior` keeps wheel momentum from
90
+ spilling into the document behind the modal. All caps stay inside the
91
+ Modal.Container's own viewport-height limit. */
92
+ max-height: 80dvh;
93
+ min-height: 0;
94
+ overflow-y: auto;
95
+ overscroll-behavior: contain;
96
+ }
97
+
98
+ @media (min-width: 40rem) {
99
+ .modal-content,
100
+ :global(.byline-field-blocks-card-cursor) {
101
+ max-height: 70dvh;
102
+ }
103
+ }
104
+
105
+ @media (min-width: 64rem) {
106
+ .modal-content,
107
+ :global(.byline-field-blocks-card-cursor) {
108
+ max-height: 60dvh;
109
+ }
57
110
  }
58
111
 
59
112
  .grid,
@@ -9,10 +9,12 @@
9
9
  import { useEffect, useMemo, useState } from 'react'
10
10
 
11
11
  import type {
12
+ BlockAdminConfig,
12
13
  BlocksField as BlocksFieldType,
13
14
  Field,
14
15
  GroupField as GroupFieldType,
15
16
  } from '@byline/core'
17
+ import { getClientConfig } from '@byline/core'
16
18
  import { useTranslation } from '@byline/i18n/react'
17
19
  import {
18
20
  Card,
@@ -59,6 +61,19 @@ export const BlocksField = ({
59
61
  const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null)
60
62
 
61
63
  const availableBlocks = useMemo(() => field.blocks ?? [], [field.blocks])
64
+
65
+ // Site-wide per-block admin config (`ClientConfig.blockAdmin`), resolved by
66
+ // blockType. Registry-based on purpose: blocks are cross-collection units,
67
+ // so their admin config applies wherever the block renders — no threading
68
+ // from the collection admin config is needed (mirrors how FieldRenderer
69
+ // resolves the global richText editor from client config).
70
+ const blockAdminByType = useMemo(() => {
71
+ const map = new Map<string, BlockAdminConfig>()
72
+ for (const entry of getClientConfig().blockAdmin ?? []) {
73
+ map.set(entry.blockType, entry)
74
+ }
75
+ return map
76
+ }, [])
62
77
  const [selectedBlockName, setSelectedBlockName] = useState<string>(
63
78
  () => availableBlocks[0]?.blockType ?? ''
64
79
  )
@@ -234,7 +249,13 @@ export const BlocksField = ({
234
249
  }
235
250
  defaultValue={fieldData}
236
251
  path={arrayElementPath}
252
+ // Arrays directly inside a block are fully sortable: each
253
+ // DraggableSortable is an independent DndContext and drag listeners
254
+ // are grip-scoped, so the inner array's drags can't leak into the
255
+ // block-level context (and vice versa).
256
+ disableSorting={false}
237
257
  contentLocale={contentLocale}
258
+ fieldAdmin={blockAdminByType.get(item._type)?.fields}
238
259
  />
239
260
  )
240
261
 
@@ -270,9 +291,25 @@ export const BlocksField = ({
270
291
  }}
271
292
  disabled={!selectedBlockName}
272
293
  aria-label={t('fields.blocks.addBlockAriaLabel')}
294
+ variant="outlined"
273
295
  >
274
296
  <PlusIcon />
275
297
  </IconButton>
298
+ {/* Text-styled button so the label is clickable too. Removed from
299
+ the tab order (tabIndex -1) — the IconButton is the single
300
+ keyboard/screen-reader control for this action. */}
301
+ <button
302
+ type="button"
303
+ tabIndex={-1}
304
+ disabled={!selectedBlockName}
305
+ onClick={() => {
306
+ setPendingInsertIndex(null)
307
+ setShowAddBlockModal(true)
308
+ }}
309
+ className={cx('byline-field-blocks-add-label', styles['add-label'])}
310
+ >
311
+ {t('fields.blocks.addBlock')}
312
+ </button>
276
313
  </div>
277
314
  </DraggableSortable>
278
315
  <Modal
@@ -283,7 +320,7 @@ export const BlocksField = ({
283
320
  setPendingInsertIndex(null)
284
321
  }}
285
322
  >
286
- <Modal.Container style={{ maxWidth: '600px' }}>
323
+ <Modal.Container style={{ maxWidth: '700px' }}>
287
324
  <Modal.Header className={cx('byline-field-blocks-modal-head', styles['modal-head'])}>
288
325
  <h3 className={cx('byline-field-blocks-modal-title', styles['modal-title'])}>
289
326
  {t('fields.blocks.modalTitle')}