yggdrasil/libs/yggdrasil-core/src/anchor-click.test.ts
xfy 067075df8a fix(post): 直接访问 #hash 时标题被 sticky header 遮住
点击锚点正常,但直接访问带 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 场景的兜底。
2026-07-13 18:31:31 +08:00

125 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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();
});
});