Files
deepseek-harness/packages/client/ui-plugin-manager/tests/header-scroll.client.spec.ts
T
Yif 3c26689899 fix(plugin-manager): make sticky header scrolling smooth
让插件主页面的吸顶过渡随滚动连续到位,而不是滚过 8px 后突然重排。

- 标题栏与列表共用滚动容器,保留展开态占位,避免收缩改变滚动范围
- 用不可交互的临时布局副本测量展开态和吸顶态,在同一组控件上按滚动距离更新位移和宽度
- 保持控件身份与焦点,补偿占空间的滚动条,防止水平偏移和临界位置来回抖动
- 小屏优先将 Tabs 与操作按钮排成一行,放不下时固定 Tabs 在上、按钮在下,吸顶前后不换序
- 仅 macOS 收起侧栏的吸顶态采用窗口安全区外 8px 留白,不再限制宽度;其他平台和状态不变
- 补充端点布局、滚动往返、短列表、滚动条和平台隔离回归

本次到插件主页面的吸顶动画与布局优化为止。实验性插件相关页面的吸顶体验留待后续单独处理。
2026-10-21 16:09:06 +08:00

391 lines
17 KiB
TypeScript

// @vitest-environment jsdom
/** Deterministic layout endpoints exercise scroll geometry without jsdom's absent layout engine. */
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { createPluginHeaderRef } from '../src/client/header-scroll.ts'
type PartName = 'title' | 'intro' | 'toolbar' | 'tabs'
interface Box { x: number; y: number; width: number; height: number }
interface Layout {
width: number
resting: { height: number; parts: Partial<Record<PartName, Box>> }
compact: { height: number; parts: Partial<Record<PartName, Box>> }
}
function box(x: number, y: number, width: number, height: number): Box {
return { x, y, width, height }
}
function wide(tabs = true): Layout {
return {
width: 960,
resting: {
height: tabs ? 140 : 90,
parts: {
title: box(0, 28, 120, 28), intro: box(0, 60, 320, 20), toolbar: box(600, 28, 220, 32),
...tabs ? { tabs: box(0, 100, 240, 30) } : {},
},
},
compact: {
height: 64,
parts: {
title: box(0, 16, 120, 28), intro: box(0, 0, 0, 0), toolbar: box(600, 16, 220, 32),
...tabs ? { tabs: box(0, 16, 180, 30) } : {},
},
},
}
}
function narrow(): Layout {
return {
width: 320,
resting: {
height: 210,
parts: {
title: box(0, 28, 120, 28), intro: box(0, 60, 280, 20),
toolbar: box(0, 90, 280, 32), tabs: box(0, 160, 280, 30),
},
},
compact: {
height: 70,
parts: {
title: box(0, 16, 120, 28), intro: box(0, 0, 0, 0),
toolbar: box(200, 16, 80, 32), tabs: box(0, 16, 160, 30),
},
},
}
}
function harness() {
const fontsDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts')
const fonts = new EventTarget()
const releases: (() => void)[] = []
onTestFinished(() => {
for (const release of releases.reverse()) release()
if (fontsDescriptor === undefined) Reflect.deleteProperty(document, 'fonts')
else Object.defineProperty(document, 'fonts', fontsDescriptor)
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
Object.defineProperty(document, 'fonts', { configurable: true, value: fonts })
const resizes: ControlledResize[] = []
const mutations: ControlledMutation[] = []
class ControlledResize implements ResizeObserver {
observe = vi.fn<(target: Element, options?: ResizeObserverOptions) => void>()
unobserve = vi.fn<(target: Element) => void>()
disconnect = vi.fn<() => void>()
constructor(private readonly callback: ResizeObserverCallback) { resizes.push(this) }
deliver(): void { this.callback([], this) }
}
class ControlledMutation implements MutationObserver {
observe = vi.fn<(target: Node, options?: MutationObserverInit) => void>()
disconnect = vi.fn<() => void>()
takeRecords(): MutationRecord[] { return [] }
constructor(private readonly callback: MutationCallback) { mutations.push(this) }
deliver(): void { this.callback([], this) }
}
vi.stubGlobal('ResizeObserver', ControlledResize)
vi.stubGlobal('MutationObserver', ControlledMutation)
function fixture(initial: Layout = wide(), ref = createPluginHeaderRef()) {
const body = document.createElement('div')
const bar = document.createElement('div')
const header = document.createElement('header')
const content = document.createElement('div')
const index = releases.length
const elements = new Map<PartName, HTMLDivElement>()
for (const name of Object.keys(initial.resting.parts) as PartName[]) {
const part = document.createElement('div')
part.dataset.pluginHeaderPart = name
part.id = `header-${index}-${name}`
if (name === 'toolbar' || name === 'intro') {
const button = document.createElement('button')
button.id = `header-${index}-${name}-button`
button.textContent = name
part.append(button)
} else part.textContent = name
header.append(part)
elements.set(name, part)
}
bar.append(header)
content.textContent = 'Plugin cards'
body.append(bar, content)
document.body.append(body)
releases.push(() => { ref(null); body.remove() })
const state = { layout: initial }
const clones: HTMLDivElement[] = []
const measurements: {
compact: boolean
inert: boolean
ariaHidden: string | null
ids: number
styles: number
natural: boolean
scrolled: boolean
connected: boolean
width: string
}[] = []
vi.spyOn(bar, 'getBoundingClientRect').mockImplementation(() => new DOMRect(50, 100, state.layout.width, state.layout.resting.height))
const cloneNode = bar.cloneNode.bind(bar)
vi.spyOn(bar, 'cloneNode').mockImplementation((deep) => {
const clone = cloneNode(deep) as HTMLDivElement
clones.push(clone)
const cloneHeader = clone.querySelector('header')!
// A different viewport origin ensures only header-relative coordinates drive the live controls.
vi.spyOn(cloneHeader, 'getBoundingClientRect').mockImplementation(() => {
measurements.push({
compact: clone.hasAttribute('data-compact'), inert: clone.inert,
ariaHidden: clone.getAttribute('aria-hidden'), ids: clone.querySelectorAll('[id]').length,
styles: clone.querySelectorAll('[data-plugin-header-part][style]').length,
natural: clone.hasAttribute('data-natural'), scrolled: clone.hasAttribute('data-scrolled'),
connected: clone.isConnected, width: clone.style.width,
})
return new DOMRect(180, 240, state.layout.width, 0)
})
vi.spyOn(clone, 'getBoundingClientRect').mockImplementation(() =>
new DOMRect(180, 240, state.layout.width, endpoint().height))
function endpoint() { return clone.hasAttribute('data-compact') ? state.layout.compact : state.layout.resting }
for (const name of elements.keys()) {
const part = clone.querySelector<HTMLElement>(`[data-plugin-header-part="${name}"]`)!
vi.spyOn(part, 'getBoundingClientRect').mockImplementation(() => {
const bounds = endpoint().parts[name]!
return new DOMRect(180 + bounds.x, 240 + bounds.y, bounds.width, bounds.height)
})
}
return clone
})
function part(name: PartName): HTMLDivElement {
const element = elements.get(name)
if (element === undefined) throw new Error(`Fixture has no ${name}`)
return element
}
function scroll(top: number): void {
body.scrollTop = top
body.dispatchEvent(new Event('scroll'))
}
return { body, bar, header, content, state, clones, measurements, part, scroll, ref, attach: () => { ref(bar) } }
}
return { fonts, resizes, mutations, fixture }
}
describe('plugin header scroll geometry', () => {
it.each([0, 12, 600])('clamps each trajectory and reverses without using a %ipx scroll range', (range) => {
const h = harness(), f = h.fixture()
Object.defineProperties(f.body, { clientHeight: { value: 500 }, scrollHeight: { value: 500 + range } })
f.attach()
expect(f.bar.style.getPropertyValue('--plugin-header-travel')).toBe('84px')
expect(f.bar.style.getPropertyValue('--plugin-header-rest-height')).toBe('140px')
expect(f.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('72px')
f.scroll(6)
expect(f.part('toolbar').style.translate).toBe('0px -6px')
expect(f.part('tabs').style.translate).toBe('0px -6px')
expect(f.bar.hasAttribute('data-compact')).toBe(false)
expect(f.bar.hasAttribute('data-scrolled')).toBe(true)
f.scroll(20)
expect(f.part('toolbar').style.translate).toBe('0px -12px')
expect(f.part('tabs').style.translate).toBe('0px -20px')
expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('120px')
f.scroll(83)
expect(f.bar.hasAttribute('data-compact')).toBe(false)
f.scroll(84)
expect(f.bar.hasAttribute('data-compact')).toBe(true)
expect(f.part('title').style.translate).toBe('0px -56px')
expect(f.part('intro').style.translate).toBe('0px -80px')
expect(f.part('tabs').style.translate).toBe('0px -84px')
f.scroll(1_000)
expect(f.part('tabs').style.translate).toBe('0px -84px')
expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('64px')
f.scroll(6)
expect(f.part('toolbar').style.translate).toBe('0px -6px')
expect(f.part('intro').inert).toBe(false)
expect(f.bar.hasAttribute('data-compact')).toBe(false)
f.scroll(-10)
expect(f.part('tabs').style.translate).toBe('0px 0px')
expect(f.bar.hasAttribute('data-scrolled')).toBe(false)
expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('140px')
expect(f.bar.style.getPropertyValue('--plugin-header-rest-height')).toBe('140px')
expect(f.body.scrollHeight - f.body.clientHeight).toBe(range)
expect(f.content.style.cssText).toBe('')
})
it('accounts for a layout scrollbar without retaining its sizing property after detachment', () => {
const h = harness(), f = h.fixture()
Object.defineProperties(f.body, { offsetWidth: { value: 960 }, clientWidth: { value: 955 } })
f.attach()
expect(f.body.style.getPropertyValue('--plugin-page-scrollbar-width')).toBe('5px')
f.ref(null)
expect(f.body.style.getPropertyValue('--plugin-page-scrollbar-width')).toBe('')
})
it('interpolates narrow wrapped controls to their measured horizontal and width endpoints', () => {
const h = harness(), f = h.fixture(narrow())
f.attach()
expect(f.part('toolbar').style.getPropertyValue('--plugin-part-y')).toBe('90px')
expect(f.part('toolbar').style.getPropertyValue('--plugin-part-width')).toBe('280px')
f.scroll(37)
expect(f.part('toolbar').style.translate).toBe('100px -37px')
expect(f.part('toolbar').style.width).toBe('180px')
f.scroll(144)
expect(f.part('toolbar').style.translate).toBe('200px -74px')
expect(f.part('toolbar').style.width).toBe('80px')
expect(f.part('tabs').style.translate).toBe('0px -144px')
expect(f.part('tabs').style.width).toBe('160px')
expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('70px')
f.scroll(0)
expect(f.part('toolbar').style.translate).toBe('0px 0px')
expect(f.part('toolbar').style.width).toBe('280px')
expect(f.part('tabs').style.width).toBe('280px')
})
it('clips the no-tabs intro beneath the pinned title and restores interaction when scrolling back', () => {
const h = harness(), f = h.fixture(wide(false))
f.attach()
f.scroll(16)
expect(f.part('title').style.translate).toBe('0px -12px')
expect(f.part('intro').style.clipPath).toBe('inset(0% 0 0)')
expect(f.part('intro').inert).toBe(false)
f.scroll(26)
expect(f.part('intro').style.translate).toBe('0px -26px')
expect(f.part('intro').style.clipPath).toBe('inset(50% 0 0)')
expect(f.part('intro').inert).toBe(false)
f.scroll(36)
expect(f.part('intro').style.clipPath).toBe('inset(100% 0 0)')
expect(f.part('intro').inert).toBe(true)
expect(f.bar.hasAttribute('data-compact')).toBe(true)
f.scroll(0)
expect(f.part('intro').style.clipPath).toBe('inset(0% 0 0)')
expect(f.part('intro').inert).toBe(false)
})
it('scrolls the tabs intro fully out without the no-tabs clipping treatment', () => {
const h = harness(), f = h.fixture()
f.part('intro').style.clipPath = 'inset(100% 0 0)'
f.attach()
f.scroll(79)
expect(f.part('intro').style.clipPath).toBe('')
expect(f.part('intro').inert).toBe(false)
f.scroll(80)
expect(f.part('intro').style.translate).toBe('0px -80px')
expect(f.part('intro').inert).toBe(true)
f.scroll(40)
expect(f.part('intro').inert).toBe(false)
expect(f.part('intro').style.translate).toBe('0px -40px')
})
it.each(['resize', 'mutation', 'font'] as const)('remeasures changed endpoints on %s without replacing focused controls', (event) => {
const h = harness(), f = h.fixture()
const button = f.part('toolbar').querySelector('button')!
const clicked = vi.fn()
button.addEventListener('click', clicked)
f.attach()
f.scroll(144)
button.focus()
f.state.layout = narrow()
if (event === 'resize') h.resizes[0]!.deliver()
else if (event === 'mutation') h.mutations[0]!.deliver()
else h.fonts.dispatchEvent(new Event('loadingdone'))
expect(f.part('toolbar').style.translate).toBe('200px -74px')
expect(f.part('toolbar').style.width).toBe('80px')
expect(f.bar.style.getPropertyValue('--plugin-header-rest-height')).toBe('210px')
expect(f.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('78px')
expect(document.activeElement).toBe(button)
expect(f.part('toolbar').querySelector('button')).toBe(button)
button.click()
expect(clicked).toHaveBeenCalledTimes(1)
expect(f.clones).toHaveLength(2)
expect(f.clones.every(clone => !clone.isConnected)).toBe(true)
expect(f.body.querySelector('[data-measuring]')).toBeNull()
expect(f.measurements.slice(-2)).toEqual([false, true].map(compact => ({
compact, inert: true, ariaHidden: 'true', ids: 0, styles: 0,
natural: false, scrolled: false, connected: true, width: '320px',
})))
const ids = [...f.body.querySelectorAll('[id]')].map(element => element.id)
expect(new Set(ids).size).toBe(ids.length)
expect(h.resizes[0]!.observe).toHaveBeenCalledWith(f.body)
expect(h.mutations[0]!.observe).toHaveBeenCalledWith(f.header, {
childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ['hidden'],
})
})
it('removes the inert measurement copy when a compact layout read fails', () => {
const h = harness(), f = h.fixture()
const failure = new Error('Layout read interrupted')
const resting = f.state.layout.resting
Object.defineProperty(f.state, 'layout', {
get: () => ({
width: 960,
resting,
get compact(): Layout['compact'] { throw failure },
}),
})
expect(() => { f.attach() }).toThrow(failure)
expect(f.clones).toHaveLength(1)
expect(f.clones[0]!.isConnected).toBe(false)
expect(f.body.querySelector('[data-measuring]')).toBeNull()
expect(h.resizes).toHaveLength(0)
expect(h.mutations).toHaveLength(0)
})
it('disconnects observers and listeners and ignores callbacks already queued before detachment', () => {
const h = harness(), f = h.fixture()
f.attach()
f.scroll(20)
const before = f.bar.outerHTML
f.ref(null)
expect(h.resizes[0]!.disconnect).toHaveBeenCalledTimes(1)
expect(h.mutations[0]!.disconnect).toHaveBeenCalledTimes(1)
expect(f.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('')
f.state.layout = narrow()
f.scroll(144)
h.resizes[0]!.deliver()
h.mutations[0]!.deliver()
h.fonts.dispatchEvent(new Event('loadingdone'))
expect(f.bar.outerHTML).toBe(before)
expect(f.clones).toHaveLength(1)
f.ref(null)
expect(h.resizes[0]!.disconnect).toHaveBeenCalledTimes(1)
})
it('rebinds one ref without allowing the old element or its callbacks to change the replacement', () => {
const h = harness(), ref = createPluginHeaderRef(), first = h.fixture(wide(), ref), second = h.fixture(narrow(), ref)
first.attach()
first.scroll(20)
const oldMarkup = first.bar.outerHTML
second.attach()
second.scroll(37)
const newMarkup = second.bar.outerHTML
first.scroll(144)
h.resizes[0]!.deliver()
h.mutations[0]!.deliver()
expect(first.bar.outerHTML).toBe(oldMarkup)
expect(second.bar.outerHTML).toBe(newMarkup)
expect(first.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('')
expect(second.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('78px')
expect(first.clones).toHaveLength(1)
expect(second.clones).toHaveLength(1)
})
it('keeps two mounted refs independent when scrolling, remeasuring, and disposing either instance', () => {
const h = harness(), first = h.fixture(), second = h.fixture(narrow())
first.attach()
second.attach()
first.scroll(20)
expect(second.part('toolbar').style.translate).toBe('0px 0px')
second.scroll(37)
expect(first.part('toolbar').style.translate).toBe('0px -12px')
expect(second.part('toolbar').style.translate).toBe('100px -37px')
first.ref(null)
const oldMarkup = first.bar.outerHTML
second.state.layout = wide()
h.fonts.dispatchEvent(new Event('loadingdone'))
expect(first.bar.outerHTML).toBe(oldMarkup)
expect(first.clones).toHaveLength(1)
expect(second.clones).toHaveLength(2)
expect(second.part('toolbar').style.translate).toBe('0px -12px')
expect(second.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('72px')
expect(h.resizes[1]!.disconnect).not.toHaveBeenCalled()
second.scroll(0)
expect(second.part('toolbar').style.translate).toBe('0px 0px')
})
})