@byline/admin 6.1.0 → 6.2.1

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 (119) hide show
  1. package/dist/forms/form-layout-styles.test.node.d.ts +8 -0
  2. package/dist/forms/form-layout.d.ts +39 -0
  3. package/dist/forms/form-layout.js +100 -0
  4. package/dist/forms/form-page-chrome.d.ts +110 -0
  5. package/dist/forms/form-page-chrome.js +253 -0
  6. package/dist/forms/form-renderer.d.ts +2 -18
  7. package/dist/forms/form-renderer.js +104 -418
  8. package/dist/forms/form-renderer.module.js +2 -0
  9. package/dist/forms/form-renderer_module.css +13 -0
  10. package/dist/forms/use-form-submission.d.ts +74 -0
  11. package/dist/forms/use-form-submission.js +166 -0
  12. package/dist/forms/use-form-tabs.d.ts +29 -0
  13. package/dist/forms/use-form-tabs.js +45 -0
  14. package/dist/presentation/tab-strip-drag.d.ts +75 -0
  15. package/dist/presentation/tab-strip-drag.js +25 -0
  16. package/dist/presentation/tab-strip-drag.test.node.d.ts +8 -0
  17. package/dist/presentation/tab-strip-geometry.d.ts +75 -0
  18. package/dist/presentation/tab-strip-geometry.js +36 -0
  19. package/dist/presentation/tab-strip-geometry.test.node.d.ts +8 -0
  20. package/dist/presentation/tabs.d.ts +30 -7
  21. package/dist/presentation/tabs.js +260 -29
  22. package/dist/presentation/tabs.module.js +11 -1
  23. package/dist/presentation/tabs_module.css +166 -5
  24. package/package.json +7 -7
  25. package/src/forms/form-layout-styles.test.node.ts +32 -0
  26. package/src/forms/form-layout.test.tsx +276 -0
  27. package/src/forms/form-layout.tsx +198 -0
  28. package/src/forms/form-page-chrome.tsx +460 -0
  29. package/src/forms/form-renderer-submit.test.tsx +40 -0
  30. package/src/forms/form-renderer-tab-persistence.test.tsx +145 -0
  31. package/src/forms/form-renderer.module.css +18 -0
  32. package/src/forms/form-renderer.tsx +115 -593
  33. package/src/forms/use-form-submission.test.tsx +564 -0
  34. package/src/forms/use-form-submission.ts +293 -0
  35. package/src/forms/use-form-tabs.ts +89 -0
  36. package/src/presentation/tab-strip-drag.test.node.ts +143 -0
  37. package/src/presentation/tab-strip-drag.ts +114 -0
  38. package/src/presentation/tab-strip-geometry.test.node.ts +254 -0
  39. package/src/presentation/tab-strip-geometry.ts +158 -0
  40. package/src/presentation/tabs.module.css +209 -11
  41. package/src/presentation/tabs.test.tsx +481 -0
  42. package/src/presentation/tabs.tsx +369 -34
  43. package/dist/exports-parity.test.node.js +0 -37
  44. package/dist/fields/array/array-field.jsx +0 -209
  45. package/dist/fields/blocks/blocks-field.jsx +0 -248
  46. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  47. package/dist/fields/code/code-editor.jsx +0 -211
  48. package/dist/fields/code/code-field.jsx +0 -109
  49. package/dist/fields/column-formatter.jsx +0 -25
  50. package/dist/fields/date-time-formatter.jsx +0 -18
  51. package/dist/fields/datetime/datetime-field.jsx +0 -22
  52. package/dist/fields/draggable-context-menu.jsx +0 -50
  53. package/dist/fields/field-admin.test.node.js +0 -40
  54. package/dist/fields/field-renderer.jsx +0 -136
  55. package/dist/fields/field-services-context.jsx +0 -17
  56. package/dist/fields/file/file-field.jsx +0 -176
  57. package/dist/fields/file/file-upload-field.jsx +0 -89
  58. package/dist/fields/group/group-field.jsx +0 -46
  59. package/dist/fields/image/image-field.jsx +0 -183
  60. package/dist/fields/image/image-upload-field.jsx +0 -157
  61. package/dist/fields/local-date-time.jsx +0 -69
  62. package/dist/fields/locale-badge.jsx +0 -19
  63. package/dist/fields/numerical/numerical-field.jsx +0 -93
  64. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  65. package/dist/fields/relation/relation-display.jsx +0 -118
  66. package/dist/fields/relation/relation-field.jsx +0 -108
  67. package/dist/fields/relation/relation-many-field.jsx +0 -142
  68. package/dist/fields/relation/relation-picker.jsx +0 -240
  69. package/dist/fields/relation/relation-summary.jsx +0 -45
  70. package/dist/fields/select/select-field.jsx +0 -27
  71. package/dist/fields/sortable-item.jsx +0 -54
  72. package/dist/fields/text/text-field.jsx +0 -79
  73. package/dist/fields/text-area/text-area-field.jsx +0 -79
  74. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  75. package/dist/forms/available-locales-widget.jsx +0 -63
  76. package/dist/forms/document-actions.jsx +0 -486
  77. package/dist/forms/form-context.jsx +0 -598
  78. package/dist/forms/form-modals.jsx +0 -128
  79. package/dist/forms/form-renderer.jsx +0 -532
  80. package/dist/forms/form-status-display.jsx +0 -69
  81. package/dist/forms/navigation-guard.jsx +0 -68
  82. package/dist/forms/nested-path.test.node.js +0 -115
  83. package/dist/forms/path-widget.jsx +0 -125
  84. package/dist/forms/pending-uploads.test.node.js +0 -19
  85. package/dist/forms/repeating-items.test.node.js +0 -29
  86. package/dist/forms/scheduled-publication-control.jsx +0 -389
  87. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  88. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  89. package/dist/forms/status-transitions.test.node.js +0 -76
  90. package/dist/forms/tree-placement-widget.jsx +0 -160
  91. package/dist/forms/upload-executor.test.node.js +0 -368
  92. package/dist/forms/use-form-layout.test.node.js +0 -69
  93. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  94. package/dist/modules/admin-account/components/container.jsx +0 -164
  95. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  96. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  97. package/dist/modules/admin-account/components/update.jsx +0 -186
  98. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  99. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  100. package/dist/modules/admin-roles/components/create.jsx +0 -155
  101. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  102. package/dist/modules/admin-roles/components/update.jsx +0 -148
  103. package/dist/modules/admin-users/components/create.jsx +0 -206
  104. package/dist/modules/admin-users/components/roles.jsx +0 -126
  105. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  106. package/dist/modules/admin-users/components/update.jsx +0 -215
  107. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  108. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  109. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  110. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  111. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  112. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  113. package/dist/presentation/group.jsx +0 -26
  114. package/dist/presentation/row.jsx +0 -23
  115. package/dist/presentation/tabs.jsx +0 -39
  116. package/dist/services/admin-services-context.jsx +0 -17
  117. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  118. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  119. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -0,0 +1,460 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ /**
12
+ * Page-level chrome for the full-page document editor: the parts that frame a
13
+ * form rather than render it. The embedded creation view renders none of this.
14
+ *
15
+ * Each component takes only the props it needs — deliberately not a
16
+ * `FormRendererProps` pass-through, so what a region actually depends on stays
17
+ * visible at its call site.
18
+ */
19
+
20
+ import type { ReactNode, RefObject } from 'react'
21
+
22
+ import type { SlugifierFn, StructuralMutationReceipt, WorkflowStatus } from '@byline/core'
23
+ import { useTranslation } from '@byline/i18n/react'
24
+ import { Alert, Button, ComboButton, LoaderEllipsis } from '@byline/ui/react'
25
+ import cx from 'clsx'
26
+
27
+ import { AvailableLocalesWidget } from './available-locales-widget'
28
+ import { DocumentActions, type DocumentActionsLocaleOption } from './document-actions'
29
+ import styles from './form-renderer.module.css'
30
+ import { FormStatusDisplay } from './form-status-display'
31
+ import { PathWidget } from './path-widget'
32
+ import {
33
+ ScheduledPublicationCell,
34
+ ScheduledPublicationNotice,
35
+ type UseScheduledPublicationReturn,
36
+ } from './scheduled-publication-control'
37
+ import { TreePlacementWidget } from './tree-placement-widget'
38
+ import type { PublishedVersionInfo } from './form-renderer'
39
+ import type { StatusTransitions } from './status-transitions'
40
+
41
+ export interface FormHeadingRowProps {
42
+ heading: ReactNode
43
+ /** Host-supplied controls rendered beside the heading (locale switcher, …). */
44
+ headerSlot?: ReactNode
45
+ }
46
+
47
+ export const FormHeadingRow = ({ heading, headerSlot }: FormHeadingRowProps): ReactNode => (
48
+ <div className={cx('byline-form-heading-row', styles['heading-row'])}>
49
+ <h1 className={cx('byline-form-heading', styles.heading)}>{heading}</h1>
50
+ {/* Source-locale anchor indicator removed pending heading-layout work.
51
+ To re-enable: render `<SourceLocaleBadge locale={sourceLocale} />`
52
+ here from `initialData.sourceLocale` (mismatch-only is the intended
53
+ end state). See docs/08-internationalization/index.md. */}
54
+ {headerSlot}
55
+ </div>
56
+ )
57
+
58
+ export interface FormStatusBarProps {
59
+ /** Mutations blocked or a discard in flight. */
60
+ disabled: boolean
61
+ // biome-ignore lint/suspicious/noExplicitAny: document shape is collection-specific
62
+ initialData?: any
63
+ workflowStatuses?: WorkflowStatus[]
64
+ publishedVersion?: PublishedVersionInfo | null
65
+ scheduling: UseScheduledPublicationReturn
66
+ hasChanges: boolean
67
+ isUploading: boolean
68
+ isSubmitting: boolean
69
+ onCancel: () => void
70
+ /** Status transitions, already computed for the current workflow position. */
71
+ transitions: StatusTransitions
72
+ statusBusy: boolean
73
+ onStatusBusyChange: (busy: boolean) => void
74
+ onStatusChange?: (nextStatus: string) => Promise<void>
75
+ onMutationError?: (error: unknown) => 'blocked' | 'committed' | null | void
76
+ /** Read synchronously at click time, not at render time. */
77
+ isBlocked: () => boolean
78
+ /** A guarded action was attempted while the form is dirty. */
79
+ onUnsavedChanges: () => void
80
+ onUnpublish?: () => Promise<void>
81
+ onDelete?: () => Promise<void>
82
+ onDuplicate?: () => Promise<void>
83
+ onCopyToLocale?: (args: { targetLocale: string; overwrite: boolean }) => Promise<void>
84
+ onDeleteLocale?: (args: { targetLocale: string }) => Promise<void>
85
+ useAsTitle?: string
86
+ contentLocale: string
87
+ contentLocales?: ReadonlyArray<DocumentActionsLocaleOption>
88
+ defaultLocale: string
89
+ }
90
+
91
+ export const FormStatusBar = ({
92
+ disabled,
93
+ initialData,
94
+ workflowStatuses,
95
+ publishedVersion,
96
+ scheduling,
97
+ hasChanges,
98
+ isUploading,
99
+ isSubmitting,
100
+ onCancel,
101
+ transitions,
102
+ statusBusy,
103
+ onStatusBusyChange,
104
+ onStatusChange,
105
+ onMutationError,
106
+ isBlocked,
107
+ onUnsavedChanges,
108
+ onUnpublish,
109
+ onDelete,
110
+ onDuplicate,
111
+ onCopyToLocale,
112
+ onDeleteLocale,
113
+ useAsTitle,
114
+ contentLocale,
115
+ contentLocales,
116
+ defaultLocale,
117
+ }: FormStatusBarProps): ReactNode => {
118
+ const { t } = useTranslation('byline-admin')
119
+ const { primaryStatus, secondaryStatuses, isTerminal } = transitions
120
+
121
+ return (
122
+ <div className={cx('byline-form-status-bar', styles['status-bar'])}>
123
+ <div className={cx('byline-form-status-details', styles['status-details'])}>
124
+ <FormStatusDisplay
125
+ disabled={disabled}
126
+ initialData={initialData}
127
+ workflowStatuses={workflowStatuses}
128
+ publishedVersion={publishedVersion}
129
+ onUnpublish={onUnpublish}
130
+ afterStatusCells={
131
+ <ScheduledPublicationCell state={scheduling.state} timeZone={scheduling.timeZone} />
132
+ }
133
+ />
134
+ </div>
135
+ <div className={cx('byline-form-actions', styles.actions)}>
136
+ <Button
137
+ className={cx('byline-form-actions-button', styles['actions-button'])}
138
+ size="sm"
139
+ intent="noeffect"
140
+ type="button"
141
+ onClick={onCancel}
142
+ >
143
+ {hasChanges === false ? t('common.actions.close') : t('common.actions.cancel')}
144
+ </Button>
145
+ <Button
146
+ className={cx('byline-form-actions-button', styles['actions-button'])}
147
+ size="sm"
148
+ type="submit"
149
+ disabled={disabled || hasChanges === false || isUploading || isSubmitting}
150
+ aria-label={isSubmitting ? t('common.actions.save') : undefined}
151
+ >
152
+ {isUploading ? (
153
+ t('forms.actions.uploading')
154
+ ) : (
155
+ <span className={cx('byline-form-save-content', styles['save-content'])}>
156
+ <span
157
+ className={cx(
158
+ 'byline-form-save-label',
159
+ styles['save-label'],
160
+ isSubmitting && styles['save-label-hidden']
161
+ )}
162
+ >
163
+ {t('common.actions.save')}
164
+ </span>
165
+ {isSubmitting ? (
166
+ <span className={cx('byline-form-save-loader', styles['save-loader'])}>
167
+ <LoaderEllipsis size={28} aria-hidden="true" />
168
+ </span>
169
+ ) : null}
170
+ </span>
171
+ )}
172
+ </Button>
173
+ {primaryStatus && onStatusChange && (
174
+ <div className={cx('byline-form-actions-status-wrap', styles['actions-status-wrap'])}>
175
+ <ComboButton
176
+ buttonClassName={cx(
177
+ 'byline-form-actions-combo-button',
178
+ styles['actions-combo-button']
179
+ )}
180
+ triggerClassName={cx(
181
+ 'byline-form-actions-combo-trigger',
182
+ styles['actions-combo-trigger']
183
+ )}
184
+ options={secondaryStatuses.map((s) => ({
185
+ label: isTerminal
186
+ ? t('forms.actions.revertTo', { label: s.label ?? s.name })
187
+ : (s.verb ?? s.label ?? s.name),
188
+ value: s.name,
189
+ }))}
190
+ sideOffset={5}
191
+ size="sm"
192
+ type="button"
193
+ intent={isTerminal ? 'info' : 'success'}
194
+ disabled={disabled || statusBusy}
195
+ onOptionSelect={async (value: string) => {
196
+ if (isBlocked()) return
197
+ if (hasChanges) {
198
+ onUnsavedChanges()
199
+ return
200
+ }
201
+ onStatusBusyChange(true)
202
+ try {
203
+ await onStatusChange(value)
204
+ } catch (error) {
205
+ onMutationError?.(error)
206
+ } finally {
207
+ onStatusBusyChange(false)
208
+ }
209
+ }}
210
+ onButtonClick={
211
+ isTerminal
212
+ ? undefined
213
+ : async () => {
214
+ if (isBlocked()) return
215
+ if (hasChanges) {
216
+ onUnsavedChanges()
217
+ return
218
+ }
219
+ onStatusBusyChange(true)
220
+ try {
221
+ await onStatusChange(primaryStatus.name)
222
+ } catch (error) {
223
+ onMutationError?.(error)
224
+ } finally {
225
+ onStatusBusyChange(false)
226
+ }
227
+ }
228
+ }
229
+ >
230
+ {statusBusy
231
+ ? '...'
232
+ : isTerminal
233
+ ? (primaryStatus.label ?? primaryStatus.name)
234
+ : (primaryStatus.verb ?? primaryStatus.label ?? primaryStatus.name)}
235
+ </ComboButton>
236
+ </div>
237
+ )}
238
+ <DocumentActions
239
+ disabled={disabled}
240
+ publishedVersion={publishedVersion}
241
+ onUnpublish={onUnpublish}
242
+ onDelete={onDelete}
243
+ onDuplicate={onDuplicate}
244
+ sourceTitle={
245
+ useAsTitle != null && initialData != null
246
+ ? ((initialData as Record<string, unknown>)[useAsTitle] as string | null | undefined)
247
+ : null
248
+ }
249
+ onCopyToLocale={onCopyToLocale}
250
+ sourceLocale={contentLocale}
251
+ contentLocales={contentLocales}
252
+ hasUnsavedChanges={hasChanges}
253
+ onUnsavedChanges={() => onUnsavedChanges()}
254
+ onDeleteLocale={onDeleteLocale}
255
+ defaultLocale={defaultLocale}
256
+ availableLocales={initialData?._availableVersionLocales as string[] | undefined}
257
+ scheduledPublicationState={scheduling.state}
258
+ onSchedulePublication={scheduling.openSchedule}
259
+ onConfirmScheduledPublication={scheduling.confirm}
260
+ onCancelScheduledPublication={scheduling.cancel}
261
+ />
262
+ </div>
263
+ </div>
264
+ )
265
+ }
266
+
267
+ export interface FormConcurrencyNoticesProps {
268
+ /** Mutations blocked or a discard in flight. */
269
+ disabled: boolean
270
+ mutationIssue?: 'stale' | 'reload' | 'lock' | 'unavailable' | 'committed' | null
271
+ scheduledPublicationsNeedReconfirmation?: boolean
272
+ scheduledPublicationsHref?: string
273
+ /**
274
+ * Focus target when a mutation issue appears. The ref belongs to the form
275
+ * component, which restores focus after a save; this region only attaches it.
276
+ */
277
+ warningRef: RefObject<HTMLDivElement | null>
278
+ discarding: boolean
279
+ reloadFailed: boolean
280
+ onDiscardRequested: () => void
281
+ scheduling: UseScheduledPublicationReturn
282
+ restoreWarnings?: string[]
283
+ }
284
+
285
+ export const FormConcurrencyNotices = ({
286
+ disabled,
287
+ mutationIssue,
288
+ scheduledPublicationsNeedReconfirmation,
289
+ scheduledPublicationsHref,
290
+ warningRef,
291
+ discarding,
292
+ reloadFailed,
293
+ onDiscardRequested,
294
+ scheduling,
295
+ restoreWarnings,
296
+ }: FormConcurrencyNoticesProps): ReactNode => {
297
+ const { t } = useTranslation('byline-admin')
298
+
299
+ return (
300
+ <>
301
+ {(mutationIssue || scheduledPublicationsNeedReconfirmation) && (
302
+ <div
303
+ ref={warningRef}
304
+ tabIndex={-1}
305
+ role="alert"
306
+ aria-live="assertive"
307
+ className={cx('byline-document-concurrency', styles.concurrency)}
308
+ >
309
+ {mutationIssue && (
310
+ <Alert
311
+ intent="warning"
312
+ icon
313
+ close={false}
314
+ title={t(`documentConcurrency.${mutationIssue}Title`)}
315
+ >
316
+ <p>{t(`documentConcurrency.${mutationIssue}`)}</p>
317
+ {mutationIssue !== 'committed' && (
318
+ <Button type="button" disabled={discarding} onClick={onDiscardRequested}>
319
+ {t('documentConcurrency.reloadAction')}
320
+ </Button>
321
+ )}
322
+ {reloadFailed && <p>{t('documentConcurrency.reloadFailed')}</p>}
323
+ </Alert>
324
+ )}
325
+ {scheduledPublicationsNeedReconfirmation && (
326
+ <Alert
327
+ intent="warning"
328
+ icon
329
+ close={false}
330
+ title={t('documentConcurrency.schedulesTitle')}
331
+ >
332
+ <p>{t('documentConcurrency.schedules')}</p>
333
+ {scheduledPublicationsHref && (
334
+ <a href={scheduledPublicationsHref}>{t('documentConcurrency.reviewSchedules')}</a>
335
+ )}
336
+ </Alert>
337
+ )}
338
+ </div>
339
+ )}
340
+ <ScheduledPublicationNotice
341
+ state={scheduling.state}
342
+ timeZone={scheduling.timeZone}
343
+ busy={disabled || scheduling.busy}
344
+ onConfirm={scheduling.confirm}
345
+ onReschedule={scheduling.openSchedule}
346
+ onCancel={scheduling.cancel}
347
+ />
348
+ {scheduling.modal}
349
+ {restoreWarnings && restoreWarnings.length > 0 && (
350
+ <Alert
351
+ className="m-0 mt-4"
352
+ intent="warning"
353
+ icon={true}
354
+ close={false}
355
+ title={t('forms.restoreWarnings.title')}
356
+ >
357
+ <p>{t('forms.restoreWarnings.body', { count: restoreWarnings.length })}</p>
358
+ <ul>
359
+ {restoreWarnings.map((w) => (
360
+ <li key={w}>{w}</li>
361
+ ))}
362
+ </ul>
363
+ </Alert>
364
+ )}
365
+ </>
366
+ )
367
+ }
368
+
369
+ export interface FormSidebarWidgetsProps {
370
+ /** Mutations blocked or a discard in flight. */
371
+ disabled: boolean
372
+ mode: 'create' | 'edit'
373
+ // biome-ignore lint/suspicious/noExplicitAny: document shape is collection-specific
374
+ initialData?: any
375
+ collectionPath?: string
376
+ defaultLocale: string
377
+ contentLocale: string
378
+ contentLocales?: ReadonlyArray<DocumentActionsLocaleOption>
379
+ showPath: boolean
380
+ useAsPath?: string
381
+ lockPath?: boolean
382
+ pathSlugifier?: SlugifierFn
383
+ pathSourceLocked?: boolean
384
+ tree?: boolean
385
+ useAsTitle?: string
386
+ advertiseLocales?: boolean
387
+ observedRevision?: number
388
+ onMutationError?: (error: unknown) => 'blocked' | 'committed' | null | void
389
+ onTreeMutationCommitted?: (receipt: StructuralMutationReceipt) => void
390
+ }
391
+
392
+ /**
393
+ * The page-level widgets in the editor's sidebar: path, tree placement and
394
+ * advertised locales. They are page concerns rather than schema fields, so they
395
+ * reach `FormLayout` through its `sidebarSlot` and the embedded creation view
396
+ * passes none of them.
397
+ */
398
+ export const FormSidebarWidgets = ({
399
+ disabled,
400
+ mode,
401
+ initialData,
402
+ collectionPath,
403
+ defaultLocale,
404
+ contentLocale,
405
+ contentLocales,
406
+ showPath,
407
+ useAsPath,
408
+ lockPath,
409
+ pathSlugifier,
410
+ pathSourceLocked,
411
+ tree,
412
+ useAsTitle,
413
+ advertiseLocales,
414
+ observedRevision,
415
+ onMutationError,
416
+ onTreeMutationCommitted,
417
+ }: FormSidebarWidgetsProps): ReactNode => (
418
+ <>
419
+ {/* A locked collection's widget renders even with no `useAsPath`
420
+ and nothing stored yet: its path is managed, and the editor
421
+ needs to see that. `showPath: false` still wins — it marks a
422
+ path that must never be presented at all. */}
423
+ {showPath &&
424
+ (useAsPath ||
425
+ lockPath === true ||
426
+ (typeof initialData?.path === 'string' && initialData.path.length > 0)) && (
427
+ <PathWidget
428
+ disabled={disabled}
429
+ useAsPath={useAsPath}
430
+ collectionPath={collectionPath ?? ''}
431
+ defaultLocale={defaultLocale}
432
+ activeLocale={contentLocale}
433
+ mode={mode}
434
+ slugifier={pathSlugifier}
435
+ sourceLocked={pathSourceLocked}
436
+ lockPath={lockPath}
437
+ />
438
+ )}
439
+ {tree && mode === 'edit' && typeof initialData?.id === 'string' && (
440
+ <TreePlacementWidget
441
+ disabled={disabled}
442
+ onMutationError={onMutationError}
443
+ onCommitted={onTreeMutationCommitted}
444
+ expectedRevision={observedRevision ?? initialData.revision}
445
+ collectionPath={collectionPath ?? ''}
446
+ documentId={initialData.id as string}
447
+ useAsTitle={useAsTitle}
448
+ />
449
+ )}
450
+ {advertiseLocales && (
451
+ <AvailableLocalesWidget
452
+ disabled={disabled}
453
+ contentLocales={contentLocales ?? []}
454
+ availableVersionLocales={
455
+ (initialData?._availableVersionLocales as string[] | undefined) ?? []
456
+ }
457
+ />
458
+ )}
459
+ </>
460
+ )
@@ -211,6 +211,46 @@ describe('FormRenderer submit contract', () => {
211
211
  expect(saveButton?.querySelector('.byline-loader-ellipsis')).toBeNull()
212
212
  })
213
213
 
214
+ it('restores focus to the control that had it before the form went busy', async () => {
215
+ // `inert` drops the active control out of the tab order mid-save. The hook
216
+ // signals the transition through onBeforeBusy; the component owns the DOM
217
+ // refs. Drop that signal and focus silently lands on <body>.
218
+ //
219
+ // This covers the KEYBOARD path only: focus is in a field and the form is
220
+ // submitted from there, so the captured element is still focusable when the
221
+ // form comes back. Clicking Save is different -- the captured element is the
222
+ // Save button, which is disabled once the save succeeds -- and that path is
223
+ // broken in a real browser. See issue #98. Do not read this passing test as
224
+ // "focus recovery works" in general.
225
+ const submission = deferred()
226
+ render({ ...baseProps, onSubmit: () => submission.promise })
227
+ typeIntoTitle('Hello')
228
+
229
+ const input = container.querySelector<HTMLInputElement>('input[name="title"]')
230
+ if (input == null) throw new Error('title input not found')
231
+ act(() => {
232
+ input.focus()
233
+ })
234
+ expect(document.activeElement).toBe(input)
235
+
236
+ submitForm()
237
+ await act(async () => {})
238
+
239
+ // jsdom does not implement `inert`, so the browser's own focus removal has
240
+ // to be stood in for here. What is under test is the recovery afterwards.
241
+ act(() => {
242
+ input.blur()
243
+ })
244
+ expect(document.activeElement).not.toBe(input)
245
+
246
+ await act(async () => {
247
+ submission.resolve()
248
+ await submission.promise
249
+ })
250
+
251
+ expect(document.activeElement).toBe(input)
252
+ })
253
+
214
254
  it('hides the busy indicator when onSubmit rejects', async () => {
215
255
  const submission = deferred()
216
256
  render({ ...baseProps, onSubmit: () => submission.promise })
@@ -0,0 +1,145 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { act } from 'react'
10
+
11
+ import { defineAdminConfig } from '@byline/core'
12
+ import { adminTranslations } from '@byline/i18n/admin'
13
+ import { I18nProvider } from '@byline/i18n/react'
14
+ import { createRoot, type Root } from 'react-dom/client'
15
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
16
+
17
+ import { BylineFieldServicesProvider } from '../fields/field-services-context'
18
+ import { FormRenderer } from './form-renderer'
19
+
20
+ const globalWithAct = globalThis as any
21
+ globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
22
+
23
+ defineAdminConfig({
24
+ i18n: {
25
+ admin: { defaultLocale: 'en', locales: ['en'] },
26
+ content: { defaultLocale: 'en', locales: ['en', 'fr'] },
27
+ },
28
+ collections: [
29
+ {
30
+ path: 'pages',
31
+ labels: { singular: 'Page', plural: 'Pages' },
32
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
33
+ },
34
+ ],
35
+ slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
36
+ })
37
+
38
+ const fields = [
39
+ { name: 'title', label: 'Title', type: 'text' as const },
40
+ { name: 'seoTitle', label: 'SEO title', type: 'text' as const },
41
+ ]
42
+
43
+ const adminConfig = {
44
+ layout: { main: ['content'] },
45
+ tabSets: [
46
+ {
47
+ name: 'content',
48
+ tabs: [
49
+ { name: 'general', label: 'General', fields: ['title'] },
50
+ { name: 'seo', label: 'SEO', fields: ['seoTitle'] },
51
+ ],
52
+ },
53
+ ],
54
+ }
55
+
56
+ let container: HTMLDivElement
57
+ let root: Root
58
+
59
+ beforeEach(() => {
60
+ container = document.createElement('div')
61
+ document.body.appendChild(container)
62
+ root = createRoot(container)
63
+ })
64
+
65
+ afterEach(() => {
66
+ act(() => {
67
+ root.unmount()
68
+ })
69
+ container.remove()
70
+ })
71
+
72
+ const mount = (locale: string, versionId: string) => {
73
+ act(() => {
74
+ root.render(
75
+ <I18nProvider
76
+ bundle={adminTranslations({ locales: ['en'] })}
77
+ activeLocale="en"
78
+ defaultLocale="en"
79
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
80
+ >
81
+ <BylineFieldServicesProvider
82
+ services={
83
+ {
84
+ getCollectionDocuments: async () => ({ docs: [], total: 0 }),
85
+ uploadField: async () => ({}),
86
+ } as any
87
+ }
88
+ >
89
+ <FormRenderer
90
+ mode="edit"
91
+ fields={fields as any}
92
+ initialLocale={locale}
93
+ initialData={{
94
+ id: 'doc1',
95
+ versionId,
96
+ fields: { title: locale, seoTitle: `${locale} SEO` },
97
+ }}
98
+ adminConfig={adminConfig as any}
99
+ collectionPath="pages"
100
+ onSubmit={() => {}}
101
+ onCancel={() => {}}
102
+ />
103
+ </BylineFieldServicesProvider>
104
+ </I18nProvider>
105
+ )
106
+ })
107
+ }
108
+
109
+ const selectedTabLabel = () =>
110
+ container.querySelector('[role="tab"][aria-selected="true"]')?.textContent
111
+
112
+ describe('FormRenderer tab persistence across keyed remounts', () => {
113
+ // FormProvider is keyed on `${initialLocale}-${versionId}`, so changing either
114
+ // remounts the whole subtree. Tab selection is lifted above that key on
115
+ // purpose; this asserts the wiring, which a controlled-prop test on
116
+ // FormLayout alone cannot reach.
117
+ //
118
+ // Adopted from the reviewer's Checkpoint 2 regression fixture.
119
+ it('retains the selected tab through locale and version remounts', () => {
120
+ mount('en', 'v1')
121
+
122
+ const seoTab = [...container.querySelectorAll('[role="tab"]')].find(
123
+ (t) => t.textContent === 'SEO'
124
+ )
125
+ if (seoTab == null) throw new Error('SEO tab not found')
126
+ act(() => {
127
+ ;(seoTab as HTMLElement).click()
128
+ })
129
+ const first = container.querySelector<HTMLInputElement>('input[name="seoTitle"]')
130
+ expect(first).not.toBeNull()
131
+
132
+ mount('fr', 'v1') // locale change remounts FormProvider via its key
133
+ const french = container.querySelector<HTMLInputElement>('input[name="seoTitle"]')
134
+ expect(french).not.toBeNull()
135
+ expect(french).not.toBe(first)
136
+ expect(french?.value).toBe('fr SEO')
137
+ expect(selectedTabLabel()).toBe('SEO')
138
+
139
+ mount('fr', 'v2') // version change remounts it again
140
+ const versioned = container.querySelector<HTMLInputElement>('input[name="seoTitle"]')
141
+ expect(versioned).not.toBeNull()
142
+ expect(versioned).not.toBe(french)
143
+ expect(selectedTabLabel()).toBe('SEO')
144
+ })
145
+ })
@@ -304,6 +304,24 @@
304
304
  }
305
305
  }
306
306
 
307
+ /* One column at every width. The split layout above becomes a two-column grid
308
+ at >=60rem; a stacked form must not inherit that, or it reserves an empty
309
+ second track and narrows its only content column. */
310
+ .layout-stacked,
311
+ :global(.byline-form-layout-stacked) {
312
+ display: flex;
313
+ flex-direction: column;
314
+ gap: var(--spacing-12);
315
+ padding-top: var(--spacing-32);
316
+ }
317
+
318
+ @media (min-width: 60rem) {
319
+ .layout-stacked,
320
+ :global(.byline-form-layout-stacked) {
321
+ gap: var(--spacing-32);
322
+ }
323
+ }
324
+
307
325
  .content,
308
326
  :global(.byline-form-content) {
309
327
  display: flex;