@byline/admin 6.0.3 → 6.2.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.
@@ -1,39 +1,270 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
3
4
  import { useTranslation } from "@byline/i18n/react";
4
- import { Badge } from "@byline/ui/react";
5
+ import { Badge, Dropdown, EllipsisIcon } from "@byline/ui/react";
5
6
  import clsx from "clsx";
7
+ import { advanceDrag, shouldStartDrag, shouldSuppressClick } from "./tab-strip-drag.js";
8
+ import { computeRevealScroll, computeStripState } from "./tab-strip-geometry.js";
6
9
  import tabs_module from "./tabs.module.js";
7
- const AdminTabs = ({ tabs, activeTab, onChange, errorCounts, className })=>{
10
+ const useIsoLayoutEffect = "u" > typeof window ? useLayoutEffect : useEffect;
11
+ const TRIGGER_SIZE_PX = 32;
12
+ const TRIGGER_GAP_PX = 12;
13
+ const INITIAL_STRIP_STATE = {
14
+ overflowing: false,
15
+ viewportWidth: 0,
16
+ atStart: true,
17
+ atEnd: true
18
+ };
19
+ const tabTriggerId = (idBase, name)=>`${idBase}-tab-${name}`;
20
+ const tabPanelId = (idBase, name)=>`${idBase}-panel-${name}`;
21
+ function prefersReducedMotion() {
22
+ if ("u" < typeof window || 'function' != typeof window.matchMedia) return false;
23
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
24
+ }
25
+ const AdminTabs = ({ tabs, activeTab, onChange, idBase, errorCounts, className })=>{
8
26
  const { t } = useTranslation('byline-admin');
9
- return /*#__PURE__*/ jsx("div", {
10
- role: "tablist",
11
- "aria-label": t('presentation.formTabsAriaLabel'),
27
+ const containerRef = useRef(null);
28
+ const viewportRef = useRef(null);
29
+ const rowRef = useRef(null);
30
+ const tabRefs = useRef(new Map());
31
+ const focusAfterChange = useRef(null);
32
+ const [strip, setStrip] = useState(INITIAL_STRIP_STATE);
33
+ const measure = useCallback(()=>{
34
+ const container = containerRef.current;
35
+ const row = rowRef.current;
36
+ if (null == container || null == row) return;
37
+ const next = computeStripState({
38
+ rowWidth: row.scrollWidth,
39
+ containerWidth: container.clientWidth,
40
+ triggerWidth: TRIGGER_SIZE_PX,
41
+ triggerGap: TRIGGER_GAP_PX,
42
+ scrollLeft: viewportRef.current?.scrollLeft ?? 0
43
+ });
44
+ setStrip((prev)=>prev.overflowing === next.overflowing && prev.viewportWidth === next.viewportWidth && prev.atStart === next.atStart && prev.atEnd === next.atEnd ? prev : next);
45
+ }, []);
46
+ useIsoLayoutEffect(()=>{
47
+ measure();
48
+ const container = containerRef.current;
49
+ const row = rowRef.current;
50
+ if (null == container || null == row || "u" < typeof ResizeObserver) return;
51
+ const observer = new ResizeObserver(()=>measure());
52
+ observer.observe(container);
53
+ observer.observe(row);
54
+ return ()=>observer.disconnect();
55
+ }, [
56
+ measure,
57
+ tabs
58
+ ]);
59
+ const reveal = useCallback((name)=>{
60
+ const viewport = viewportRef.current;
61
+ const row = rowRef.current;
62
+ const tab = tabRefs.current.get(name);
63
+ if (null == viewport || null == row || null == tab) return;
64
+ const next = computeRevealScroll({
65
+ viewportWidth: viewport.clientWidth,
66
+ scrollLeft: viewport.scrollLeft,
67
+ tabOffsetLeft: tab.offsetLeft,
68
+ tabWidth: tab.offsetWidth,
69
+ rowWidth: row.scrollWidth
70
+ });
71
+ if (null == next) return;
72
+ viewport.scrollTo?.({
73
+ left: next,
74
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth'
75
+ });
76
+ }, []);
77
+ useEffect(()=>{
78
+ const pending = focusAfterChange.current;
79
+ if (pending === activeTab) {
80
+ focusAfterChange.current = null;
81
+ tabRefs.current.get(activeTab)?.focus({
82
+ preventScroll: true
83
+ });
84
+ }
85
+ reveal(activeTab);
86
+ }, [
87
+ activeTab,
88
+ reveal
89
+ ]);
90
+ const select = useCallback((name, moveFocus)=>{
91
+ if (moveFocus) focusAfterChange.current = name;
92
+ onChange(name);
93
+ }, [
94
+ onChange
95
+ ]);
96
+ const dragRef = useRef(null);
97
+ const dragEndedAtRef = useRef(null);
98
+ const [dragging, setDragging] = useState(false);
99
+ const handlePointerDown = (event)=>{
100
+ const viewport = viewportRef.current;
101
+ if (null == viewport) return;
102
+ if (false === shouldStartDrag({
103
+ pointerType: event.pointerType,
104
+ button: event.button,
105
+ overflowing: strip.overflowing
106
+ })) return;
107
+ dragRef.current = {
108
+ origin: {
109
+ pointerX: event.clientX,
110
+ scrollLeft: viewport.scrollLeft
111
+ },
112
+ dragging: false
113
+ };
114
+ };
115
+ const handlePointerMove = (event)=>{
116
+ const state = dragRef.current;
117
+ const viewport = viewportRef.current;
118
+ const row = rowRef.current;
119
+ if (null == state || null == viewport || null == row) return;
120
+ const maxScroll = Math.max(0, row.scrollWidth - viewport.clientWidth);
121
+ const next = advanceDrag(state, event.clientX, maxScroll);
122
+ if (null == next.scrollLeft) return;
123
+ if (false === state.dragging) {
124
+ state.dragging = true;
125
+ setDragging(true);
126
+ viewport.setPointerCapture?.(event.pointerId);
127
+ }
128
+ viewport.scrollLeft = next.scrollLeft;
129
+ };
130
+ const handlePointerEnd = (event)=>{
131
+ const state = dragRef.current;
132
+ if (null == state) return;
133
+ dragRef.current = null;
134
+ if (false === state.dragging) return;
135
+ setDragging(false);
136
+ viewportRef.current?.releasePointerCapture?.(event.pointerId);
137
+ dragEndedAtRef.current = performance.now();
138
+ };
139
+ const handleClickCapture = (event)=>{
140
+ if (false === shouldSuppressClick(dragEndedAtRef.current, performance.now())) return;
141
+ dragEndedAtRef.current = null;
142
+ event.stopPropagation();
143
+ event.preventDefault();
144
+ };
145
+ const handleKeyDown = (event)=>{
146
+ const current = tabs.findIndex((tab)=>tab.name === activeTab);
147
+ if (current < 0 || 0 === tabs.length) return;
148
+ let nextIndex;
149
+ switch(event.key){
150
+ case 'ArrowRight':
151
+ nextIndex = (current + 1) % tabs.length;
152
+ break;
153
+ case 'ArrowLeft':
154
+ nextIndex = (current - 1 + tabs.length) % tabs.length;
155
+ break;
156
+ case 'Home':
157
+ nextIndex = 0;
158
+ break;
159
+ case 'End':
160
+ nextIndex = tabs.length - 1;
161
+ break;
162
+ default:
163
+ return;
164
+ }
165
+ event.preventDefault();
166
+ const next = tabs[nextIndex];
167
+ if (null == next || next.name === activeTab) return;
168
+ select(next.name, true);
169
+ };
170
+ const sizing = {
171
+ '--byline-admin-tabs-trigger-size': `${TRIGGER_SIZE_PX}px`,
172
+ '--byline-admin-tabs-trigger-gap': `${TRIGGER_GAP_PX}px`
173
+ };
174
+ return /*#__PURE__*/ jsxs("div", {
175
+ ref: containerRef,
176
+ style: sizing,
12
177
  className: clsx('byline-admin-tabs', tabs_module.tabs, className),
13
- children: tabs.map((tab)=>{
14
- const isActive = tab.name === activeTab;
15
- return /*#__PURE__*/ jsx("button", {
16
- type: "button",
17
- role: "tab",
18
- "aria-selected": isActive,
19
- onClick: ()=>onChange(tab.name),
20
- className: clsx('byline-admin-tab', tabs_module.tab, isActive && [
21
- 'byline-admin-tab-active',
22
- tabs_module["tab-active"]
23
- ]),
24
- children: /*#__PURE__*/ jsxs("span", {
25
- className: clsx('byline-admin-tab-label', tabs_module.label),
26
- children: [
27
- tab.label,
28
- (errorCounts?.[tab.name] ?? 0) > 0 && /*#__PURE__*/ jsx(Badge, {
29
- intent: "danger",
30
- className: clsx('byline-admin-tab-badge', tabs_module.badge),
31
- children: errorCounts?.[tab.name]
32
- })
33
- ]
178
+ children: [
179
+ /*#__PURE__*/ jsx("div", {
180
+ ref: viewportRef,
181
+ onScroll: measure,
182
+ onPointerDown: handlePointerDown,
183
+ onPointerMove: handlePointerMove,
184
+ onPointerUp: handlePointerEnd,
185
+ onPointerCancel: handlePointerEnd,
186
+ onClickCapture: handleClickCapture,
187
+ "data-at-start": strip.atStart,
188
+ "data-at-end": strip.atEnd,
189
+ "data-draggable": strip.overflowing,
190
+ "data-dragging": dragging,
191
+ className: clsx('byline-admin-tabs-viewport', tabs_module.viewport),
192
+ children: /*#__PURE__*/ jsx("div", {
193
+ ref: rowRef,
194
+ role: "tablist",
195
+ "aria-label": t('presentation.formTabsAriaLabel'),
196
+ onKeyDown: handleKeyDown,
197
+ className: clsx('byline-admin-tablist', tabs_module.tablist),
198
+ children: tabs.map((tab)=>{
199
+ const isActive = tab.name === activeTab;
200
+ const errorCount = errorCounts?.[tab.name] ?? 0;
201
+ return /*#__PURE__*/ jsx("button", {
202
+ ref: (node)=>{
203
+ if (null == node) tabRefs.current.delete(tab.name);
204
+ else tabRefs.current.set(tab.name, node);
205
+ },
206
+ type: "button",
207
+ role: "tab",
208
+ id: tabTriggerId(idBase, tab.name),
209
+ "aria-controls": tabPanelId(idBase, tab.name),
210
+ "aria-selected": isActive,
211
+ tabIndex: isActive ? 0 : -1,
212
+ onClick: ()=>select(tab.name, false),
213
+ onFocus: ()=>reveal(tab.name),
214
+ className: clsx('byline-admin-tab', tabs_module.tab, isActive && [
215
+ 'byline-admin-tab-active',
216
+ tabs_module["tab-active"]
217
+ ]),
218
+ children: /*#__PURE__*/ jsxs("span", {
219
+ className: clsx('byline-admin-tab-label', tabs_module.label),
220
+ children: [
221
+ tab.label,
222
+ errorCount > 0 && /*#__PURE__*/ jsx(Badge, {
223
+ intent: "danger",
224
+ className: clsx('byline-admin-tab-badge', tabs_module.badge),
225
+ children: errorCount
226
+ })
227
+ ]
228
+ })
229
+ }, tab.name);
230
+ })
34
231
  })
35
- }, tab.name);
36
- })
232
+ }),
233
+ strip.overflowing && /*#__PURE__*/ jsxs(Dropdown.Root, {
234
+ children: [
235
+ /*#__PURE__*/ jsx(Dropdown.Trigger, {
236
+ "aria-label": t('presentation.formTabsAllTabsLabel'),
237
+ className: clsx('byline-admin-tabs-overflow-trigger', tabs_module["overflow-trigger"]),
238
+ children: /*#__PURE__*/ jsx(EllipsisIcon, {
239
+ className: clsx('byline-admin-tabs-overflow-icon', tabs_module["overflow-icon"])
240
+ })
241
+ }),
242
+ /*#__PURE__*/ jsx(Dropdown.Portal, {
243
+ children: /*#__PURE__*/ jsx(Dropdown.Content, {
244
+ sideOffset: 6,
245
+ align: "end",
246
+ className: clsx('byline-admin-tabs-overflow-menu', tabs_module["overflow-menu"]),
247
+ children: tabs.map((tab)=>{
248
+ const errorCount = errorCounts?.[tab.name] ?? 0;
249
+ return /*#__PURE__*/ jsxs(Dropdown.Item, {
250
+ onClick: ()=>select(tab.name, true),
251
+ "data-active": tab.name === activeTab,
252
+ className: clsx('byline-admin-tabs-overflow-item', tabs_module["overflow-item"]),
253
+ children: [
254
+ tab.label,
255
+ errorCount > 0 && /*#__PURE__*/ jsx(Badge, {
256
+ intent: "danger",
257
+ className: clsx('byline-admin-tab-badge', tabs_module.badge),
258
+ children: errorCount
259
+ })
260
+ ]
261
+ }, tab.name);
262
+ })
263
+ })
264
+ })
265
+ ]
266
+ })
267
+ ]
37
268
  });
38
269
  };
39
- export { AdminTabs };
270
+ export { AdminTabs, tabPanelId, tabTriggerId };
@@ -1,10 +1,20 @@
1
1
  import "./tabs_module.css";
2
2
  const tabs_module = {
3
3
  tabs: "tabs-AUVgW2",
4
+ viewport: "viewport-Q34w7N",
5
+ tablist: "tablist-a3JXdm",
4
6
  tab: "tab-Tn7xcQ",
5
7
  "tab-active": "tab-active-cGf5N5",
6
8
  tabActive: "tab-active-cGf5N5",
7
9
  label: "label-PvJgbb",
8
- badge: "badge-NJVOEv"
10
+ badge: "badge-NJVOEv",
11
+ "overflow-trigger": "overflow-trigger-sQPQE8",
12
+ overflowTrigger: "overflow-trigger-sQPQE8",
13
+ "overflow-icon": "overflow-icon-E4aR_Z",
14
+ overflowIcon: "overflow-icon-E4aR_Z",
15
+ "overflow-menu": "overflow-menu-BkkmwS",
16
+ overflowMenu: "overflow-menu-BkkmwS",
17
+ "overflow-item": "overflow-item-ahgTA4",
18
+ overflowItem: "overflow-item-ahgTA4"
9
19
  };
10
20
  export default tabs_module;
@@ -1,18 +1,109 @@
1
1
  :is(.tabs-AUVgW2, .byline-admin-tabs) {
2
+ align-items: stretch;
3
+ gap: var(--byline-admin-tabs-trigger-gap, 12px);
4
+ display: flex;
5
+ position: relative;
6
+ }
7
+
8
+ .tabs-AUVgW2:after {
9
+ content: "";
10
+ z-index: 0;
11
+ height: var(--border-width-thin);
12
+ background-color: var(--gray-200);
13
+ position: absolute;
14
+ bottom: 0;
15
+ left: 0;
16
+ right: 0;
17
+ }
18
+
19
+ .byline-admin-tabs:after {
20
+ content: "";
21
+ z-index: 0;
22
+ height: var(--border-width-thin);
23
+ background-color: var(--gray-200);
24
+ position: absolute;
25
+ bottom: 0;
26
+ left: 0;
27
+ right: 0;
28
+ }
29
+
30
+ :is(.viewport-Q34w7N, .byline-admin-tabs-viewport) {
31
+ z-index: 1;
32
+ overscroll-behavior-x: contain;
33
+ scrollbar-width: none;
34
+ flex: auto;
35
+ min-width: 0;
36
+ position: relative;
37
+ overflow: auto hidden;
38
+ }
39
+
40
+ .viewport-Q34w7N::-webkit-scrollbar {
41
+ display: none;
42
+ }
43
+
44
+ .byline-admin-tabs-viewport::-webkit-scrollbar {
45
+ display: none;
46
+ }
47
+
48
+ :is(.viewport-Q34w7N[data-draggable="true"], .byline-admin-tabs-viewport[data-draggable="true"]) {
49
+ cursor: grab;
50
+ }
51
+
52
+ :is(.viewport-Q34w7N[data-dragging="true"], .byline-admin-tabs-viewport[data-dragging="true"]) {
53
+ cursor: grabbing;
54
+ -webkit-user-select: none;
55
+ user-select: none;
56
+ }
57
+
58
+ :is(.viewport-Q34w7N[data-dragging="true"] .byline-admin-tab, .byline-admin-tabs-viewport[data-dragging="true"] .byline-admin-tab) {
59
+ cursor: grabbing;
60
+ }
61
+
62
+ :is(.viewport-Q34w7N, .byline-admin-tabs-viewport) {
63
+ --fade-start: 0px;
64
+ --fade-end: 0px;
65
+ -webkit-mask-image: linear-gradient(to right,
66
+ transparent 0,
67
+ black var(--fade-start),
68
+ black calc(100% - var(--fade-end)),
69
+ transparent 100%);
70
+ -webkit-mask-image: linear-gradient(to right,
71
+ transparent 0,
72
+ black var(--fade-start),
73
+ black calc(100% - var(--fade-end)),
74
+ transparent 100%);
75
+ mask-image: linear-gradient(to right,
76
+ transparent 0,
77
+ black var(--fade-start),
78
+ black calc(100% - var(--fade-end)),
79
+ transparent 100%);
80
+ }
81
+
82
+ :is(.viewport-Q34w7N[data-at-start="false"], .byline-admin-tabs-viewport[data-at-start="false"]) {
83
+ --fade-start: 1.5rem;
84
+ }
85
+
86
+ :is(.viewport-Q34w7N[data-at-end="false"], .byline-admin-tabs-viewport[data-at-end="false"]) {
87
+ --fade-end: 1.5rem;
88
+ }
89
+
90
+ :is(.tablist-a3JXdm, .byline-admin-tablist) {
2
91
  gap: var(--spacing-16);
3
- border-bottom: var(--border-width-thin) var(--border-style-solid) var(--gray-200);
92
+ width: max-content;
4
93
  display: flex;
94
+ position: relative;
5
95
  }
6
96
 
7
97
  :is(.tab-Tn7xcQ, .byline-admin-tab) {
8
98
  color: var(--gray-500);
9
99
  font-size: 1.1rem;
10
100
  font-weight: var(--font-weight-medium);
101
+ white-space: nowrap;
11
102
  cursor: pointer;
12
103
  background: none;
13
104
  border-bottom: 2px solid #0000;
14
105
  outline: none;
15
- margin-bottom: -1px;
106
+ flex: none;
16
107
  padding: .625rem 0;
17
108
  transition: color .15s, border-color .15s;
18
109
  position: relative;
@@ -24,7 +115,8 @@
24
115
  }
25
116
 
26
117
  :is(.tab-Tn7xcQ:focus-visible, .byline-admin-tab:focus-visible) {
27
- box-shadow: 0 0 0 2px var(--blue-500);
118
+ box-shadow: inset 0 0 0 2px var(--blue-500);
119
+ border-radius: var(--radius-sm, 3px);
28
120
  }
29
121
 
30
122
  :is(.tab-active-cGf5N5, .byline-admin-tab-active), :is(.tab-active-cGf5N5:hover, .byline-admin-tab-active:hover) {
@@ -48,8 +140,64 @@
48
140
  display: flex;
49
141
  }
50
142
 
51
- :is(:is([data-theme="dark"], .dark) .tabs-AUVgW2, :is([data-theme="dark"], .dark) .byline-admin-tabs) {
52
- border-bottom-color: var(--gray-700);
143
+ :is(.overflow-trigger-sQPQE8, .byline-admin-tabs-overflow-trigger) {
144
+ z-index: 1;
145
+ width: var(--byline-admin-tabs-trigger-size, 32px);
146
+ height: var(--byline-admin-tabs-trigger-size, 32px);
147
+ border: var(--border-width-thin) var(--border-style-solid) transparent;
148
+ border-radius: var(--radius-sm, 3px);
149
+ color: var(--gray-500);
150
+ cursor: pointer;
151
+ background: none;
152
+ flex: none;
153
+ justify-content: center;
154
+ align-self: center;
155
+ align-items: center;
156
+ padding: 0;
157
+ transition: color .15s, background-color .15s;
158
+ display: flex;
159
+ position: relative;
160
+ }
161
+
162
+ :is(.overflow-trigger-sQPQE8:hover, .byline-admin-tabs-overflow-trigger:hover) {
163
+ color: var(--gray-800);
164
+ background-color: var(--gray-100);
165
+ }
166
+
167
+ :is(.overflow-trigger-sQPQE8:focus-visible, .byline-admin-tabs-overflow-trigger:focus-visible) {
168
+ outline: 2px solid var(--blue-500);
169
+ outline-offset: 1px;
170
+ }
171
+
172
+ :is(.overflow-icon-E4aR_Z, .byline-admin-tabs-overflow-icon) {
173
+ width: 1.125rem;
174
+ height: 1.125rem;
175
+ }
176
+
177
+ :is(.overflow-menu-BkkmwS, .byline-admin-tabs-overflow-menu) {
178
+ min-width: 12rem;
179
+ max-height: 60vh;
180
+ overflow-y: auto;
181
+ }
182
+
183
+ :is(.overflow-item-ahgTA4, .byline-admin-tabs-overflow-item) {
184
+ justify-content: space-between;
185
+ align-items: center;
186
+ gap: var(--spacing-8);
187
+ display: flex;
188
+ }
189
+
190
+ :is(.overflow-item-ahgTA4[data-active="true"], .byline-admin-tabs-overflow-item[data-active="true"]) {
191
+ color: var(--primary-600);
192
+ font-weight: var(--font-weight-medium);
193
+ }
194
+
195
+ :is([data-theme="dark"], .dark) .tabs-AUVgW2:after {
196
+ background-color: var(--gray-700);
197
+ }
198
+
199
+ :is([data-theme="dark"], .dark) .byline-admin-tabs:after {
200
+ background-color: var(--gray-700);
53
201
  }
54
202
 
55
203
  :is(:is([data-theme="dark"], .dark) .tab-Tn7xcQ, :is([data-theme="dark"], .dark) .byline-admin-tab) {
@@ -66,3 +214,16 @@
66
214
  border-bottom-color: var(--primary-400);
67
215
  }
68
216
 
217
+ :is(:is([data-theme="dark"], .dark) .overflow-trigger-sQPQE8, :is([data-theme="dark"], .dark) .byline-admin-tabs-overflow-trigger) {
218
+ color: var(--gray-400);
219
+ }
220
+
221
+ :is(:is([data-theme="dark"], .dark) .overflow-trigger-sQPQE8:hover, :is([data-theme="dark"], .dark) .byline-admin-tabs-overflow-trigger:hover) {
222
+ color: var(--gray-200);
223
+ background-color: var(--gray-800);
224
+ }
225
+
226
+ :is(:is([data-theme="dark"], .dark) .overflow-item-ahgTA4[data-active="true"], :is([data-theme="dark"], .dark) .byline-admin-tabs-overflow-item[data-active="true"]) {
227
+ color: var(--primary-200);
228
+ }
229
+
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/admin",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "6.0.3",
5
+ "version": "6.2.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -183,12 +183,12 @@
183
183
  "react-diff-viewer-continued": "^4.4.0",
184
184
  "uuid": "^14.0.2",
185
185
  "zod": "^4.6.2",
186
- "@byline/analytics": "6.0.3",
187
- "@byline/analytics-agent": "6.0.3",
188
- "@byline/core": "6.0.3",
189
- "@byline/i18n": "6.0.3",
190
- "@byline/ui": "6.0.3",
191
- "@byline/auth": "6.0.3"
186
+ "@byline/analytics": "6.2.0",
187
+ "@byline/analytics-agent": "6.2.0",
188
+ "@byline/core": "6.2.0",
189
+ "@byline/auth": "6.2.0",
190
+ "@byline/i18n": "6.2.0",
191
+ "@byline/ui": "6.2.0"
192
192
  },
193
193
  "peerDependencies": {
194
194
  "react": "^19.0.0",
@@ -46,8 +46,8 @@ vi.mock('@byline/ui/react', async (importOriginal) => {
46
46
  Separator: () => <hr />,
47
47
  },
48
48
  Modal,
49
- Input: ({ name, value, onChange }: any) => (
50
- <input name={name} value={value ?? ''} onChange={onChange} />
49
+ Input: ({ name, value, onChange, readOnly }: any) => (
50
+ <input name={name} value={value ?? ''} onChange={onChange} readOnly={readOnly ?? false} />
51
51
  ),
52
52
  }
53
53
  })
@@ -130,6 +130,76 @@ describe('FormRenderer capabilities', () => {
130
130
  expect(input?.value).toBe('about-us')
131
131
  })
132
132
 
133
+ it('forwards adminConfig.lockPath to the path widget', () => {
134
+ render({ ...baseProps, adminConfig: { slug: 'pages', lockPath: true } })
135
+
136
+ const input = container.querySelector<HTMLInputElement>('input[name="__systemPath__"]')
137
+ expect(input?.readOnly).toBe(true)
138
+ expect(input?.value).toBe('about-us')
139
+ })
140
+
141
+ it('leaves the path widget editable when no lockPath is declared', () => {
142
+ render({ ...baseProps, adminConfig: { slug: 'pages' } })
143
+
144
+ const input = container.querySelector<HTMLInputElement>('input[name="__systemPath__"]')
145
+ expect(input?.readOnly).toBe(false)
146
+ })
147
+
148
+ it('keeps a counter source suppressing Regenerate without locking the input', () => {
149
+ // `sourceLocked` and `lockPath` stay independent: a counter-sourced path
150
+ // still has no derivable preview, but remains editable unless declared.
151
+ // The source needs a real value whose slug ('448') differs from the
152
+ // stored path ('about-us'), or Regenerate would be absent anyway and the
153
+ // assertion would hold even if `sourceLocked` forwarding broke.
154
+ render({
155
+ ...baseProps,
156
+ fields: [{ name: 'ref', label: 'Ref', type: 'counter' as const }],
157
+ useAsPath: 'ref',
158
+ initialData: { id: 'doc-1', path: 'about-us', fields: { ref: 448 } },
159
+ adminConfig: { slug: 'pages' },
160
+ })
161
+
162
+ const input = container.querySelector<HTMLInputElement>('input[name="__systemPath__"]')
163
+ expect(input?.readOnly).toBe(false)
164
+ expect(container.querySelector('.byline-form-path-regenerate')).toBeNull()
165
+ })
166
+
167
+ it('renders the locked widget on create with no useAsPath and no stored path', () => {
168
+ // A collection whose paths come from an external system declares no
169
+ // `useAsPath` and has nothing stored yet — the case the old render gate
170
+ // hid outright.
171
+ render({
172
+ ...baseProps,
173
+ mode: 'create' as const,
174
+ useAsPath: undefined,
175
+ initialData: { fields: {} },
176
+ adminConfig: { slug: 'pages', lockPath: true },
177
+ })
178
+
179
+ expect(container.querySelector('.byline-form-path')).not.toBeNull()
180
+ const input = container.querySelector<HTMLInputElement>('input[name="__systemPath__"]')
181
+ expect(input?.readOnly).toBe(true)
182
+ expect(input?.value).toBe('')
183
+ })
184
+
185
+ it('keeps the widget hidden on create without useAsPath when unlocked', () => {
186
+ render({
187
+ ...baseProps,
188
+ mode: 'create' as const,
189
+ useAsPath: undefined,
190
+ initialData: { fields: {} },
191
+ adminConfig: { slug: 'pages' },
192
+ })
193
+
194
+ expect(container.querySelector('.byline-form-path')).toBeNull()
195
+ })
196
+
197
+ it('lets showPath: false override lockPath', () => {
198
+ render({ ...baseProps, showPath: false, adminConfig: { slug: 'pages', lockPath: true } })
199
+
200
+ expect(container.querySelector('.byline-form-path')).toBeNull()
201
+ })
202
+
133
203
  it('suppresses the path widget when showPath is false', () => {
134
204
  render({ ...baseProps, showPath: false })
135
205
  expect(container.querySelector('.byline-form-path')).toBeNull()