@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.
@@ -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
  }