@byline/admin 6.0.1 → 6.0.3

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 (92) hide show
  1. package/dist/modules/admin-preferences/index.d.ts +7 -3
  2. package/dist/modules/admin-preferences/index.js +1 -1
  3. package/dist/modules/admin-preferences/schemas.d.ts +19 -16
  4. package/dist/modules/admin-preferences/schemas.js +46 -2
  5. package/dist/modules/analytics/components/timeseries.d.ts +28 -0
  6. package/dist/modules/analytics/components/timeseries.js +107 -35
  7. package/dist/modules/analytics/components/timeseries.module.js +15 -0
  8. package/dist/modules/analytics/components/timeseries_module.css +110 -3
  9. package/package.json +7 -7
  10. package/src/modules/admin-preferences/index.ts +7 -2
  11. package/src/modules/admin-preferences/schemas.test.node.ts +74 -0
  12. package/src/modules/admin-preferences/schemas.ts +85 -8
  13. package/src/modules/analytics/components/dashboard.test.node.ts +51 -0
  14. package/src/modules/analytics/components/timeseries.module.css +176 -3
  15. package/src/modules/analytics/components/timeseries.tsx +153 -40
  16. package/dist/exports-parity.test.node.js +0 -37
  17. package/dist/fields/array/array-field.jsx +0 -209
  18. package/dist/fields/blocks/blocks-field.jsx +0 -248
  19. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  20. package/dist/fields/code/code-editor.jsx +0 -211
  21. package/dist/fields/code/code-field.jsx +0 -109
  22. package/dist/fields/column-formatter.jsx +0 -25
  23. package/dist/fields/date-time-formatter.jsx +0 -18
  24. package/dist/fields/datetime/datetime-field.jsx +0 -22
  25. package/dist/fields/draggable-context-menu.jsx +0 -50
  26. package/dist/fields/field-admin.test.node.js +0 -40
  27. package/dist/fields/field-renderer.jsx +0 -136
  28. package/dist/fields/field-services-context.jsx +0 -17
  29. package/dist/fields/file/file-field.jsx +0 -176
  30. package/dist/fields/file/file-upload-field.jsx +0 -89
  31. package/dist/fields/group/group-field.jsx +0 -46
  32. package/dist/fields/image/image-field.jsx +0 -183
  33. package/dist/fields/image/image-upload-field.jsx +0 -157
  34. package/dist/fields/local-date-time.jsx +0 -69
  35. package/dist/fields/locale-badge.jsx +0 -19
  36. package/dist/fields/numerical/numerical-field.jsx +0 -93
  37. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  38. package/dist/fields/relation/relation-display.jsx +0 -118
  39. package/dist/fields/relation/relation-field.jsx +0 -108
  40. package/dist/fields/relation/relation-many-field.jsx +0 -142
  41. package/dist/fields/relation/relation-picker.jsx +0 -240
  42. package/dist/fields/relation/relation-summary.jsx +0 -45
  43. package/dist/fields/select/select-field.jsx +0 -27
  44. package/dist/fields/sortable-item.jsx +0 -54
  45. package/dist/fields/text/text-field.jsx +0 -79
  46. package/dist/fields/text-area/text-area-field.jsx +0 -79
  47. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  48. package/dist/forms/available-locales-widget.jsx +0 -63
  49. package/dist/forms/document-actions.jsx +0 -486
  50. package/dist/forms/form-context.jsx +0 -598
  51. package/dist/forms/form-modals.jsx +0 -128
  52. package/dist/forms/form-renderer.jsx +0 -532
  53. package/dist/forms/form-status-display.jsx +0 -69
  54. package/dist/forms/navigation-guard.jsx +0 -68
  55. package/dist/forms/nested-path.test.node.js +0 -115
  56. package/dist/forms/path-widget.jsx +0 -125
  57. package/dist/forms/pending-uploads.test.node.js +0 -19
  58. package/dist/forms/repeating-items.test.node.js +0 -29
  59. package/dist/forms/scheduled-publication-control.jsx +0 -389
  60. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  61. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  62. package/dist/forms/status-transitions.test.node.js +0 -76
  63. package/dist/forms/tree-placement-widget.jsx +0 -160
  64. package/dist/forms/upload-executor.test.node.js +0 -368
  65. package/dist/forms/use-form-layout.test.node.js +0 -69
  66. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  67. package/dist/modules/admin-account/components/container.jsx +0 -164
  68. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  69. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  70. package/dist/modules/admin-account/components/update.jsx +0 -186
  71. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  72. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  73. package/dist/modules/admin-roles/components/create.jsx +0 -155
  74. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  75. package/dist/modules/admin-roles/components/update.jsx +0 -148
  76. package/dist/modules/admin-users/components/create.jsx +0 -206
  77. package/dist/modules/admin-users/components/roles.jsx +0 -126
  78. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  79. package/dist/modules/admin-users/components/update.jsx +0 -215
  80. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  81. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  82. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  83. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  84. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  85. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  86. package/dist/presentation/group.jsx +0 -26
  87. package/dist/presentation/row.jsx +0 -23
  88. package/dist/presentation/tabs.jsx +0 -39
  89. package/dist/services/admin-services-context.jsx +0 -17
  90. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  91. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  92. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -124,6 +124,55 @@ export function buildAnalyticsColumns(
124
124
  })
125
125
  }
126
126
 
127
+ /**
128
+ * Where the hover card sits relative to the plot box.
129
+ *
130
+ * `leftPercent` / `topPercent` are percentages of the plot rather than user
131
+ * -space units because the card is HTML laid over a stretched SVG: the plot
132
+ * is drawn `preserveAspectRatio="none"`, so anything measured in viewBox
133
+ * units would skew with the container width (the same reason the axis labels
134
+ * are HTML). Percentages survive the stretch.
135
+ */
136
+ export interface AnalyticsHoverCardPlacement {
137
+ /** Horizontal centre of the hovered column, as a percentage of plot width. */
138
+ leftPercent: number
139
+ /** Top edge of the hovered column, as a percentage of plot height. */
140
+ topPercent: number
141
+ /**
142
+ * How the card lines up with `leftPercent`. Columns near a plot edge anchor
143
+ * that edge instead of centring, so the card never overhangs the chart.
144
+ */
145
+ align: 'start' | 'center' | 'end'
146
+ /**
147
+ * Which side of the column top the card occupies. A column tall enough that
148
+ * a card above it would clip the top of the plot takes the card below
149
+ * instead.
150
+ */
151
+ side: 'above' | 'below'
152
+ }
153
+
154
+ /** Distance from a plot edge, in percent, inside which the card anchors. */
155
+ const CARD_EDGE_MARGIN = 15
156
+ /** Bar tops above this percentage leave no room for a card above them. */
157
+ const CARD_FLIP_THRESHOLD = 45
158
+
159
+ /** Project one column's geometry into hover-card placement. */
160
+ export function resolveHoverCardPlacement(column: AnalyticsColumn): AnalyticsHoverCardPlacement {
161
+ const leftPercent = ((column.hitX + column.hitWidth / 2) / VIEWBOX_WIDTH) * 100
162
+ const topPercent = (column.y / VIEWBOX_HEIGHT) * 100
163
+ return {
164
+ leftPercent,
165
+ topPercent,
166
+ align:
167
+ leftPercent < CARD_EDGE_MARGIN
168
+ ? 'start'
169
+ : leftPercent > 100 - CARD_EDGE_MARGIN
170
+ ? 'end'
171
+ : 'center',
172
+ side: topPercent < CARD_FLIP_THRESHOLD ? 'below' : 'above',
173
+ }
174
+ }
175
+
127
176
  export interface AnalyticsTimeseriesProps {
128
177
  days: readonly AnalyticsSummaryDay[]
129
178
  granularity: AnalyticsChartGranularity
@@ -159,6 +208,24 @@ export function AnalyticsTimeseries({
159
208
  ? t('analytics.stats.dailyUniques')
160
209
  : t('analytics.stats.summedDailyUniques')
161
210
  const active = hovered == null ? undefined : buckets[hovered]
211
+ const activePlacement =
212
+ hovered == null || columns[hovered] == null
213
+ ? undefined
214
+ : resolveHoverCardPlacement(columns[hovered])
215
+
216
+ // The hover card stays mounted so it can transition in and out — an element
217
+ // that mounts already-visible has no previous state to animate from, which
218
+ // is why a transition alone would still pop. Retaining the last hovered
219
+ // bucket keeps its figures on screen through the fade-out, rather than
220
+ // blanking the card the instant the pointer leaves.
221
+ const lastShown = useRef<{
222
+ bucket: AnalyticsChartBucket
223
+ placement: AnalyticsHoverCardPlacement
224
+ } | null>(null)
225
+ if (active != null && activePlacement != null) {
226
+ lastShown.current = { bucket: active, placement: activePlacement }
227
+ }
228
+ const shown = lastShown.current
162
229
  const readBucket = (bucket: AnalyticsChartBucket) =>
163
230
  [
164
231
  formatBucket(bucket),
@@ -206,47 +273,93 @@ export function AnalyticsTimeseries({
206
273
  onKeyDown={trackKey}
207
274
  onBlur={() => setHovered(null)}
208
275
  >
209
- <svg
210
- ref={plot}
211
- className={cx('byline-analytics-chart', styles.chart)}
212
- viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
213
- preserveAspectRatio="none"
214
- aria-hidden="true"
215
- >
216
- <title>{t('analytics.chart.views')}</title>
217
- {[0.5, 1].map((fraction) => (
218
- <line
219
- key={fraction}
220
- className={styles.gridline}
221
- x1={0}
222
- x2={VIEWBOX_WIDTH}
223
- y1={VIEWBOX_HEIGHT - VIEWBOX_HEIGHT * fraction}
224
- y2={VIEWBOX_HEIGHT - VIEWBOX_HEIGHT * fraction}
225
- vectorEffect="non-scaling-stroke"
226
- />
227
- ))}
228
- {columns.map((column) => (
229
- <g
230
- key={buckets[column.index]?.from}
231
- className={cx(styles.column, hovered === column.index && styles.columnActive)}
232
- >
233
- <rect
234
- className={styles.views}
235
- x={column.x}
236
- width={column.width}
237
- y={column.y}
238
- height={column.height}
239
- />
240
- <rect
241
- className={styles.visitors}
242
- x={column.insetX}
243
- width={column.insetWidth}
244
- y={column.insetY}
245
- height={column.insetHeight}
276
+ {/* The plot and its hover card share a positioned box, so the card can
277
+ be placed against the plot's own edges rather than the whole block
278
+ (which also carries the axis and the readout). */}
279
+ <div className={cx('byline-analytics-plot', styles.plot)}>
280
+ <svg
281
+ ref={plot}
282
+ className={cx('byline-analytics-chart', styles.chart)}
283
+ viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
284
+ preserveAspectRatio="none"
285
+ aria-hidden="true"
286
+ >
287
+ <title>{t('analytics.chart.views')}</title>
288
+ {[0.5, 1].map((fraction) => (
289
+ <line
290
+ key={fraction}
291
+ className={styles.gridline}
292
+ x1={0}
293
+ x2={VIEWBOX_WIDTH}
294
+ y1={VIEWBOX_HEIGHT - VIEWBOX_HEIGHT * fraction}
295
+ y2={VIEWBOX_HEIGHT - VIEWBOX_HEIGHT * fraction}
296
+ vectorEffect="non-scaling-stroke"
246
297
  />
247
- </g>
248
- ))}
249
- </svg>
298
+ ))}
299
+ {columns.map((column) => (
300
+ <g
301
+ key={buckets[column.index]?.from}
302
+ className={cx(styles.column, hovered === column.index && styles.columnActive)}
303
+ >
304
+ <rect
305
+ className={styles.views}
306
+ x={column.x}
307
+ width={column.width}
308
+ y={column.y}
309
+ height={column.height}
310
+ />
311
+ <rect
312
+ className={styles.visitors}
313
+ x={column.insetX}
314
+ width={column.insetWidth}
315
+ y={column.insetY}
316
+ height={column.insetHeight}
317
+ />
318
+ </g>
319
+ ))}
320
+ </svg>
321
+
322
+ {/* Sits over the hovered column. `aria-hidden` because the slider's
323
+ `aria-valuetext` already reads the same figures to assistive
324
+ technology — the card is the sighted equivalent of that readout,
325
+ not a second announcement. */}
326
+ {buckets.length > 0 && (
327
+ <div
328
+ className={cx(
329
+ 'byline-analytics-hovercard',
330
+ styles.hoverCard,
331
+ shown != null && styles[shown.placement.align],
332
+ shown != null && (shown.placement.side === 'below' ? styles.below : styles.above),
333
+ active != null && styles.hoverCardVisible
334
+ )}
335
+ style={{
336
+ left: `${shown?.placement.leftPercent ?? 50}%`,
337
+ top: `${shown?.placement.topPercent ?? 100}%`,
338
+ }}
339
+ aria-hidden="true"
340
+ >
341
+ {shown != null && (
342
+ <>
343
+ <span className={styles.hoverCardDate}>{formatBucket(shown.bucket)}</span>
344
+ <span className={styles.hoverCardRow}>
345
+ <span className={cx(styles.keyLine, styles.keyViews)} />
346
+ <span className={styles.hoverCardLabel}>{t('analytics.stats.views')}</span>
347
+ <span className={styles.hoverCardValue}>
348
+ {numbers.format(shown.bucket.views)}
349
+ </span>
350
+ </span>
351
+ <span className={styles.hoverCardRow}>
352
+ <span className={cx(styles.keyLine, styles.keyVisitors)} />
353
+ <span className={styles.hoverCardLabel}>{visitorLabel(shown.bucket)}</span>
354
+ <span className={styles.hoverCardValue}>
355
+ {numbers.format(shown.bucket.visitors)}
356
+ </span>
357
+ </span>
358
+ </>
359
+ )}
360
+ </div>
361
+ )}
362
+ </div>
250
363
 
251
364
  {/* HTML axis labels remain undistorted while the SVG stretches. */}
252
365
  <div className={cx('byline-analytics-axis', styles.axis)} aria-hidden="true">
@@ -1,37 +0,0 @@
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
- * Guard against `publishConfig.exports` drift.
10
- *
11
- * `@byline/admin` carries a `publishConfig.exports` block that npm uses to
12
- * **override** the top-level `exports` at publish time. The workspace and dev
13
- * builds resolve through the top-level `exports` (or source), so a subpath
14
- * added there but forgotten in `publishConfig.exports` typechecks and builds
15
- * locally yet is **missing from the published package** — surfacing only as a
16
- * downstream consumer's build error ("X is not exported …"). That is exactly
17
- * how `./admin-activity` slipped through in v3.11.0.
18
- *
19
- * This test fails the moment the two blocks drift, so the gap is caught in
20
- * `pnpm test` / CI rather than in a production Docker build.
21
- */
22
- import { readFileSync } from 'node:fs';
23
- import { dirname, resolve } from 'node:path';
24
- import { fileURLToPath } from 'node:url';
25
- import { describe, expect, it } from 'vitest';
26
- const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '../package.json');
27
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
28
- describe('package.json export parity', () => {
29
- it('every top-level export subpath is also declared in publishConfig.exports', () => {
30
- const publishExports = pkg.publishConfig?.exports;
31
- // If there is no override, the top-level exports ship as-is — nothing to check.
32
- if (publishExports == null)
33
- return;
34
- const missing = Object.keys(pkg.exports).filter((key) => !(key in publishExports));
35
- expect(missing, `publishConfig.exports is missing subpath(s) present in the top-level exports: ${missing.join(', ')}. The published package would not expose them — add them to BOTH blocks.`).toEqual([]);
36
- });
37
- });
@@ -1,209 +0,0 @@
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 { useEffect, useState } from 'react';
9
- import { useTranslation } from '@byline/i18n/react';
10
- import { DraggableSortable, IconButton, moveItem, PlusIcon } from '@byline/ui/react';
11
- import cx from 'clsx';
12
- import { sliceFieldAdmin } from '../../fields/field-admin';
13
- import { defaultScalarForField } from '../../fields/field-helpers';
14
- import { FieldRenderer } from '../../fields/field-renderer';
15
- import { SortableItem, StaticItem } from '../../fields/sortable-item';
16
- import { useFormContext } from '../../forms/form-context';
17
- import { hasExistingIdTargets } from '../../forms/nested-path';
18
- import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from '../../forms/repeating-items';
19
- import styles from './array-field.module.css';
20
- // ---------------------------------------------------------------------------
21
- // ArrayField — renders `type: 'array'` fields. Each item renders every child
22
- // field declared in the array's `fields` (value fields and/or groups, in any
23
- // combination). Supports D&D.
24
- // ---------------------------------------------------------------------------
25
- export const ArrayField = ({ field, defaultValue, path, disableSorting = false, contentLocale, fieldAdmin, }) => {
26
- const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } = useFormContext();
27
- const { t } = useTranslation('byline-admin');
28
- const [items, setItems] = useState([]);
29
- useEffect(() => {
30
- // Prefer the live form-store value over `defaultValue` when seeding the
31
- // rendered list. The store lives in FormProvider — above the tab layout —
32
- // so it survives this field unmounting/remounting as the editor switches
33
- // tabs; `defaultValue` is only the initial seed and is empty in create
34
- // mode. Re-seeding from `defaultValue` on remount would drop items the
35
- // editor had already added on an earlier visit to this tab.
36
- const storeValue = getFieldValue(path);
37
- const source = Array.isArray(storeValue) ? storeValue : defaultValue;
38
- if (Array.isArray(source)) {
39
- setItems(source.map((item) => ({
40
- id: item && typeof item === 'object' && '_id' in item
41
- ? String(item._id)
42
- : item && typeof item === 'object' && 'id' in item
43
- ? String(item.id)
44
- : crypto.randomUUID(),
45
- data: item,
46
- })));
47
- }
48
- else {
49
- setItems([]);
50
- }
51
- }, [defaultValue, getFieldValue, path]);
52
- const handleDragEnd = ({ moveFromIndex, moveToIndex, }) => {
53
- const currentArray = (getFieldValue(path) ?? defaultValue);
54
- if (!Array.isArray(currentArray))
55
- return;
56
- const move = moveRepeatingItems(currentArray, moveFromIndex, moveToIndex);
57
- if (move == null)
58
- return;
59
- setItems((prev) => moveItem(prev, move.fromIndex, move.toIndex));
60
- setFieldStore(path, move.items);
61
- appendPatch({
62
- kind: 'array.move',
63
- path,
64
- itemId: move.itemId,
65
- toIndex: move.toIndex,
66
- });
67
- };
68
- const handleAddItem = async (atIndex) => {
69
- const childFields = field.fields ?? [];
70
- if (childFields.length === 0)
71
- return;
72
- const newId = crypto.randomUUID();
73
- // Build a new item with default values for ALL child fields.
74
- // Assign the stable `_id` client-side — exactly like BlocksField's
75
- // handleAddItem — so the item is patch-addressable (move / remove by
76
- // `_id`) within the same editing session, before any server write has
77
- // assigned identity. The storage layer persists `_id` via store_meta.
78
- const newItem = { _id: newId };
79
- for (const childField of childFields) {
80
- if (childField.type === 'group' && childField.fields && childField.fields.length > 0) {
81
- // Group child — build a nested object with defaults for each inner field
82
- const groupObj = {};
83
- for (const innerField of childField.fields) {
84
- groupObj[innerField.name] = await defaultScalarForField(innerField, getFieldValues);
85
- }
86
- newItem[childField.name] = groupObj;
87
- }
88
- else {
89
- newItem[childField.name] = await defaultScalarForField(childField, getFieldValues);
90
- }
91
- }
92
- // Defaults may resolve asynchronously. If an enclosing stable-id item was
93
- // removed in the meantime, do not append a structural patch that would
94
- // recreate that missing parent on the server.
95
- if (!hasExistingIdTargets(getFieldValues(), path))
96
- return;
97
- const currentArray = (getFieldValue(path) ?? defaultValue);
98
- const insertAt = atIndex != null ? atIndex : currentArray ? currentArray.length : 0;
99
- const newItemWrapper = { id: newId, data: newItem };
100
- setItems((prev) => {
101
- const next = [...prev];
102
- next.splice(insertAt, 0, newItemWrapper);
103
- return next;
104
- });
105
- appendPatch({
106
- kind: 'array.insert',
107
- path: path,
108
- index: insertAt,
109
- item: newItem,
110
- });
111
- const newArrayValue = currentArray ? [...currentArray] : [];
112
- newArrayValue.splice(insertAt, 0, newItem);
113
- setFieldStore(path, newArrayValue);
114
- };
115
- const handleRemoveItem = (index) => {
116
- const currentArray = (getFieldValue(path) ?? defaultValue);
117
- if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length)
118
- return;
119
- const item = currentArray[index];
120
- const itemPath = repeatingItemPath(path, item, index);
121
- setItems((prev) => prev.filter((_, i) => i !== index));
122
- removePendingUploadsUnder(itemPath);
123
- appendPatch({
124
- kind: 'array.remove',
125
- path: path,
126
- itemId: repeatingItemId(item) ?? String(index),
127
- });
128
- const newArrayValue = [...currentArray];
129
- newArrayValue.splice(index, 1);
130
- setFieldStore(path, newArrayValue);
131
- };
132
- const handleInsertBelow = (index) => {
133
- void handleAddItem(index + 1);
134
- };
135
- const renderItem = (itemWrapper, index) => {
136
- const item = itemWrapper.data;
137
- const arrayElementPath = repeatingItemPath(path, item, index);
138
- if (!item || typeof item !== 'object')
139
- return null;
140
- const childFields = field.fields ?? [];
141
- if (childFields.length === 0)
142
- return null;
143
- // Render ALL child fields defined in the array's field schema
144
- const innerBody = childFields.map((childField) => {
145
- const initial = item[childField.name];
146
- if (childField.type === 'group' && childField.fields && childField.fields.length > 0) {
147
- // Group child — render its inner fields with the group's sub-object.
148
- // fieldAdmin slices one level per structural hop: the group's
149
- // descendant map first, then each inner field's own slice.
150
- const groupData = initial && typeof initial === 'object' ? initial : {};
151
- const groupAdmin = sliceFieldAdmin(fieldAdmin, childField.name);
152
- return (<div key={childField.name} className={cx('byline-field-array-group-fields', styles['group-fields'])}>
153
- {childField.label && (<h4 className={cx('byline-field-array-group-header', styles['group-header'])}>
154
- {childField.label}
155
- </h4>)}
156
- {childField.fields.map((innerField) => (<FieldRenderer key={innerField.name} field={innerField} defaultValue={groupData[innerField.name]} basePath={`${arrayElementPath}.${childField.name}`} disableSorting={true} contentLocale={contentLocale} components={groupAdmin?.[innerField.name]?.components} editor={groupAdmin?.[innerField.name]?.editor} fieldAdmin={sliceFieldAdmin(groupAdmin, innerField.name)}/>))}
157
- </div>);
158
- }
159
- return (<FieldRenderer key={childField.name} field={childField} defaultValue={initial} basePath={arrayElementPath} disableSorting={true} contentLocale={contentLocale} components={fieldAdmin?.[childField.name]?.components} editor={fieldAdmin?.[childField.name]?.editor} fieldAdmin={sliceFieldAdmin(fieldAdmin, childField.name)}/>);
160
- });
161
- // Per-item label: the array label plus a 1-based position, so items
162
- // read "Questions 1", "Questions 2" rather than all repeating the bare
163
- // array label.
164
- const itemLabel = `${field.label ?? field.name} ${index + 1}`;
165
- // `disableSorting` removes only the drag affordance. Structural editing
166
- // (add-below / remove / collapse) is always available — StaticItem is
167
- // the grip-less sibling of SortableItem (see sortable-item.tsx for why
168
- // it is a separate component rather than a prop).
169
- if (disableSorting) {
170
- return (<StaticItem key={itemWrapper.id} label={itemLabel} onAddBelow={() => handleInsertBelow(index)} onRemove={() => handleRemoveItem(index)}>
171
- <div className={cx('byline-field-array-group-fields', styles['group-fields'])}>
172
- {innerBody}
173
- </div>
174
- </StaticItem>);
175
- }
176
- return (<SortableItem key={itemWrapper.id} id={itemWrapper.id} label={itemLabel} onAddBelow={() => handleInsertBelow(index)} onRemove={() => handleRemoveItem(index)}>
177
- <div className={cx('byline-field-array-group-fields', styles['group-fields'])}>
178
- {innerBody}
179
- </div>
180
- </SortableItem>);
181
- };
182
- // The add-row renders in both branches — structural editing does not
183
- // depend on sortability.
184
- const addRow = (<div className={cx('byline-field-array-add-row', styles['add-row'])}>
185
- <IconButton onClick={() => {
186
- void handleAddItem();
187
- }} aria-label={t('fields.array.addItemAriaLabel')}>
188
- <PlusIcon />
189
- </IconButton>
190
- {/* Text-styled button so the label is clickable too. Removed from
191
- the tab order (tabIndex -1) — the IconButton is the single
192
- keyboard/screen-reader control for this action. */}
193
- <button type="button" tabIndex={-1} onClick={() => {
194
- void handleAddItem();
195
- }} className={cx('byline-field-array-add-label', styles['add-label'])}>
196
- {t('fields.array.addItem')}
197
- </button>
198
- </div>);
199
- return (<div className={`byline-field-array ${field.name}`}>
200
- {field.label && (<h3 className={cx('byline-field-array-title', styles.title)}>{field.label}</h3>)}
201
- {disableSorting ? (<div className={cx('byline-field-array-stack', styles.stack)}>
202
- {items.map((item, index) => renderItem(item, index))}
203
- {addRow}
204
- </div>) : (<DraggableSortable ids={items.map((i) => i.id)} onDragEnd={handleDragEnd} className={cx('byline-field-array-stack', styles.stack)}>
205
- {items.map((item, index) => renderItem(item, index))}
206
- {addRow}
207
- </DraggableSortable>)}
208
- </div>);
209
- };