frontend/test/overlay-scrollbar.test.ts
Rama master · réplica de lectura del repositorio original.
import { afterEach, expect, it, vi } from 'vitest';
import { attachOverlayScrollbar, pruneOverlayScrollbars } from '../src/ui-kit/overlay-scrollbar';
import { observeScrollRegions, wireOverlayScrollbars } from '../src/ui-kit/scroll';
afterEach(() => { document.body.replaceChildren(); pruneOverlayScrollbars(); vi.restoreAllMocks(); });
it('连续滚动按帧更新位置,内容尺寸变化后刷新滑块比例', async () => {
const host=document.createElement('div'),container=document.createElement('div');
host.append(container);document.body.append(host);
const clock=document.createElement('span');clock.textContent='0:00';container.append(clock);
let height=100,content=500;
const measure=vi.fn(()=>height),measureContent=vi.fn(()=>content);
Object.defineProperties(container,{
clientHeight:{get:measure},scrollHeight:{get:measureContent},
clientWidth:{get:()=>200},scrollWidth:{get:()=>200},
});
const frame:FrameRequestCallback[]=[];
vi.spyOn(globalThis,'requestAnimationFrame').mockImplementation(callback=>{frame.push(callback);return frame.length});
vi.spyOn(HTMLElement.prototype,'clientHeight','get').mockImplementation(function(this:HTMLElement){
return this.classList.contains('ov-y')?84:0;
});
const sync=attachOverlayScrollbar(container)!;
// 挂上时不量,第一次尺寸观察回调才量;这里直接走同一个函数。
expect(host.querySelector<HTMLElement>('.ov-y')!.hidden).toBe(true);
expect(measure).not.toHaveBeenCalled();expect(measureContent).not.toHaveBeenCalled();
sync();
const thumb=host.querySelector<HTMLElement>('.ov-y .ovthumb')!;
expect(thumb.style.height).toBe('24px');
measure.mockClear();measureContent.mockClear();
clock.textContent='0:01';
await new Promise(resolve=>setTimeout(resolve,0));
expect(measure).not.toHaveBeenCalled();expect(measureContent).not.toHaveBeenCalled();
container.scrollTop=100;container.dispatchEvent(new Event('scroll'));
container.scrollTop=200;container.dispatchEvent(new Event('scroll'));
expect(frame).toHaveLength(1);
frame.shift()!(16);
expect(thumb.style.transform).toBe('translateY(30px)');
expect(measure).not.toHaveBeenCalled();expect(measureContent).not.toHaveBeenCalled();
expect(host.querySelector('.ui-ov-edges')!.classList.contains('ui-can-scroll-top')).toBe(true);
content=300;sync();
expect(thumb.style.height).toBe('28px');
expect(thumb.style.transform).toBe('translateY(56px)');
expect(host.querySelector('.ui-ov-edges')!.classList.contains('ui-can-scroll-bottom')).toBe(false);
height=300;sync();
expect(host.querySelector<HTMLElement>('.ov-y')!.hidden).toBe(true);
expect(container.hasAttribute('data-scroll-edges')).toBe(false);
});
it('短轨道的滑块保持在可见范围内', () => {
const host=document.createElement('div'),container=document.createElement('div');
host.append(container);document.body.append(host);
Object.defineProperties(container,{
clientHeight:{get:()=>32},scrollHeight:{get:()=>500},
clientWidth:{get:()=>32},scrollWidth:{get:()=>500},
});
vi.spyOn(HTMLElement.prototype,'clientHeight','get').mockImplementation(function(this:HTMLElement){
return this.classList.contains('ov-y')?16:0;
});
vi.spyOn(HTMLElement.prototype,'clientWidth','get').mockImplementation(function(this:HTMLElement){
return this.classList.contains('ov-x')?16:0;
});
container.scrollTop=468;container.scrollLeft=468;
attachOverlayScrollbar(container)!();
const vertical=host.querySelector<HTMLElement>('.ov-y .ovthumb')!;
const horizontal=host.querySelector<HTMLElement>('.ov-x .ovthumb')!;
expect(vertical.style.height).toBe('16px');
expect(horizontal.style.width).toBe('16px');
expect(vertical.style.transform).toBe('translateY(0px)');
expect(horizontal.style.transform).toBe('translateX(0px)');
});
it('浮层轨道留在同一层级并补偿滚动位移,卸载后可以重新绑定', () => {
const floating=document.createElement('div');
floating.style.position='fixed';document.body.append(floating);
Object.defineProperties(floating,{
clientHeight:{get:()=>100},scrollHeight:{get:()=>500},
clientWidth:{get:()=>200},scrollWidth:{get:()=>200},
});
const control=attachOverlayScrollbar(floating)!;
control();
const track=floating.querySelector<HTMLElement>(':scope > .ov-y')!;
expect(track.hidden).toBe(false);
floating.scrollTop=150;control();
expect(track.style.transform).toBe('translate(0px,150px)');
floating.replaceChildren(document.createElement('span'));
pruneOverlayScrollbars();control();
expect(floating.querySelector(':scope > .ov-y')).toBe(track);
expect(floating.querySelectorAll(':scope > .ovtrack')).toHaveLength(2);
control.destroy();
expect(floating.hasAttribute('data-overlay-scrollbar')).toBe(false);
expect(floating.querySelector('.ovtrack')).toBeNull();
expect(attachOverlayScrollbar(floating)).not.toBeNull();
});
it('动态出现及样式启用的滚动区自动接管,销毁后不留下轨道', async () => {
const host=document.createElement('div');document.body.append(host);
const stop=observeScrollRegions(host);
try{
const region=document.createElement('div');region.style.overflowY='auto';host.append(region);
await vi.waitFor(()=>expect(region.dataset.overlayScrollbar).toBe('true'));
const delayed=document.createElement('div');host.append(delayed);
await new Promise(resolve=>setTimeout(resolve,30));
expect(delayed.hasAttribute('data-overlay-scrollbar')).toBe(false);
delayed.style.overflowX='auto';
await vi.waitFor(()=>expect(delayed.dataset.overlayScrollbar).toBe('true'));
region.remove();
await vi.waitFor(()=>expect(region.hasAttribute('data-overlay-scrollbar')).toBe(false));
}finally{stop()}
expect(host.querySelector('.ovtrack')).toBeNull();
});
it('横向手势与纵向滑块各自保留对应方向的操作', () => {
const host=document.createElement('div'),region=document.createElement('div');
host.append(region);document.body.append(host);
region.style.overflowX='auto';region.style.overflowY='auto';
wireOverlayScrollbars(host);
expect(host.querySelectorAll('.ovtrack')).toHaveLength(2);
region.dataset.horizontalScroll='';
wireOverlayScrollbars(host);
expect(host.querySelectorAll('.ov-y')).toHaveLength(1);
expect(host.querySelector('.ov-x')).toBeNull();
region.style.overflowY='hidden';
delete region.dataset.horizontalScroll;
wireOverlayScrollbars(host);
expect(host.querySelectorAll('.ovtrack')).toHaveLength(2);
});
it('影院模式改变 overflow 后自动接管已有容器', async () => {
const host=document.createElement('div');
host.innerHTML='<style>[data-theater] .cinema-scroll-region{overflow-y:auto}</style><div class="cinema-scroll-region"></div>';
document.body.append(host);
const region=host.querySelector<HTMLElement>('.cinema-scroll-region')!;
const stop=observeScrollRegions(host);
try{
await new Promise(resolve=>setTimeout(resolve,30));
expect(region.hasAttribute('data-overlay-scrollbar')).toBe(false);
host.setAttribute('data-theater','');
await vi.waitFor(()=>expect(region.dataset.overlayScrollbar).toBe('true'));
expect(host.querySelectorAll('.ovtrack')).toHaveLength(2);
}finally{stop()}
});
for(const marker of ['data-skeleton','data-skeleton-tier'])it(`${marker} 移除旧轨道,内容恢复后重新接管`, async () => {
const host=document.createElement('div'),region=document.createElement('div');
region.style.overflowY='auto';host.append(region);document.body.append(host);
const stop=observeScrollRegions(host);
try{
await vi.waitFor(()=>expect(region.dataset.overlayScrollbar).toBe('true'));
host.setAttribute(marker,'');host.setAttribute('inert','');
await vi.waitFor(()=>expect(host.querySelector('.ovtrack')).toBeNull());
expect(region.hasAttribute('data-overlay-scrollbar')).toBe(false);
host.removeAttribute(marker);host.removeAttribute('inert');
await vi.waitFor(()=>expect(region.dataset.overlayScrollbar).toBe('true'));
expect(host.querySelectorAll('.ovtrack')).toHaveLength(2);
}finally{stop()}
});
it('影院模式只改变内容长度时刷新已绑定轨道', async () => {
const host=document.createElement('div'),region=document.createElement('div');
region.style.overflowY='auto';host.append(region);document.body.append(host);
let content=100;
Object.defineProperties(region,{
clientHeight:{get:()=>100},scrollHeight:{get:()=>content},
clientWidth:{get:()=>200},scrollWidth:{get:()=>200},
});
const stop=observeScrollRegions(host);
try{
await vi.waitFor(()=>expect(region.dataset.overlayScrollbar).toBe('true'));
const track=host.querySelector<HTMLElement>('.ov-y')!;
expect(track.hidden).toBe(true);
content=500;host.setAttribute('data-theater','');
await vi.waitFor(()=>expect(track.hidden).toBe(false));
content=100;host.removeAttribute('data-theater');
await vi.waitFor(()=>expect(track.hidden).toBe(true));
}finally{stop()}
});