点击锚点正常,但直接访问带 hashtag 的链接时滚动后标题被 80px sticky header 遮住。根因:浏览器原生 fragment-scroll(直接访问触发)不读 CSS scroll-margin-top,在我们 scrollToHash 的平滑滚动之后触发,把标题顶到 视口最顶端(header 下方)。 抽出共享 scrollToHeading:用 getBoundingClientRect 手动计算绝对滚动位置, 减去动态测量的 header 高度(+16px 呼吸空间)后 window.scrollTo,不再依赖 scroll-margin-top。hash-scroll(直接访问)与 anchor-click(点击)统一调用它。 scrollToHash 额外用双 requestAnimationFrame 延后做一次即时校正,覆盖原生 fragment-scroll 的触发窗口,确保落点在原生 scroll 之后生效。 scroll-margin-top: 6rem 保留在 input.css 作其它原生 scroll 场景的兜底。
125 lines
4.2 KiB
TypeScript
125 lines
4.2 KiB
TypeScript
/**
|
||
* anchor-click 测试:钉住 hash 锚点点击的拦截、滚动与 URL 更新。
|
||
* 黑盒驱动:只通过 window.__initAnchorClick 入口 + 真实 DOM 事件派发。
|
||
*/
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import './index';
|
||
|
||
// 模拟 Dioxus 委托监听器:挂在 document 的 bubble 阶段 click,模拟 handleClickNavigate
|
||
// 会被触发时的副作用(preventDefault + 整页跳转)。测试用它验证拦截器确实阻止了冒泡。
|
||
let dioxusInterceptorCalled = false;
|
||
|
||
describe('initAnchorClick', () => {
|
||
beforeEach(() => {
|
||
document.body.innerHTML = '';
|
||
history.replaceState(null, '', '#');
|
||
vi.restoreAllMocks();
|
||
dioxusInterceptorCalled = false;
|
||
// 模拟 Dioxus 在 document bubble 阶段的 click 委托监听器。
|
||
// 拦截器在 window capture 阶段 stopPropagation 后,这个监听器不应被触发。
|
||
document.addEventListener(
|
||
'click',
|
||
() => {
|
||
dioxusInterceptorCalled = true;
|
||
},
|
||
false,
|
||
);
|
||
window.__initAnchorClick();
|
||
});
|
||
afterEach(() => {
|
||
document.body.innerHTML = '';
|
||
history.replaceState(null, '', '#');
|
||
});
|
||
|
||
it('点击 hash 锚点滚动到目标并阻止 Dioxus 委托监听器', () => {
|
||
const heading = document.createElement('h2');
|
||
heading.id = 'ru-men-zhi-nan';
|
||
document.body.appendChild(heading);
|
||
|
||
const anchor = document.createElement('a');
|
||
anchor.setAttribute('href', '#ru-men-zhi-nan');
|
||
document.body.appendChild(anchor);
|
||
|
||
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||
anchor.click();
|
||
|
||
expect(spy).toHaveBeenCalled();
|
||
// 关键:Dioxus 委托监听器未被触发 → handleClickNavigate/browser_open 不会执行。
|
||
expect(dioxusInterceptorCalled).toBe(false);
|
||
});
|
||
|
||
it('点击后 URL hash 更新为锚点 id(不触发整页刷新)', () => {
|
||
const heading = document.createElement('h2');
|
||
heading.id = 'section-2';
|
||
document.body.appendChild(heading);
|
||
|
||
const anchor = document.createElement('a');
|
||
anchor.setAttribute('href', '#section-2');
|
||
document.body.appendChild(anchor);
|
||
|
||
anchor.click();
|
||
|
||
expect(window.location.hash).toBe('#section-2');
|
||
});
|
||
|
||
it('目标元素不存在时放行,不阻止 Dioxus 原行为', () => {
|
||
const anchor = document.createElement('a');
|
||
anchor.setAttribute('href', '#no-such-heading');
|
||
document.body.appendChild(anchor);
|
||
|
||
anchor.click();
|
||
|
||
// 目标不存在:拦截器不接管,事件继续冒泡到 Dioxus 监听器。
|
||
expect(dioxusInterceptorCalled).toBe(true);
|
||
});
|
||
|
||
it('非 hash 链接不拦截(外链/路径链接交给原行为)', () => {
|
||
const anchor = document.createElement('a');
|
||
anchor.setAttribute('href', '/post/other');
|
||
document.body.appendChild(anchor);
|
||
|
||
anchor.click();
|
||
|
||
expect(dioxusInterceptorCalled).toBe(true);
|
||
});
|
||
|
||
it('带修饰键的点击不拦截(新窗口等交给浏览器)', () => {
|
||
const heading = document.createElement('h2');
|
||
heading.id = 'target';
|
||
document.body.appendChild(heading);
|
||
|
||
const anchor = document.createElement('a');
|
||
anchor.setAttribute('href', '#target');
|
||
document.body.appendChild(anchor);
|
||
|
||
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||
|
||
// 模拟 ctrl+click(新标签页):dispatchEvent 携带 ctrlKey。
|
||
const event = new MouseEvent('click', { bubbles: true, ctrlKey: true });
|
||
anchor.dispatchEvent(event);
|
||
|
||
expect(spy).not.toHaveBeenCalled();
|
||
expect(dioxusInterceptorCalled).toBe(true);
|
||
});
|
||
|
||
it('重复调用 initAnchorClick 幂等,不会重复注册监听器', () => {
|
||
const heading = document.createElement('h2');
|
||
heading.id = 'dup-target';
|
||
document.body.appendChild(heading);
|
||
|
||
const anchor = document.createElement('a');
|
||
anchor.setAttribute('href', '#dup-target');
|
||
document.body.appendChild(anchor);
|
||
|
||
// 再次调用(模拟 PostContent 多次挂载)。
|
||
window.__initAnchorClick();
|
||
window.__initAnchorClick();
|
||
|
||
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||
anchor.click();
|
||
|
||
// 即使重复调用,scrollTo 仍只触发一次(监听器只注册一份)。
|
||
expect(spy).toHaveBeenCalledOnce();
|
||
});
|
||
});
|