iter(g101): 新增锚点导航组件 anchor
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { AnchorItem, AnchorProps } from './types.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;
|
||||
host.addEventListener('scroll', sync, { passive: true }); // 被动监听:不阻塞滚动
|
||||
window.addEventListener('resize', sync); // 视口变化会改变判定线
|
||||
sync(); // 首屏同步一次,刷新后停在中间位置也能高亮正确项
|
||||
return root;
|
||||
}
|
||||
@@ -57,3 +57,4 @@ export { colorPicker } from './colorPicker.js';
|
||||
export { tour } from './tour.js';
|
||||
export { watermark } from './watermark.js';
|
||||
export { affix } from './affix.js';
|
||||
export { anchor } from './anchor.js';
|
||||
|
||||
@@ -679,4 +679,20 @@ export interface SpaceProps {
|
||||
block?: boolean; // 是否占满父容器宽度;缺省按内容收缩
|
||||
}
|
||||
|
||||
/*** 锚点导航项 ***/
|
||||
export interface AnchorItem {
|
||||
href: string; // 目标区块地址(#id);对应元素须存在于页面上
|
||||
title: string; // 链接文案
|
||||
disabled?: boolean; // 是否禁用该项;缺省可用
|
||||
}
|
||||
|
||||
/*** 锚点导出入参 ***/
|
||||
export interface AnchorProps {
|
||||
items: AnchorItem[]; // 锚点项集合,按显示顺序
|
||||
offset?: number; // 定位时预留的顶部距离(吸顶栏高度);缺省 0
|
||||
container?: HTMLElement | Window; // 监听的滚动容器;缺省 window(整页)
|
||||
label?: string; // nav 的无障碍名称;缺省「锚点导航」
|
||||
onChange?: (href: string) => void; // 跳转后的回调(埋点或联动滚动)
|
||||
}
|
||||
|
||||
export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
"watermark(props, ...children):内容容器 + 平铺水印覆盖层,canvas 生成单元、颜色取自令牌,覆盖层不挡交互",
|
||||
"checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项",
|
||||
"affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应",
|
||||
"multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用"
|
||||
"multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用",
|
||||
"anchor(props):锚点导航,竖排区块链接,滚动时按目标位置高亮当前区块(aria-current 同步),点击按 offset 偏移量平滑定位并回写 hash;container 可指定内滚容器"
|
||||
],
|
||||
"keyTypes": [
|
||||
"ButtonProps",
|
||||
@@ -2701,6 +2702,45 @@
|
||||
"↑↓ 环形移动高亮、Enter 切换当前项、Esc 收起(与其它弹层共用一个 Esc 栈)",
|
||||
"value 传数组信号即受控,外部写入会同步标签区"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "anchor",
|
||||
"title": "锚点导航 Anchor",
|
||||
"demo": "sec-anchor",
|
||||
"props": [
|
||||
{
|
||||
"n": "items",
|
||||
"t": "AnchorItem[]",
|
||||
"d": "锚点项集合,每项含 href(形如 #sec-x)、title、可选 disabled"
|
||||
},
|
||||
{
|
||||
"n": "offset",
|
||||
"t": "number",
|
||||
"d": "定位与高亮判定预留的顶部距离,适合有吸顶栏的页面;缺省 0"
|
||||
},
|
||||
{
|
||||
"n": "container",
|
||||
"t": "HTMLElement | Window",
|
||||
"d": "监听的滚动容器;缺省 window(整页滚动)"
|
||||
},
|
||||
{
|
||||
"n": "label",
|
||||
"t": "string",
|
||||
"d": "nav 的无障碍名称;缺省「锚点导航」"
|
||||
},
|
||||
{
|
||||
"n": "onChange",
|
||||
"t": "(href: string) => void",
|
||||
"d": "跳转后的回调,参数为命中的 href(适合埋点或联动)"
|
||||
}
|
||||
],
|
||||
"example": "anchor({ items: [{ href: '#sec-button', title: '按钮' }, { href: '#sec-card', title: '卡片' }], offset: 72 })",
|
||||
"steps": [
|
||||
"在页面任意位置放一个容器,调用 anchor 并把返回的 nav 塞进去(固定侧栏或吸顶栏均可)",
|
||||
"items 里每条 href 指向页面上真实存在的区块 id,标题即链接文案;目标缺失的项在滚动判定时自动跳过",
|
||||
"页面有吸顶导航时把它的高度传给 offset,跳转与高亮判定都会预留这段距离",
|
||||
"滚动容器不是整页时用 container 传入该元素;点击走平滑滚动,系统开启「减少动效」时改为瞬移,hash 用 replaceState 回写不堆历史"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3611,3 +3611,67 @@ button.ua-step-dot:hover {
|
||||
.ua-space.is-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* —— 锚点导航 —— */
|
||||
.ua-anchor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-left: var(--ua-space-3);
|
||||
}
|
||||
.ua-anchor::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--ua-color-border);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.ua-anchor-link {
|
||||
position: relative;
|
||||
padding: var(--ua-space-1) var(--ua-space-2);
|
||||
border-radius: var(--ua-radius-sm);
|
||||
color: var(--ua-color-text-muted);
|
||||
font-size: var(--ua-font-size-md);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s, background-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.ua-anchor-link:hover {
|
||||
color: var(--ua-color-text);
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 6%, transparent);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.ua-anchor-link.is-active {
|
||||
color: var(--ua-color-text);
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent);
|
||||
}
|
||||
.ua-anchor-link.is-active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(-1 * var(--ua-space-3));
|
||||
top: 50%;
|
||||
width: 2px;
|
||||
height: 70%;
|
||||
transform: translateY(-50%);
|
||||
background: var(--ua-color-accent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.ua-anchor-link.is-disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ua-anchor-link:focus-visible {
|
||||
outline: 2px solid var(--ua-color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ua-anchor-link {
|
||||
transition: none;
|
||||
}
|
||||
.ua-anchor-link:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user