77 lines
3.5 KiB
TypeScript
77 lines
3.5 KiB
TypeScript
import { h } from '@ui-arch/frame';
|
|
import type { AnchorItem, AnchorProps } from './types.js';
|
|
import { watchViewport } from './watch.js';
|
|
|
|
/*** 锚点导航:竖排区块链接,滚动时高亮当前区块,点击平滑跳转(尊重减少动效偏好) ***/
|
|
export function anchor(props: AnchorProps): HTMLElement {
|
|
// —— 状态集中声明 ——
|
|
const items = props.items; // 锚点项,href 形如 #sec-xxx
|
|
const offset = props.offset ?? 0; // 目标顶部预留距离(吸顶栏高度)
|
|
const scroller = props.container instanceof HTMLElement ? props.container : null; // 内滚容器:null 表示整页滚动
|
|
const links: HTMLAnchorElement[] = []; // 与 items 一一对应的链接节点
|
|
const root = h('nav', { class: 'ua-anchor', 'aria-label': props.label ?? '锚点导航' });
|
|
|
|
/*** 目标元素:href 去掉 # 后按 id 查找,找不到返回 null ***/
|
|
const targetOf = (href: string): HTMLElement | null => document.getElementById(href.replace(/^#/, ''));
|
|
|
|
/*** 目标相对判定原点的垂直坐标:整页用视口坐标,容器用容器内偏移 ***/
|
|
const topOf = (el: HTMLElement): number => (scroller
|
|
? el.getBoundingClientRect().top - scroller.getBoundingClientRect().top
|
|
: el.getBoundingClientRect().top);
|
|
|
|
/*** 当前区块:取最后一个已越过判定线的目标,全未越过时落到首项 ***/
|
|
const current = (): AnchorItem | undefined => {
|
|
let hit: AnchorItem | undefined = items[0];
|
|
for (const item of items) {
|
|
const el = targetOf(item.href);
|
|
if (!el) continue; // 卫语句:目标不存在就跳过,不打断整表判定
|
|
if (topOf(el) - offset <= 1) hit = item;
|
|
}
|
|
return hit;
|
|
};
|
|
|
|
/*** 同步高亮:类名与 aria-current 一起切换,读屏能念出当前所在区块 ***/
|
|
const sync = (): void => {
|
|
const hit = current();
|
|
links.forEach((link, i) => {
|
|
const on = items[i] === hit;
|
|
link.classList.toggle('is-active', on);
|
|
if (on) link.setAttribute('aria-current', 'true');
|
|
else link.removeAttribute('aria-current');
|
|
});
|
|
};
|
|
|
|
/*** 跳转:按偏移量滚动到位并回写 hash,减少动效偏好下改瞬移 ***/
|
|
const jump = (item: AnchorItem): void => {
|
|
const el = targetOf(item.href);
|
|
if (!el) return; // 卫语句:目标不在页面上就什么都不做
|
|
const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
const behavior: ScrollBehavior = smooth ? 'smooth' : 'auto';
|
|
const top = topOf(el) + (scroller ? scroller.scrollTop : window.scrollY) - offset;
|
|
if (scroller) scroller.scrollTo({ top, behavior });
|
|
else window.scrollTo({ top, behavior });
|
|
history.replaceState(null, '', item.href); // 用 replaceState:跳转不堆积历史条目
|
|
props.onChange?.(item.href);
|
|
sync();
|
|
};
|
|
|
|
for (const item of items) {
|
|
const link = h('a', {
|
|
class: item.disabled ? 'ua-anchor-link is-disabled' : 'ua-anchor-link',
|
|
href: item.href,
|
|
'aria-disabled': item.disabled ? 'true' : undefined,
|
|
onClick: (e: Event) => {
|
|
e.preventDefault(); // 一律接管:自绘偏移量,不走浏览器默认锚点定位
|
|
if (item.disabled) return; // 卫语句:禁用项只吞掉点击
|
|
jump(item);
|
|
},
|
|
}, item.title) as HTMLAnchorElement;
|
|
links.push(link);
|
|
root.append(link);
|
|
}
|
|
|
|
const host: HTMLElement | Window = scroller ?? window;
|
|
watchViewport(root, host, sync); // 滚动 / 缩放后同步高亮,节点脱离文档即自摘监听
|
|
return root;
|
|
}
|