/**
* This Source Code is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) Infonomic Company Limited
*/
import { act } from 'react'
import { adminTranslations } from '@byline/i18n/admin'
import { I18nProvider } from '@byline/i18n/react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Stub the uikit surface this component touches. The overflow menu is rendered
// open and inline so the assertions can read its items: base-ui's Menu portals
// into a floating layer that jsdom cannot position, and the contract under test
// is what AdminTabs puts *into* the menu, not base-ui's popup behaviour.
vi.mock('@byline/ui/react', () => ({
Badge: ({ children, className }: { children?: React.ReactNode; className?: string }) => (
{children}
),
EllipsisIcon: ({ className }: { className?: string }) => (
),
Dropdown: {
Root: ({ children }: { children?: React.ReactNode }) => <>{children}>,
Trigger: ({ children, ...rest }: any) => (
),
Portal: ({ children }: { children?: React.ReactNode }) => <>{children}>,
Content: ({ children, ...rest }: any) =>
{children}
,
Item: ({ children, onClick, ...rest }: any) => (
),
},
}))
// biome-ignore lint/correctness/useImportExtensions: package TS resolves via tsconfig paths
import { AdminTabs } from './tabs'
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
/**
* jsdom performs no layout, so every width is 0 and the strip would never
* report overflow. Drive the two widths the component reads off its stable
* override handles.
*/
const layout = { container: 0, row: 0, rowReads: 0 }
function installPointerStubs() {
// jsdom implements neither pointer capture nor scrollTo; the component guards
// both, and these stubs let the drag path run end to end in a test.
HTMLElement.prototype.setPointerCapture = () => {}
HTMLElement.prototype.releasePointerCapture = () => {}
HTMLElement.prototype.hasPointerCapture = () => false
Object.defineProperty(HTMLElement.prototype, 'scrollLeft', {
configurable: true,
get(this: HTMLElement & { _scrollLeft?: number }) {
return this._scrollLeft ?? 0
},
set(this: HTMLElement & { _scrollLeft?: number }, value: number) {
this._scrollLeft = value
},
})
}
/** jsdom has no PointerEvent constructor, so synthesise one from MouseEvent. */
function pointerEvent(
type: string,
init: { clientX?: number; pointerType?: string; button?: number }
) {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
clientX: init.clientX ?? 0,
button: init.button ?? 0,
})
Object.defineProperty(event, 'pointerType', { value: init.pointerType ?? 'mouse' })
Object.defineProperty(event, 'pointerId', { value: 1 })
return event
}
function installLayoutStubs() {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get(this: HTMLElement) {
return this.classList.contains('byline-admin-tabs') ? layout.container : 0
},
})
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
configurable: true,
get(this: HTMLElement) {
if (!this.classList.contains('byline-admin-tablist')) return 0
// Counted, not just answered: in a browser this getter is what forces
// the synchronous layout, so the count is the cost under test.
layout.rowReads += 1
return layout.row
},
})
}
const resizeCallbacks: ResizeObserverCallback[] = []
const observed: Element[] = []
class StubResizeObserver {
constructor(cb: ResizeObserverCallback) {
resizeCallbacks.push(cb)
}
observe(target: Element) {
observed.push(target)
}
unobserve() {}
disconnect() {}
}
const TABS = [
{ name: 'details', label: 'Details' },
{ name: 'classification', label: 'Classification' },
{ name: 'bibliographic', label: 'Bibliographic' },
{ name: 'abstract', label: 'Abstract' },
]
describe('AdminTabs', () => {
let container: HTMLDivElement
let root: Root
let onChange: ReturnType
beforeEach(() => {
installLayoutStubs()
installPointerStubs()
;(globalThis as any).ResizeObserver = StubResizeObserver
resizeCallbacks.length = 0
observed.length = 0
layout.container = 1000
layout.row = 400
layout.rowReads = 0
onChange = vi.fn()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
})
const render = (props: Partial> = {}) => {
act(() => {
root.render(
// The real English admin bundle rather than a stubbed `t`, so a renamed
// or deleted key fails the test rather than passing silently.
)
})
}
const tabButtons = () => Array.from(container.querySelectorAll('[role="tab"]'))
const trigger = () =>
container.querySelector('.byline-admin-tabs-overflow-trigger')
const menuItems = () => Array.from(container.querySelectorAll('[role="menuitem"]'))
it('renders every tab as a tab button', () => {
render()
expect(tabButtons().map((b) => b.textContent)).toEqual([
'Details',
'Classification',
'Bibliographic',
'Abstract',
])
})
it('marks the active tab as selected', () => {
render({ activeTab: 'bibliographic' })
const selected = tabButtons().filter((b) => b.getAttribute('aria-selected') === 'true')
expect(selected.map((b) => b.textContent)).toEqual(['Bibliographic'])
})
it('does not render the overflow trigger when the row fits', () => {
layout.row = 400
render()
expect(trigger()).toBeNull()
})
it('renders the overflow trigger when the row does not fit', () => {
layout.row = 1600
render()
expect(trigger()).not.toBeNull()
})
/**
* The menu is a jump list, not a remainder list. Listing only the tabs that
* happen to be scrolled out would make the menu's contents shift under the
* reader as they scroll.
*/
it('lists every tab in the overflow menu, not only the hidden ones', () => {
layout.row = 1600
render()
expect(menuItems().map((i) => i.textContent)).toEqual([
'Details',
'Classification',
'Bibliographic',
'Abstract',
])
})
it('labels the overflow trigger for assistive technology', () => {
layout.row = 1600
render()
expect(trigger()?.getAttribute('aria-label')).toBe('All tabs')
})
it('selects a tab chosen from the overflow menu', () => {
layout.row = 1600
render()
act(() => {
menuItems()[2].click()
})
expect(onChange).toHaveBeenCalledWith('bibliographic')
})
it('keeps the overflow trigger outside the tablist', () => {
layout.row = 1600
render()
expect(trigger()?.closest('[role="tablist"]')).toBeNull()
})
/**
* The roving tabindex covers the tablist only. The overflow trigger is a
* sibling control, not a tab, so it keeps its own place in the tab order —
* folding it into the roving set would make it unreachable whenever the
* active tab is not adjacent to it.
*/
it('leaves the overflow trigger independently reachable from the keyboard', () => {
layout.row = 1600
render()
expect(trigger()?.tabIndex).not.toBe(-1)
})
it('gives the tablist a single tab stop', () => {
render({ activeTab: 'classification' })
const focusable = tabButtons().filter((b) => b.tabIndex === 0)
expect(focusable.map((b) => b.textContent)).toEqual(['Classification'])
})
it('moves selection to the next tab on ArrowRight', () => {
render({ activeTab: 'classification' })
act(() => {
tabButtons()[1].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
)
})
expect(onChange).toHaveBeenCalledWith('bibliographic')
})
it('moves selection to the previous tab on ArrowLeft', () => {
render({ activeTab: 'classification' })
act(() => {
tabButtons()[1].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
)
})
expect(onChange).toHaveBeenCalledWith('details')
})
it('wraps from the first tab to the last on ArrowLeft', () => {
render({ activeTab: 'details' })
act(() => {
tabButtons()[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
)
})
expect(onChange).toHaveBeenCalledWith('abstract')
})
it('jumps to the first tab on Home', () => {
render({ activeTab: 'bibliographic' })
act(() => {
tabButtons()[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }))
})
expect(onChange).toHaveBeenCalledWith('details')
})
it('jumps to the last tab on End', () => {
render({ activeTab: 'details' })
act(() => {
tabButtons()[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }))
})
expect(onChange).toHaveBeenCalledWith('abstract')
})
it('associates each tab with its panel', () => {
render()
expect(tabButtons()[1].getAttribute('aria-controls')).toBe('tabset:main-panel-classification')
expect(tabButtons()[1].id).toBe('tabset:main-tab-classification')
})
it('shows the error badge for a tab that has errors', () => {
render({ errorCounts: { bibliographic: 2 } })
expect(tabButtons()[2].textContent).toBe('Bibliographic2')
})
it('re-measures when the observed elements resize', () => {
layout.row = 400
render()
expect(trigger()).toBeNull()
layout.row = 1600
act(() => {
for (const cb of resizeCallbacks) cb([], {} as ResizeObserver)
})
expect(trigger()).not.toBeNull()
})
/**
* Both the container and the tab row are observed. A label change, a badge
* appearing, or a conditional tab unmounting alters the row's width without
* the container's changing at all, so watching only the container would miss
* every content-driven overflow change.
*/
it('observes the tab row as well as the container', () => {
render()
const observedClasses = observed.map((el) => el.className)
expect(observedClasses.some((c) => c.includes('byline-admin-tabs-viewport'))).toBe(false)
expect(observedClasses.some((c) => c.includes('byline-admin-tablist'))).toBe(true)
expect(observedClasses.some((c) => c.includes('byline-admin-tabs '))).toBe(true)
})
/**
* The tab array arrives with a fresh identity on every render of the form
* layout: `useFormTabs.resolve()` filters `set.tabs` each time it is called,
* and the live form data it filters against updates per keystroke. Keying the
* measurement effect on that identity made every unrelated render tear down
* the ResizeObserver and force a synchronous layout read — measured in Chrome
* against a seeded `pages` document as one forced read per keystroke, and two
* on an editor mount.
*/
it('does not re-measure when the parent re-renders with an equal tab array', () => {
render()
const readsAfterMount = layout.rowReads
const observedAfterMount = observed.length
render({ tabs: TABS.map((tab) => ({ ...tab })) })
expect(layout.rowReads).toBe(readsAfterMount)
expect(observed.length).toBe(observedAfterMount)
})
it('re-measures when a tab is added or removed', () => {
render()
const readsAfterMount = layout.rowReads
render({ tabs: [...TABS, { name: 'indexing', label: 'Indexing' }] })
expect(layout.rowReads).toBeGreaterThan(readsAfterMount)
})
/**
* A label carries the width, so a translation change has to re-measure even
* though the tab names are untouched.
*/
it('re-measures when a label changes', () => {
render()
const readsAfterMount = layout.rowReads
render({ tabs: TABS.map((tab, i) => (i === 0 ? { ...tab, label: 'Détails' } : tab)) })
expect(layout.rowReads).toBeGreaterThan(readsAfterMount)
})
// ─── Drag-to-scroll (desktop) ─────────────────────────────────────────
const viewport = () =>
container.querySelector('.byline-admin-tabs-viewport') as HTMLElement
const drag = (from: number, to: number, pointerType = 'mouse') => {
const vp = viewport()
act(() => {
vp.dispatchEvent(pointerEvent('pointerdown', { clientX: from, pointerType }))
vp.dispatchEvent(pointerEvent('pointermove', { clientX: to, pointerType }))
vp.dispatchEvent(pointerEvent('pointerup', { clientX: to, pointerType }))
})
}
it('scrolls the strip when dragged with the mouse', () => {
layout.row = 1600
render()
viewport().scrollLeft = 100
drag(300, 240)
expect(viewport().scrollLeft).toBe(160)
})
it('leaves touch dragging to the browser', () => {
layout.row = 1600
render()
viewport().scrollLeft = 100
drag(300, 240, 'touch')
expect(viewport().scrollLeft).toBe(100)
})
it('does not drag a strip that has no overflow', () => {
layout.row = 400
render()
viewport().scrollLeft = 0
drag(300, 240)
expect(viewport().scrollLeft).toBe(0)
})
/**
* The whole reason for the movement threshold: a plain click must still
* select its tab.
*/
it('still selects a tab on a click that never became a drag', () => {
layout.row = 1600
render()
const vp = viewport()
act(() => {
vp.dispatchEvent(pointerEvent('pointerdown', { clientX: 300 }))
vp.dispatchEvent(pointerEvent('pointermove', { clientX: 302 }))
vp.dispatchEvent(pointerEvent('pointerup', { clientX: 302 }))
tabButtons()[2].click()
})
expect(onChange).toHaveBeenCalledWith('bibliographic')
})
it('does not select a tab on the click that ends a drag', () => {
layout.row = 1600
render()
drag(300, 240)
act(() => {
tabButtons()[2].click()
})
expect(onChange).not.toHaveBeenCalled()
})
/**
* The case that a consume-once flag gets wrong. A drag released outside the
* strip produces no click at all, so nothing arrives to clear the flag, and
* the reader's next deliberate click on a tab would be swallowed instead.
*/
it('selects normally on a later click when the drag produced no click', () => {
layout.row = 1600
render()
drag(300, 240)
// No click followed the drag. Time passes, then the reader clicks a tab.
const now = performance.now()
const clock = vi.spyOn(performance, 'now').mockReturnValue(now + 5000)
act(() => {
tabButtons()[2].click()
})
clock.mockRestore()
expect(onChange).toHaveBeenCalledWith('bibliographic')
})
/** The suppression is one-shot — the next click must select normally. */
it('selects again on the click after the drag-ending one', () => {
layout.row = 1600
render()
drag(300, 240)
act(() => {
tabButtons()[2].click()
tabButtons()[1].click()
})
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith('classification')
})
})