锚点 anchor
导航组件。三层同源:无头内核给出解剖与状态机,Vue 组件与自定义元素只是它的两层外壳,行为完全一致。
示例
基础用法
目录跟着滚动位置自己换高亮;scroll-element 把判定线挂到指定滚动容器上,不给就挂在窗口上
滚动这一栏,看左边哪一条亮起来。
滚动这一栏,看左边哪一条亮起来。
滚动这一栏,看左边哪一条亮起来。
滚动这一栏,看左边哪一条亮起来。
<script setup lang="ts">
import { ref } from "vue";
import {
XhAnchorIndicator,
XhAnchorItem,
XhAnchorLink,
XhAnchorList,
XhAnchorRoot,
} from "@xihan-ui/vue";
// 链接的 value 就是目标区块的 id:href 由组件按它派生
const sections = [
{ value: "anchor-basic-intro", label: "这是什么" },
{ value: "anchor-basic-keyboard", label: "键盘怎么走" },
{ value: "anchor-basic-edge", label: "边界在哪" },
{ value: "anchor-basic-token", label: "主题与令牌" },
];
const scrollEl = ref<HTMLElement | null>(null);
</script>
<template>
<div
style="
display: grid;
grid-template-columns: 140px 1fr;
gap: 20px;
inline-size: 100%;
align-items: start;
"
>
<XhAnchorRoot :scroll-element="scrollEl" smooth>
<XhAnchorList>
<XhAnchorItem v-for="s in sections" :key="s.value">
<XhAnchorLink :value="s.value">{{ s.label }}</XhAnchorLink>
</XhAnchorItem>
<!-- 指示条必须住在 list 里:它以 list 为定位参照系,而 ul 里只放得下 li -->
<XhAnchorIndicator />
</XhAnchorList>
</XhAnchorRoot>
<div
ref="scrollEl"
style="
block-size: 240px;
overflow: auto;
padding: 12px;
border: 1px solid var(--xh-border-default);
border-radius: 8px;
"
>
<!-- 目标区块是页面内容、不是组件的部件:组件按链接的 value 现查 id -->
<div
v-for="s in sections"
:id="s.value"
:key="s.value"
style="block-size: 180px"
>
<strong>{{ s.label }}</strong>
<p>滚动这一栏,看左边哪一条亮起来。</p>
</div>
</div>
</div>
</template>受控
传了 value 就由宿主说了算;一节都没越过判定线时它是 null,此时谁都不亮、指示条整条收起
这一节的正文。
这一节的正文。
这一节的正文。
<script setup lang="ts">
import { ref } from "vue";
import {
XhAnchorIndicator,
XhAnchorItem,
XhAnchorLink,
XhAnchorList,
XhAnchorRoot,
XhButton,
} from "@xihan-ui/vue";
const sections = [
{ value: "anchor-ctl-install", label: "安装" },
{ value: "anchor-ctl-usage", label: "用法" },
{ value: "anchor-ctl-faq", label: "常见问题" },
];
const active = ref<string | null>(null);
const scrollEl = ref<HTMLElement | null>(null);
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 12px; inline-size: 100%">
<div
style="
display: grid;
grid-template-columns: 140px 1fr;
gap: 20px;
align-items: start;
"
>
<XhAnchorRoot v-model:value="active" :scroll-element="scrollEl" smooth>
<XhAnchorList>
<XhAnchorItem v-for="s in sections" :key="s.value">
<XhAnchorLink :value="s.value">{{ s.label }}</XhAnchorLink>
</XhAnchorItem>
<XhAnchorIndicator />
</XhAnchorList>
</XhAnchorRoot>
<div
ref="scrollEl"
style="
block-size: 220px;
overflow: auto;
padding: 12px;
border: 1px solid var(--xh-border-default);
border-radius: 8px;
"
>
<div
v-for="s in sections"
:id="s.value"
:key="s.value"
style="block-size: 180px"
>
<strong>{{ s.label }}</strong>
<p>这一节的正文。</p>
</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 8px">
<XhButton variant="outline" @click="active = 'anchor-ctl-faq'">
点亮「常见问题」
</XhButton>
<span>当前:{{ active ?? "(还没有一节越过判定线)" }}</span>
</div>
</div>
</template>判定线偏移
offset 是判定线距容器视口顶边的距离,有吸顶栏就把栏高填进去,越过它的最后一节才算当前节
这一节被吸顶栏挡住时不算当前节。
这一节被吸顶栏挡住时不算当前节。
这一节被吸顶栏挡住时不算当前节。
<script setup lang="ts">
import { ref } from "vue";
import {
XhAnchorIndicator,
XhAnchorItem,
XhAnchorLink,
XhAnchorList,
XhAnchorRoot,
} from "@xihan-ui/vue";
const sections = [
{ value: "anchor-offset-a", label: "第一节" },
{ value: "anchor-offset-b", label: "第二节" },
{ value: "anchor-offset-c", label: "第三节" },
];
const scrollEl = ref<HTMLElement | null>(null);
</script>
<template>
<div
style="
display: grid;
grid-template-columns: 140px 1fr;
gap: 20px;
inline-size: 100%;
align-items: start;
"
>
<XhAnchorRoot :scroll-element="scrollEl" :offset="44" smooth>
<XhAnchorList>
<XhAnchorItem v-for="s in sections" :key="s.value">
<XhAnchorLink :value="s.value">{{ s.label }}</XhAnchorLink>
</XhAnchorItem>
<XhAnchorIndicator />
</XhAnchorList>
</XhAnchorRoot>
<div
ref="scrollEl"
style="
position: relative;
block-size: 240px;
overflow: auto;
border: 1px solid var(--xh-border-default);
border-radius: 8px;
"
>
<!-- 44px 高的吸顶栏,判定线正好压在它下沿 -->
<div
style="
position: sticky;
inset-block-start: 0;
z-index: 1;
block-size: 44px;
display: flex;
align-items: center;
padding-inline: 12px;
background: var(--xh-bg-surface);
border-block-end: 1px solid var(--xh-border-default);
"
>
吸顶栏(44px)
</div>
<div
v-for="s in sections"
:id="s.value"
:key="s.value"
style="block-size: 180px; padding: 12px"
>
<strong>{{ s.label }}</strong>
<p>这一节被吸顶栏挡住时不算当前节。</p>
</div>
</div>
</div>
</template>横排目录
orientation="horizontal" 只改样式:条目排成一行,轨道与指示条从起始缘挪到底边
这一节的正文。
这一节的正文。
这一节的正文。
这一节的正文。
<script setup lang="ts">
import { ref } from "vue";
import {
XhAnchorIndicator,
XhAnchorItem,
XhAnchorLink,
XhAnchorList,
XhAnchorRoot,
} from "@xihan-ui/vue";
const sections = [
{ value: "anchor-h-overview", label: "概览" },
{ value: "anchor-h-props", label: "属性" },
{ value: "anchor-h-events", label: "事件" },
{ value: "anchor-h-slots", label: "插槽" },
];
const scrollEl = ref<HTMLElement | null>(null);
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 12px; inline-size: 100%">
<XhAnchorRoot
:scroll-element="scrollEl"
orientation="horizontal"
smooth
>
<XhAnchorList>
<XhAnchorItem v-for="s in sections" :key="s.value">
<XhAnchorLink :value="s.value">{{ s.label }}</XhAnchorLink>
</XhAnchorItem>
<XhAnchorIndicator />
</XhAnchorList>
</XhAnchorRoot>
<div
ref="scrollEl"
style="
block-size: 220px;
overflow: auto;
padding: 12px;
border: 1px solid var(--xh-border-default);
border-radius: 8px;
"
>
<div
v-for="s in sections"
:id="s.value"
:key="s.value"
style="block-size: 170px"
>
<strong>{{ s.label }}</strong>
<p>这一节的正文。</p>
</div>
</div>
</div>
</template>语气
tone 换的是选中那一节的指示条与文字颜色,这里用 default-value 预置「用法」为选中项
<script setup lang="ts">
import {
XhAnchorIndicator,
XhAnchorItem,
XhAnchorLink,
XhAnchorList,
XhAnchorRoot,
} from "@xihan-ui/vue";
const tones = ["brand", "neutral", "success", "warning", "danger", "info"] as const;
const sections = [
{ value: "anchor-tone-install", label: "安装" },
{ value: "anchor-tone-usage", label: "用法" },
{ value: "anchor-tone-faq", label: "常见问题" },
];
</script>
<template>
<div
style="
inline-size: 100%;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
"
>
<div v-for="t in tones" :key="t">
<div style="margin-block-end: 8px; font-size: 12px">{{ t }}</div>
<XhAnchorRoot :tone="t" default-value="anchor-tone-usage">
<XhAnchorList>
<XhAnchorItem v-for="s in sections" :key="s.value">
<XhAnchorLink :value="s.value">{{ s.label }}</XhAnchorLink>
</XhAnchorItem>
<!-- 指示条必须住在 list 里:它以 list 为定位参照系 -->
<XhAnchorIndicator />
</XhAnchorList>
</XhAnchorRoot>
</div>
</div>
</template>尺寸
size 换条目的字号与左右内边距,不传 size 即默认档
<script setup lang="ts">
import {
XhAnchorIndicator,
XhAnchorItem,
XhAnchorLink,
XhAnchorList,
XhAnchorRoot,
} from "@xihan-ui/vue";
// 中间一档不写 size,用 undefined 表达
const sizes = [
{ size: "sm", label: "小" },
{ size: undefined, label: "默认" },
{ size: "lg", label: "大" },
] as const;
const sections = [
{ value: "anchor-size-install", label: "安装" },
{ value: "anchor-size-usage", label: "用法" },
{ value: "anchor-size-faq", label: "常见问题" },
];
</script>
<template>
<div
style="
inline-size: 100%;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
align-items: start;
"
>
<div v-for="s in sizes" :key="s.label">
<div style="margin-block-end: 8px; font-size: 12px">{{ s.label }}</div>
<XhAnchorRoot :size="s.size" default-value="anchor-size-usage">
<XhAnchorList>
<XhAnchorItem v-for="sec in sections" :key="sec.value">
<XhAnchorLink :value="sec.value">{{ sec.label }}</XhAnchorLink>
</XhAnchorItem>
<XhAnchorIndicator />
</XhAnchorList>
</XhAnchorRoot>
</div>
</div>
</template>吸顶目录
目录用 sticky 钉在滚动容器顶边,滚动时留在原处;判定线仍由 offset 定
滚动整块区域,左边的目录会一直贴在顶边。
滚动整块区域,左边的目录会一直贴在顶边。
滚动整块区域,左边的目录会一直贴在顶边。
滚动整块区域,左边的目录会一直贴在顶边。
<script setup lang="ts">
import { ref } from "vue";
import {
XhAnchorIndicator,
XhAnchorItem,
XhAnchorLink,
XhAnchorList,
XhAnchorRoot,
} from "@xihan-ui/vue";
const sections = [
{ value: "anchor-affix-intro", label: "简介" },
{ value: "anchor-affix-install", label: "安装" },
{ value: "anchor-affix-usage", label: "用法" },
{ value: "anchor-affix-faq", label: "常见问题" },
];
const scrollEl = ref<HTMLElement | null>(null);
</script>
<template>
<div
ref="scrollEl"
style="
block-size: 260px;
overflow: auto;
inline-size: 100%;
border: 1px solid var(--xh-border-default);
border-radius: 8px;
"
>
<div style="display: grid; grid-template-columns: 140px 1fr; gap: 20px; padding: 12px">
<!-- 外层这格随内容拉满,目录在它内部 sticky,才有可移动的余量 -->
<div>
<XhAnchorRoot
:scroll-element="scrollEl"
:offset="12"
smooth
style="position: sticky; inset-block-start: 0; background: var(--xh-bg-surface)"
>
<XhAnchorList>
<XhAnchorItem v-for="s in sections" :key="s.value">
<XhAnchorLink :value="s.value">{{ s.label }}</XhAnchorLink>
</XhAnchorItem>
<XhAnchorIndicator />
</XhAnchorList>
</XhAnchorRoot>
</div>
<div>
<div
v-for="s in sections"
:id="s.value"
:key="s.value"
style="block-size: 200px"
>
<strong>{{ s.label }}</strong>
<p>滚动整块区域,左边的目录会一直贴在顶边。</p>
</div>
</div>
</div>
</div>
</template>二级目录
子链接嵌在父项里的原生列表中,按文档序照常参与结算;父级要不要跟着亮由宿主自己算
这一节的正文。
这一节的正文。
这一节的正文。
这一节的正文。
这一节的正文。
这一节的正文。
<script setup lang="ts">
import { computed, ref } from "vue";
import {
XhAnchorIndicator,
XhAnchorItem,
XhAnchorLink,
XhAnchorList,
XhAnchorRoot,
} from "@xihan-ui/vue";
const groups = [
{
value: "anchor-nested-guide",
label: "指南",
children: [
{ value: "anchor-nested-install", label: "安装" },
{ value: "anchor-nested-start", label: "快速开始" },
],
},
{
value: "anchor-nested-api",
label: "接口",
children: [
{ value: "anchor-nested-props", label: "属性" },
{ value: "anchor-nested-events", label: "事件" },
],
},
];
// 正文区块按文档序摊平,父节与子节共用一份清单
const sections = computed(() =>
groups.flatMap((g) => [{ value: g.value, label: g.label }, ...g.children]),
);
const active = ref<string | null>(null);
const scrollEl = ref<HTMLElement | null>(null);
// 子节命中时父节一起点亮
function isGroupActive(group: {
value: string;
children: readonly { value: string }[];
}): boolean {
return (
active.value === group.value
|| group.children.some((c) => c.value === active.value)
);
}
</script>
<template>
<div
style="
display: grid;
grid-template-columns: 160px 1fr;
gap: 20px;
inline-size: 100%;
align-items: start;
"
>
<XhAnchorRoot v-model:value="active" :scroll-element="scrollEl" smooth>
<XhAnchorList>
<XhAnchorItem
v-for="g in groups"
:key="g.value"
style="flex-direction: column; align-items: stretch"
>
<XhAnchorLink
:value="g.value"
:style="isGroupActive(g) ? { color: 'var(--xh-fg-brand)' } : undefined"
>
{{ g.label }}
</XhAnchorLink>
<!-- 子级用一层原生 ul 承载:再嵌一个 XhAnchorList 会把指示条的参照系抢走 -->
<ul
style="margin: 0; padding: 0; padding-inline-start: 12px; list-style: none"
>
<XhAnchorItem v-for="c in g.children" :key="c.value">
<XhAnchorLink :value="c.value">{{ c.label }}</XhAnchorLink>
</XhAnchorItem>
</ul>
</XhAnchorItem>
<XhAnchorIndicator />
</XhAnchorList>
</XhAnchorRoot>
<div
ref="scrollEl"
style="
block-size: 240px;
overflow: auto;
padding: 12px;
border: 1px solid var(--xh-border-default);
border-radius: 8px;
"
>
<div
v-for="s in sections"
:id="s.value"
:key="s.value"
style="block-size: 140px"
>
<strong>{{ s.label }}</strong>
<p>这一节的正文。</p>
</div>
</div>
</div>
</template>从外部跳到某一节
组件只在点链接时滚动;程序化跳转由宿主自己滚,滚完观察器会把高亮结算过来
这一节的正文。
这一节的正文。
这一节的正文。
<script setup lang="ts">
import { ref } from "vue";
import {
XhAnchorIndicator,
XhAnchorItem,
XhAnchorLink,
XhAnchorList,
XhAnchorRoot,
XhButton,
} from "@xihan-ui/vue";
// 判定线与滚动落点用同一个偏移,跳过去之后高亮正好落在这一节
const OFFSET = 12;
const sections = [
{ value: "anchor-goto-intro", label: "简介" },
{ value: "anchor-goto-usage", label: "用法" },
{ value: "anchor-goto-faq", label: "常见问题" },
];
const active = ref<string | null>(null);
const scrollEl = ref<HTMLElement | null>(null);
function jumpTo(id: string): void {
const container = scrollEl.value;
const target = container?.querySelector<HTMLElement>(`#${id}`);
if (!container || !target) {
return;
}
const delta
= target.getBoundingClientRect().top
- container.getBoundingClientRect().top
- OFFSET;
container.scrollTo({ top: container.scrollTop + delta, behavior: "smooth" });
}
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 12px; inline-size: 100%">
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<XhButton
v-for="s in sections"
:key="s.value"
size="sm"
variant="outline"
@click="jumpTo(s.value)"
>
跳到{{ s.label }}
</XhButton>
<span>当前:{{ active ?? "(还没有一节越过判定线)" }}</span>
</div>
<div
style="
display: grid;
grid-template-columns: 140px 1fr;
gap: 20px;
align-items: start;
"
>
<XhAnchorRoot
v-model:value="active"
:scroll-element="scrollEl"
:offset="OFFSET"
smooth
>
<XhAnchorList>
<XhAnchorItem v-for="s in sections" :key="s.value">
<XhAnchorLink :value="s.value">{{ s.label }}</XhAnchorLink>
</XhAnchorItem>
<XhAnchorIndicator />
</XhAnchorList>
</XhAnchorRoot>
<div
ref="scrollEl"
style="
block-size: 220px;
overflow: auto;
padding: 12px;
border: 1px solid var(--xh-border-default);
border-radius: 8px;
"
>
<div
v-for="s in sections"
:id="s.value"
:key="s.value"
style="block-size: 180px"
>
<strong>{{ s.label }}</strong>
<p>这一节的正文。</p>
</div>
</div>
</div>
</div>
</template>产物
| 层 | 值 |
|---|---|
| 自定义元素 | <xh-anchor> |
| Vue 组件 | XhAnchorIndicator XhAnchorItem XhAnchorLink XhAnchorList XhAnchorRoot |
| 组合式函数 | useAnchor |
| 状态机 | anchorMachine |
| 皮肤 | @xihan-ui/styles/anchor.css |
解剖
部件名即 data-part 属性值,也是皮肤的选择器。加粗的是必备部件,不渲染它组件不工作(Web Components 适配器会在诊断通道上报 wc.missing-part)。
data-scope="anchor":root · list · item · link · indicator
Props
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
value | string | null | 当前激活的锚点 id,给定即受控。 | |
defaultValue | string | null | ||
targets | readonly string[] | 目标区块的 id 清单,按文档序给;不给则按渲染出来的 link 现查。 | |
offset | number | 判定线距滚动容器视口顶边的距离(px),默认 0。 | |
smooth | boolean | 点链接时平滑滚动到目标,默认 false。 | |
dir | Direction | 文字方向,作用于排版与指示条的起始缘。 | |
orientation | Orientation | 列表轴向,默认 vertical,只影响样式。 | |
translations | Partial<AnchorTranslations> | ||
tone | Tone | 语气:brand / neutral / success / warning / danger / info,决定用哪族颜色。 | |
size | Size | 尺寸:sm / md / lg。 | |
onValueChange | (details: AnchorValueChangeDetails) => void | value 变化意图回调。 |
状态机
状态:idle · scrolling
事件:SPY.RESOLVE · LINK.CLICK · VALUE.SET · after.scrollLock
判据:isSmooth · isTargetReached
connect API
useAnchor 产出的对象。getXxxProps() 铺到对应部件的宿主元素上,其余是可读状态与操作入口。
| 成员 | 类型 | 说明 |
|---|---|---|
value | string | null | 当前激活的锚点 id;一个都没越过判定线时为 null。 |
isActive | (value: string) => boolean | |
setValue | (next: string | null) => void | |
getRootProps | () => T['element'] | |
getListProps | () => T['element'] | |
getItemProps | () => T['element'] | |
getLinkProps | (props: AnchorLinkProps) => T['element'] | |
getIndicatorProps | () => T['element'] |
键盘
规格出处:W3C APG
| 按键 | 生效条件 | 行为 |
|---|---|---|
Enter | focus in link | 跳到目标区块:smooth 关时由原生 <a href="#id"> 跳转,开时组件拦下并平滑滚动(两种情况都当场把激活项切过去,不等观察器) |
Tab / Shift+Tab | focus in root | 逐条走过目录里的链接;锚点导航不做 roving tabindex,每一条都是独立的 Tab 停靠点 |
