@byline/ui 2.5.1 → 2.6.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 (211) hide show
  1. package/dist/dnd/draggable-sortable/demo/draggable-list-demo.js +1 -1
  2. package/dist/react.d.ts +18 -54
  3. package/dist/react.js +0 -35
  4. package/dist/uikit.d.ts +1 -0
  5. package/dist/uikit.js +1 -0
  6. package/package.json +2 -8
  7. package/src/dnd/draggable-sortable/demo/draggable-list-demo.tsx +1 -1
  8. package/src/react.ts +20 -68
  9. package/src/uikit.ts +1 -0
  10. package/dist/admin/group.d.ts +0 -27
  11. package/dist/admin/group.js +0 -14
  12. package/dist/admin/group.module.js +0 -6
  13. package/dist/admin/group_module.css +0 -19
  14. package/dist/admin/row.d.ts +0 -25
  15. package/dist/admin/row.js +0 -8
  16. package/dist/admin/row.module.js +0 -5
  17. package/dist/admin/row_module.css +0 -18
  18. package/dist/admin/tabs.d.ts +0 -25
  19. package/dist/admin/tabs.js +0 -35
  20. package/dist/admin/tabs.module.js +0 -10
  21. package/dist/admin/tabs_module.css +0 -68
  22. package/dist/fields/array/array-field.d.ts +0 -14
  23. package/dist/fields/array/array-field.js +0 -176
  24. package/dist/fields/array/array-field.module.js +0 -11
  25. package/dist/fields/array/array-field_module.css +0 -32
  26. package/dist/fields/blocks/blocks-field.d.ts +0 -13
  27. package/dist/fields/blocks/blocks-field.js +0 -244
  28. package/dist/fields/blocks/blocks-field.module.js +0 -26
  29. package/dist/fields/blocks/blocks-field_module.css +0 -107
  30. package/dist/fields/checkbox/checkbox-field.d.ts +0 -16
  31. package/dist/fields/checkbox/checkbox-field.js +0 -28
  32. package/dist/fields/checkbox/checkbox-field.module.js +0 -6
  33. package/dist/fields/checkbox/checkbox-field_module.css +0 -4
  34. package/dist/fields/column-formatter.d.ts +0 -20
  35. package/dist/fields/column-formatter.js +0 -15
  36. package/dist/fields/date-time-formatter.d.ts +0 -16
  37. package/dist/fields/date-time-formatter.js +0 -8
  38. package/dist/fields/datetime/datetime-field.d.ts +0 -16
  39. package/dist/fields/datetime/datetime-field.js +0 -37
  40. package/dist/fields/datetime/datetime-field.module.js +0 -5
  41. package/dist/fields/datetime/datetime-field_module.css +0 -4
  42. package/dist/fields/draggable-context-menu.d.ts +0 -6
  43. package/dist/fields/draggable-context-menu.js +0 -83
  44. package/dist/fields/draggable-context-menu.module.js +0 -15
  45. package/dist/fields/draggable-context-menu_module.css +0 -91
  46. package/dist/fields/field-helpers.d.ts +0 -26
  47. package/dist/fields/field-helpers.js +0 -50
  48. package/dist/fields/field-renderer.d.ts +0 -37
  49. package/dist/fields/field-renderer.js +0 -206
  50. package/dist/fields/field-renderer.module.js +0 -8
  51. package/dist/fields/field-renderer_module.css +0 -11
  52. package/dist/fields/file/file-field.d.ts +0 -19
  53. package/dist/fields/file/file-field.js +0 -226
  54. package/dist/fields/file/file-field.module.js +0 -18
  55. package/dist/fields/file/file-field_module.css +0 -131
  56. package/dist/fields/file/file-upload-field.d.ts +0 -21
  57. package/dist/fields/file/file-upload-field.js +0 -128
  58. package/dist/fields/file/file-upload-field.module.js +0 -15
  59. package/dist/fields/file/file-upload-field_module.css +0 -74
  60. package/dist/fields/group/group-field.d.ts +0 -15
  61. package/dist/fields/group/group-field.js +0 -59
  62. package/dist/fields/group/group-field.module.js +0 -9
  63. package/dist/fields/group/group-field_module.css +0 -27
  64. package/dist/fields/image/image-field.d.ts +0 -19
  65. package/dist/fields/image/image-field.js +0 -242
  66. package/dist/fields/image/image-field.module.js +0 -22
  67. package/dist/fields/image/image-field_module.css +0 -121
  68. package/dist/fields/image/image-upload-field.d.ts +0 -21
  69. package/dist/fields/image/image-upload-field.js +0 -187
  70. package/dist/fields/image/image-upload-field.module.js +0 -19
  71. package/dist/fields/image/image-upload-field_module.css +0 -92
  72. package/dist/fields/local-date-time.d.ts +0 -27
  73. package/dist/fields/local-date-time.js +0 -49
  74. package/dist/fields/locale-badge.d.ts +0 -18
  75. package/dist/fields/locale-badge.js +0 -10
  76. package/dist/fields/locale-badge.module.js +0 -5
  77. package/dist/fields/locale-badge_module.css +0 -27
  78. package/dist/fields/numerical/numerical-field.d.ts +0 -18
  79. package/dist/fields/numerical/numerical-field.js +0 -74
  80. package/dist/fields/relation/relation-display.d.ts +0 -40
  81. package/dist/fields/relation/relation-display.js +0 -58
  82. package/dist/fields/relation/relation-display.module.js +0 -9
  83. package/dist/fields/relation/relation-display_module.css +0 -21
  84. package/dist/fields/relation/relation-field.d.ts +0 -18
  85. package/dist/fields/relation/relation-field.js +0 -146
  86. package/dist/fields/relation/relation-field.module.js +0 -13
  87. package/dist/fields/relation/relation-field_module.css +0 -62
  88. package/dist/fields/relation/relation-picker.d.ts +0 -49
  89. package/dist/fields/relation/relation-picker.js +0 -233
  90. package/dist/fields/relation/relation-picker.module.js +0 -26
  91. package/dist/fields/relation/relation-picker_module.css +0 -124
  92. package/dist/fields/relation/relation-summary.d.ts +0 -31
  93. package/dist/fields/relation/relation-summary.js +0 -50
  94. package/dist/fields/relation/relation-summary.module.js +0 -11
  95. package/dist/fields/relation/relation-summary_module.css +0 -37
  96. package/dist/fields/select/select-field.d.ts +0 -16
  97. package/dist/fields/select/select-field.js +0 -50
  98. package/dist/fields/select/select-field.module.js +0 -5
  99. package/dist/fields/select/select-field_module.css +0 -4
  100. package/dist/fields/sortable-item.d.ts +0 -15
  101. package/dist/fields/sortable-item.js +0 -80
  102. package/dist/fields/sortable-item.module.js +0 -22
  103. package/dist/fields/sortable-item_module.css +0 -124
  104. package/dist/fields/text/text-field.d.ts +0 -20
  105. package/dist/fields/text/text-field.js +0 -104
  106. package/dist/fields/text/text-field.module.js +0 -6
  107. package/dist/fields/text/text-field_module.css +0 -5
  108. package/dist/fields/text-area/text-area-field.d.ts +0 -20
  109. package/dist/fields/text-area/text-area-field.js +0 -105
  110. package/dist/fields/text-area/text-area-field.module.js +0 -6
  111. package/dist/fields/text-area/text-area-field_module.css +0 -5
  112. package/dist/fields/use-field-change-handler.d.ts +0 -23
  113. package/dist/fields/use-field-change-handler.js +0 -52
  114. package/dist/forms/document-actions.d.ts +0 -48
  115. package/dist/forms/document-actions.js +0 -469
  116. package/dist/forms/document-actions.module.js +0 -34
  117. package/dist/forms/document-actions_module.css +0 -118
  118. package/dist/forms/form-context.d.ts +0 -89
  119. package/dist/forms/form-context.js +0 -466
  120. package/dist/forms/form-renderer.d.ts +0 -98
  121. package/dist/forms/form-renderer.js +0 -591
  122. package/dist/forms/form-renderer.module.js +0 -46
  123. package/dist/forms/form-renderer_module.css +0 -245
  124. package/dist/forms/navigation-guard.d.ts +0 -54
  125. package/dist/forms/navigation-guard.js +0 -22
  126. package/dist/forms/path-widget.d.ts +0 -36
  127. package/dist/forms/path-widget.js +0 -107
  128. package/dist/forms/path-widget.module.js +0 -8
  129. package/dist/forms/path-widget_module.css +0 -29
  130. package/dist/forms/upload-executor.d.ts +0 -57
  131. package/dist/forms/upload-executor.js +0 -92
  132. package/dist/services/field-services-context.d.ts +0 -16
  133. package/dist/services/field-services-context.js +0 -13
  134. package/dist/services/field-services-types.d.ts +0 -63
  135. package/dist/services/field-services-types.js +0 -1
  136. package/dist/widgets/diff-viewer/diff-modal.d.ts +0 -22
  137. package/dist/widgets/diff-viewer/diff-modal.js +0 -146
  138. package/dist/widgets/diff-viewer/diff-modal.module.js +0 -14
  139. package/dist/widgets/diff-viewer/diff-modal_module.css +0 -56
  140. package/dist/widgets/status-badge/status-badge.d.ts +0 -25
  141. package/dist/widgets/status-badge/status-badge.js +0 -35
  142. package/dist/widgets/status-badge/status-badge.module.js +0 -7
  143. package/dist/widgets/status-badge/status-badge_module.css +0 -20
  144. package/src/admin/group.module.css +0 -41
  145. package/src/admin/group.tsx +0 -40
  146. package/src/admin/row.module.css +0 -32
  147. package/src/admin/row.tsx +0 -33
  148. package/src/admin/tabs.module.css +0 -107
  149. package/src/admin/tabs.tsx +0 -82
  150. package/src/fields/array/array-field.module.css +0 -48
  151. package/src/fields/array/array-field.tsx +0 -266
  152. package/src/fields/blocks/blocks-field.module.css +0 -148
  153. package/src/fields/blocks/blocks-field.tsx +0 -312
  154. package/src/fields/checkbox/checkbox-field.module.css +0 -4
  155. package/src/fields/checkbox/checkbox-field.tsx +0 -54
  156. package/src/fields/column-formatter.tsx +0 -31
  157. package/src/fields/date-time-formatter.tsx +0 -22
  158. package/src/fields/datetime/datetime-field.module.css +0 -13
  159. package/src/fields/datetime/datetime-field.tsx +0 -54
  160. package/src/fields/draggable-context-menu.module.css +0 -127
  161. package/src/fields/draggable-context-menu.tsx +0 -85
  162. package/src/fields/field-helpers.ts +0 -69
  163. package/src/fields/field-renderer.module.css +0 -22
  164. package/src/fields/field-renderer.tsx +0 -288
  165. package/src/fields/file/file-field.module.css +0 -153
  166. package/src/fields/file/file-field.tsx +0 -271
  167. package/src/fields/file/file-upload-field.module.css +0 -101
  168. package/src/fields/file/file-upload-field.tsx +0 -183
  169. package/src/fields/group/group-field.module.css +0 -43
  170. package/src/fields/group/group-field.tsx +0 -84
  171. package/src/fields/image/image-field.module.css +0 -155
  172. package/src/fields/image/image-field.tsx +0 -291
  173. package/src/fields/image/image-upload-field.module.css +0 -123
  174. package/src/fields/image/image-upload-field.tsx +0 -270
  175. package/src/fields/local-date-time.tsx +0 -88
  176. package/src/fields/locale-badge.module.css +0 -37
  177. package/src/fields/locale-badge.tsx +0 -32
  178. package/src/fields/numerical/numerical-field.tsx +0 -114
  179. package/src/fields/relation/relation-display.module.css +0 -36
  180. package/src/fields/relation/relation-display.tsx +0 -130
  181. package/src/fields/relation/relation-field.module.css +0 -83
  182. package/src/fields/relation/relation-field.tsx +0 -206
  183. package/src/fields/relation/relation-picker.module.css +0 -168
  184. package/src/fields/relation/relation-picker.tsx +0 -325
  185. package/src/fields/relation/relation-summary.module.css +0 -55
  186. package/src/fields/relation/relation-summary.tsx +0 -123
  187. package/src/fields/select/select-field.module.css +0 -13
  188. package/src/fields/select/select-field.tsx +0 -61
  189. package/src/fields/sortable-item.module.css +0 -167
  190. package/src/fields/sortable-item.tsx +0 -101
  191. package/src/fields/text/text-field.module.css +0 -13
  192. package/src/fields/text/text-field.tsx +0 -146
  193. package/src/fields/text-area/text-area-field.module.css +0 -13
  194. package/src/fields/text-area/text-area-field.tsx +0 -147
  195. package/src/fields/use-field-change-handler.ts +0 -112
  196. package/src/forms/document-actions.module.css +0 -160
  197. package/src/forms/document-actions.tsx +0 -487
  198. package/src/forms/form-context.tsx +0 -704
  199. package/src/forms/form-renderer.module.css +0 -321
  200. package/src/forms/form-renderer.tsx +0 -888
  201. package/src/forms/navigation-guard.tsx +0 -98
  202. package/src/forms/path-widget.module.css +0 -41
  203. package/src/forms/path-widget.test.tsx +0 -217
  204. package/src/forms/path-widget.tsx +0 -181
  205. package/src/forms/upload-executor.ts +0 -190
  206. package/src/services/field-services-context.tsx +0 -35
  207. package/src/services/field-services-types.ts +0 -68
  208. package/src/widgets/diff-viewer/diff-modal.module.css +0 -79
  209. package/src/widgets/diff-viewer/diff-modal.tsx +0 -184
  210. package/src/widgets/status-badge/status-badge.module.css +0 -31
  211. package/src/widgets/status-badge/status-badge.tsx +0 -69
@@ -1,98 +0,0 @@
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
- /**
10
- * Framework-agnostic navigation guard adapter.
11
- *
12
- * Different router frameworks (TanStack Router, Next.js, React Router, etc.)
13
- * each have their own mechanism for blocking navigation when a form has unsaved
14
- * changes. This module defines a common interface so that `FormRenderer` can
15
- * remain framework-independent — the consuming application injects the
16
- * appropriate adapter via a prop or React context.
17
- */
18
-
19
- import { createContext, useContext, useEffect } from 'react'
20
-
21
- // ---------------------------------------------------------------------------
22
- // Types
23
- // ---------------------------------------------------------------------------
24
-
25
- /** The result returned by a `UseNavigationGuard` hook. */
26
- export interface NavigationGuardResult {
27
- /** Whether a navigation attempt is currently being blocked (show confirmation UI). */
28
- isBlocked: boolean
29
- /** Cancel the pending navigation — stay on the current page. */
30
- stay: () => void
31
- /** Confirm the pending navigation — leave the page. */
32
- proceed: () => void
33
- }
34
-
35
- /**
36
- * A hook that blocks in-app navigation and (optionally) browser unload when
37
- * `shouldBlock` is `true`.
38
- *
39
- * Each framework adapter implements this signature.
40
- */
41
- export type UseNavigationGuard = (shouldBlock: boolean) => NavigationGuardResult
42
-
43
- // ---------------------------------------------------------------------------
44
- // Default (no-op) implementation — browser `beforeunload` only
45
- // ---------------------------------------------------------------------------
46
-
47
- /**
48
- * Fallback navigation guard that only handles the browser's native
49
- * `beforeunload` event. In-app (client-side) route changes are **not**
50
- * intercepted — `isBlocked` will never become `true`.
51
- *
52
- * This is used when no framework-specific adapter has been provided.
53
- */
54
- export const useBeforeUnloadGuard: UseNavigationGuard = (shouldBlock) => {
55
- useEffect(() => {
56
- if (!shouldBlock) return
57
- const handler = (e: BeforeUnloadEvent) => {
58
- e.preventDefault()
59
- }
60
- window.addEventListener('beforeunload', handler)
61
- return () => window.removeEventListener('beforeunload', handler)
62
- }, [shouldBlock])
63
-
64
- return { isBlocked: false, stay: () => {}, proceed: () => {} }
65
- }
66
-
67
- // ---------------------------------------------------------------------------
68
- // React context — allows setting the adapter once at the app shell level
69
- // ---------------------------------------------------------------------------
70
-
71
- const NavigationGuardContext = createContext<UseNavigationGuard>(useBeforeUnloadGuard)
72
-
73
- /**
74
- * Provide a framework-specific `UseNavigationGuard` hook to all descendant
75
- * `FormRenderer` instances.
76
- *
77
- * ```tsx
78
- * import { NavigationGuardProvider } from './navigation-guard'
79
- * import { useTanStackNavigationGuard } from './tanstack-navigation-guard'
80
- *
81
- * function App() {
82
- * return (
83
- * <NavigationGuardProvider value={useTanStackNavigationGuard}>
84
- * <Outlet />
85
- * </NavigationGuardProvider>
86
- * )
87
- * }
88
- * ```
89
- */
90
- export const NavigationGuardProvider = NavigationGuardContext.Provider
91
-
92
- /**
93
- * Consume the current `UseNavigationGuard` hook from context.
94
- * Falls back to `useBeforeUnloadGuard` when no provider is present.
95
- */
96
- export const useNavigationGuardAdapter = (): UseNavigationGuard => {
97
- return useContext(NavigationGuardContext)
98
- }
@@ -1,41 +0,0 @@
1
- /**
2
- * PathWidget — system-managed `documentVersions.path` form widget.
3
- *
4
- * Override handles:
5
- * .byline-form-path — wrapper div
6
- * .byline-form-path-header — label + regenerate-button row
7
- * .byline-form-path-regenerate — regenerate-link button
8
- * .byline-form-path-sr-only — visually-hidden screen-reader hint
9
- */
10
-
11
- .header,
12
- :global(.byline-form-path-header) {
13
- display: flex;
14
- align-items: center;
15
- justify-content: space-between;
16
- gap: var(--spacing-8);
17
- }
18
-
19
- .regenerate,
20
- :global(.byline-form-path-regenerate) {
21
- background: none;
22
- border: none;
23
- padding: 0;
24
- color: inherit;
25
- font-size: 0.8rem;
26
- text-decoration: underline;
27
- cursor: pointer;
28
- }
29
-
30
- .sr-only,
31
- :global(.byline-form-path-sr-only) {
32
- position: absolute;
33
- width: 1px;
34
- height: 1px;
35
- padding: 0;
36
- margin: -1px;
37
- overflow: hidden;
38
- clip: rect(0, 0, 0, 0);
39
- white-space: nowrap;
40
- border: 0;
41
- }
@@ -1,217 +0,0 @@
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 { createRoot, type Root } from 'react-dom/client'
12
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
13
-
14
- // Lightweight uikit stubs — we don't care about the visual rendering, only
15
- // that the Input forwards props and the Label renders its htmlFor.
16
- vi.mock('@byline/ui/react', () => ({
17
- Label: ({ id, htmlFor, label }: { id?: string; htmlFor?: string; label?: string }) => (
18
- <label id={id} htmlFor={htmlFor}>
19
- {label}
20
- </label>
21
- ),
22
- Input: ({
23
- id,
24
- name,
25
- value,
26
- placeholder,
27
- onChange,
28
- helpText,
29
- ...rest
30
- }: {
31
- id?: string
32
- name?: string
33
- value?: string
34
- placeholder?: string
35
- onChange?: (e: { target: { value: string } }) => void
36
- helpText?: string
37
- [key: string]: any
38
- }) => (
39
- <>
40
- <input
41
- id={id}
42
- name={name}
43
- value={value ?? ''}
44
- placeholder={placeholder}
45
- onChange={onChange}
46
- {...rest}
47
- />
48
- {helpText ? <span data-testid="help-text">{helpText}</span> : null}
49
- </>
50
- ),
51
- }))
52
-
53
- // Mutable mocks controlled per-test via the setFixture helper below.
54
- const fixture: {
55
- systemPath: string | null
56
- sourceValue: unknown
57
- setSystemPath: ReturnType<typeof vi.fn>
58
- } = {
59
- systemPath: null,
60
- sourceValue: '',
61
- setSystemPath: vi.fn(),
62
- }
63
-
64
- vi.mock('./form-context', () => ({
65
- useFormContext: () => ({ setSystemPath: fixture.setSystemPath }),
66
- useSystemPath: () => fixture.systemPath,
67
- useFieldValue: () => fixture.sourceValue,
68
- }))
69
-
70
- // Import AFTER the mocks so PathWidget picks them up.
71
- // biome-ignore lint/correctness/useImportExtensions: webapp TS resolves via tsconfig paths
72
- import { PathWidget } from './path-widget'
73
-
74
- ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
75
-
76
- interface Fixture {
77
- systemPath?: string | null
78
- sourceValue?: unknown
79
- }
80
-
81
- function setFixture(next: Fixture = {}) {
82
- fixture.systemPath = next.systemPath ?? null
83
- fixture.sourceValue = next.sourceValue ?? ''
84
- fixture.setSystemPath = vi.fn()
85
- }
86
-
87
- describe('PathWidget', () => {
88
- let container: HTMLDivElement
89
- let root: Root
90
-
91
- beforeEach(() => {
92
- container = document.createElement('div')
93
- document.body.appendChild(container)
94
- root = createRoot(container)
95
- })
96
-
97
- afterEach(() => {
98
- act(() => {
99
- root.unmount()
100
- })
101
- container.remove()
102
- })
103
-
104
- const render = (
105
- props: Partial<{
106
- useAsPath: string | undefined
107
- mode: 'create' | 'edit'
108
- }> = {}
109
- ) => {
110
- act(() => {
111
- root.render(
112
- <PathWidget
113
- useAsPath={props.useAsPath ?? 'title'}
114
- collectionPath="pages"
115
- defaultLocale="en"
116
- mode={props.mode ?? 'create'}
117
- />
118
- )
119
- })
120
- }
121
-
122
- const getInput = () => container.querySelector('#system-path') as HTMLInputElement
123
-
124
- it('shows the live-derived preview as placeholder when creating with an empty override', () => {
125
- setFixture({ systemPath: null, sourceValue: 'Hello World' })
126
- render({ mode: 'create' })
127
-
128
- const input = getInput()
129
- expect(input).toBeTruthy()
130
- expect(input.value).toBe('')
131
- expect(input.getAttribute('placeholder')).toBe('Will be saved as "hello-world"')
132
- })
133
-
134
- it('shows the persisted path in edit mode (no placeholder preview)', () => {
135
- setFixture({ systemPath: 'existing-path', sourceValue: 'Hello World' })
136
- render({ mode: 'edit' })
137
-
138
- const input = getInput()
139
- expect(input.value).toBe('existing-path')
140
- // livePreview === 'hello-world' differs from persisted 'existing-path',
141
- // so the regenerate button is rendered.
142
- const regenerate = container.querySelector('button')
143
- expect(regenerate).toBeTruthy()
144
- expect(regenerate?.textContent).toContain('Regenerate from title')
145
- })
146
-
147
- it('"Regenerate" writes the live preview into the systemPath slot', () => {
148
- setFixture({ systemPath: 'stale-path', sourceValue: 'Brand New Title' })
149
- render({ mode: 'edit' })
150
-
151
- const regenerate = container.querySelector('button') as HTMLButtonElement
152
- expect(regenerate).toBeTruthy()
153
-
154
- act(() => {
155
- regenerate.click()
156
- })
157
-
158
- expect(fixture.setSystemPath).toHaveBeenCalledWith('brand-new-title')
159
- })
160
-
161
- it('clearing the input reverts the slot to null (sticky-from-previous)', () => {
162
- setFixture({ systemPath: 'my-path', sourceValue: 'My Path' })
163
- render({ mode: 'edit' })
164
-
165
- const input = getInput()
166
- act(() => {
167
- const setter = Object.getOwnPropertyDescriptor(
168
- window.HTMLInputElement.prototype,
169
- 'value'
170
- )?.set
171
- setter?.call(input, '')
172
- input.dispatchEvent(new Event('input', { bubbles: true }))
173
- })
174
-
175
- expect(fixture.setSystemPath).toHaveBeenCalledWith(null)
176
- })
177
-
178
- it('typing a non-empty value writes a string override', () => {
179
- setFixture({ systemPath: null, sourceValue: 'Anything' })
180
- render({ mode: 'create' })
181
-
182
- const input = getInput()
183
- act(() => {
184
- const setter = Object.getOwnPropertyDescriptor(
185
- window.HTMLInputElement.prototype,
186
- 'value'
187
- )?.set
188
- setter?.call(input, 'custom-slug')
189
- input.dispatchEvent(new Event('input', { bubbles: true }))
190
- })
191
-
192
- expect(fixture.setSystemPath).toHaveBeenCalledWith('custom-slug')
193
- })
194
-
195
- it('links the input to an sr-only description via aria-describedby', () => {
196
- setFixture({ systemPath: null, sourceValue: 'Hi' })
197
- render({ mode: 'create' })
198
-
199
- const input = getInput()
200
- expect(input.getAttribute('aria-describedby')).toBe('system-path-description')
201
- const description = container.querySelector('#system-path-description')
202
- expect(description).toBeTruthy()
203
- expect(description?.textContent).toContain('System-managed URL path')
204
- })
205
-
206
- it('does not render the Regenerate button when livePreview equals systemPath', () => {
207
- setFixture({ systemPath: 'hello-world', sourceValue: 'Hello World' })
208
- render({ mode: 'edit' })
209
- expect(container.querySelector('button')).toBeNull()
210
- })
211
-
212
- it('does not render the Regenerate button when there is no useAsPath', () => {
213
- setFixture({ systemPath: 'whatever', sourceValue: '' })
214
- render({ mode: 'edit', useAsPath: undefined })
215
- expect(container.querySelector('button')).toBeNull()
216
- })
217
- })
@@ -1,181 +0,0 @@
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, useMemo } from 'react'
12
-
13
- import { slugify } from '@byline/core'
14
- import cx from 'classnames'
15
-
16
- import { Input, Label } from '../uikit.js'
17
- import { useFieldValue, useFormContext, useSystemPath } from './form-context'
18
- import styles from './path-widget.module.css'
19
-
20
- /**
21
- * Coerce an arbitrary source-field value (string, Date, or other) into
22
- * a string suitable for slugification. Mirrors the lifecycle's coercion
23
- * so the live preview matches what the server will store.
24
- */
25
- function coerceToString(value: unknown): string {
26
- if (value == null) return ''
27
- if (value instanceof Date) return value.toISOString()
28
- return String(value)
29
- }
30
-
31
- export interface PathWidgetProps {
32
- /** The collection's `useAsPath` source field name, when configured. */
33
- useAsPath: string | undefined
34
- /** Collection path, forwarded to the slugifier as context. */
35
- collectionPath: string
36
- /** Default content locale, forwarded to the slugifier as context. */
37
- defaultLocale: string
38
- /**
39
- * The locale currently being edited in the form. When this differs
40
- * from `defaultLocale` (i.e. the editor is editing a translation),
41
- * the widget renders read-only — phase 1 paths are default-locale
42
- * territory, and the lifecycle drops translation-locale path changes
43
- * with a warn. Locking the input prevents the warn path being hit
44
- * through the admin form and gives editors a clear cue.
45
- */
46
- activeLocale: string
47
- /** `'create'` shows the live derived preview as placeholder text. */
48
- mode: 'create' | 'edit'
49
- }
50
-
51
- /**
52
- * System-managed `path` widget.
53
- *
54
- * Edits the path stored in `byline_document_paths` for the current
55
- * (document, locale) row. Displays the current persisted/overridden
56
- * value as an editable input.
57
- * In create mode, when the user hasn't supplied an override, the input
58
- * shows the live-derived preview (slugified `useAsPath` source field) as
59
- * a placeholder so the user sees what will be saved. The "Regenerate"
60
- * action explicitly writes the current live preview into the override
61
- * slot so the user can re-anchor a path against the source field after
62
- * editing the title.
63
- *
64
- * Stable override handles: `.byline-form-path`, `.byline-form-path-header`,
65
- * `.byline-form-path-regenerate`.
66
- */
67
- export const PathWidget = ({
68
- useAsPath,
69
- collectionPath,
70
- defaultLocale,
71
- activeLocale,
72
- mode,
73
- }: PathWidgetProps) => {
74
- const { setSystemPath } = useFormContext()
75
- const systemPath = useSystemPath()
76
- const sourceValue = useFieldValue<unknown>(useAsPath ?? '')
77
-
78
- // Phase 1: paths are written/edited only under the default content
79
- // locale. When editing a translation, the widget locks down — the
80
- // input is read-only, the Regenerate action is suppressed, and a
81
- // helpText line explains why.
82
- const isReadOnly = activeLocale !== defaultLocale
83
-
84
- // Live preview — what the server would derive from the current source
85
- // field value if no override were set. Used as placeholder in create
86
- // mode and as the target of the "Regenerate" action.
87
- const livePreview = useMemo(() => {
88
- if (!useAsPath) return ''
89
- const asString = coerceToString(sourceValue)
90
- if (asString.length === 0) return ''
91
- return slugify(asString, { locale: defaultLocale, collectionPath })
92
- }, [useAsPath, sourceValue, defaultLocale, collectionPath])
93
-
94
- const inputValue = systemPath ?? ''
95
-
96
- const handleChange = useCallback(
97
- (next: string) => {
98
- // Empty string clears the override — server falls back to derive
99
- // (create) or sticky (update).
100
- setSystemPath(next.length === 0 ? null : next)
101
- },
102
- [setSystemPath]
103
- )
104
-
105
- const handleRegenerate = useCallback(() => {
106
- if (livePreview.length > 0) {
107
- setSystemPath(livePreview)
108
- }
109
- }, [livePreview, setSystemPath])
110
-
111
- // Validate live: if the typed value differs from its slugified form,
112
- // surface an inline hint without blocking input (mirrors the previous
113
- // field-hook advisory behaviour).
114
- const formatted = useMemo(() => {
115
- if (inputValue.length === 0) return ''
116
- return slugify(inputValue, { locale: defaultLocale, collectionPath })
117
- }, [inputValue, defaultLocale, collectionPath])
118
-
119
- const validationHint =
120
- inputValue.length > 0 && formatted !== inputValue ? `Suggested: "${formatted}"` : undefined
121
-
122
- // When read-only, replace the live validation hint with a fixed
123
- // explanatory line so editors understand why the field is locked.
124
- const readOnlyHint = isReadOnly
125
- ? `Path is set in the default locale ("${defaultLocale}") and applies across translations.`
126
- : undefined
127
-
128
- const hint = readOnlyHint ?? validationHint
129
-
130
- const placeholder =
131
- !isReadOnly && mode === 'create' && livePreview.length > 0
132
- ? `Will be saved as "${livePreview}"`
133
- : undefined
134
-
135
- // Screen-reader description. The input's base purpose ("System-managed
136
- // URL path") plus whichever of the visible hints (placeholder preview
137
- // in create mode, "Suggested" validation hint, or read-only explainer)
138
- // currently applies. The visible helpText/placeholder cover sighted
139
- // users; this element makes the same information addressable via
140
- // aria-describedby for AT.
141
- const srDescription = ['System-managed URL path for this document.', placeholder, hint]
142
- .filter(Boolean)
143
- .join(' ')
144
-
145
- const showRegenerate =
146
- !isReadOnly && useAsPath && livePreview.length > 0 && livePreview !== systemPath
147
-
148
- return (
149
- <div className="byline-form-path">
150
- <div className={cx('byline-form-path-header', styles.header)}>
151
- <Label id="system-path-label" htmlFor="system-path" label="Path" />
152
- {showRegenerate && (
153
- <button
154
- type="button"
155
- onClick={handleRegenerate}
156
- className={cx('byline-form-path-regenerate', styles.regenerate)}
157
- aria-label={`Regenerate path from ${useAsPath} field`}
158
- >
159
- Regenerate from {useAsPath}
160
- </button>
161
- )}
162
- </div>
163
- <Input
164
- id="system-path"
165
- name="__systemPath__"
166
- value={inputValue}
167
- placeholder={placeholder}
168
- onChange={(e) => handleChange(e.target.value)}
169
- helpText={hint}
170
- readOnly={isReadOnly}
171
- aria-describedby="system-path-description"
172
- />
173
- <span
174
- id="system-path-description"
175
- className={cx('byline-form-path-sr-only', styles['sr-only'])}
176
- >
177
- {srDescription}
178
- </span>
179
- </div>
180
- )
181
- }