@byline/admin 4.18.0 → 4.19.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.
@@ -1,9 +1,9 @@
1
1
  "use client";
2
- import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { getAdminConfig } from "@byline/core";
5
5
  import { useTranslation } from "@byline/i18n/react";
6
- import { Alert, Button, ComboButton } from "@byline/ui/react";
6
+ import { Alert, Button, ComboButton, LoaderEllipsis } from "@byline/ui/react";
7
7
  import clsx from "clsx";
8
8
  import { sliceFieldAdmin } from "../fields/field-admin.js";
9
9
  import { FieldRenderer } from "../fields/field-renderer.js";
@@ -33,6 +33,10 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
33
33
  const [isUploading, setIsUploading] = useState(false);
34
34
  const submittingRef = useRef(false);
35
35
  const [isSubmitting, setIsSubmitting] = useState(false);
36
+ const isBusy = isUploading || isSubmitting;
37
+ const formRef = useRef(null);
38
+ const focusBeforeBusyRef = useRef(null);
39
+ const restoreFocusAfterBusyRef = useRef(false);
36
40
  const [showUnsavedModal, setShowUnsavedModal] = useState(false);
37
41
  const [pendingSystemFieldsSubmit, setPendingSystemFieldsSubmit] = useState(null);
38
42
  const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale);
@@ -106,6 +110,26 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
106
110
  subscribeMeta,
107
111
  getFieldValues
108
112
  ]);
113
+ useEffect(()=>{
114
+ if (isBusy || !restoreFocusAfterBusyRef.current) return;
115
+ restoreFocusAfterBusyRef.current = false;
116
+ const original = focusBeforeBusyRef.current;
117
+ focusBeforeBusyRef.current = null;
118
+ const originalCanReceiveFocus = original?.isConnected === true && !original.matches(':disabled, [aria-disabled="true"]');
119
+ const target = originalCanReceiveFocus ? original : formRef.current?.querySelector('input:not(:disabled), textarea:not(:disabled), select:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])');
120
+ target?.focus({
121
+ preventScroll: true
122
+ });
123
+ }, [
124
+ isBusy
125
+ ]);
126
+ const captureFocusBeforeBusy = useCallback(()=>{
127
+ if (null != focusBeforeBusyRef.current) return;
128
+ const activeElement = document.activeElement;
129
+ if (!(activeElement instanceof HTMLElement) || !formRef.current?.contains(activeElement)) return;
130
+ focusBeforeBusyRef.current = activeElement;
131
+ restoreFocusAfterBusyRef.current = true;
132
+ }, []);
109
133
  const handleCancel = ()=>{
110
134
  if (onCancel && 'function' == typeof onCancel) onCancel();
111
135
  };
@@ -113,6 +137,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
113
137
  if ('function' != typeof onSubmit) return;
114
138
  if (submittingRef.current) return;
115
139
  submittingRef.current = true;
140
+ captureFocusBeforeBusy();
116
141
  setIsSubmitting(true);
117
142
  try {
118
143
  await onSubmit(payload);
@@ -122,6 +147,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
122
147
  setIsSubmitting(false);
123
148
  }
124
149
  }, [
150
+ captureFocusBeforeBusy,
125
151
  onSubmit,
126
152
  resetHasChanges
127
153
  ]);
@@ -137,6 +163,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
137
163
  if (allErrors.length > 0) return void console.error('Form validation failed:', allErrors);
138
164
  const pendingUploads = getPendingUploads();
139
165
  if (pendingUploads.size > 0) {
166
+ captureFocusBeforeBusy();
140
167
  setIsUploading(true);
141
168
  try {
142
169
  const uploadResult = await executeUploadsWithProgress(pendingUploads, uploadField, ({ fieldPath, status })=>{
@@ -245,196 +272,226 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
245
272
  ]
246
273
  }, `tabset:${set.name}`);
247
274
  };
248
- return /*#__PURE__*/ jsxs("form", {
249
- method: "post",
250
- noValidate: true,
251
- onSubmit: handleSubmit,
252
- className: clsx('byline-form', form_renderer_module.form),
253
- inert: isUploading ? true : void 0,
254
- "aria-busy": isUploading,
275
+ const busyAnnouncement = isUploading ? t('forms.actions.uploading') : isSubmitting ? t('forms.actions.saving') : '';
276
+ return /*#__PURE__*/ jsxs(Fragment, {
255
277
  children: [
256
- /*#__PURE__*/ jsxs("div", {
257
- className: clsx('byline-form-heading-row', form_renderer_module["heading-row"]),
258
- children: [
259
- /*#__PURE__*/ jsx("h1", {
260
- className: clsx('byline-form-heading', form_renderer_module.heading),
261
- children: computedHeading
262
- }),
263
- headerSlot
264
- ]
278
+ /*#__PURE__*/ jsx("span", {
279
+ className: clsx('byline-form-busy-status', form_renderer_module["busy-status"]),
280
+ role: "status",
281
+ "aria-live": "polite",
282
+ children: busyAnnouncement
265
283
  }),
266
- /*#__PURE__*/ jsxs("div", {
267
- className: clsx('byline-form-status-bar', form_renderer_module["status-bar"]),
268
- children: [
269
- /*#__PURE__*/ jsx("div", {
270
- className: clsx('byline-form-status-details', form_renderer_module["status-details"]),
271
- children: /*#__PURE__*/ jsx(FormStatusDisplay, {
272
- initialData: initialData,
273
- workflowStatuses: workflowStatuses,
274
- publishedVersion: publishedVersion,
275
- onUnpublish: onUnpublish,
276
- afterStatusCells: /*#__PURE__*/ jsx(ScheduledPublicationCell, {
277
- state: scheduling.state,
278
- timeZone: scheduling.timeZone
279
- })
280
- })
281
- }),
282
- /*#__PURE__*/ jsxs("div", {
283
- className: clsx('byline-form-actions', form_renderer_module.actions),
284
- children: [
285
- /*#__PURE__*/ jsx(Button, {
286
- className: clsx('byline-form-actions-button', form_renderer_module["actions-button"]),
287
- size: "sm",
288
- intent: "noeffect",
289
- type: "button",
290
- onClick: handleCancel,
291
- children: false === hasChanges ? t('common.actions.close') : t('common.actions.cancel')
292
- }),
293
- /*#__PURE__*/ jsx(Button, {
294
- className: clsx('byline-form-actions-button', form_renderer_module["actions-button"]),
295
- size: "sm",
296
- type: "submit",
297
- disabled: false === hasChanges || isUploading || isSubmitting,
298
- children: isUploading ? t('forms.actions.uploading') : t('common.actions.save')
299
- }),
300
- primaryStatus && onStatusChange && /*#__PURE__*/ jsx("div", {
301
- className: clsx('byline-form-actions-status-wrap', form_renderer_module["actions-status-wrap"]),
302
- children: /*#__PURE__*/ jsx(ComboButton, {
303
- buttonClassName: clsx('byline-form-actions-combo-button', form_renderer_module["actions-combo-button"]),
304
- triggerClassName: clsx('byline-form-actions-combo-trigger', form_renderer_module["actions-combo-trigger"]),
305
- options: secondaryStatuses.map((s)=>({
306
- label: isTerminal ? t('forms.actions.revertTo', {
307
- label: s.label ?? s.name
308
- }) : s.verb ?? s.label ?? s.name,
309
- value: s.name
310
- })),
311
- sideOffset: 5,
312
- size: "sm",
313
- type: "button",
314
- intent: isTerminal ? 'info' : 'success',
315
- disabled: statusBusy,
316
- onOptionSelect: async (value)=>{
317
- if (hasChanges) return void setShowUnsavedModal(true);
318
- setStatusBusy(true);
319
- try {
320
- await onStatusChange(value);
321
- } finally{
322
- setStatusBusy(false);
323
- }
324
- },
325
- onButtonClick: isTerminal ? void 0 : async ()=>{
326
- if (hasChanges) return void setShowUnsavedModal(true);
327
- setStatusBusy(true);
328
- try {
329
- await onStatusChange(primaryStatus.name);
330
- } finally{
331
- setStatusBusy(false);
332
- }
333
- },
334
- children: statusBusy ? '...' : isTerminal ? primaryStatus.label ?? primaryStatus.name : primaryStatus.verb ?? primaryStatus.label ?? primaryStatus.name
284
+ /*#__PURE__*/ jsx("div", {
285
+ "aria-busy": isBusy,
286
+ children: /*#__PURE__*/ jsxs("form", {
287
+ ref: formRef,
288
+ method: "post",
289
+ noValidate: true,
290
+ onSubmit: handleSubmit,
291
+ className: clsx('byline-form', form_renderer_module.form),
292
+ inert: isBusy ? true : void 0,
293
+ children: [
294
+ /*#__PURE__*/ jsxs("div", {
295
+ className: clsx('byline-form-heading-row', form_renderer_module["heading-row"]),
296
+ children: [
297
+ /*#__PURE__*/ jsx("h1", {
298
+ className: clsx('byline-form-heading', form_renderer_module.heading),
299
+ children: computedHeading
300
+ }),
301
+ headerSlot
302
+ ]
303
+ }),
304
+ /*#__PURE__*/ jsxs("div", {
305
+ className: clsx('byline-form-status-bar', form_renderer_module["status-bar"]),
306
+ children: [
307
+ /*#__PURE__*/ jsx("div", {
308
+ className: clsx('byline-form-status-details', form_renderer_module["status-details"]),
309
+ children: /*#__PURE__*/ jsx(FormStatusDisplay, {
310
+ initialData: initialData,
311
+ workflowStatuses: workflowStatuses,
312
+ publishedVersion: publishedVersion,
313
+ onUnpublish: onUnpublish,
314
+ afterStatusCells: /*#__PURE__*/ jsx(ScheduledPublicationCell, {
315
+ state: scheduling.state,
316
+ timeZone: scheduling.timeZone
317
+ })
318
+ })
319
+ }),
320
+ /*#__PURE__*/ jsxs("div", {
321
+ className: clsx('byline-form-actions', form_renderer_module.actions),
322
+ children: [
323
+ /*#__PURE__*/ jsx(Button, {
324
+ className: clsx('byline-form-actions-button', form_renderer_module["actions-button"]),
325
+ size: "sm",
326
+ intent: "noeffect",
327
+ type: "button",
328
+ onClick: handleCancel,
329
+ children: false === hasChanges ? t('common.actions.close') : t('common.actions.cancel')
330
+ }),
331
+ /*#__PURE__*/ jsx(Button, {
332
+ className: clsx('byline-form-actions-button', form_renderer_module["actions-button"]),
333
+ size: "sm",
334
+ type: "submit",
335
+ disabled: false === hasChanges || isUploading || isSubmitting,
336
+ "aria-label": isSubmitting ? t('common.actions.save') : void 0,
337
+ children: isUploading ? t('forms.actions.uploading') : /*#__PURE__*/ jsxs("span", {
338
+ className: clsx('byline-form-save-content', form_renderer_module["save-content"]),
339
+ children: [
340
+ /*#__PURE__*/ jsx("span", {
341
+ className: clsx('byline-form-save-label', form_renderer_module["save-label"], isSubmitting && form_renderer_module["save-label-hidden"]),
342
+ children: t('common.actions.save')
343
+ }),
344
+ isSubmitting ? /*#__PURE__*/ jsx("span", {
345
+ className: clsx('byline-form-save-loader', form_renderer_module["save-loader"]),
346
+ children: /*#__PURE__*/ jsx(LoaderEllipsis, {
347
+ size: 28,
348
+ "aria-hidden": "true"
349
+ })
350
+ }) : null
351
+ ]
352
+ })
353
+ }),
354
+ primaryStatus && onStatusChange && /*#__PURE__*/ jsx("div", {
355
+ className: clsx('byline-form-actions-status-wrap', form_renderer_module["actions-status-wrap"]),
356
+ children: /*#__PURE__*/ jsx(ComboButton, {
357
+ buttonClassName: clsx('byline-form-actions-combo-button', form_renderer_module["actions-combo-button"]),
358
+ triggerClassName: clsx('byline-form-actions-combo-trigger', form_renderer_module["actions-combo-trigger"]),
359
+ options: secondaryStatuses.map((s)=>({
360
+ label: isTerminal ? t('forms.actions.revertTo', {
361
+ label: s.label ?? s.name
362
+ }) : s.verb ?? s.label ?? s.name,
363
+ value: s.name
364
+ })),
365
+ sideOffset: 5,
366
+ size: "sm",
367
+ type: "button",
368
+ intent: isTerminal ? 'info' : 'success',
369
+ disabled: statusBusy,
370
+ onOptionSelect: async (value)=>{
371
+ if (hasChanges) return void setShowUnsavedModal(true);
372
+ setStatusBusy(true);
373
+ try {
374
+ await onStatusChange(value);
375
+ } finally{
376
+ setStatusBusy(false);
377
+ }
378
+ },
379
+ onButtonClick: isTerminal ? void 0 : async ()=>{
380
+ if (hasChanges) return void setShowUnsavedModal(true);
381
+ setStatusBusy(true);
382
+ try {
383
+ await onStatusChange(primaryStatus.name);
384
+ } finally{
385
+ setStatusBusy(false);
386
+ }
387
+ },
388
+ children: statusBusy ? '...' : isTerminal ? primaryStatus.label ?? primaryStatus.name : primaryStatus.verb ?? primaryStatus.label ?? primaryStatus.name
389
+ })
390
+ }),
391
+ /*#__PURE__*/ jsx(DocumentActions, {
392
+ publishedVersion: publishedVersion,
393
+ onUnpublish: onUnpublish,
394
+ onDelete: onDelete,
395
+ onDuplicate: onDuplicate,
396
+ sourceTitle: null != useAsTitle && null != initialData ? initialData[useAsTitle] : null,
397
+ onCopyToLocale: onCopyToLocale,
398
+ sourceLocale: contentLocale,
399
+ contentLocales: contentLocales,
400
+ hasUnsavedChanges: hasChanges,
401
+ onUnsavedChanges: ()=>setShowUnsavedModal(true),
402
+ onDeleteLocale: onDeleteLocale,
403
+ defaultLocale: defaultLocale,
404
+ availableLocales: initialData?._availableVersionLocales,
405
+ scheduledPublicationState: scheduling.state,
406
+ onSchedulePublication: scheduling.openSchedule,
407
+ onConfirmScheduledPublication: scheduling.confirm,
408
+ onCancelScheduledPublication: scheduling.cancel
409
+ })
410
+ ]
335
411
  })
336
- }),
337
- /*#__PURE__*/ jsx(DocumentActions, {
338
- publishedVersion: publishedVersion,
339
- onUnpublish: onUnpublish,
340
- onDelete: onDelete,
341
- onDuplicate: onDuplicate,
342
- sourceTitle: null != useAsTitle && null != initialData ? initialData[useAsTitle] : null,
343
- onCopyToLocale: onCopyToLocale,
344
- sourceLocale: contentLocale,
345
- contentLocales: contentLocales,
346
- hasUnsavedChanges: hasChanges,
347
- onUnsavedChanges: ()=>setShowUnsavedModal(true),
348
- onDeleteLocale: onDeleteLocale,
349
- defaultLocale: defaultLocale,
350
- availableLocales: initialData?._availableVersionLocales,
351
- scheduledPublicationState: scheduling.state,
352
- onSchedulePublication: scheduling.openSchedule,
353
- onConfirmScheduledPublication: scheduling.confirm,
354
- onCancelScheduledPublication: scheduling.cancel
355
- })
356
- ]
357
- })
358
- ]
359
- }),
360
- /*#__PURE__*/ jsx(ScheduledPublicationNotice, {
361
- state: scheduling.state,
362
- timeZone: scheduling.timeZone,
363
- busy: scheduling.busy,
364
- onConfirm: scheduling.confirm,
365
- onReschedule: scheduling.openSchedule,
366
- onCancel: scheduling.cancel
367
- }),
368
- scheduling.modal,
369
- restoreWarnings && restoreWarnings.length > 0 && /*#__PURE__*/ jsxs(Alert, {
370
- className: "m-0 mt-4",
371
- intent: "warning",
372
- icon: true,
373
- close: false,
374
- title: t('forms.restoreWarnings.title'),
375
- children: [
376
- /*#__PURE__*/ jsx("p", {
377
- children: t('forms.restoreWarnings.body', {
378
- count: restoreWarnings.length
412
+ ]
413
+ }),
414
+ /*#__PURE__*/ jsx(ScheduledPublicationNotice, {
415
+ state: scheduling.state,
416
+ timeZone: scheduling.timeZone,
417
+ busy: scheduling.busy,
418
+ onConfirm: scheduling.confirm,
419
+ onReschedule: scheduling.openSchedule,
420
+ onCancel: scheduling.cancel
421
+ }),
422
+ scheduling.modal,
423
+ restoreWarnings && restoreWarnings.length > 0 && /*#__PURE__*/ jsxs(Alert, {
424
+ className: "m-0 mt-4",
425
+ intent: "warning",
426
+ icon: true,
427
+ close: false,
428
+ title: t('forms.restoreWarnings.title'),
429
+ children: [
430
+ /*#__PURE__*/ jsx("p", {
431
+ children: t('forms.restoreWarnings.body', {
432
+ count: restoreWarnings.length
433
+ })
434
+ }),
435
+ /*#__PURE__*/ jsx("ul", {
436
+ children: restoreWarnings.map((w)=>/*#__PURE__*/ jsx("li", {
437
+ children: w
438
+ }, w))
439
+ })
440
+ ]
441
+ }),
442
+ /*#__PURE__*/ jsxs("div", {
443
+ className: clsx('byline-form-layout', form_renderer_module.layout),
444
+ children: [
445
+ /*#__PURE__*/ jsx("div", {
446
+ className: clsx('byline-form-content', form_renderer_module.content),
447
+ children: layout.main.map((name)=>renderItem(name))
448
+ }),
449
+ /*#__PURE__*/ jsxs("div", {
450
+ className: clsx('byline-form-sidebar', form_renderer_module.sidebar),
451
+ children: [
452
+ showPath && (useAsPath || 'string' == typeof initialData?.path && initialData.path.length > 0) && /*#__PURE__*/ jsx(PathWidget, {
453
+ useAsPath: useAsPath,
454
+ collectionPath: collectionPath ?? '',
455
+ defaultLocale: defaultLocale,
456
+ activeLocale: contentLocale,
457
+ mode: mode,
458
+ slugifier: pathSlugifier,
459
+ sourceLocked: pathSourceLocked
460
+ }),
461
+ tree && 'edit' === mode && 'string' == typeof initialData?.id && /*#__PURE__*/ jsx(TreePlacementWidget, {
462
+ collectionPath: collectionPath ?? '',
463
+ documentId: initialData.id,
464
+ useAsTitle: useAsTitle
465
+ }),
466
+ advertiseLocales && /*#__PURE__*/ jsx(AvailableLocalesWidget, {
467
+ contentLocales: contentLocales ?? [],
468
+ availableVersionLocales: initialData?._availableVersionLocales ?? []
469
+ }),
470
+ (layout.sidebar ?? []).map((name)=>renderItem(name))
471
+ ]
472
+ })
473
+ ]
474
+ }),
475
+ showUnsavedModal && /*#__PURE__*/ jsx(UnsavedChangesModal, {
476
+ onClose: ()=>setShowUnsavedModal(false)
477
+ }),
478
+ null != pendingSystemFieldsSubmit && /*#__PURE__*/ jsx(SystemFieldsConfirmModal, {
479
+ contentDirty: pendingSystemFieldsSubmit.contentDirty,
480
+ pathDirty: pendingSystemFieldsSubmit.pathDirty,
481
+ availableLocalesDirty: pendingSystemFieldsSubmit.availableLocalesDirty,
482
+ onCancel: ()=>setPendingSystemFieldsSubmit(null),
483
+ onConfirm: ()=>{
484
+ const payload = pendingSystemFieldsSubmit;
485
+ setPendingSystemFieldsSubmit(null);
486
+ submitPayload(payload);
487
+ }
488
+ }),
489
+ guard.isBlocked && /*#__PURE__*/ jsx(NavigationGuardModal, {
490
+ onStay: guard.stay,
491
+ onProceed: guard.proceed
379
492
  })
380
- }),
381
- /*#__PURE__*/ jsx("ul", {
382
- children: restoreWarnings.map((w)=>/*#__PURE__*/ jsx("li", {
383
- children: w
384
- }, w))
385
- })
386
- ]
387
- }),
388
- /*#__PURE__*/ jsxs("div", {
389
- className: clsx('byline-form-layout', form_renderer_module.layout),
390
- children: [
391
- /*#__PURE__*/ jsx("div", {
392
- className: clsx('byline-form-content', form_renderer_module.content),
393
- children: layout.main.map((name)=>renderItem(name))
394
- }),
395
- /*#__PURE__*/ jsxs("div", {
396
- className: clsx('byline-form-sidebar', form_renderer_module.sidebar),
397
- children: [
398
- showPath && (useAsPath || 'string' == typeof initialData?.path && initialData.path.length > 0) && /*#__PURE__*/ jsx(PathWidget, {
399
- useAsPath: useAsPath,
400
- collectionPath: collectionPath ?? '',
401
- defaultLocale: defaultLocale,
402
- activeLocale: contentLocale,
403
- mode: mode,
404
- slugifier: pathSlugifier,
405
- sourceLocked: pathSourceLocked
406
- }),
407
- tree && 'edit' === mode && 'string' == typeof initialData?.id && /*#__PURE__*/ jsx(TreePlacementWidget, {
408
- collectionPath: collectionPath ?? '',
409
- documentId: initialData.id,
410
- useAsTitle: useAsTitle
411
- }),
412
- advertiseLocales && /*#__PURE__*/ jsx(AvailableLocalesWidget, {
413
- contentLocales: contentLocales ?? [],
414
- availableVersionLocales: initialData?._availableVersionLocales ?? []
415
- }),
416
- (layout.sidebar ?? []).map((name)=>renderItem(name))
417
- ]
418
- })
419
- ]
420
- }),
421
- showUnsavedModal && /*#__PURE__*/ jsx(UnsavedChangesModal, {
422
- onClose: ()=>setShowUnsavedModal(false)
423
- }),
424
- null != pendingSystemFieldsSubmit && /*#__PURE__*/ jsx(SystemFieldsConfirmModal, {
425
- contentDirty: pendingSystemFieldsSubmit.contentDirty,
426
- pathDirty: pendingSystemFieldsSubmit.pathDirty,
427
- availableLocalesDirty: pendingSystemFieldsSubmit.availableLocalesDirty,
428
- onCancel: ()=>setPendingSystemFieldsSubmit(null),
429
- onConfirm: ()=>{
430
- const payload = pendingSystemFieldsSubmit;
431
- setPendingSystemFieldsSubmit(null);
432
- submitPayload(payload);
433
- }
434
- }),
435
- guard.isBlocked && /*#__PURE__*/ jsx(NavigationGuardModal, {
436
- onStay: guard.stay,
437
- onProceed: guard.proceed
493
+ ]
494
+ })
438
495
  })
439
496
  ]
440
497
  });
@@ -24,6 +24,16 @@ const form_renderer_module = {
24
24
  actions: "actions-AZBIdR",
25
25
  "actions-button": "actions-button-VIwbST",
26
26
  actionsButton: "actions-button-VIwbST",
27
+ "busy-status": "busy-status-kzSMVH",
28
+ busyStatus: "busy-status-kzSMVH",
29
+ "save-content": "save-content-bc8XeC",
30
+ saveContent: "save-content-bc8XeC",
31
+ "save-label": "save-label-iJw9DA",
32
+ saveLabel: "save-label-iJw9DA",
33
+ "save-label-hidden": "save-label-hidden-Wct46I",
34
+ saveLabelHidden: "save-label-hidden-Wct46I",
35
+ "save-loader": "save-loader-g2wcnV",
36
+ saveLoader: "save-loader-g2wcnV",
27
37
  "actions-status-wrap": "actions-status-wrap-hSvlps",
28
38
  actionsStatusWrap: "actions-status-wrap-hSvlps",
29
39
  "actions-combo-button": "actions-combo-button-XvqHCC",
@@ -141,6 +141,40 @@
141
141
  min-height: 28px;
142
142
  }
143
143
 
144
+ :is(.busy-status-kzSMVH, .byline-form-busy-status) {
145
+ clip: rect(0 0 0 0);
146
+ clip-path: inset(50%);
147
+ white-space: nowrap;
148
+ border: 0;
149
+ width: 1px;
150
+ height: 1px;
151
+ margin: -1px;
152
+ padding: 0;
153
+ position: absolute;
154
+ overflow: hidden;
155
+ }
156
+
157
+ :is(.save-content-bc8XeC, .byline-form-save-content) {
158
+ display: inline-block;
159
+ position: relative;
160
+ }
161
+
162
+ :is(.save-label-iJw9DA, .byline-form-save-label) {
163
+ display: inline-block;
164
+ }
165
+
166
+ .save-label-hidden-Wct46I {
167
+ visibility: hidden;
168
+ }
169
+
170
+ :is(.save-loader-g2wcnV, .byline-form-save-loader) {
171
+ display: inline-flex;
172
+ position: absolute;
173
+ top: 50%;
174
+ left: 50%;
175
+ transform: translate(-50%, -50%);
176
+ }
177
+
144
178
  :is(.actions-status-wrap-hSvlps, .byline-form-actions-status-wrap) {
145
179
  z-index: 10;
146
180
  position: relative;
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.18.0",
5
+ "version": "4.19.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -183,12 +183,12 @@
183
183
  "react-diff-viewer-continued": "^4.4.0",
184
184
  "uuid": "^14.0.2",
185
185
  "zod": "^4.4.3",
186
- "@byline/analytics": "4.18.0",
187
- "@byline/core": "4.18.0",
188
- "@byline/analytics-agent": "4.18.0",
189
- "@byline/auth": "4.18.0",
190
- "@byline/ui": "4.18.0",
191
- "@byline/i18n": "4.18.0"
186
+ "@byline/analytics": "4.19.0",
187
+ "@byline/analytics-agent": "4.19.0",
188
+ "@byline/auth": "4.19.0",
189
+ "@byline/ui": "4.19.0",
190
+ "@byline/i18n": "4.19.0",
191
+ "@byline/core": "4.19.0"
192
192
  },
193
193
  "peerDependencies": {
194
194
  "react": "^19.0.0",
@@ -129,6 +129,16 @@ const submitForm = () => {
129
129
  })
130
130
  }
131
131
 
132
+ const deferred = () => {
133
+ let resolve: () => void = () => {}
134
+ let reject: (reason?: unknown) => void = () => {}
135
+ const promise = new Promise<void>((resolvePromise, rejectPromise) => {
136
+ resolve = resolvePromise
137
+ reject = rejectPromise
138
+ })
139
+ return { promise, resolve, reject }
140
+ }
141
+
132
142
  describe('FormRenderer submit contract', () => {
133
143
  const render = (props: Record<string, unknown>) =>
134
144
  renderInProvider(<FormRenderer {...(props as any)} />)
@@ -169,13 +179,97 @@ describe('FormRenderer submit contract', () => {
169
179
  expect(container.textContent).toContain('Cancel')
170
180
  })
171
181
 
172
- it('ignores a second submit while the first is still in flight', async () => {
173
- let release: () => void = () => {}
174
- const gate = new Promise<void>((resolve) => {
175
- release = resolve
182
+ it('shows a named busy indicator until onSubmit resolves', async () => {
183
+ const submission = deferred()
184
+ render({ ...baseProps, onSubmit: () => submission.promise })
185
+ typeIntoTitle('Hello')
186
+ submitForm()
187
+ await act(async () => {})
188
+
189
+ const form = container.querySelector('form')
190
+ const busyRegion = form?.parentElement
191
+ const liveStatus = container.querySelector('[role="status"]')
192
+ const saveButton = container.querySelector<HTMLButtonElement>('button[type="submit"]')
193
+ expect(busyRegion?.getAttribute('aria-busy')).toBe('true')
194
+ expect(form?.getAttribute('aria-busy')).toBeNull()
195
+ expect(form?.hasAttribute('inert')).toBe(true)
196
+ expect(liveStatus?.textContent).toBe('Saving…')
197
+ expect(busyRegion?.contains(liveStatus)).toBe(false)
198
+ expect(saveButton?.getAttribute('aria-label')).toBe('Save')
199
+ expect(saveButton?.querySelector('.byline-loader-ellipsis')).not.toBeNull()
200
+ expect(saveButton?.querySelector('.byline-form-save-label')?.textContent).toBe('Save')
201
+
202
+ await act(async () => {
203
+ submission.resolve()
204
+ await submission.promise
176
205
  })
206
+
207
+ expect(busyRegion?.getAttribute('aria-busy')).toBe('false')
208
+ expect(form?.hasAttribute('inert')).toBe(false)
209
+ expect(liveStatus?.textContent).toBe('')
210
+ expect(saveButton?.getAttribute('aria-label')).toBeNull()
211
+ expect(saveButton?.querySelector('.byline-loader-ellipsis')).toBeNull()
212
+ })
213
+
214
+ it('hides the busy indicator when onSubmit rejects', async () => {
215
+ const submission = deferred()
216
+ render({ ...baseProps, onSubmit: () => submission.promise })
217
+ typeIntoTitle('Hello')
218
+ submitForm()
219
+ await act(async () => {})
220
+
221
+ const form = container.querySelector('form')
222
+ const busyRegion = form?.parentElement
223
+ const saveButton = container.querySelector<HTMLButtonElement>('button[type="submit"]')
224
+ expect(busyRegion?.getAttribute('aria-busy')).toBe('true')
225
+ expect(form?.getAttribute('aria-busy')).toBeNull()
226
+ expect(saveButton?.querySelector('.byline-loader-ellipsis')).not.toBeNull()
227
+
228
+ await act(async () => {
229
+ submission.reject(new Error('save failed'))
230
+ try {
231
+ await submission.promise
232
+ } catch {
233
+ // FormRenderer intentionally handles host-reported submission failures.
234
+ }
235
+ })
236
+
237
+ expect(form?.getAttribute('aria-busy')).toBeNull()
238
+ expect(busyRegion?.getAttribute('aria-busy')).toBe('false')
239
+ expect(form?.hasAttribute('inert')).toBe(false)
240
+ expect(saveButton?.getAttribute('aria-label')).toBeNull()
241
+ expect(saveButton?.querySelector('.byline-loader-ellipsis')).toBeNull()
242
+ })
243
+
244
+ it('restores keyboard focus after the inert submitting window closes', async () => {
245
+ const submission = deferred()
246
+ render({ ...baseProps, onSubmit: () => submission.promise })
247
+ typeIntoTitle('Hello')
248
+ const input = container.querySelector<HTMLInputElement>('input[name="title"]')
249
+ if (input == null) throw new Error('title input not found')
250
+ input.focus()
251
+ expect(document.activeElement).toBe(input)
252
+
253
+ submitForm()
254
+ await act(async () => {})
255
+
256
+ document.body.tabIndex = -1
257
+ document.body.focus()
258
+ expect(document.activeElement).toBe(document.body)
259
+
260
+ await act(async () => {
261
+ submission.resolve()
262
+ await submission.promise
263
+ })
264
+
265
+ expect(document.activeElement).toBe(input)
266
+ document.body.removeAttribute('tabindex')
267
+ })
268
+
269
+ it('ignores a second submit while the first is still in flight', async () => {
270
+ const submission = deferred()
177
271
  const onSubmit = vi.fn(async () => {
178
- await gate
272
+ await submission.promise
179
273
  })
180
274
  render({ ...baseProps, onSubmit })
181
275
  typeIntoTitle('Hello')
@@ -187,7 +281,7 @@ describe('FormRenderer submit contract', () => {
187
281
  await act(async () => {})
188
282
  expect(onSubmit).toHaveBeenCalledTimes(1)
189
283
  await act(async () => {
190
- release()
284
+ submission.resolve()
191
285
  })
192
286
  expect(onSubmit).toHaveBeenCalledTimes(1)
193
287
  })
@@ -20,6 +20,10 @@
20
20
  * .byline-form-actions-status-wrap — relative positioned wrapper around the ComboButton
21
21
  * .byline-form-actions-combo-button — ComboButton's main button minimums
22
22
  * .byline-form-actions-combo-trigger — ComboButton's trigger button minimums
23
+ * .byline-form-busy-status — visually-hidden save/upload announcement
24
+ * .byline-form-save-content — stable-width Save label/loader wrapper
25
+ * .byline-form-save-label — visible label that determines button width
26
+ * .byline-form-save-loader — centered progress overlay
23
27
  *
24
28
  * .byline-form-layout — main + sidebar grid wrapper
25
29
  * .byline-form-content — main column
@@ -206,6 +210,44 @@
206
210
  min-height: 28px;
207
211
  }
208
212
 
213
+ .busy-status,
214
+ :global(.byline-form-busy-status) {
215
+ position: absolute;
216
+ overflow: hidden;
217
+ width: 1px;
218
+ height: 1px;
219
+ padding: 0;
220
+ border: 0;
221
+ margin: -1px;
222
+ clip: rect(0 0 0 0);
223
+ clip-path: inset(50%);
224
+ white-space: nowrap;
225
+ }
226
+
227
+ .save-content,
228
+ :global(.byline-form-save-content) {
229
+ position: relative;
230
+ display: inline-block;
231
+ }
232
+
233
+ .save-label,
234
+ :global(.byline-form-save-label) {
235
+ display: inline-block;
236
+ }
237
+
238
+ .save-label-hidden {
239
+ visibility: hidden;
240
+ }
241
+
242
+ .save-loader,
243
+ :global(.byline-form-save-loader) {
244
+ position: absolute;
245
+ top: 50%;
246
+ left: 50%;
247
+ display: inline-flex;
248
+ transform: translate(-50%, -50%);
249
+ }
250
+
209
251
  .actions-status-wrap,
210
252
  :global(.byline-form-actions-status-wrap) {
211
253
  position: relative;
@@ -21,7 +21,7 @@ import type {
21
21
  import { getAdminConfig } from '@byline/core'
22
22
  import type { DocumentPatch } from '@byline/core/patches'
23
23
  import { useTranslation } from '@byline/i18n/react'
24
- import { Alert, Button, ComboButton } from '@byline/ui/react'
24
+ import { Alert, Button, ComboButton, LoaderEllipsis } from '@byline/ui/react'
25
25
  import cx from 'clsx'
26
26
 
27
27
  import { sliceFieldAdmin } from '../fields/field-admin'
@@ -263,6 +263,10 @@ const FormContent = ({
263
263
  const [isUploading, setIsUploading] = useState(false)
264
264
  const submittingRef = useRef(false)
265
265
  const [isSubmitting, setIsSubmitting] = useState(false)
266
+ const isBusy = isUploading || isSubmitting
267
+ const formRef = useRef<HTMLFormElement>(null)
268
+ const focusBeforeBusyRef = useRef<HTMLElement | null>(null)
269
+ const restoreFocusAfterBusyRef = useRef(false)
266
270
  // Block-only "save first" guard. Set true when the editor triggers a
267
271
  // guarded action (status change, duplicate, copy-to-locale) while the form
268
272
  // is dirty — those actions operate on the saved version, so unsaved edits
@@ -392,6 +396,34 @@ const FormContent = ({
392
396
  return subscribeMeta(() => setFormData(getFieldValues()))
393
397
  }, [subscribeMeta, getFieldValues])
394
398
 
399
+ // `inert` removes the active control from the tab order while a save is in
400
+ // flight. Restore the editor's position after React has removed `inert`;
401
+ // when the original control became disabled, fall back to the first usable
402
+ // form control instead of leaving focus on <body>.
403
+ useEffect(() => {
404
+ if (isBusy || !restoreFocusAfterBusyRef.current) return
405
+ restoreFocusAfterBusyRef.current = false
406
+
407
+ const original = focusBeforeBusyRef.current
408
+ focusBeforeBusyRef.current = null
409
+ const originalCanReceiveFocus =
410
+ original?.isConnected === true && !original.matches(':disabled, [aria-disabled="true"]')
411
+ const target = originalCanReceiveFocus
412
+ ? original
413
+ : formRef.current?.querySelector<HTMLElement>(
414
+ 'input:not(:disabled), textarea:not(:disabled), select:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])'
415
+ )
416
+ target?.focus({ preventScroll: true })
417
+ }, [isBusy])
418
+
419
+ const captureFocusBeforeBusy = useCallback(() => {
420
+ if (focusBeforeBusyRef.current != null) return
421
+ const activeElement = document.activeElement
422
+ if (!(activeElement instanceof HTMLElement) || !formRef.current?.contains(activeElement)) return
423
+ focusBeforeBusyRef.current = activeElement
424
+ restoreFocusAfterBusyRef.current = true
425
+ }, [])
426
+
395
427
  const handleCancel = () => {
396
428
  if (onCancel && typeof onCancel === 'function') {
397
429
  onCancel()
@@ -409,6 +441,7 @@ const FormContent = ({
409
441
  if (typeof onSubmit !== 'function') return
410
442
  if (submittingRef.current) return
411
443
  submittingRef.current = true
444
+ captureFocusBeforeBusy()
412
445
  setIsSubmitting(true)
413
446
  try {
414
447
  await onSubmit(payload)
@@ -421,7 +454,7 @@ const FormContent = ({
421
454
  setIsSubmitting(false)
422
455
  }
423
456
  },
424
- [onSubmit, resetHasChanges]
457
+ [captureFocusBeforeBusy, onSubmit, resetHasChanges]
425
458
  )
426
459
 
427
460
  const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
@@ -441,6 +474,7 @@ const FormContent = ({
441
474
  // Execute any pending uploads before submitting
442
475
  const pendingUploads = getPendingUploads()
443
476
  if (pendingUploads.size > 0) {
477
+ captureFocusBeforeBusy()
444
478
  setIsUploading(true)
445
479
  try {
446
480
  const uploadResult = await executeUploadsWithProgress(
@@ -605,217 +639,261 @@ const FormContent = ({
605
639
  )
606
640
  }
607
641
 
642
+ const busyAnnouncement = isUploading
643
+ ? t('forms.actions.uploading')
644
+ : isSubmitting
645
+ ? t('forms.actions.saving')
646
+ : ''
647
+
608
648
  return (
609
- <form
610
- method="post"
611
- noValidate
612
- onSubmit={handleSubmit}
613
- className={cx('byline-form', styles.form)}
614
- inert={isUploading ? true : undefined}
615
- aria-busy={isUploading}
616
- >
617
- <div className={cx('byline-form-heading-row', styles['heading-row'])}>
618
- <h1 className={cx('byline-form-heading', styles.heading)}>{computedHeading}</h1>
619
- {/* Source-locale anchor indicator removed pending heading-layout work.
649
+ <>
650
+ <span
651
+ className={cx('byline-form-busy-status', styles['busy-status'])}
652
+ role="status"
653
+ aria-live="polite"
654
+ >
655
+ {busyAnnouncement}
656
+ </span>
657
+ <div aria-busy={isBusy}>
658
+ <form
659
+ ref={formRef}
660
+ method="post"
661
+ noValidate
662
+ onSubmit={handleSubmit}
663
+ className={cx('byline-form', styles.form)}
664
+ inert={isBusy ? true : undefined}
665
+ >
666
+ <div className={cx('byline-form-heading-row', styles['heading-row'])}>
667
+ <h1 className={cx('byline-form-heading', styles.heading)}>{computedHeading}</h1>
668
+ {/* Source-locale anchor indicator removed pending heading-layout work.
620
669
  To re-enable: render `<SourceLocaleBadge locale={sourceLocale} />`
621
670
  here from `initialData.sourceLocale` (mismatch-only is the intended
622
671
  end state). See docs/08-internationalization/index.md. */}
623
- {headerSlot}
624
- </div>
625
- <div className={cx('byline-form-status-bar', styles['status-bar'])}>
626
- <div className={cx('byline-form-status-details', styles['status-details'])}>
627
- <FormStatusDisplay
628
- initialData={initialData}
629
- workflowStatuses={workflowStatuses}
630
- publishedVersion={publishedVersion}
631
- onUnpublish={onUnpublish}
632
- afterStatusCells={
633
- <ScheduledPublicationCell state={scheduling.state} timeZone={scheduling.timeZone} />
634
- }
635
- />
636
- </div>
637
- <div className={cx('byline-form-actions', styles.actions)}>
638
- <Button
639
- className={cx('byline-form-actions-button', styles['actions-button'])}
640
- size="sm"
641
- intent="noeffect"
642
- type="button"
643
- onClick={handleCancel}
644
- >
645
- {hasChanges === false ? t('common.actions.close') : t('common.actions.cancel')}
646
- </Button>
647
- <Button
648
- className={cx('byline-form-actions-button', styles['actions-button'])}
649
- size="sm"
650
- type="submit"
651
- disabled={hasChanges === false || isUploading || isSubmitting}
652
- >
653
- {isUploading ? t('forms.actions.uploading') : t('common.actions.save')}
654
- </Button>
655
- {primaryStatus && onStatusChange && (
656
- <div className={cx('byline-form-actions-status-wrap', styles['actions-status-wrap'])}>
657
- <ComboButton
658
- buttonClassName={cx(
659
- 'byline-form-actions-combo-button',
660
- styles['actions-combo-button']
661
- )}
662
- triggerClassName={cx(
663
- 'byline-form-actions-combo-trigger',
664
- styles['actions-combo-trigger']
665
- )}
666
- options={secondaryStatuses.map((s) => ({
667
- label: isTerminal
668
- ? t('forms.actions.revertTo', { label: s.label ?? s.name })
669
- : (s.verb ?? s.label ?? s.name),
670
- value: s.name,
671
- }))}
672
- sideOffset={5}
672
+ {headerSlot}
673
+ </div>
674
+ <div className={cx('byline-form-status-bar', styles['status-bar'])}>
675
+ <div className={cx('byline-form-status-details', styles['status-details'])}>
676
+ <FormStatusDisplay
677
+ initialData={initialData}
678
+ workflowStatuses={workflowStatuses}
679
+ publishedVersion={publishedVersion}
680
+ onUnpublish={onUnpublish}
681
+ afterStatusCells={
682
+ <ScheduledPublicationCell
683
+ state={scheduling.state}
684
+ timeZone={scheduling.timeZone}
685
+ />
686
+ }
687
+ />
688
+ </div>
689
+ <div className={cx('byline-form-actions', styles.actions)}>
690
+ <Button
691
+ className={cx('byline-form-actions-button', styles['actions-button'])}
673
692
  size="sm"
693
+ intent="noeffect"
674
694
  type="button"
675
- intent={isTerminal ? 'info' : 'success'}
676
- disabled={statusBusy}
677
- onOptionSelect={async (value: string) => {
678
- if (hasChanges) {
679
- setShowUnsavedModal(true)
680
- return
681
- }
682
- setStatusBusy(true)
683
- try {
684
- await onStatusChange(value)
685
- } finally {
686
- setStatusBusy(false)
687
- }
688
- }}
689
- onButtonClick={
690
- isTerminal
691
- ? undefined
692
- : async () => {
693
- if (hasChanges) {
694
- setShowUnsavedModal(true)
695
- return
696
- }
697
- setStatusBusy(true)
698
- try {
699
- await onStatusChange(primaryStatus.name)
700
- } finally {
701
- setStatusBusy(false)
702
- }
695
+ onClick={handleCancel}
696
+ >
697
+ {hasChanges === false ? t('common.actions.close') : t('common.actions.cancel')}
698
+ </Button>
699
+ <Button
700
+ className={cx('byline-form-actions-button', styles['actions-button'])}
701
+ size="sm"
702
+ type="submit"
703
+ disabled={hasChanges === false || isUploading || isSubmitting}
704
+ aria-label={isSubmitting ? t('common.actions.save') : undefined}
705
+ >
706
+ {isUploading ? (
707
+ t('forms.actions.uploading')
708
+ ) : (
709
+ <span className={cx('byline-form-save-content', styles['save-content'])}>
710
+ <span
711
+ className={cx(
712
+ 'byline-form-save-label',
713
+ styles['save-label'],
714
+ isSubmitting && styles['save-label-hidden']
715
+ )}
716
+ >
717
+ {t('common.actions.save')}
718
+ </span>
719
+ {isSubmitting ? (
720
+ <span className={cx('byline-form-save-loader', styles['save-loader'])}>
721
+ <LoaderEllipsis size={28} aria-hidden="true" />
722
+ </span>
723
+ ) : null}
724
+ </span>
725
+ )}
726
+ </Button>
727
+ {primaryStatus && onStatusChange && (
728
+ <div
729
+ className={cx('byline-form-actions-status-wrap', styles['actions-status-wrap'])}
730
+ >
731
+ <ComboButton
732
+ buttonClassName={cx(
733
+ 'byline-form-actions-combo-button',
734
+ styles['actions-combo-button']
735
+ )}
736
+ triggerClassName={cx(
737
+ 'byline-form-actions-combo-trigger',
738
+ styles['actions-combo-trigger']
739
+ )}
740
+ options={secondaryStatuses.map((s) => ({
741
+ label: isTerminal
742
+ ? t('forms.actions.revertTo', { label: s.label ?? s.name })
743
+ : (s.verb ?? s.label ?? s.name),
744
+ value: s.name,
745
+ }))}
746
+ sideOffset={5}
747
+ size="sm"
748
+ type="button"
749
+ intent={isTerminal ? 'info' : 'success'}
750
+ disabled={statusBusy}
751
+ onOptionSelect={async (value: string) => {
752
+ if (hasChanges) {
753
+ setShowUnsavedModal(true)
754
+ return
703
755
  }
756
+ setStatusBusy(true)
757
+ try {
758
+ await onStatusChange(value)
759
+ } finally {
760
+ setStatusBusy(false)
761
+ }
762
+ }}
763
+ onButtonClick={
764
+ isTerminal
765
+ ? undefined
766
+ : async () => {
767
+ if (hasChanges) {
768
+ setShowUnsavedModal(true)
769
+ return
770
+ }
771
+ setStatusBusy(true)
772
+ try {
773
+ await onStatusChange(primaryStatus.name)
774
+ } finally {
775
+ setStatusBusy(false)
776
+ }
777
+ }
778
+ }
779
+ >
780
+ {statusBusy
781
+ ? '...'
782
+ : isTerminal
783
+ ? (primaryStatus.label ?? primaryStatus.name)
784
+ : (primaryStatus.verb ?? primaryStatus.label ?? primaryStatus.name)}
785
+ </ComboButton>
786
+ </div>
787
+ )}
788
+ <DocumentActions
789
+ publishedVersion={publishedVersion}
790
+ onUnpublish={onUnpublish}
791
+ onDelete={onDelete}
792
+ onDuplicate={onDuplicate}
793
+ sourceTitle={
794
+ useAsTitle != null && initialData != null
795
+ ? ((initialData as Record<string, unknown>)[useAsTitle] as
796
+ | string
797
+ | null
798
+ | undefined)
799
+ : null
704
800
  }
705
- >
706
- {statusBusy
707
- ? '...'
708
- : isTerminal
709
- ? (primaryStatus.label ?? primaryStatus.name)
710
- : (primaryStatus.verb ?? primaryStatus.label ?? primaryStatus.name)}
711
- </ComboButton>
712
- </div>
713
- )}
714
- <DocumentActions
715
- publishedVersion={publishedVersion}
716
- onUnpublish={onUnpublish}
717
- onDelete={onDelete}
718
- onDuplicate={onDuplicate}
719
- sourceTitle={
720
- useAsTitle != null && initialData != null
721
- ? ((initialData as Record<string, unknown>)[useAsTitle] as
722
- | string
723
- | null
724
- | undefined)
725
- : null
726
- }
727
- onCopyToLocale={onCopyToLocale}
728
- sourceLocale={contentLocale}
729
- contentLocales={contentLocales}
730
- hasUnsavedChanges={hasChanges}
731
- onUnsavedChanges={() => setShowUnsavedModal(true)}
732
- onDeleteLocale={onDeleteLocale}
733
- defaultLocale={defaultLocale}
734
- availableLocales={initialData?._availableVersionLocales as string[] | undefined}
735
- scheduledPublicationState={scheduling.state}
736
- onSchedulePublication={scheduling.openSchedule}
737
- onConfirmScheduledPublication={scheduling.confirm}
738
- onCancelScheduledPublication={scheduling.cancel}
739
- />
740
- </div>
741
- </div>
742
- <ScheduledPublicationNotice
743
- state={scheduling.state}
744
- timeZone={scheduling.timeZone}
745
- busy={scheduling.busy}
746
- onConfirm={scheduling.confirm}
747
- onReschedule={scheduling.openSchedule}
748
- onCancel={scheduling.cancel}
749
- />
750
- {scheduling.modal}
751
- {restoreWarnings && restoreWarnings.length > 0 && (
752
- <Alert
753
- className="m-0 mt-4"
754
- intent="warning"
755
- icon={true}
756
- close={false}
757
- title={t('forms.restoreWarnings.title')}
758
- >
759
- <p>{t('forms.restoreWarnings.body', { count: restoreWarnings.length })}</p>
760
- <ul>
761
- {restoreWarnings.map((w) => (
762
- <li key={w}>{w}</li>
763
- ))}
764
- </ul>
765
- </Alert>
766
- )}
767
- <div className={cx('byline-form-layout', styles.layout)}>
768
- <div className={cx('byline-form-content', styles.content)}>
769
- {layout.main.map((name) => renderItem(name))}
770
- </div>
771
- <div className={cx('byline-form-sidebar', styles.sidebar)}>
772
- {showPath &&
773
- (useAsPath ||
774
- (typeof initialData?.path === 'string' && initialData.path.length > 0)) && (
775
- <PathWidget
776
- useAsPath={useAsPath}
777
- collectionPath={collectionPath ?? ''}
801
+ onCopyToLocale={onCopyToLocale}
802
+ sourceLocale={contentLocale}
803
+ contentLocales={contentLocales}
804
+ hasUnsavedChanges={hasChanges}
805
+ onUnsavedChanges={() => setShowUnsavedModal(true)}
806
+ onDeleteLocale={onDeleteLocale}
778
807
  defaultLocale={defaultLocale}
779
- activeLocale={contentLocale}
780
- mode={mode}
781
- slugifier={pathSlugifier}
782
- sourceLocked={pathSourceLocked}
808
+ availableLocales={initialData?._availableVersionLocales as string[] | undefined}
809
+ scheduledPublicationState={scheduling.state}
810
+ onSchedulePublication={scheduling.openSchedule}
811
+ onConfirmScheduledPublication={scheduling.confirm}
812
+ onCancelScheduledPublication={scheduling.cancel}
783
813
  />
784
- )}
785
- {tree && mode === 'edit' && typeof initialData?.id === 'string' && (
786
- <TreePlacementWidget
787
- collectionPath={collectionPath ?? ''}
788
- documentId={initialData.id as string}
789
- useAsTitle={useAsTitle}
790
- />
814
+ </div>
815
+ </div>
816
+ <ScheduledPublicationNotice
817
+ state={scheduling.state}
818
+ timeZone={scheduling.timeZone}
819
+ busy={scheduling.busy}
820
+ onConfirm={scheduling.confirm}
821
+ onReschedule={scheduling.openSchedule}
822
+ onCancel={scheduling.cancel}
823
+ />
824
+ {scheduling.modal}
825
+ {restoreWarnings && restoreWarnings.length > 0 && (
826
+ <Alert
827
+ className="m-0 mt-4"
828
+ intent="warning"
829
+ icon={true}
830
+ close={false}
831
+ title={t('forms.restoreWarnings.title')}
832
+ >
833
+ <p>{t('forms.restoreWarnings.body', { count: restoreWarnings.length })}</p>
834
+ <ul>
835
+ {restoreWarnings.map((w) => (
836
+ <li key={w}>{w}</li>
837
+ ))}
838
+ </ul>
839
+ </Alert>
791
840
  )}
792
- {advertiseLocales && (
793
- <AvailableLocalesWidget
794
- contentLocales={contentLocales ?? []}
795
- availableVersionLocales={
796
- (initialData?._availableVersionLocales as string[] | undefined) ?? []
797
- }
841
+ <div className={cx('byline-form-layout', styles.layout)}>
842
+ <div className={cx('byline-form-content', styles.content)}>
843
+ {layout.main.map((name) => renderItem(name))}
844
+ </div>
845
+ <div className={cx('byline-form-sidebar', styles.sidebar)}>
846
+ {showPath &&
847
+ (useAsPath ||
848
+ (typeof initialData?.path === 'string' && initialData.path.length > 0)) && (
849
+ <PathWidget
850
+ useAsPath={useAsPath}
851
+ collectionPath={collectionPath ?? ''}
852
+ defaultLocale={defaultLocale}
853
+ activeLocale={contentLocale}
854
+ mode={mode}
855
+ slugifier={pathSlugifier}
856
+ sourceLocked={pathSourceLocked}
857
+ />
858
+ )}
859
+ {tree && mode === 'edit' && typeof initialData?.id === 'string' && (
860
+ <TreePlacementWidget
861
+ collectionPath={collectionPath ?? ''}
862
+ documentId={initialData.id as string}
863
+ useAsTitle={useAsTitle}
864
+ />
865
+ )}
866
+ {advertiseLocales && (
867
+ <AvailableLocalesWidget
868
+ contentLocales={contentLocales ?? []}
869
+ availableVersionLocales={
870
+ (initialData?._availableVersionLocales as string[] | undefined) ?? []
871
+ }
872
+ />
873
+ )}
874
+ {(layout.sidebar ?? []).map((name) => renderItem(name))}
875
+ </div>
876
+ </div>
877
+ {showUnsavedModal && <UnsavedChangesModal onClose={() => setShowUnsavedModal(false)} />}
878
+ {pendingSystemFieldsSubmit != null && (
879
+ <SystemFieldsConfirmModal
880
+ contentDirty={pendingSystemFieldsSubmit.contentDirty}
881
+ pathDirty={pendingSystemFieldsSubmit.pathDirty}
882
+ availableLocalesDirty={pendingSystemFieldsSubmit.availableLocalesDirty}
883
+ onCancel={() => setPendingSystemFieldsSubmit(null)}
884
+ onConfirm={() => {
885
+ const payload = pendingSystemFieldsSubmit
886
+ setPendingSystemFieldsSubmit(null)
887
+ void submitPayload(payload)
888
+ }}
798
889
  />
799
890
  )}
800
- {(layout.sidebar ?? []).map((name) => renderItem(name))}
801
- </div>
891
+ {guard.isBlocked && (
892
+ <NavigationGuardModal onStay={guard.stay} onProceed={guard.proceed} />
893
+ )}
894
+ </form>
802
895
  </div>
803
- {showUnsavedModal && <UnsavedChangesModal onClose={() => setShowUnsavedModal(false)} />}
804
- {pendingSystemFieldsSubmit != null && (
805
- <SystemFieldsConfirmModal
806
- contentDirty={pendingSystemFieldsSubmit.contentDirty}
807
- pathDirty={pendingSystemFieldsSubmit.pathDirty}
808
- availableLocalesDirty={pendingSystemFieldsSubmit.availableLocalesDirty}
809
- onCancel={() => setPendingSystemFieldsSubmit(null)}
810
- onConfirm={() => {
811
- const payload = pendingSystemFieldsSubmit
812
- setPendingSystemFieldsSubmit(null)
813
- void submitPayload(payload)
814
- }}
815
- />
816
- )}
817
- {guard.isBlocked && <NavigationGuardModal onStay={guard.stay} onProceed={guard.proceed} />}
818
- </form>
896
+ </>
819
897
  )
820
898
  }
821
899