@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
@@ -211,6 +211,46 @@ describe('FormRenderer submit contract', () => {
211
211
  expect(saveButton?.querySelector('.byline-loader-ellipsis')).toBeNull()
212
212
  })
213
213
 
214
+ it('restores focus to the control that had it before the form went busy', async () => {
215
+ // `inert` drops the active control out of the tab order mid-save. The hook
216
+ // signals the transition through onBeforeBusy; the component owns the DOM
217
+ // refs. Drop that signal and focus silently lands on <body>.
218
+ //
219
+ // This covers the KEYBOARD path only: focus is in a field and the form is
220
+ // submitted from there, so the captured element is still focusable when the
221
+ // form comes back. Clicking Save is different -- the captured element is the
222
+ // Save button, which is disabled once the save succeeds -- and that path is
223
+ // broken in a real browser. See issue #98. Do not read this passing test as
224
+ // "focus recovery works" in general.
225
+ const submission = deferred()
226
+ render({ ...baseProps, onSubmit: () => submission.promise })
227
+ typeIntoTitle('Hello')
228
+
229
+ const input = container.querySelector<HTMLInputElement>('input[name="title"]')
230
+ if (input == null) throw new Error('title input not found')
231
+ act(() => {
232
+ input.focus()
233
+ })
234
+ expect(document.activeElement).toBe(input)
235
+
236
+ submitForm()
237
+ await act(async () => {})
238
+
239
+ // jsdom does not implement `inert`, so the browser's own focus removal has
240
+ // to be stood in for here. What is under test is the recovery afterwards.
241
+ act(() => {
242
+ input.blur()
243
+ })
244
+ expect(document.activeElement).not.toBe(input)
245
+
246
+ await act(async () => {
247
+ submission.resolve()
248
+ await submission.promise
249
+ })
250
+
251
+ expect(document.activeElement).toBe(input)
252
+ })
253
+
214
254
  it('hides the busy indicator when onSubmit rejects', async () => {
215
255
  const submission = deferred()
216
256
  render({ ...baseProps, onSubmit: () => submission.promise })
@@ -0,0 +1,145 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { act } from 'react'
10
+
11
+ import { defineAdminConfig } from '@byline/core'
12
+ import { adminTranslations } from '@byline/i18n/admin'
13
+ import { I18nProvider } from '@byline/i18n/react'
14
+ import { createRoot, type Root } from 'react-dom/client'
15
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
16
+
17
+ import { BylineFieldServicesProvider } from '../fields/field-services-context'
18
+ import { FormRenderer } from './form-renderer'
19
+
20
+ const globalWithAct = globalThis as any
21
+ globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
22
+
23
+ defineAdminConfig({
24
+ i18n: {
25
+ admin: { defaultLocale: 'en', locales: ['en'] },
26
+ content: { defaultLocale: 'en', locales: ['en', 'fr'] },
27
+ },
28
+ collections: [
29
+ {
30
+ path: 'pages',
31
+ labels: { singular: 'Page', plural: 'Pages' },
32
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
33
+ },
34
+ ],
35
+ slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
36
+ })
37
+
38
+ const fields = [
39
+ { name: 'title', label: 'Title', type: 'text' as const },
40
+ { name: 'seoTitle', label: 'SEO title', type: 'text' as const },
41
+ ]
42
+
43
+ const adminConfig = {
44
+ layout: { main: ['content'] },
45
+ tabSets: [
46
+ {
47
+ name: 'content',
48
+ tabs: [
49
+ { name: 'general', label: 'General', fields: ['title'] },
50
+ { name: 'seo', label: 'SEO', fields: ['seoTitle'] },
51
+ ],
52
+ },
53
+ ],
54
+ }
55
+
56
+ let container: HTMLDivElement
57
+ let root: Root
58
+
59
+ beforeEach(() => {
60
+ container = document.createElement('div')
61
+ document.body.appendChild(container)
62
+ root = createRoot(container)
63
+ })
64
+
65
+ afterEach(() => {
66
+ act(() => {
67
+ root.unmount()
68
+ })
69
+ container.remove()
70
+ })
71
+
72
+ const mount = (locale: string, versionId: string) => {
73
+ act(() => {
74
+ root.render(
75
+ <I18nProvider
76
+ bundle={adminTranslations({ locales: ['en'] })}
77
+ activeLocale="en"
78
+ defaultLocale="en"
79
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
80
+ >
81
+ <BylineFieldServicesProvider
82
+ services={
83
+ {
84
+ getCollectionDocuments: async () => ({ docs: [], total: 0 }),
85
+ uploadField: async () => ({}),
86
+ } as any
87
+ }
88
+ >
89
+ <FormRenderer
90
+ mode="edit"
91
+ fields={fields as any}
92
+ initialLocale={locale}
93
+ initialData={{
94
+ id: 'doc1',
95
+ versionId,
96
+ fields: { title: locale, seoTitle: `${locale} SEO` },
97
+ }}
98
+ adminConfig={adminConfig as any}
99
+ collectionPath="pages"
100
+ onSubmit={() => {}}
101
+ onCancel={() => {}}
102
+ />
103
+ </BylineFieldServicesProvider>
104
+ </I18nProvider>
105
+ )
106
+ })
107
+ }
108
+
109
+ const selectedTabLabel = () =>
110
+ container.querySelector('[role="tab"][aria-selected="true"]')?.textContent
111
+
112
+ describe('FormRenderer tab persistence across keyed remounts', () => {
113
+ // FormProvider is keyed on `${initialLocale}-${versionId}`, so changing either
114
+ // remounts the whole subtree. Tab selection is lifted above that key on
115
+ // purpose; this asserts the wiring, which a controlled-prop test on
116
+ // FormLayout alone cannot reach.
117
+ //
118
+ // Adopted from the reviewer's Checkpoint 2 regression fixture.
119
+ it('retains the selected tab through locale and version remounts', () => {
120
+ mount('en', 'v1')
121
+
122
+ const seoTab = [...container.querySelectorAll('[role="tab"]')].find(
123
+ (t) => t.textContent === 'SEO'
124
+ )
125
+ if (seoTab == null) throw new Error('SEO tab not found')
126
+ act(() => {
127
+ ;(seoTab as HTMLElement).click()
128
+ })
129
+ const first = container.querySelector<HTMLInputElement>('input[name="seoTitle"]')
130
+ expect(first).not.toBeNull()
131
+
132
+ mount('fr', 'v1') // locale change remounts FormProvider via its key
133
+ const french = container.querySelector<HTMLInputElement>('input[name="seoTitle"]')
134
+ expect(french).not.toBeNull()
135
+ expect(french).not.toBe(first)
136
+ expect(french?.value).toBe('fr SEO')
137
+ expect(selectedTabLabel()).toBe('SEO')
138
+
139
+ mount('fr', 'v2') // version change remounts it again
140
+ const versioned = container.querySelector<HTMLInputElement>('input[name="seoTitle"]')
141
+ expect(versioned).not.toBeNull()
142
+ expect(versioned).not.toBe(french)
143
+ expect(selectedTabLabel()).toBe('SEO')
144
+ })
145
+ })
@@ -304,6 +304,24 @@
304
304
  }
305
305
  }
306
306
 
307
+ /* One column at every width. The split layout above becomes a two-column grid
308
+ at >=60rem; a stacked form must not inherit that, or it reserves an empty
309
+ second track and narrows its only content column. */
310
+ .layout-stacked,
311
+ :global(.byline-form-layout-stacked) {
312
+ display: flex;
313
+ flex-direction: column;
314
+ gap: var(--spacing-12);
315
+ padding-top: var(--spacing-32);
316
+ }
317
+
318
+ @media (min-width: 60rem) {
319
+ .layout-stacked,
320
+ :global(.byline-form-layout-stacked) {
321
+ gap: var(--spacing-32);
322
+ }
323
+ }
324
+
307
325
  .content,
308
326
  :global(.byline-form-content) {
309
327
  display: flex;