@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
+ /**
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, useEffect } 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 { FormProvider, useFormContext } from '../forms/form-context'
19
+ import { FormLayout } from '../forms/form-layout'
20
+ import { BylineFieldServicesProvider } from './field-services-context'
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
+ // `helpText` makes the uikit control emit a real `help-for-…` element and point
45
+ // `aria-describedby` at it; without it the ARIA assertions have nothing to bite on.
46
+ { name: 'title', label: 'Title', type: 'text', helpText: 'The public title' },
47
+ ]
48
+ const adminConfig = { layout: { main: ['title'] } } as AdminResourceConfig
49
+
50
+ /**
51
+ * Two groups in ONE form, each declaring a field called `title`. Field names are
52
+ * only unique within their level, so this is where a name-derived id collides
53
+ * without a second form being involved at all.
54
+ */
55
+ const nestedFields: Field[] = [
56
+ {
57
+ name: 'intro',
58
+ label: 'Intro',
59
+ type: 'group',
60
+ fields: [{ name: 'title', label: 'Title', type: 'text', helpText: 'Intro title' }],
61
+ } as unknown as Field,
62
+ {
63
+ name: 'outro',
64
+ label: 'Outro',
65
+ type: 'group',
66
+ fields: [{ name: 'title', label: 'Title', type: 'text', helpText: 'Outro title' }],
67
+ } as unknown as Field,
68
+ ]
69
+ const nestedAdminConfig = { layout: { main: ['intro', 'outro'] } } as AdminResourceConfig
70
+
71
+ let container: HTMLDivElement
72
+ let root: Root
73
+
74
+ beforeEach(() => {
75
+ container = document.createElement('div')
76
+ document.body.appendChild(container)
77
+ root = createRoot(container)
78
+ })
79
+
80
+ afterEach(() => {
81
+ act(() => {
82
+ root.unmount()
83
+ })
84
+ container.remove()
85
+ })
86
+
87
+ /**
88
+ * Puts a field into its error state through the real form context, so the widget
89
+ * renders an `error-for-…` element and references it — the same path validation
90
+ * takes.
91
+ */
92
+ const ErrorProbe = ({ field }: { field: string }) => {
93
+ const { setFieldError } = useFormContext()
94
+ useEffect(() => {
95
+ setFieldError(field, 'Required')
96
+ }, [field, setFieldError])
97
+ return null
98
+ }
99
+
100
+ /**
101
+ * One form, wrapped in its own `<form>` so the assertions can ask which form an
102
+ * element belongs to — the same question a browser answers when it resolves a
103
+ * label's `for`.
104
+ */
105
+ const Form = ({ name }: { name: string }) => (
106
+ <form aria-label={name}>
107
+ <FormProvider initialData={{ fields: {} }} collectionPath="pages">
108
+ <FormLayout
109
+ fields={fields}
110
+ adminConfig={adminConfig}
111
+ activeTabBySet={{}}
112
+ onTabChange={() => {}}
113
+ />
114
+ <ErrorProbe field="title" />
115
+ </FormProvider>
116
+ </form>
117
+ )
118
+
119
+ /**
120
+ * Two forms over the same collection, mounted at once — the shape the relationship
121
+ * picker's creation view produces, and the shape in which a field name is no longer
122
+ * unique in the document.
123
+ */
124
+ const renderTwoForms = () => {
125
+ act(() => {
126
+ root.render(
127
+ <I18nProvider
128
+ bundle={adminTranslations({ locales: ['en'] })}
129
+ activeLocale="en"
130
+ defaultLocale="en"
131
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
132
+ >
133
+ <BylineFieldServicesProvider
134
+ services={
135
+ {
136
+ getCollectionDocuments: async () => ({ docs: [], total: 0 }),
137
+ uploadField: async () => ({}),
138
+ } as any
139
+ }
140
+ >
141
+ <Form name="parent" />
142
+ <Form name="child" />
143
+ </BylineFieldServicesProvider>
144
+ </I18nProvider>
145
+ )
146
+ })
147
+ }
148
+
149
+ describe('field DOM ids across concurrently mounted forms', () => {
150
+ it('gives two mounted forms distinct ids for the same field', () => {
151
+ renderTwoForms()
152
+
153
+ const ids = Array.from(container.querySelectorAll('input')).map((node) => node.id)
154
+
155
+ expect(ids).toHaveLength(2)
156
+ expect(ids.every((id) => id !== '')).toBe(true)
157
+ expect(new Set(ids).size).toBe(2)
158
+ })
159
+
160
+ it('associates each label with the input in its own form', () => {
161
+ renderTwoForms()
162
+
163
+ const labels = Array.from(container.querySelectorAll('label[for]'))
164
+ expect(labels).toHaveLength(2)
165
+
166
+ for (const label of labels) {
167
+ const target = document.getElementById(label.getAttribute('for') as string)
168
+ expect(target).not.toBeNull()
169
+ // The browser resolves `for` document-wide, so an unscoped id silently
170
+ // pairs the second form's label with the first form's input.
171
+ expect(target?.closest('form')).toBe(label.closest('form'))
172
+ }
173
+ })
174
+
175
+ /**
176
+ * Every id an assistive technology follows has to land in the same form, not
177
+ * just the input's own id: a stale `aria-describedby` announces another form's
178
+ * error text, which is worse than announcing nothing.
179
+ *
180
+ * The earlier version of this test skipped references whose target was missing
181
+ * and rendered no help or error state, so it could pass with nothing exercised
182
+ * and with every association broken. It now requires the references to exist.
183
+ */
184
+ it('keeps every aria reference inside its own form, and resolves all of them', () => {
185
+ renderTwoForms()
186
+
187
+ const referencing = Array.from(
188
+ container.querySelectorAll('[aria-describedby], [aria-labelledby]')
189
+ )
190
+
191
+ // Both forms are in an error state and both fields carry help text, so there
192
+ // is something to check; an empty list would mean the fixture stopped
193
+ // producing ARIA references and the assertions below became vacuous.
194
+ expect(referencing.length).toBeGreaterThanOrEqual(2)
195
+
196
+ let checkedRefs = 0
197
+ for (const node of referencing) {
198
+ const refs = [
199
+ ...(node.getAttribute('aria-describedby') ?? '').split(/\s+/),
200
+ ...(node.getAttribute('aria-labelledby') ?? '').split(/\s+/),
201
+ ].filter(Boolean)
202
+
203
+ expect(refs.length).toBeGreaterThan(0)
204
+
205
+ for (const ref of refs) {
206
+ const target = document.getElementById(ref)
207
+ // A dangling reference is a defect in its own right: the browser
208
+ // announces nothing, and it is invisible without this assertion.
209
+ expect(target, `dangling aria reference: ${ref}`).not.toBeNull()
210
+ expect(target?.closest('form')).toBe(node.closest('form'))
211
+ checkedRefs += 1
212
+ }
213
+ }
214
+
215
+ expect(checkedRefs).toBeGreaterThanOrEqual(2)
216
+ })
217
+
218
+ it('renders the error element inside the form whose field is in error', () => {
219
+ renderTwoForms()
220
+
221
+ const errors = Array.from(container.querySelectorAll('[id^="error-for-"]'))
222
+ expect(errors).toHaveLength(2)
223
+
224
+ const ids = errors.map((node) => node.id)
225
+ expect(new Set(ids).size).toBe(2)
226
+
227
+ for (const error of errors) {
228
+ const described = container.querySelector(`[aria-describedby~="${error.id}"]`)
229
+ expect(described).not.toBeNull()
230
+ expect(described?.closest('form')).toBe(error.closest('form'))
231
+ }
232
+ })
233
+
234
+ /**
235
+ * The nested case, inside a single form: field names are unique only within
236
+ * their level, so two groups can both declare `title`. A name-derived id
237
+ * collides here with no second form involved.
238
+ */
239
+ it('keeps ids distinct for a repeated field name in two groups of one form', () => {
240
+ act(() => {
241
+ root.render(
242
+ <I18nProvider
243
+ bundle={adminTranslations({ locales: ['en'] })}
244
+ activeLocale="en"
245
+ defaultLocale="en"
246
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
247
+ >
248
+ <BylineFieldServicesProvider
249
+ services={
250
+ {
251
+ getCollectionDocuments: async () => ({ docs: [], total: 0 }),
252
+ uploadField: async () => ({}),
253
+ } as any
254
+ }
255
+ >
256
+ <form aria-label="single">
257
+ <FormProvider initialData={{ fields: {} }} collectionPath="pages">
258
+ <FormLayout
259
+ fields={nestedFields}
260
+ adminConfig={nestedAdminConfig}
261
+ activeTabBySet={{}}
262
+ onTabChange={() => {}}
263
+ />
264
+ <ErrorProbe field="intro.title" />
265
+ <ErrorProbe field="outro.title" />
266
+ </FormProvider>
267
+ </form>
268
+ </BylineFieldServicesProvider>
269
+ </I18nProvider>
270
+ )
271
+ })
272
+
273
+ const inputIds = Array.from(container.querySelectorAll('input')).map((node) => node.id)
274
+ expect(inputIds).toHaveLength(2)
275
+ expect(new Set(inputIds).size).toBe(2)
276
+
277
+ const errorIds = Array.from(container.querySelectorAll('[id^="error-for-"]')).map((n) => n.id)
278
+ expect(errorIds).toHaveLength(2)
279
+ expect(new Set(errorIds).size).toBe(2)
280
+
281
+ const allIds = Array.from(container.querySelectorAll('[id]')).map((node) => node.id)
282
+ expect(allIds.filter((id, index) => allIds.indexOf(id) !== index)).toEqual([])
283
+ })
284
+
285
+ it('leaves every id unique across the document', () => {
286
+ renderTwoForms()
287
+
288
+ const ids = Array.from(container.querySelectorAll('[id]')).map((node) => node.id)
289
+ const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index)
290
+
291
+ expect(duplicates).toEqual([])
292
+ })
293
+ })
@@ -19,6 +19,7 @@ import { getAdminConfig } from '@byline/core'
19
19
  import cx from 'clsx'
20
20
 
21
21
  import { useFormContext } from '../forms/form-context'
22
+ import { useScopedDomId } from '../forms/form-dom-scope'
22
23
  import { ArrayField } from './array/array-field'
23
24
  import { BlocksField } from './blocks/blocks-field'
24
25
  import { CheckboxField } from './checkbox/checkbox-field'
@@ -90,7 +91,7 @@ export const FieldRenderer = ({
90
91
  fieldAdmin,
91
92
  }: FieldRendererProps) => {
92
93
  const path = basePath ? `${basePath}.${field.name}` : field.name
93
- const htmlId = path.replace(/[[\].]/g, '-')
94
+ const htmlId = useScopedDomId(path.replace(/[[\].]/g, '-'))
94
95
 
95
96
  const handleChange = useFieldChangeHandler(field, path)
96
97
  const { getFieldValue } = useFormContext()
@@ -111,6 +111,12 @@ export type GetTreeParentFn = (input: {
111
111
  documentId: string
112
112
  }) => Promise<{ placed: boolean; parentDocumentId: string | null }>
113
113
 
114
+ /** See `BylineFieldServices.canCreateInCollection`. */
115
+ export type CanCreateInCollectionFn = (collectionPath: string) => boolean
116
+
117
+ /** See `BylineFieldServices.getCreateDocumentUrl`. */
118
+ export type GetCreateDocumentUrlFn = (collectionPath: string) => string
119
+
114
120
  export interface BylineFieldServices {
115
121
  getCollectionDocuments: GetCollectionDocumentsFn
116
122
  uploadField: UploadFieldFn
@@ -123,4 +129,23 @@ export interface BylineFieldServices {
123
129
  removeFromTree?: RemoveFromTreeFn
124
130
  getTreeAncestors?: GetTreeAncestorsFn
125
131
  getTreeParent?: GetTreeParentFn
132
+ /**
133
+ * Whether the viewer may create documents in a collection, used to decide
134
+ * whether to offer a create affordance in the relation picker.
135
+ *
136
+ * Cosmetic only: the create view enforces the ability server-side regardless
137
+ * of what this returns. Optional, like the tree operations above — a host that
138
+ * wires neither this nor `getCreateDocumentUrl` simply gets no affordance, and
139
+ * the widget guards on their presence.
140
+ */
141
+ canCreateInCollection?: CanCreateInCollectionFn
142
+ /**
143
+ * Root-relative URL of a collection's create view, built from the host's
144
+ * configured admin path.
145
+ *
146
+ * Root-relative rather than absolute because no origin is available during the
147
+ * server render, and the admin base path is host configuration —
148
+ * `@byline/admin` must not assume `/admin`.
149
+ */
150
+ getCreateDocumentUrl?: GetCreateDocumentUrlFn
126
151
  }
@@ -32,6 +32,7 @@ import {
32
32
  useIsDirty,
33
33
  useIsFieldUploading,
34
34
  } from '../../forms/form-context'
35
+ import { useScopedDomId } from '../../forms/form-dom-scope'
35
36
  import { useFieldChangeHandler } from '../use-field-change-handler'
36
37
  import styles from './file-field.module.css'
37
38
  import { FileUploadField } from './file-upload-field'
@@ -123,7 +124,7 @@ export const FileField = ({
123
124
  const isVideo = incomingValue?.mimeType?.startsWith('video/') === true
124
125
  const FileGlyph = isVideo ? VideoIcon : DocumentIcon
125
126
 
126
- const htmlId = fieldPath
127
+ const htmlId = useScopedDomId(fieldPath)
127
128
 
128
129
  return (
129
130
  <div className={`byline-field-file ${field.name}`}>
@@ -294,7 +295,7 @@ export const FileField = ({
294
295
 
295
296
  {field.helpText && <HelpText text={field.helpText} />}
296
297
 
297
- {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
298
+ {fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
298
299
  </div>
299
300
  )
300
301
  }
@@ -16,6 +16,7 @@ import { sliceFieldAdmin } from '../../fields/field-admin'
16
16
  import { placeholderForField } from '../../fields/field-helpers'
17
17
  import { FieldRenderer } from '../../fields/field-renderer'
18
18
  import { useFieldError } from '../../forms/form-context'
19
+ import { useScopedDomId } from '../../forms/form-dom-scope'
19
20
  import styles from './group-field.module.css'
20
21
 
21
22
  // ---------------------------------------------------------------------------
@@ -70,6 +71,7 @@ export const GroupField = ({
70
71
  contentLocale,
71
72
  fieldAdmin,
72
73
  }: GroupFieldProps) => {
74
+ const htmlId = useScopedDomId(path ?? field.name)
73
75
  const fieldError = useFieldError(field.name)
74
76
  // Default value for a group field is a plain object: { rating: 5, comment: '...' }
75
77
  // Normalize to a plain object if not already one.
@@ -117,7 +119,7 @@ export const GroupField = ({
117
119
  )
118
120
  })}
119
121
  </div>
120
- {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
122
+ {fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
121
123
  </div>
122
124
  )
123
125
  }
@@ -32,6 +32,7 @@ import {
32
32
  useIsDirty,
33
33
  useIsFieldUploading,
34
34
  } from '../../forms/form-context'
35
+ import { useScopedDomId } from '../../forms/form-dom-scope'
35
36
  import { useFieldChangeHandler } from '../use-field-change-handler'
36
37
  import styles from './image-field.module.css'
37
38
  import { ImageUploadField } from './image-upload-field'
@@ -114,7 +115,7 @@ export const ImageField = ({
114
115
  const [lightboxOpen, setLightboxOpen] = useState(false)
115
116
  const canOpenLightbox = !isPending && !!incomingValue?.storageUrl
116
117
 
117
- const htmlId = fieldPath
118
+ const htmlId = useScopedDomId(fieldPath)
118
119
 
119
120
  return (
120
121
  <div className={`byline-field-image ${field.name}`}>
@@ -297,7 +298,7 @@ export const ImageField = ({
297
298
 
298
299
  {field.helpText && <HelpText text={field.helpText} />}
299
300
 
300
- {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
301
+ {fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
301
302
 
302
303
  {canOpenLightbox && incomingValue?.storageUrl && (
303
304
  <ImageLightbox
@@ -20,6 +20,7 @@ import { normalizeNumericValue } from '@byline/core'
20
20
  import { Input } from '@byline/ui/react'
21
21
 
22
22
  import { useFieldError, useFieldValue } from '../../forms/form-context'
23
+ import { useScopedDomId } from '../../forms/form-dom-scope'
23
24
 
24
25
  type NumericalValue = string | number | null
25
26
 
@@ -48,7 +49,7 @@ export const NumericalField = ({
48
49
  const fieldValue = useFieldValue<NumericalValue | undefined>(fieldPath)
49
50
  const incomingValue =
50
51
  value !== undefined ? value : fieldValue !== undefined ? fieldValue : (defaultValue ?? null)
51
- const htmlId = id ?? fieldPath
52
+ const htmlId = useScopedDomId(fieldPath, id)
52
53
  const canonicalDisplay = incomingValue == null ? '' : String(incomingValue)
53
54
  const [displayValue, setDisplayValue] = useState(canonicalDisplay)
54
55
  const [isEditing, setIsEditing] = useState(false)
@@ -28,6 +28,7 @@ import {
28
28
  import cx from 'clsx'
29
29
 
30
30
  import { useFieldError, useFieldValue } from '../../forms/form-context'
31
+ import { useScopedDomId } from '../../forms/form-dom-scope'
31
32
  import styles from './relation-field.module.css'
32
33
  import { RelationPicker } from './relation-picker'
33
34
  import { RelationSummary } from './relation-summary'
@@ -65,7 +66,7 @@ export const RelationField = ({
65
66
  path,
66
67
  }: RelationFieldProps) => {
67
68
  const fieldPath = path ?? field.name
68
- const htmlId = id ?? fieldPath
69
+ const htmlId = useScopedDomId(fieldPath, id)
69
70
  const fieldError = useFieldError(fieldPath)
70
71
  const fieldValue = useFieldValue<IncomingRelationValue | null | undefined>(fieldPath)
71
72
 
@@ -208,7 +209,7 @@ export const RelationField = ({
208
209
 
209
210
  {field.helpText != null && <HelpText text={field.helpText} />}
210
211
 
211
- {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
212
+ {fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
212
213
 
213
214
  {!isUnknown && (
214
215
  <RelationPicker
@@ -32,6 +32,7 @@ import {
32
32
  import cx from 'clsx'
33
33
 
34
34
  import { useFieldError, useFieldValue, useFormContext } from '../../forms/form-context'
35
+ import { useScopedDomId } from '../../forms/form-dom-scope'
35
36
  import styles from './relation-field.module.css'
36
37
  import { RelationPicker, type RelationPickerSelection } from './relation-picker'
37
38
  import { RelationSummary } from './relation-summary'
@@ -117,7 +118,7 @@ interface RelationManyFieldProps {
117
118
 
118
119
  export const RelationManyField = ({ field, defaultValue, id, path }: RelationManyFieldProps) => {
119
120
  const fieldPath = path ?? field.name
120
- const htmlId = id ?? fieldPath
121
+ const htmlId = useScopedDomId(fieldPath, id)
121
122
  const { t } = useTranslation('byline-admin')
122
123
  const fieldError = useFieldError(fieldPath)
123
124
  const { getFieldValue, setFieldValue } = useFormContext()
@@ -268,7 +269,7 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
268
269
 
269
270
  {field.helpText != null && <HelpText text={field.helpText} />}
270
271
 
271
- {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
272
+ {fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
272
273
 
273
274
  <RelationPicker
274
275
  multiple