@byline/admin 6.2.0 → 6.3.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.
Files changed (75) hide show
  1. package/dist/fields/checkbox/checkbox-field.js +3 -1
  2. package/dist/fields/code/code-field.js +2 -1
  3. package/dist/fields/datetime/datetime-field.js +4 -2
  4. package/dist/fields/field-renderer.js +2 -1
  5. package/dist/fields/field-services-types.d.ts +23 -0
  6. package/dist/fields/file/file-field.js +3 -2
  7. package/dist/fields/group/group-field.js +3 -1
  8. package/dist/fields/image/image-field.js +3 -2
  9. package/dist/fields/numerical/numerical-field.js +2 -1
  10. package/dist/fields/relation/relation-field.js +3 -2
  11. package/dist/fields/relation/relation-many-field.js +3 -2
  12. package/dist/fields/relation/relation-picker.js +38 -7
  13. package/dist/fields/relation/relation-picker.module.js +3 -0
  14. package/dist/fields/relation/relation-picker_module.css +20 -0
  15. package/dist/fields/select/select-field.js +3 -2
  16. package/dist/fields/text/text-field.js +2 -1
  17. package/dist/fields/text-area/text-area-field.js +2 -1
  18. package/dist/forms/form-context.js +4 -1
  19. package/dist/forms/form-dom-scope.d.ts +34 -0
  20. package/dist/forms/form-dom-scope.js +20 -0
  21. package/dist/forms/form-layout-styles.test.node.d.ts +8 -0
  22. package/dist/forms/form-layout.d.ts +39 -0
  23. package/dist/forms/form-layout.js +102 -0
  24. package/dist/forms/form-page-chrome.d.ts +110 -0
  25. package/dist/forms/form-page-chrome.js +253 -0
  26. package/dist/forms/form-renderer.d.ts +2 -18
  27. package/dist/forms/form-renderer.js +105 -423
  28. package/dist/forms/form-renderer.module.js +2 -0
  29. package/dist/forms/form-renderer_module.css +13 -0
  30. package/dist/forms/use-form-submission.d.ts +74 -0
  31. package/dist/forms/use-form-submission.js +166 -0
  32. package/dist/forms/use-form-tabs.d.ts +29 -0
  33. package/dist/forms/use-form-tabs.js +45 -0
  34. package/dist/presentation/tabs.js +2 -1
  35. package/dist/react.d.ts +1 -1
  36. package/package.json +7 -7
  37. package/src/fields/checkbox/checkbox-field.tsx +3 -1
  38. package/src/fields/code/code-field.tsx +2 -1
  39. package/src/fields/datetime/datetime-field.tsx +4 -2
  40. package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
  41. package/src/fields/field-dom-ids.test.tsx +293 -0
  42. package/src/fields/field-renderer.tsx +2 -1
  43. package/src/fields/field-services-types.ts +25 -0
  44. package/src/fields/file/file-field.tsx +3 -2
  45. package/src/fields/group/group-field.tsx +3 -1
  46. package/src/fields/image/image-field.tsx +3 -2
  47. package/src/fields/numerical/numerical-field.tsx +2 -1
  48. package/src/fields/relation/relation-field.tsx +3 -2
  49. package/src/fields/relation/relation-many-field.tsx +3 -2
  50. package/src/fields/relation/relation-picker-create.test.tsx +203 -0
  51. package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
  52. package/src/fields/relation/relation-picker.module.css +32 -0
  53. package/src/fields/relation/relation-picker.tsx +59 -1
  54. package/src/fields/select/select-field.tsx +3 -2
  55. package/src/fields/text/text-field.tsx +2 -1
  56. package/src/fields/text-area/text-area-field.tsx +2 -1
  57. package/src/forms/form-context.tsx +2 -1
  58. package/src/forms/form-dom-scope.test.tsx +213 -0
  59. package/src/forms/form-dom-scope.tsx +70 -0
  60. package/src/forms/form-layout-styles.test.node.ts +32 -0
  61. package/src/forms/form-layout.test.tsx +276 -0
  62. package/src/forms/form-layout.tsx +204 -0
  63. package/src/forms/form-page-chrome.tsx +460 -0
  64. package/src/forms/form-renderer-submit.test.tsx +40 -0
  65. package/src/forms/form-renderer-tab-persistence.test.tsx +145 -0
  66. package/src/forms/form-renderer.module.css +18 -0
  67. package/src/forms/form-renderer.tsx +126 -600
  68. package/src/forms/form-submit-isolation.test.tsx +247 -0
  69. package/src/forms/use-form-submission.test.tsx +564 -0
  70. package/src/forms/use-form-submission.ts +293 -0
  71. package/src/forms/use-form-tabs.ts +89 -0
  72. package/src/presentation/tab-dom-ids.test.tsx +153 -0
  73. package/src/presentation/tabs.test.tsx +49 -2
  74. package/src/presentation/tabs.tsx +11 -1
  75. package/src/react.ts +2 -0
@@ -0,0 +1,293 @@
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 { useCallback, useRef, useState } from 'react'
12
+
13
+ import type { Field } from '@byline/core'
14
+ import type { DocumentPatch } from '@byline/core/patches'
15
+ import { useTranslation } from '@byline/i18n/react'
16
+
17
+ import { useBylineFieldServices } from '../fields/field-services-context'
18
+ import { useFormContext } from './form-context'
19
+ import { executeUploadsWithProgress } from './upload-executor'
20
+
21
+ /**
22
+ * Payload emitted by the form on Save. Carries the content (field data +
23
+ * patches) alongside the document-grain system fields (path / advertised
24
+ * locales) and per-bucket dirty flags so the host can route each piece to the
25
+ * right write path — versioned for content, immediate/non-versioned for the
26
+ * system fields. See docs/08-internationalization/index.md.
27
+ */
28
+ export interface SystemFieldsSubmitPayload {
29
+ // biome-ignore lint/suspicious/noExplicitAny: data is collection-specific
30
+ data: any
31
+ patches: DocumentPatch[]
32
+ contentDirty: boolean
33
+ pathDirty: boolean
34
+ systemPath?: string | null
35
+ availableLocalesDirty: boolean
36
+ systemAvailableLocales?: string[]
37
+ }
38
+
39
+ /**
40
+ * Where a save has got to. Mutually exclusive by construction: the form cannot
41
+ * be validating and uploading at once, which two independent booleans allowed.
42
+ */
43
+ export type SubmissionPhase =
44
+ | { kind: 'idle' }
45
+ | { kind: 'validating' }
46
+ | { kind: 'uploading' }
47
+ | { kind: 'confirmingSystemFields'; payload: SystemFieldsSubmitPayload }
48
+ | { kind: 'submitting' }
49
+
50
+ export interface UseFormSubmissionOptions {
51
+ mode: 'create' | 'edit'
52
+ fields: Field[]
53
+ /** Persisted document id (edit mode only); feeds server-side upload hooks. */
54
+ documentId?: string
55
+ /**
56
+ * Only collections that opted into the advertised-locale widget emit the
57
+ * locale set, so the write path never touches
58
+ * `byline_document_available_locales` for the others.
59
+ */
60
+ advertiseLocales?: boolean
61
+ onSubmit: (payload: SystemFieldsSubmitPayload) => void | Promise<void>
62
+ /** Mutations blocked (stale/lock/discarding). Read synchronously, per call. */
63
+ isBlocked: () => boolean
64
+ /**
65
+ * Called immediately before the form becomes `inert` — once before uploads
66
+ * and once before submission — so the caller can record the focused element
67
+ * and restore it when busy ends. The DOM refs stay in the component; the
68
+ * hook only signals the transition.
69
+ */
70
+ onBeforeBusy?: () => void
71
+ }
72
+
73
+ export interface UseFormSubmissionResult {
74
+ phase: SubmissionPhase
75
+ isBusy: boolean
76
+ submit: () => Promise<void>
77
+ confirmSystemFields: () => Promise<void>
78
+ cancelSystemFields: () => void
79
+ }
80
+
81
+ const IDLE: SubmissionPhase = { kind: 'idle' }
82
+
83
+ /**
84
+ * Owns one save: validate → upload → (confirm) → submit.
85
+ *
86
+ * Admission is decided by a ref, not by `phase`. Rendered state is not a
87
+ * re-entry guard: two `submit()` calls in the same turn both observe
88
+ * `phase.kind === 'idle'` because React has not rerendered between them. The
89
+ * ref is set synchronously at the top of `submit()` and cleared in `finally`,
90
+ * spanning validation through submission — the whole window, not just the
91
+ * final request. `phase` drives the UI; the ref decides admission.
92
+ */
93
+ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSubmissionResult {
94
+ const { mode, fields, documentId, advertiseLocales, onSubmit, isBlocked, onBeforeBusy } = options
95
+
96
+ const {
97
+ getFieldValues,
98
+ runFieldHooks,
99
+ validateForm,
100
+ resetHasChanges,
101
+ getPatches,
102
+ getDirtyBreakdown,
103
+ getSystemPath,
104
+ getSystemAvailableLocales,
105
+ setFieldValue,
106
+ setFieldError,
107
+ getPendingUploads,
108
+ clearPendingUploads,
109
+ setFieldUploading,
110
+ } = useFormContext()
111
+ const { t } = useTranslation('byline-admin')
112
+ const { uploadField } = useBylineFieldServices()
113
+
114
+ const [phase, setPhase] = useState<SubmissionPhase>(IDLE)
115
+ const inFlightRef = useRef(false)
116
+
117
+ const isBusy = phase.kind === 'uploading' || phase.kind === 'submitting'
118
+
119
+ // Await the host handler. Resolution means the save succeeded and the clean
120
+ // baseline can be committed; rejection preserves dirty state so the editor
121
+ // does not lose work and the navigation guard keeps blocking. Host handlers
122
+ // that surface their own toast MUST rethrow afterwards.
123
+ const deliver = useCallback(
124
+ async (payload: SystemFieldsSubmitPayload) => {
125
+ // Settle before returning. `runUploads` may have left the phase at
126
+ // `uploading`, and this is the only reset on the delivery path: returning
127
+ // without it strands the form busy and inert with nothing in flight.
128
+ // Dirty state is deliberately untouched, so the save can be retried once
129
+ // the block clears.
130
+ if (isBlocked() || typeof onSubmit !== 'function') {
131
+ setPhase(IDLE)
132
+ return
133
+ }
134
+ onBeforeBusy?.()
135
+ setPhase({ kind: 'submitting' })
136
+ try {
137
+ await onSubmit(payload)
138
+ resetHasChanges()
139
+ } catch {
140
+ // Intentionally swallowed here — the host has already reported the
141
+ // failure to the user. Dirty state is preserved by not resetting.
142
+ } finally {
143
+ setPhase(IDLE)
144
+ }
145
+ },
146
+ [isBlocked, onBeforeBusy, onSubmit, resetHasChanges]
147
+ )
148
+
149
+ const runUploads = useCallback(async (): Promise<boolean> => {
150
+ const pendingUploads = getPendingUploads()
151
+ if (pendingUploads.size === 0) return true
152
+
153
+ onBeforeBusy?.()
154
+ setPhase({ kind: 'uploading' })
155
+ try {
156
+ const uploadResult = await executeUploadsWithProgress(
157
+ pendingUploads,
158
+ uploadField,
159
+ ({ fieldPath, status }) => {
160
+ setFieldUploading(fieldPath, status === 'uploading')
161
+ },
162
+ {
163
+ // Document context for server-side upload hooks: the persisted
164
+ // document id (edit mode only) plus any `upload.context` form
165
+ // values declared on the schema field. See UploadConfig.context
166
+ // in @byline/core.
167
+ documentId: mode === 'edit' ? documentId : undefined,
168
+ fields,
169
+ getFormValues: getFieldValues,
170
+ }
171
+ )
172
+
173
+ if (!uploadResult.allSucceeded) {
174
+ for (const [fieldPath, errorMessage] of uploadResult.errors.entries()) {
175
+ setFieldError(fieldPath, t('forms.uploadFailedFieldError', { message: errorMessage }))
176
+ }
177
+ console.error('One or more uploads failed:', uploadResult.errors)
178
+ return false
179
+ }
180
+
181
+ // Replace pending StoredFileValues with real ones in form data
182
+ for (const [fieldPath, storedFile] of uploadResult.successful.entries()) {
183
+ setFieldValue(fieldPath, storedFile)
184
+ }
185
+
186
+ // Clear pending uploads (blob URLs already revoked by clearPendingUploads)
187
+ clearPendingUploads()
188
+ return true
189
+ } catch (err) {
190
+ console.error('Upload execution error:', err)
191
+ return false
192
+ }
193
+ }, [
194
+ clearPendingUploads,
195
+ documentId,
196
+ fields,
197
+ getFieldValues,
198
+ getPendingUploads,
199
+ mode,
200
+ onBeforeBusy,
201
+ setFieldError,
202
+ setFieldUploading,
203
+ setFieldValue,
204
+ t,
205
+ uploadField,
206
+ ])
207
+
208
+ const buildPayload = useCallback((): { payload: SystemFieldsSubmitPayload; reason: string } => {
209
+ const { contentDirty, pathDirty, availableLocalesDirty, reason } = getDirtyBreakdown()
210
+ return {
211
+ reason,
212
+ payload: {
213
+ data: getFieldValues(),
214
+ patches: getPatches(),
215
+ contentDirty,
216
+ pathDirty,
217
+ systemPath: getSystemPath(),
218
+ availableLocalesDirty,
219
+ systemAvailableLocales: advertiseLocales ? getSystemAvailableLocales() : undefined,
220
+ },
221
+ }
222
+ }, [
223
+ advertiseLocales,
224
+ getDirtyBreakdown,
225
+ getFieldValues,
226
+ getPatches,
227
+ getSystemAvailableLocales,
228
+ getSystemPath,
229
+ ])
230
+
231
+ const submit = useCallback(async () => {
232
+ if (isBlocked()) return
233
+ if (inFlightRef.current) return
234
+ inFlightRef.current = true
235
+ try {
236
+ setPhase({ kind: 'validating' })
237
+
238
+ // Run field-level beforeValidate hooks (submit-time), then validate
239
+ const hookErrors = await runFieldHooks(fields)
240
+ const formErrors = validateForm(fields)
241
+ const allErrors = [...hookErrors, ...formErrors]
242
+
243
+ if (allErrors.length > 0) {
244
+ console.error('Form validation failed:', allErrors)
245
+ setPhase(IDLE)
246
+ return
247
+ }
248
+
249
+ if (isBlocked()) {
250
+ setPhase(IDLE)
251
+ return
252
+ }
253
+
254
+ if (!(await runUploads())) {
255
+ setPhase(IDLE)
256
+ return
257
+ }
258
+
259
+ const { payload, reason } = buildPayload()
260
+
261
+ // Editing the document-grain system fields (path / advertised locales) is
262
+ // an immediate, non-versioned write that does NOT reset workflow status,
263
+ // so confirm it before saving. Create mode writes everything as part of
264
+ // the initial version, so no confirmation applies there.
265
+ if (mode === 'edit' && (reason === 'direct-write' || reason === 'both')) {
266
+ setPhase({ kind: 'confirmingSystemFields', payload })
267
+ return
268
+ }
269
+
270
+ await deliver(payload)
271
+ } finally {
272
+ inFlightRef.current = false
273
+ }
274
+ }, [buildPayload, deliver, fields, isBlocked, mode, runUploads, runFieldHooks, validateForm])
275
+
276
+ const confirmSystemFields = useCallback(async () => {
277
+ if (phase.kind !== 'confirmingSystemFields') return
278
+ if (inFlightRef.current) return
279
+ inFlightRef.current = true
280
+ const { payload } = phase
281
+ try {
282
+ await deliver(payload)
283
+ } finally {
284
+ inFlightRef.current = false
285
+ }
286
+ }, [deliver, phase])
287
+
288
+ const cancelSystemFields = useCallback(() => {
289
+ setPhase((current) => (current.kind === 'confirmingSystemFields' ? IDLE : current))
290
+ }, [])
291
+
292
+ return { phase, isBusy, submit, confirmSystemFields, cancelSystemFields }
293
+ }
@@ -0,0 +1,89 @@
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 { useEffect, useMemo, useState } from 'react'
12
+
13
+ import type { TabDefinition, TabSetDefinition } from '@byline/core'
14
+
15
+ import { useFormContext } from './form-context'
16
+
17
+ /** One tab set, resolved against live form data and current errors. */
18
+ export interface ResolvedTabSet {
19
+ /** Tabs whose `condition` is satisfied, in declaration order. */
20
+ visibleTabs: TabDefinition[]
21
+ /** The tab that should render, after falling back off a hidden selection. */
22
+ activeTabName: string
23
+ activeTab: TabDefinition | undefined
24
+ /** Error count per tab name, for the tab bar's badges. */
25
+ errorCounts: Record<string, number> | undefined
26
+ }
27
+
28
+ export interface UseFormTabsResult {
29
+ resolve: (set: TabSetDefinition) => ResolvedTabSet
30
+ errorCountsBySet: Record<string, Record<string, number>>
31
+ }
32
+
33
+ /**
34
+ * Tab visibility, per-tab error counts, and fallback when the selected tab is
35
+ * hidden.
36
+ *
37
+ * It deliberately does **not** own the selection. `FormRenderer` lifts that
38
+ * above the keyed `FormProvider` so the editor's tab choices survive the
39
+ * remount a locale change triggers; a hook living inside that subtree would
40
+ * lose them on every switch. The selection arrives as `activeTabBySet` and
41
+ * changes are reported upward.
42
+ */
43
+ export function useFormTabs(
44
+ fieldToTabPath: Map<string, { tabSetName: string; tabName: string }>,
45
+ activeTabBySet: Record<string, string>
46
+ ): UseFormTabsResult {
47
+ const { errors: initialErrors, subscribeErrors, subscribeMeta, getFieldValues } = useFormContext()
48
+
49
+ const [errors, setErrors] = useState(initialErrors)
50
+ useEffect(() => {
51
+ return subscribeErrors((newErrors) => setErrors(newErrors))
52
+ }, [subscribeErrors])
53
+
54
+ // Live form data, so TabDefinition.condition re-evaluates per keystroke.
55
+ const [formData, setFormData] = useState<Record<string, any>>(() => getFieldValues())
56
+ useEffect(() => {
57
+ return subscribeMeta(() => setFormData(getFieldValues()))
58
+ }, [subscribeMeta, getFieldValues])
59
+
60
+ // Per-tab-set error counts: { [tabSetName]: { [tabName]: count } }.
61
+ // Each tab bar consumes its own slice.
62
+ const errorCountsBySet = useMemo<Record<string, Record<string, number>>>(() => {
63
+ const result: Record<string, Record<string, number>> = {}
64
+ for (const err of errors) {
65
+ const path = fieldToTabPath.get(err.field)
66
+ if (!path) continue
67
+ result[path.tabSetName] ??= {}
68
+ result[path.tabSetName]![path.tabName] = (result[path.tabSetName]?.[path.tabName] ?? 0) + 1
69
+ }
70
+ return result
71
+ }, [errors, fieldToTabPath])
72
+
73
+ const resolve = (set: TabSetDefinition): ResolvedTabSet => {
74
+ const visibleTabs = set.tabs.filter((tab) => !tab.condition || tab.condition(formData))
75
+ const requested = activeTabBySet[set.name] ?? ''
76
+ const activeTabName =
77
+ visibleTabs.length > 0 && !visibleTabs.some((t) => t.name === requested)
78
+ ? (visibleTabs[0]?.name ?? requested)
79
+ : requested
80
+ return {
81
+ visibleTabs,
82
+ activeTabName,
83
+ activeTab: visibleTabs.find((t) => t.name === activeTabName),
84
+ errorCounts: errorCountsBySet[set.name],
85
+ }
86
+ }
87
+
88
+ return { resolve, errorCountsBySet }
89
+ }
@@ -0,0 +1,153 @@
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 } from 'vitest'
17
+
18
+ import { BylineFieldServicesProvider } from '../fields/field-services-context'
19
+ import { FormProvider } from '../forms/form-context'
20
+ import { FormLayout } from '../forms/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: 'seoTitle', label: 'SEO title', type: 'text' },
46
+ ]
47
+
48
+ /**
49
+ * Both forms declare a tab set under the same name, which is the ordinary case:
50
+ * two forms over one collection share its admin configuration verbatim.
51
+ */
52
+ const adminConfig = {
53
+ layout: { main: ['main'] },
54
+ tabSets: [
55
+ {
56
+ name: 'main',
57
+ tabs: [
58
+ { name: 'general', label: 'General', fields: ['title'] },
59
+ { name: 'seo', label: 'SEO', fields: ['seoTitle'] },
60
+ ],
61
+ },
62
+ ],
63
+ } as unknown as AdminResourceConfig
64
+
65
+ let container: HTMLDivElement
66
+ let root: Root
67
+
68
+ beforeEach(() => {
69
+ container = document.createElement('div')
70
+ document.body.appendChild(container)
71
+ root = createRoot(container)
72
+ })
73
+
74
+ afterEach(() => {
75
+ act(() => {
76
+ root.unmount()
77
+ })
78
+ container.remove()
79
+ })
80
+
81
+ const Form = ({ name }: { name: string }) => (
82
+ <form aria-label={name}>
83
+ <FormProvider initialData={{ fields: {} }} collectionPath="pages">
84
+ <FormLayout
85
+ fields={fields}
86
+ adminConfig={adminConfig}
87
+ activeTabBySet={{ main: 'general' }}
88
+ onTabChange={() => {}}
89
+ />
90
+ </FormProvider>
91
+ </form>
92
+ )
93
+
94
+ const renderTwoForms = () => {
95
+ act(() => {
96
+ root.render(
97
+ <I18nProvider
98
+ bundle={adminTranslations({ locales: ['en'] })}
99
+ activeLocale="en"
100
+ defaultLocale="en"
101
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
102
+ >
103
+ <BylineFieldServicesProvider
104
+ services={
105
+ {
106
+ getCollectionDocuments: async () => ({ docs: [], total: 0 }),
107
+ uploadField: async () => ({}),
108
+ } as any
109
+ }
110
+ >
111
+ <Form name="parent" />
112
+ <Form name="child" />
113
+ </BylineFieldServicesProvider>
114
+ </I18nProvider>
115
+ )
116
+ })
117
+ }
118
+
119
+ describe('tab DOM ids across concurrently mounted forms', () => {
120
+ it('keeps tab trigger ids distinct across two forms with a "main" tab set', () => {
121
+ renderTwoForms()
122
+
123
+ const triggers = Array.from(container.querySelectorAll('[role="tab"]')).map((n) => n.id)
124
+
125
+ expect(triggers.length).toBeGreaterThan(0)
126
+ expect(triggers.every((id) => id !== '')).toBe(true)
127
+ expect(new Set(triggers).size).toBe(triggers.length)
128
+ })
129
+
130
+ it('labels each tab panel from a trigger in its own form', () => {
131
+ renderTwoForms()
132
+
133
+ const panels = Array.from(container.querySelectorAll('[role="tabpanel"]'))
134
+ expect(panels).toHaveLength(2)
135
+
136
+ for (const panel of panels) {
137
+ const owner = document.getElementById(panel.getAttribute('aria-labelledby') as string)
138
+ expect(owner).not.toBeNull()
139
+ // Resolution is document-wide, so an unscoped id points the second form's
140
+ // panel at the first form's trigger.
141
+ expect(owner?.closest('form')).toBe(panel.closest('form'))
142
+ }
143
+ })
144
+
145
+ it('keeps panel ids distinct so aria-controls cannot cross forms', () => {
146
+ renderTwoForms()
147
+
148
+ const panelIds = Array.from(container.querySelectorAll('[role="tabpanel"]')).map((n) => n.id)
149
+
150
+ expect(panelIds.every((id) => id !== '')).toBe(true)
151
+ expect(new Set(panelIds).size).toBe(panelIds.length)
152
+ })
153
+ })
@@ -51,7 +51,7 @@ import { AdminTabs } from './tabs'
51
51
  * report overflow. Drive the two widths the component reads off its stable
52
52
  * override handles.
53
53
  */
54
- const layout = { container: 0, row: 0 }
54
+ const layout = { container: 0, row: 0, rowReads: 0 }
55
55
 
56
56
  function installPointerStubs() {
57
57
  // jsdom implements neither pointer capture nor scrollTo; the component guards
@@ -96,7 +96,11 @@ function installLayoutStubs() {
96
96
  Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
97
97
  configurable: true,
98
98
  get(this: HTMLElement) {
99
- return this.classList.contains('byline-admin-tablist') ? layout.row : 0
99
+ if (!this.classList.contains('byline-admin-tablist')) return 0
100
+ // Counted, not just answered: in a browser this getter is what forces
101
+ // the synchronous layout, so the count is the cost under test.
102
+ layout.rowReads += 1
103
+ return layout.row
100
104
  },
101
105
  })
102
106
  }
@@ -135,6 +139,7 @@ describe('AdminTabs', () => {
135
139
  observed.length = 0
136
140
  layout.container = 1000
137
141
  layout.row = 400
142
+ layout.rowReads = 0
138
143
  onChange = vi.fn()
139
144
  container = document.createElement('div')
140
145
  document.body.appendChild(container)
@@ -367,6 +372,48 @@ describe('AdminTabs', () => {
367
372
  expect(observedClasses.some((c) => c.includes('byline-admin-tabs '))).toBe(true)
368
373
  })
369
374
 
375
+ /**
376
+ * The tab array arrives with a fresh identity on every render of the form
377
+ * layout: `useFormTabs.resolve()` filters `set.tabs` each time it is called,
378
+ * and the live form data it filters against updates per keystroke. Keying the
379
+ * measurement effect on that identity made every unrelated render tear down
380
+ * the ResizeObserver and force a synchronous layout read — measured in Chrome
381
+ * against a seeded `pages` document as one forced read per keystroke, and two
382
+ * on an editor mount.
383
+ */
384
+ it('does not re-measure when the parent re-renders with an equal tab array', () => {
385
+ render()
386
+ const readsAfterMount = layout.rowReads
387
+ const observedAfterMount = observed.length
388
+
389
+ render({ tabs: TABS.map((tab) => ({ ...tab })) })
390
+
391
+ expect(layout.rowReads).toBe(readsAfterMount)
392
+ expect(observed.length).toBe(observedAfterMount)
393
+ })
394
+
395
+ it('re-measures when a tab is added or removed', () => {
396
+ render()
397
+ const readsAfterMount = layout.rowReads
398
+
399
+ render({ tabs: [...TABS, { name: 'indexing', label: 'Indexing' }] })
400
+
401
+ expect(layout.rowReads).toBeGreaterThan(readsAfterMount)
402
+ })
403
+
404
+ /**
405
+ * A label carries the width, so a translation change has to re-measure even
406
+ * though the tab names are untouched.
407
+ */
408
+ it('re-measures when a label changes', () => {
409
+ render()
410
+ const readsAfterMount = layout.rowReads
411
+
412
+ render({ tabs: TABS.map((tab, i) => (i === 0 ? { ...tab, label: 'Détails' } : tab)) })
413
+
414
+ expect(layout.rowReads).toBeGreaterThan(readsAfterMount)
415
+ })
416
+
370
417
  // ─── Drag-to-scroll (desktop) ─────────────────────────────────────────
371
418
 
372
419
  const viewport = () =>
@@ -124,6 +124,16 @@ export const AdminTabs = ({
124
124
 
125
125
  const [strip, setStrip] = useState<StripState>(INITIAL_STRIP_STATE)
126
126
 
127
+ // The measurement effect is keyed on what the tabs *are*, not on the identity
128
+ // of the array carrying them. A form layout rebuilds that array on every
129
+ // render — `useFormTabs.resolve()` filters `set.tabs` against live form data,
130
+ // so a new array arrives per keystroke — and re-running the effect for one of
131
+ // those renders would disconnect the observer and force a synchronous layout
132
+ // read on a tree the browser has just invalidated. Labels are part of the key
133
+ // because the label is what carries the width: a translation change has to
134
+ // re-measure, a keystroke elsewhere in the form does not.
135
+ const tabsKey = tabs.map((tab) => `${tab.name}:${tab.label}`).join('\u0000')
136
+
127
137
  const measure = useCallback(() => {
128
138
  const container = containerRef.current
129
139
  const row = rowRef.current
@@ -162,7 +172,7 @@ export const AdminTabs = ({
162
172
  observer.observe(container)
163
173
  observer.observe(row)
164
174
  return () => observer.disconnect()
165
- }, [measure, tabs])
175
+ }, [measure, tabsKey])
166
176
 
167
177
  const reveal = useCallback((name: string) => {
168
178
  const viewport = viewportRef.current
package/src/react.ts CHANGED
@@ -81,10 +81,12 @@ export * from './widgets/status-badge/status-badge.js'
81
81
  // Field-side service contract types.
82
82
  export type {
83
83
  BylineFieldServices,
84
+ CanCreateInCollectionFn,
84
85
  CollectionListDoc,
85
86
  CollectionListParams,
86
87
  CollectionListResponse,
87
88
  GetCollectionDocumentsFn,
89
+ GetCreateDocumentUrlFn,
88
90
  GetTreeAncestorsFn,
89
91
  GetTreeParentFn,
90
92
  PlaceTreeNodeFn,