@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,276 @@
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 type { AdminResourceConfig, Field } from '@byline/core'
12
+ import { defineAdminConfig } from '@byline/core'
13
+ import { adminTranslations } from '@byline/i18n/admin'
14
+ import { I18nProvider } from '@byline/i18n/react'
15
+ import { createRoot, type Root } from 'react-dom/client'
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
17
+
18
+ import { BylineFieldServicesProvider } from '../fields/field-services-context'
19
+ import { FormProvider } from './form-context'
20
+ import { FormLayout } from './form-layout'
21
+
22
+ // Deliberately not the `;(globalThis as any)` idiom used elsewhere in this
23
+ // suite: Biome's import sorting can move an import across that leading
24
+ // semicolon, and without semicolons the result parses as a call on the import.
25
+ const globalWithAct = globalThis as any
26
+ globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
27
+
28
+ defineAdminConfig({
29
+ i18n: {
30
+ admin: { defaultLocale: 'en', locales: ['en'] },
31
+ content: { defaultLocale: 'en', locales: ['en'] },
32
+ },
33
+ collections: [
34
+ {
35
+ path: 'pages',
36
+ labels: { singular: 'Page', plural: 'Pages' },
37
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
38
+ },
39
+ ],
40
+ slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
41
+ })
42
+
43
+ const fields: Field[] = [
44
+ { name: 'title', label: 'Title', type: 'text' },
45
+ { name: 'featured', label: 'Featured', type: 'text' },
46
+ { name: 'seoTitle', label: 'SEO title', type: 'text' },
47
+ ]
48
+
49
+ let container: HTMLDivElement
50
+ let root: Root
51
+
52
+ beforeEach(() => {
53
+ container = document.createElement('div')
54
+ document.body.appendChild(container)
55
+ root = createRoot(container)
56
+ })
57
+
58
+ afterEach(() => {
59
+ act(() => {
60
+ root.unmount()
61
+ })
62
+ container.remove()
63
+ })
64
+
65
+ const render = (props: Partial<React.ComponentProps<typeof FormLayout>> = {}) => {
66
+ act(() => {
67
+ root.render(
68
+ <I18nProvider
69
+ bundle={adminTranslations({ locales: ['en'] })}
70
+ activeLocale="en"
71
+ defaultLocale="en"
72
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
73
+ >
74
+ <BylineFieldServicesProvider
75
+ services={
76
+ {
77
+ getCollectionDocuments: async () => ({ docs: [], total: 0 }),
78
+ uploadField: async () => ({}),
79
+ } as any
80
+ }
81
+ >
82
+ <FormProvider initialData={{ fields: {} }} collectionPath="pages">
83
+ <FormLayout
84
+ fields={fields}
85
+ activeTabBySet={{}}
86
+ onTabChange={() => {}}
87
+ {...(props as any)}
88
+ />
89
+ </FormProvider>
90
+ </BylineFieldServicesProvider>
91
+ </I18nProvider>
92
+ )
93
+ })
94
+ }
95
+
96
+ const config = (c: Partial<AdminResourceConfig>) => c as AdminResourceConfig
97
+
98
+ describe('FormLayout regions', () => {
99
+ it('renders main-region fields', () => {
100
+ render({ adminConfig: config({ layout: { main: ['title'] } }) })
101
+ expect(container.querySelector('input[name="title"]')).not.toBeNull()
102
+ })
103
+
104
+ it('renders layout.sidebar schema fields', () => {
105
+ render({ adminConfig: config({ layout: { main: ['title'], sidebar: ['featured'] } }) })
106
+ const sidebar = container.querySelector('.byline-form-sidebar')
107
+ expect(sidebar?.querySelector('input[name="featured"]')).not.toBeNull()
108
+ expect(container.querySelector('.byline-form-content input[name="featured"]')).toBeNull()
109
+ })
110
+
111
+ it('omits a region that was not requested', () => {
112
+ render({
113
+ adminConfig: config({ layout: { main: ['title'], sidebar: ['featured'] } }),
114
+ regions: ['main'],
115
+ })
116
+ expect(container.querySelector('.byline-form-sidebar')).toBeNull()
117
+ expect(container.querySelector('input[name="title"]')).not.toBeNull()
118
+ })
119
+
120
+ it('renders both regions in one column in the stacked variant', () => {
121
+ render({
122
+ adminConfig: config({ layout: { main: ['title'], sidebar: ['featured'] } }),
123
+ variant: 'stacked',
124
+ })
125
+ expect(container.querySelector('input[name="title"]')).not.toBeNull()
126
+ expect(container.querySelector('input[name="featured"]')).not.toBeNull()
127
+ expect(container.querySelector('.byline-form-sidebar')).toBeNull()
128
+ })
129
+ })
130
+
131
+ describe('FormLayout region order', () => {
132
+ // The prop is an ordered list, and both the interface and the plan promise
133
+ // order. Reducing it to membership checks silently always renders main first.
134
+ const both = config({ layout: { main: ['title'], sidebar: ['featured'] } })
135
+
136
+ const fieldOrder = () =>
137
+ [...container.querySelectorAll('input')].map((i) => i.getAttribute('name'))
138
+
139
+ it('honours a reversed region order in the split variant', () => {
140
+ render({ adminConfig: both, regions: ['sidebar', 'main'] })
141
+ expect(fieldOrder()).toEqual(['featured', 'title'])
142
+ })
143
+
144
+ it('honours a reversed region order in the stacked variant', () => {
145
+ render({ adminConfig: both, regions: ['sidebar', 'main'], variant: 'stacked' })
146
+ expect(fieldOrder()).toEqual(['featured', 'title'])
147
+ })
148
+
149
+ it('keeps the slot ahead of the schema fields within the sidebar', () => {
150
+ render({
151
+ adminConfig: both,
152
+ regions: ['sidebar', 'main'],
153
+ sidebarSlot: <div data-testid="path-widget" />,
154
+ })
155
+ const marks = [...container.querySelectorAll('input, [data-testid="path-widget"]')].map(
156
+ (n) => n.getAttribute('data-testid') ?? n.getAttribute('name')
157
+ )
158
+ expect(marks).toEqual(['path-widget', 'featured', 'title'])
159
+ })
160
+ })
161
+
162
+ describe('FormLayout stacked variant', () => {
163
+ // The split layout's own class becomes a two-column grid at >=60rem
164
+ // (form-renderer.module.css). Carrying it on a stacked form reserves an empty
165
+ // second track, which defeats the one-column variant. jsdom cannot measure
166
+ // that, so the guard is that the split class is not applied at all.
167
+ it('does not carry the two-column split layout class', () => {
168
+ render({
169
+ adminConfig: config({ layout: { main: ['title'], sidebar: ['featured'] } }),
170
+ variant: 'stacked',
171
+ })
172
+ const root = container.querySelector('[class*="layout"]')
173
+ expect(root?.className).toContain('byline-form-layout-stacked')
174
+ expect(root?.classList.contains('byline-form-layout')).toBe(false)
175
+ })
176
+
177
+ it('keeps the split class on the split variant', () => {
178
+ render({ adminConfig: config({ layout: { main: ['title'] } }) })
179
+ const root = container.querySelector('.byline-form-layout')
180
+ expect(root).not.toBeNull()
181
+ expect(root?.className).not.toContain('stacked')
182
+ })
183
+ })
184
+
185
+ describe('FormLayout sidebar slot', () => {
186
+ it('renders page-level widgets before the sidebar schema fields', () => {
187
+ // Deliberate deviation from the plan's Task 2 test, decided by Tony:
188
+ // the current editor renders page widgets first, and Phase 1's acceptance
189
+ // is behaviour preservation. Reversing the order here would visibly move
190
+ // `publishedOn` above the path widget in the shipped docs collection.
191
+ render({
192
+ adminConfig: config({ layout: { main: ['title'], sidebar: ['featured'] } }),
193
+ sidebarSlot: <div data-testid="path-widget" />,
194
+ })
195
+ const sidebar = container.querySelector('.byline-form-sidebar')
196
+ const order = [...(sidebar?.children ?? [])].map(
197
+ (n) => n.getAttribute('data-testid') ?? n.querySelector('input')?.getAttribute('name') ?? '?'
198
+ )
199
+ expect(order).toEqual(['path-widget', 'featured'])
200
+ })
201
+
202
+ it('renders a sidebar for the slot even when no sidebar fields are declared', () => {
203
+ render({
204
+ adminConfig: config({ layout: { main: ['title'] } }),
205
+ sidebarSlot: <div data-testid="path-widget" />,
206
+ })
207
+ expect(container.querySelector('[data-testid="path-widget"]')).not.toBeNull()
208
+ })
209
+ })
210
+
211
+ describe('FormLayout tabs', () => {
212
+ const tabbed = config({
213
+ layout: { main: ['content'] },
214
+ tabSets: [
215
+ {
216
+ name: 'content',
217
+ tabs: [
218
+ { name: 'general', label: 'General', fields: ['title'] },
219
+ { name: 'seo', label: 'SEO', fields: ['seoTitle'] },
220
+ ],
221
+ },
222
+ ],
223
+ })
224
+
225
+ it('renders the tab named by the controlled activeTabBySet prop', () => {
226
+ render({ adminConfig: tabbed, activeTabBySet: { content: 'seo' } })
227
+ expect(container.querySelector('input[name="seoTitle"]')).not.toBeNull()
228
+ expect(container.querySelector('input[name="title"]')).toBeNull()
229
+ })
230
+
231
+ it('falls back to the first tab when the set has no selection', () => {
232
+ render({ adminConfig: tabbed, activeTabBySet: {} })
233
+ expect(container.querySelector('input[name="title"]')).not.toBeNull()
234
+ })
235
+
236
+ it('reports a tab change upward rather than selecting it itself', () => {
237
+ // Selection stays owned above the keyed FormProvider so tab choices
238
+ // survive the locale-change remount. FormLayout must not self-select.
239
+ const onTabChange = vi.fn()
240
+ render({ adminConfig: tabbed, activeTabBySet: { content: 'general' }, onTabChange })
241
+
242
+ const seoTab = [...container.querySelectorAll('button')].find((b) => b.textContent === 'SEO')
243
+ if (seoTab == null) throw new Error('SEO tab not found')
244
+ act(() => {
245
+ seoTab.click()
246
+ })
247
+
248
+ expect(onTabChange).toHaveBeenCalledWith('content', 'seo')
249
+ expect(container.querySelector('input[name="seoTitle"]')).toBeNull()
250
+ })
251
+
252
+ it('hides a tab whose condition is unmet and falls back to a visible one', () => {
253
+ const conditional = config({
254
+ layout: { main: ['content'] },
255
+ tabSets: [
256
+ {
257
+ name: 'content',
258
+ tabs: [
259
+ { name: 'general', label: 'General', fields: ['title'] },
260
+ {
261
+ name: 'seo',
262
+ label: 'SEO',
263
+ fields: ['seoTitle'],
264
+ condition: (data: Record<string, any>) => data.title === 'show-seo',
265
+ },
266
+ ],
267
+ },
268
+ ],
269
+ })
270
+ render({ adminConfig: conditional, activeTabBySet: { content: 'seo' } })
271
+
272
+ const tabLabels = [...container.querySelectorAll('button')].map((b) => b.textContent)
273
+ expect(tabLabels).not.toContain('SEO')
274
+ expect(container.querySelector('input[name="title"]')).not.toBeNull()
275
+ })
276
+ })
@@ -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
+ }