@byline/admin 6.1.0 → 6.2.1

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 (119) hide show
  1. package/dist/forms/form-layout-styles.test.node.d.ts +8 -0
  2. package/dist/forms/form-layout.d.ts +39 -0
  3. package/dist/forms/form-layout.js +100 -0
  4. package/dist/forms/form-page-chrome.d.ts +110 -0
  5. package/dist/forms/form-page-chrome.js +253 -0
  6. package/dist/forms/form-renderer.d.ts +2 -18
  7. package/dist/forms/form-renderer.js +104 -418
  8. package/dist/forms/form-renderer.module.js +2 -0
  9. package/dist/forms/form-renderer_module.css +13 -0
  10. package/dist/forms/use-form-submission.d.ts +74 -0
  11. package/dist/forms/use-form-submission.js +166 -0
  12. package/dist/forms/use-form-tabs.d.ts +29 -0
  13. package/dist/forms/use-form-tabs.js +45 -0
  14. package/dist/presentation/tab-strip-drag.d.ts +75 -0
  15. package/dist/presentation/tab-strip-drag.js +25 -0
  16. package/dist/presentation/tab-strip-drag.test.node.d.ts +8 -0
  17. package/dist/presentation/tab-strip-geometry.d.ts +75 -0
  18. package/dist/presentation/tab-strip-geometry.js +36 -0
  19. package/dist/presentation/tab-strip-geometry.test.node.d.ts +8 -0
  20. package/dist/presentation/tabs.d.ts +30 -7
  21. package/dist/presentation/tabs.js +260 -29
  22. package/dist/presentation/tabs.module.js +11 -1
  23. package/dist/presentation/tabs_module.css +166 -5
  24. package/package.json +7 -7
  25. package/src/forms/form-layout-styles.test.node.ts +32 -0
  26. package/src/forms/form-layout.test.tsx +276 -0
  27. package/src/forms/form-layout.tsx +198 -0
  28. package/src/forms/form-page-chrome.tsx +460 -0
  29. package/src/forms/form-renderer-submit.test.tsx +40 -0
  30. package/src/forms/form-renderer-tab-persistence.test.tsx +145 -0
  31. package/src/forms/form-renderer.module.css +18 -0
  32. package/src/forms/form-renderer.tsx +115 -593
  33. package/src/forms/use-form-submission.test.tsx +564 -0
  34. package/src/forms/use-form-submission.ts +293 -0
  35. package/src/forms/use-form-tabs.ts +89 -0
  36. package/src/presentation/tab-strip-drag.test.node.ts +143 -0
  37. package/src/presentation/tab-strip-drag.ts +114 -0
  38. package/src/presentation/tab-strip-geometry.test.node.ts +254 -0
  39. package/src/presentation/tab-strip-geometry.ts +158 -0
  40. package/src/presentation/tabs.module.css +209 -11
  41. package/src/presentation/tabs.test.tsx +481 -0
  42. package/src/presentation/tabs.tsx +369 -34
  43. package/dist/exports-parity.test.node.js +0 -37
  44. package/dist/fields/array/array-field.jsx +0 -209
  45. package/dist/fields/blocks/blocks-field.jsx +0 -248
  46. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  47. package/dist/fields/code/code-editor.jsx +0 -211
  48. package/dist/fields/code/code-field.jsx +0 -109
  49. package/dist/fields/column-formatter.jsx +0 -25
  50. package/dist/fields/date-time-formatter.jsx +0 -18
  51. package/dist/fields/datetime/datetime-field.jsx +0 -22
  52. package/dist/fields/draggable-context-menu.jsx +0 -50
  53. package/dist/fields/field-admin.test.node.js +0 -40
  54. package/dist/fields/field-renderer.jsx +0 -136
  55. package/dist/fields/field-services-context.jsx +0 -17
  56. package/dist/fields/file/file-field.jsx +0 -176
  57. package/dist/fields/file/file-upload-field.jsx +0 -89
  58. package/dist/fields/group/group-field.jsx +0 -46
  59. package/dist/fields/image/image-field.jsx +0 -183
  60. package/dist/fields/image/image-upload-field.jsx +0 -157
  61. package/dist/fields/local-date-time.jsx +0 -69
  62. package/dist/fields/locale-badge.jsx +0 -19
  63. package/dist/fields/numerical/numerical-field.jsx +0 -93
  64. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  65. package/dist/fields/relation/relation-display.jsx +0 -118
  66. package/dist/fields/relation/relation-field.jsx +0 -108
  67. package/dist/fields/relation/relation-many-field.jsx +0 -142
  68. package/dist/fields/relation/relation-picker.jsx +0 -240
  69. package/dist/fields/relation/relation-summary.jsx +0 -45
  70. package/dist/fields/select/select-field.jsx +0 -27
  71. package/dist/fields/sortable-item.jsx +0 -54
  72. package/dist/fields/text/text-field.jsx +0 -79
  73. package/dist/fields/text-area/text-area-field.jsx +0 -79
  74. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  75. package/dist/forms/available-locales-widget.jsx +0 -63
  76. package/dist/forms/document-actions.jsx +0 -486
  77. package/dist/forms/form-context.jsx +0 -598
  78. package/dist/forms/form-modals.jsx +0 -128
  79. package/dist/forms/form-renderer.jsx +0 -532
  80. package/dist/forms/form-status-display.jsx +0 -69
  81. package/dist/forms/navigation-guard.jsx +0 -68
  82. package/dist/forms/nested-path.test.node.js +0 -115
  83. package/dist/forms/path-widget.jsx +0 -125
  84. package/dist/forms/pending-uploads.test.node.js +0 -19
  85. package/dist/forms/repeating-items.test.node.js +0 -29
  86. package/dist/forms/scheduled-publication-control.jsx +0 -389
  87. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  88. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  89. package/dist/forms/status-transitions.test.node.js +0 -76
  90. package/dist/forms/tree-placement-widget.jsx +0 -160
  91. package/dist/forms/upload-executor.test.node.js +0 -368
  92. package/dist/forms/use-form-layout.test.node.js +0 -69
  93. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  94. package/dist/modules/admin-account/components/container.jsx +0 -164
  95. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  96. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  97. package/dist/modules/admin-account/components/update.jsx +0 -186
  98. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  99. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  100. package/dist/modules/admin-roles/components/create.jsx +0 -155
  101. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  102. package/dist/modules/admin-roles/components/update.jsx +0 -148
  103. package/dist/modules/admin-users/components/create.jsx +0 -206
  104. package/dist/modules/admin-users/components/roles.jsx +0 -126
  105. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  106. package/dist/modules/admin-users/components/update.jsx +0 -215
  107. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  108. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  109. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  110. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  111. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  112. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  113. package/dist/presentation/group.jsx +0 -26
  114. package/dist/presentation/row.jsx +0 -23
  115. package/dist/presentation/tabs.jsx +0 -39
  116. package/dist/services/admin-services-context.jsx +0 -17
  117. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  118. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  119. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -8,12 +8,47 @@
8
8
  * Copyright (c) Infonomic Company Limited
9
9
  */
10
10
 
11
+ import {
12
+ type CSSProperties,
13
+ type KeyboardEvent,
14
+ type MouseEvent,
15
+ type PointerEvent,
16
+ useCallback,
17
+ useEffect,
18
+ useLayoutEffect,
19
+ useRef,
20
+ useState,
21
+ } from 'react'
22
+
11
23
  import { useTranslation } from '@byline/i18n/react'
12
- import { Badge } from '@byline/ui/react'
24
+ import { Badge, Dropdown, EllipsisIcon } from '@byline/ui/react'
13
25
  import cx from 'clsx'
14
26
 
27
+ import { advanceDrag, type DragState, shouldStartDrag, shouldSuppressClick } from './tab-strip-drag'
28
+ import { computeRevealScroll, computeStripState, type StripState } from './tab-strip-geometry'
15
29
  import styles from './tabs.module.css'
16
30
 
31
+ const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
32
+
33
+ /**
34
+ * Size and offset of the overflow trigger, in pixels.
35
+ *
36
+ * Pushed onto the element as custom properties so the stylesheet lays the
37
+ * trigger out from the same numbers the geometry reserves space with. Pixels
38
+ * rather than `rem` deliberately: the trigger is icon-only, so it has no text
39
+ * to scale, and a fixed size keeps the reserved width correct under text
40
+ * scaling and translation alike.
41
+ */
42
+ const TRIGGER_SIZE_PX = 32
43
+ const TRIGGER_GAP_PX = 12
44
+
45
+ const INITIAL_STRIP_STATE: StripState = {
46
+ overflowing: false,
47
+ viewportWidth: 0,
48
+ atStart: true,
49
+ atEnd: true,
50
+ }
51
+
17
52
  export interface AdminTabItem {
18
53
  name: string
19
54
  label: string
@@ -23,62 +58,362 @@ interface AdminTabsProps {
23
58
  tabs: AdminTabItem[]
24
59
  activeTab: string
25
60
  onChange: (name: string) => void
61
+ /**
62
+ * Prefix for the generated tab and panel ids. The consumer that renders the
63
+ * panels builds its panel ids from the same base via `tabPanelId`, which is
64
+ * what associates the two halves for assistive technology.
65
+ */
66
+ idBase: string
26
67
  /** Error counts keyed by tab name — shows a danger badge when > 0. */
27
68
  errorCounts?: Record<string, number>
28
69
  className?: string
29
70
  }
30
71
 
72
+ /** Id of the button for one tab. */
73
+ export const tabTriggerId = (idBase: string, name: string): string => `${idBase}-tab-${name}`
74
+
75
+ /** Id the consumer must put on the panel belonging to one tab. */
76
+ export const tabPanelId = (idBase: string, name: string): string => `${idBase}-panel-${name}`
77
+
78
+ function prefersReducedMotion(): boolean {
79
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
80
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches
81
+ }
82
+
31
83
  /**
32
84
  * Tabs navigation bar for admin form layouts.
33
85
  *
34
- * Used by FormRenderer when a CollectionAdminConfig declares a `tabs` array.
35
- * Each tab is a simple button with a bottom-border active indicator.
36
- * Inactive tabs show a subtle hover state. Fully dark-mode aware.
86
+ * Used by FormRenderer when a CollectionAdminConfig declares a `tabs` array,
87
+ * and by the collection history view. The number of tabs a collection can
88
+ * declare is unbounded while the column they sit in is not, so the strip
89
+ * scrolls horizontally and — only once the tabs genuinely exceed the column —
90
+ * grows a menu listing every tab.
91
+ *
92
+ * The menu is a jump list rather than a list of the tabs that happen to be
93
+ * out of view: its contents stay put as the strip scrolls, and no tab ever
94
+ * changes position in the strip as a result of being selected. Editors learn
95
+ * where a tab lives and it stays there.
96
+ *
97
+ * Scroll position is moved only when the selection changes or a tab takes
98
+ * focus. A reader who scrolls the strip to look at other labels keeps their
99
+ * position through re-measurement, re-render and resize.
37
100
  *
38
- * Stable override handles: `.byline-admin-tabs`, `.byline-admin-tab`,
39
- * `.byline-admin-tab-active`, `.byline-admin-tab-label`,
40
- * `.byline-admin-tab-badge`.
101
+ * Stable override handles: `.byline-admin-tabs`, `.byline-admin-tabs-viewport`,
102
+ * `.byline-admin-tablist`, `.byline-admin-tab`, `.byline-admin-tab-active`,
103
+ * `.byline-admin-tab-label`, `.byline-admin-tab-badge`,
104
+ * `.byline-admin-tabs-overflow-trigger`, `.byline-admin-tabs-overflow-menu`,
105
+ * `.byline-admin-tabs-overflow-item`.
41
106
  */
42
107
  export const AdminTabs = ({
43
108
  tabs,
44
109
  activeTab,
45
110
  onChange,
111
+ idBase,
46
112
  errorCounts,
47
113
  className,
48
114
  }: AdminTabsProps) => {
49
115
  const { t } = useTranslation('byline-admin')
116
+
117
+ const containerRef = useRef<HTMLDivElement | null>(null)
118
+ const viewportRef = useRef<HTMLDivElement | null>(null)
119
+ const rowRef = useRef<HTMLDivElement | null>(null)
120
+ const tabRefs = useRef(new Map<string, HTMLButtonElement>())
121
+ // Set when a change originates from the keyboard or the menu, so focus
122
+ // follows the selection once the parent has re-rendered with it.
123
+ const focusAfterChange = useRef<string | null>(null)
124
+
125
+ const [strip, setStrip] = useState<StripState>(INITIAL_STRIP_STATE)
126
+
127
+ const measure = useCallback(() => {
128
+ const container = containerRef.current
129
+ const row = rowRef.current
130
+ if (container == null || row == null) return
131
+
132
+ const next = computeStripState({
133
+ rowWidth: row.scrollWidth,
134
+ containerWidth: container.clientWidth,
135
+ triggerWidth: TRIGGER_SIZE_PX,
136
+ triggerGap: TRIGGER_GAP_PX,
137
+ scrollLeft: viewportRef.current?.scrollLeft ?? 0,
138
+ })
139
+
140
+ setStrip((prev) =>
141
+ prev.overflowing === next.overflowing &&
142
+ prev.viewportWidth === next.viewportWidth &&
143
+ prev.atStart === next.atStart &&
144
+ prev.atEnd === next.atEnd
145
+ ? prev
146
+ : next
147
+ )
148
+ }, [])
149
+
150
+ useIsoLayoutEffect(() => {
151
+ measure()
152
+
153
+ const container = containerRef.current
154
+ const row = rowRef.current
155
+ if (container == null || row == null || typeof ResizeObserver === 'undefined') return
156
+
157
+ // The row is observed alongside the container because most of what changes
158
+ // the strip's width is content, not layout: a label being translated, an
159
+ // error badge appearing mid-validation, a conditional tab unmounting. None
160
+ // of those resize the container at all.
161
+ const observer = new ResizeObserver(() => measure())
162
+ observer.observe(container)
163
+ observer.observe(row)
164
+ return () => observer.disconnect()
165
+ }, [measure, tabs])
166
+
167
+ const reveal = useCallback((name: string) => {
168
+ const viewport = viewportRef.current
169
+ const row = rowRef.current
170
+ const tab = tabRefs.current.get(name)
171
+ if (viewport == null || row == null || tab == null) return
172
+
173
+ const next = computeRevealScroll({
174
+ viewportWidth: viewport.clientWidth,
175
+ scrollLeft: viewport.scrollLeft,
176
+ tabOffsetLeft: tab.offsetLeft,
177
+ tabWidth: tab.offsetWidth,
178
+ rowWidth: row.scrollWidth,
179
+ })
180
+ // `null` means the tab is already visible. Writing the current offset back
181
+ // would interrupt a scroll the reader is in the middle of.
182
+ if (next == null) return
183
+
184
+ // Scrolling the viewport directly rather than via `scrollIntoView`, which
185
+ // walks up the ancestor chain and would move the page vertically as well.
186
+ viewport.scrollTo?.({ left: next, behavior: prefersReducedMotion() ? 'auto' : 'smooth' })
187
+ }, [])
188
+
189
+ useEffect(() => {
190
+ const pending = focusAfterChange.current
191
+ if (pending === activeTab) {
192
+ focusAfterChange.current = null
193
+ // `preventScroll` because the browser's own scroll-on-focus would scroll
194
+ // ancestors too; `reveal` below moves only the strip.
195
+ tabRefs.current.get(activeTab)?.focus({ preventScroll: true })
196
+ }
197
+ reveal(activeTab)
198
+ }, [activeTab, reveal])
199
+
200
+ const select = useCallback(
201
+ (name: string, moveFocus: boolean) => {
202
+ if (moveFocus) focusAfterChange.current = name
203
+ onChange(name)
204
+ },
205
+ [onChange]
206
+ )
207
+
208
+ // ─── Drag-to-scroll ────────────────────────────────────────────────────
209
+ //
210
+ // Touch scrolls the viewport natively; this exists for mouse users, who
211
+ // otherwise have no way to reach an off-screen tab but the menu — a plain
212
+ // wheel has no horizontal axis.
213
+
214
+ const dragRef = useRef<DragState | null>(null)
215
+ // When the last drag ended. A drag finishing over a tab would otherwise
216
+ // select it, and a timestamp expires on its own where a flag would sit armed
217
+ // waiting for a click that a drag released off-strip never produces.
218
+ const dragEndedAtRef = useRef<number | null>(null)
219
+ const [dragging, setDragging] = useState(false)
220
+
221
+ const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
222
+ const viewport = viewportRef.current
223
+ if (viewport == null) return
224
+ if (
225
+ shouldStartDrag({
226
+ pointerType: event.pointerType,
227
+ button: event.button,
228
+ overflowing: strip.overflowing,
229
+ }) === false
230
+ ) {
231
+ return
232
+ }
233
+ dragRef.current = {
234
+ origin: { pointerX: event.clientX, scrollLeft: viewport.scrollLeft },
235
+ dragging: false,
236
+ }
237
+ }
238
+
239
+ const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
240
+ const state = dragRef.current
241
+ const viewport = viewportRef.current
242
+ const row = rowRef.current
243
+ if (state == null || viewport == null || row == null) return
244
+
245
+ const maxScroll = Math.max(0, row.scrollWidth - viewport.clientWidth)
246
+ const next = advanceDrag(state, event.clientX, maxScroll)
247
+ // Still inside the threshold — leave the viewport alone so the gesture can
248
+ // still resolve as a click.
249
+ if (next.scrollLeft == null) return
250
+
251
+ if (state.dragging === false) {
252
+ state.dragging = true
253
+ setDragging(true)
254
+ // Capture so the drag survives the pointer leaving the strip, which is
255
+ // most of the point: you can pull well past the edge and keep going.
256
+ viewport.setPointerCapture?.(event.pointerId)
257
+ }
258
+ viewport.scrollLeft = next.scrollLeft
259
+ }
260
+
261
+ const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
262
+ const state = dragRef.current
263
+ if (state == null) return
264
+ dragRef.current = null
265
+ if (state.dragging === false) return
266
+
267
+ setDragging(false)
268
+ viewportRef.current?.releasePointerCapture?.(event.pointerId)
269
+ dragEndedAtRef.current = performance.now()
270
+ }
271
+
272
+ const handleClickCapture = (event: MouseEvent<HTMLDivElement>) => {
273
+ if (shouldSuppressClick(dragEndedAtRef.current, performance.now()) === false) return
274
+ dragEndedAtRef.current = null
275
+ event.stopPropagation()
276
+ event.preventDefault()
277
+ }
278
+
279
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
280
+ const current = tabs.findIndex((tab) => tab.name === activeTab)
281
+ if (current < 0 || tabs.length === 0) return
282
+
283
+ let nextIndex: number
284
+ switch (event.key) {
285
+ case 'ArrowRight':
286
+ nextIndex = (current + 1) % tabs.length
287
+ break
288
+ case 'ArrowLeft':
289
+ nextIndex = (current - 1 + tabs.length) % tabs.length
290
+ break
291
+ case 'Home':
292
+ nextIndex = 0
293
+ break
294
+ case 'End':
295
+ nextIndex = tabs.length - 1
296
+ break
297
+ default:
298
+ return
299
+ }
300
+
301
+ event.preventDefault()
302
+ const next = tabs[nextIndex]
303
+ if (next == null || next.name === activeTab) return
304
+ select(next.name, true)
305
+ }
306
+
307
+ const sizing = {
308
+ '--byline-admin-tabs-trigger-size': `${TRIGGER_SIZE_PX}px`,
309
+ '--byline-admin-tabs-trigger-gap': `${TRIGGER_GAP_PX}px`,
310
+ } as CSSProperties
311
+
50
312
  return (
51
313
  <div
52
- role="tablist"
53
- aria-label={t('presentation.formTabsAriaLabel')}
314
+ ref={containerRef}
315
+ style={sizing}
54
316
  className={cx('byline-admin-tabs', styles.tabs, className)}
55
317
  >
56
- {tabs.map((tab) => {
57
- const isActive = tab.name === activeTab
58
- return (
59
- <button
60
- key={tab.name}
61
- type="button"
62
- role="tab"
63
- aria-selected={isActive}
64
- onClick={() => onChange(tab.name)}
65
- className={cx(
66
- 'byline-admin-tab',
67
- styles.tab,
68
- isActive && ['byline-admin-tab-active', styles['tab-active']]
69
- )}
318
+ <div
319
+ ref={viewportRef}
320
+ onScroll={measure}
321
+ onPointerDown={handlePointerDown}
322
+ onPointerMove={handlePointerMove}
323
+ onPointerUp={handlePointerEnd}
324
+ onPointerCancel={handlePointerEnd}
325
+ onClickCapture={handleClickCapture}
326
+ data-at-start={strip.atStart}
327
+ data-at-end={strip.atEnd}
328
+ data-draggable={strip.overflowing}
329
+ data-dragging={dragging}
330
+ className={cx('byline-admin-tabs-viewport', styles.viewport)}
331
+ >
332
+ <div
333
+ ref={rowRef}
334
+ role="tablist"
335
+ aria-label={t('presentation.formTabsAriaLabel')}
336
+ onKeyDown={handleKeyDown}
337
+ className={cx('byline-admin-tablist', styles.tablist)}
338
+ >
339
+ {tabs.map((tab) => {
340
+ const isActive = tab.name === activeTab
341
+ const errorCount = errorCounts?.[tab.name] ?? 0
342
+ return (
343
+ <button
344
+ key={tab.name}
345
+ ref={(node) => {
346
+ if (node == null) tabRefs.current.delete(tab.name)
347
+ else tabRefs.current.set(tab.name, node)
348
+ }}
349
+ type="button"
350
+ role="tab"
351
+ id={tabTriggerId(idBase, tab.name)}
352
+ aria-controls={tabPanelId(idBase, tab.name)}
353
+ aria-selected={isActive}
354
+ // Roving tabindex: the strip is one stop in the tab order, and
355
+ // the arrow keys move within it.
356
+ tabIndex={isActive ? 0 : -1}
357
+ onClick={() => select(tab.name, false)}
358
+ onFocus={() => reveal(tab.name)}
359
+ className={cx(
360
+ 'byline-admin-tab',
361
+ styles.tab,
362
+ isActive && ['byline-admin-tab-active', styles['tab-active']]
363
+ )}
364
+ >
365
+ <span className={cx('byline-admin-tab-label', styles.label)}>
366
+ {tab.label}
367
+ {errorCount > 0 && (
368
+ <Badge intent="danger" className={cx('byline-admin-tab-badge', styles.badge)}>
369
+ {errorCount}
370
+ </Badge>
371
+ )}
372
+ </span>
373
+ </button>
374
+ )
375
+ })}
376
+ </div>
377
+ </div>
378
+
379
+ {strip.overflowing && (
380
+ <Dropdown.Root>
381
+ <Dropdown.Trigger
382
+ aria-label={t('presentation.formTabsAllTabsLabel')}
383
+ className={cx('byline-admin-tabs-overflow-trigger', styles['overflow-trigger'])}
70
384
  >
71
- <span className={cx('byline-admin-tab-label', styles.label)}>
72
- {tab.label}
73
- {(errorCounts?.[tab.name] ?? 0) > 0 && (
74
- <Badge intent="danger" className={cx('byline-admin-tab-badge', styles.badge)}>
75
- {errorCounts?.[tab.name]}
76
- </Badge>
77
- )}
78
- </span>
79
- </button>
80
- )
81
- })}
385
+ <EllipsisIcon
386
+ className={cx('byline-admin-tabs-overflow-icon', styles['overflow-icon'])}
387
+ />
388
+ </Dropdown.Trigger>
389
+ <Dropdown.Portal>
390
+ <Dropdown.Content
391
+ sideOffset={6}
392
+ align="end"
393
+ className={cx('byline-admin-tabs-overflow-menu', styles['overflow-menu'])}
394
+ >
395
+ {tabs.map((tab) => {
396
+ const errorCount = errorCounts?.[tab.name] ?? 0
397
+ return (
398
+ <Dropdown.Item
399
+ key={tab.name}
400
+ onClick={() => select(tab.name, true)}
401
+ data-active={tab.name === activeTab}
402
+ className={cx('byline-admin-tabs-overflow-item', styles['overflow-item'])}
403
+ >
404
+ {tab.label}
405
+ {errorCount > 0 && (
406
+ <Badge intent="danger" className={cx('byline-admin-tab-badge', styles.badge)}>
407
+ {errorCount}
408
+ </Badge>
409
+ )}
410
+ </Dropdown.Item>
411
+ )
412
+ })}
413
+ </Dropdown.Content>
414
+ </Dropdown.Portal>
415
+ </Dropdown.Root>
416
+ )}
82
417
  </div>
83
418
  )
84
419
  }
@@ -1,37 +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
- * Guard against `publishConfig.exports` drift.
10
- *
11
- * `@byline/admin` carries a `publishConfig.exports` block that npm uses to
12
- * **override** the top-level `exports` at publish time. The workspace and dev
13
- * builds resolve through the top-level `exports` (or source), so a subpath
14
- * added there but forgotten in `publishConfig.exports` typechecks and builds
15
- * locally yet is **missing from the published package** — surfacing only as a
16
- * downstream consumer's build error ("X is not exported …"). That is exactly
17
- * how `./admin-activity` slipped through in v3.11.0.
18
- *
19
- * This test fails the moment the two blocks drift, so the gap is caught in
20
- * `pnpm test` / CI rather than in a production Docker build.
21
- */
22
- import { readFileSync } from 'node:fs';
23
- import { dirname, resolve } from 'node:path';
24
- import { fileURLToPath } from 'node:url';
25
- import { describe, expect, it } from 'vitest';
26
- const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '../package.json');
27
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
28
- describe('package.json export parity', () => {
29
- it('every top-level export subpath is also declared in publishConfig.exports', () => {
30
- const publishExports = pkg.publishConfig?.exports;
31
- // If there is no override, the top-level exports ship as-is — nothing to check.
32
- if (publishExports == null)
33
- return;
34
- const missing = Object.keys(pkg.exports).filter((key) => !(key in publishExports));
35
- expect(missing, `publishConfig.exports is missing subpath(s) present in the top-level exports: ${missing.join(', ')}. The published package would not expose them — add them to BOTH blocks.`).toEqual([]);
36
- });
37
- });