@byline/admin 6.2.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.
@@ -0,0 +1,198 @@
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
+ import { Fragment, type ReactNode } from 'react'
12
+
13
+ import type {
14
+ AdminResourceConfig,
15
+ Field,
16
+ GroupDefinition,
17
+ RowDefinition,
18
+ TabSetDefinition,
19
+ } from '@byline/core'
20
+ import cx from 'clsx'
21
+
22
+ import { sliceFieldAdmin } from '../fields/field-admin'
23
+ import { FieldRenderer } from '../fields/field-renderer'
24
+ import { AdminGroup } from '../presentation/group'
25
+ import { AdminRow } from '../presentation/row'
26
+ import { AdminTabs, tabPanelId, tabTriggerId } from '../presentation/tabs'
27
+ import styles from './form-renderer.module.css'
28
+ import { useFormLayout } from './use-form-layout'
29
+ import { useFormTabs } from './use-form-tabs'
30
+
31
+ export interface FormLayoutProps {
32
+ fields: Field[]
33
+ adminConfig?: AdminResourceConfig
34
+ /** Which regions to render, in order. Default: both. */
35
+ regions?: ReadonlyArray<'main' | 'sidebar'>
36
+ /** 'split' = two columns (full page); 'stacked' = one column (embedded). */
37
+ variant?: 'split' | 'stacked'
38
+ collectionPath?: string
39
+ /** Content locale passed through to every field renderer. */
40
+ activeLocale?: string
41
+ /** Initial document, for field default values. */
42
+ // biome-ignore lint/suspicious/noExplicitAny: document shape is collection-specific
43
+ initialData?: any
44
+ /**
45
+ * Controlled active-tab state. Owned above the keyed `FormProvider` so tab
46
+ * choices survive the remount a locale change triggers — see `useFormTabs`.
47
+ */
48
+ activeTabBySet: Record<string, string>
49
+ onTabChange: (tabSetName: string, tabName: string) => void
50
+ /**
51
+ * Page-level sidebar widgets (path, tree placement, available locales),
52
+ * rendered in the same sidebar column as the sidebar schema fields. The
53
+ * embedded creation view passes nothing.
54
+ */
55
+ sidebarSlot?: ReactNode
56
+ }
57
+
58
+ const BOTH_REGIONS = ['main', 'sidebar'] as const
59
+
60
+ /**
61
+ * The layout walk: recursively dispatches each name in a region to the
62
+ * appropriate primitive renderer or to `<FieldRenderer>`.
63
+ */
64
+ export const FormLayout = ({
65
+ fields,
66
+ adminConfig,
67
+ regions = BOTH_REGIONS,
68
+ variant = 'split',
69
+ activeLocale,
70
+ initialData,
71
+ activeTabBySet,
72
+ onTabChange,
73
+ sidebarSlot,
74
+ }: FormLayoutProps): ReactNode => {
75
+ const { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath } =
76
+ useFormLayout(adminConfig, fields)
77
+ const tabs = useFormTabs(fieldToTabPath, activeTabBySet)
78
+
79
+ const renderField = (fieldName: string): ReactNode => {
80
+ const field = fieldByName.get(fieldName)
81
+ if (!field) return null
82
+ return (
83
+ <FieldRenderer
84
+ key={field.name}
85
+ field={field}
86
+ defaultValue={initialData?.fields?.[field.name]}
87
+ contentLocale={activeLocale}
88
+ components={adminConfig?.fields?.[field.name]?.components}
89
+ editor={adminConfig?.fields?.[field.name]?.editor}
90
+ fieldAdmin={sliceFieldAdmin(adminConfig?.fields, field.name)}
91
+ />
92
+ )
93
+ }
94
+
95
+ const renderItem = (name: string): ReactNode => {
96
+ const tabSet = tabSetByName.get(name)
97
+ if (tabSet) return renderTabSet(tabSet)
98
+
99
+ const group = groupByName.get(name)
100
+ if (group) return renderGroup(group)
101
+
102
+ const row = rowByName.get(name)
103
+ if (row) return renderRow(row)
104
+
105
+ return renderField(name)
106
+ }
107
+
108
+ const renderRow = (row: RowDefinition): ReactNode => (
109
+ <AdminRow key={`row:${row.name}`}>{row.fields.map((name) => renderField(name))}</AdminRow>
110
+ )
111
+
112
+ const renderGroup = (group: GroupDefinition): ReactNode => (
113
+ <AdminGroup key={`group:${group.name}`} label={group.label}>
114
+ {group.fields.map((name) => renderItem(name))}
115
+ </AdminGroup>
116
+ )
117
+
118
+ const renderTabSet = (set: TabSetDefinition): ReactNode => {
119
+ const { visibleTabs, activeTabName, activeTab, errorCounts } = tabs.resolve(set)
120
+ const idBase = `tabset:${set.name}`
121
+
122
+ return (
123
+ <div key={idBase} className={cx('byline-form-tabset', styles.tabset)}>
124
+ {visibleTabs.length > 0 && (
125
+ <AdminTabs
126
+ idBase={idBase}
127
+ tabs={visibleTabs}
128
+ activeTab={activeTabName}
129
+ onChange={(tabName) => onTabChange(set.name, tabName)}
130
+ errorCounts={errorCounts}
131
+ className={cx('byline-form-tabset-tabs', styles['tabset-tabs'])}
132
+ />
133
+ )}
134
+ {activeTab && (
135
+ <div
136
+ role="tabpanel"
137
+ id={tabPanelId(idBase, activeTab.name)}
138
+ aria-labelledby={tabTriggerId(idBase, activeTab.name)}
139
+ className={cx('byline-form-tabset-fields', styles['tabset-fields'])}
140
+ >
141
+ {activeTab.fields.map((name) => renderItem(name))}
142
+ </div>
143
+ )}
144
+ </div>
145
+ )
146
+ }
147
+
148
+ const sidebarNames = layout.sidebar ?? []
149
+
150
+ // Page-level widgets come first, matching the current editor. The plan's
151
+ // Task 2 sketch had them after the schema fields; that would visibly reorder
152
+ // a shipped collection's sidebar during a refactor whose acceptance is
153
+ // behaviour preservation.
154
+ const sidebarContents = (
155
+ <>
156
+ {sidebarSlot}
157
+ {sidebarNames.map((name) => renderItem(name))}
158
+ </>
159
+ )
160
+
161
+ // `regions` is an ordered list, not a set: iterate it in sequence rather than
162
+ // testing membership, or a caller asking for ['sidebar', 'main'] silently
163
+ // still gets main first.
164
+ const renderRegion = (region: 'main' | 'sidebar'): ReactNode =>
165
+ region === 'main' ? (
166
+ <div key="main" className={cx('byline-form-content', styles.content)}>
167
+ {layout.main.map((name) => renderItem(name))}
168
+ </div>
169
+ ) : (
170
+ <div key="sidebar" className={cx('byline-form-sidebar', styles.sidebar)}>
171
+ {sidebarContents}
172
+ </div>
173
+ )
174
+
175
+ // Stacked: one column, both regions inline, and deliberately **not** the
176
+ // split layout class - that class becomes a two-column grid at >=60rem, so
177
+ // carrying it here would reserve an empty second track and narrow the only
178
+ // column. Removing the sidebar element does not remove the grid track.
179
+ if (variant === 'stacked') {
180
+ return (
181
+ <div className={cx('byline-form-layout-stacked', styles['layout-stacked'])}>
182
+ <div className={cx('byline-form-content', styles.content)}>
183
+ {regions.map((region) => (
184
+ <Fragment key={region}>
185
+ {region === 'main' ? layout.main.map((name) => renderItem(name)) : sidebarContents}
186
+ </Fragment>
187
+ ))}
188
+ </div>
189
+ </div>
190
+ )
191
+ }
192
+
193
+ return (
194
+ <div className={cx('byline-form-layout', styles.layout)}>
195
+ {regions.map((region) => renderRegion(region))}
196
+ </div>
197
+ )
198
+ }
@@ -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 })