From 265842ef63b5865ee441b559c11be88758a4405b Mon Sep 17 00:00:00 2001 From: HIsheR Date: Mon, 26 Oct 2026 13:50:12 +0800 Subject: [PATCH] =?UTF-8?q?iter(g101):=20=E6=96=B0=E5=A2=9E=E9=94=9A?= =?UTF-8?q?=E7=82=B9=E5=AF=BC=E8=88=AA=E7=BB=84=E4=BB=B6=20anchor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ua/backlog.json | 8 ++++ .ua/index.json | 44 +++++++++++++++++- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- README.md | 8 ++-- apps/demo/main.ts | 20 ++++++++ llms-full.txt | 28 ++++++++++- llms.txt | 1 + packages/skin/ui/src/anchor.ts | 77 +++++++++++++++++++++++++++++++ packages/skin/ui/src/index.ts | 1 + packages/skin/ui/src/types.ts | 16 +++++++ packages/skin/ui/ua.manifest.json | 42 ++++++++++++++++- packages/skin/ui/ui.css | 64 +++++++++++++++++++++++++ 13 files changed, 303 insertions(+), 10 deletions(-) create mode 100644 packages/skin/ui/src/anchor.ts diff --git a/.ua/backlog.json b/.ua/backlog.json index cf1a6d6..c17110c 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -1164,6 +1164,14 @@ "acceptance": "npm run changelog 重新生成(273 提交),changelog:check 一致;npm run check 全绿", "notes": "自补:发版前刷新步骤;本条为本次唤起第 22 轮收尾", "status": "done" + }, + { + "id": "g101", + "title": "新增锚点导航组件 anchor", + "motivation": "文档页、设置页、长表单都需要「侧边跳转 + 滚动高亮」,现在库里没有任何可复用件:demo 的侧栏目录 (buildToc) 是手写 IntersectionObserver + 类名切换,偏移量、hash 回写、禁用项、减少动效各写各的,调用方要复制一遍才用得上", + "acceptance": "新增 anchor(props)(packages/skin/ui/src/anchor.ts,< 200 行):items/href/title/disabled、offset 偏移量、container 内滚容器、onChange 回调;滚动按目标位置高亮并同步 aria-current,点击平滑定位 + replaceState 回写 hash,prefers-reduced-motion 下瞬移;types/manifest/index/ui.css/demo/README/AGENTS/ARCHITECTURE 同步,npm run check 全绿", + "notes": "自补:组件补充;样式全走令牌,选出态维持低视觉比重(accent 10% 底色 + 左侧 2px 指示条)", + "status": "open" } ] } diff --git a/.ua/index.json b/.ua/index.json index 1210853..0a542e0 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T05:16:55.417Z", + "generatedAt": "2026-10-26T05:49:43.206Z", "repoRoot": ".", "packages": [ { @@ -200,7 +200,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", @@ -2830,6 +2831,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 回写不堆历史" + ] } ] }, diff --git a/AGENTS.md b/AGENTS.md index cda7eff..24a76cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ - `packages/core/table-core`(@ui-arch/table-core):Headless 表格逻辑内核 - `packages/core/frame`(@ui-arch/frame):signal + hyperscript 微框架 -- `packages/skin/ui`(@ui-arch/ui):57 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌 +- `packages/skin/ui`(@ui-arch/ui):58 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌 - `packages/design/tokens`(@ui-arch/tokens):设计令牌 - `packages/tools/cli`(@ui-arch/cli):`ua` 命令行 + MCP 注册表服务(private) - `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d914aaa..71189bd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -34,7 +34,7 @@ ui-arch-mvp/ │ │ ├── table-core/ # 表格状态内核 │ │ └── frame/ # 信号 + hyperscript 微框架 │ ├── skin/ # 皮肤层(DOM 组件,可发布) -│ │ └── ui/ # 57 个组件 + ui.css +│ │ └── ui/ # 58 个组件 + ui.css │ ├── design/ # 资产层(可发布) │ │ └── tokens/ # --ua-* 设计令牌 │ └── tools/ # 工程层(private,不发布) diff --git a/README.md b/README.md index 84eaffe..5755342 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全 npm run changelog # 依据 git 提交史重写 CHANGELOG.md(发版前跑;changelog:check 可查漂移) ``` -## 组件(共 57 个) +## 组件(共 58 个) -accordion · affix · alert · autoComplete · avatar · backTop · badge · breadcrumb · button · carousel · card · cascader · checkbox · checkboxGroup · colorPicker · dataTable · datePicker · descriptions · divider · drawer · empty · form · image · inputNumber · list · menu · modal · multiSelect · notification · pagination · popover · progress · radioGroup · rate · result · segmented · select · skeleton · slider · space · spinner · splitter · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip · tour · transfer · tree · upload · watermark +accordion · affix · alert · anchor · autoComplete · avatar · backTop · badge · breadcrumb · button · carousel · card · cascader · checkbox · checkboxGroup · colorPicker · dataTable · datePicker · descriptions · divider · drawer · empty · form · image · inputNumber · list · menu · modal · multiSelect · notification · pagination · popover · progress · radioGroup · rate · result · segmented · select · skeleton · slider · space · spinner · splitter · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip · tour · transfer · tree · upload · watermark -分组:表单(button / textField / inputNumber / textarea / checkbox / checkboxGroup / colorPicker / switchButton / select / multiSelect / radioGroup / slider / rate / autoComplete / datePicker / segmented / upload / form + formItem / transfer / cascader)、反馈(alert / toast / notification / result / spinner / progress / skeleton / badge / empty / statistic)、浮层(tooltip / popover / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree)、数据展示(dataTable / descriptions / card / list / timeline / tag / avatar / image / carousel)、其他(space / divider / splitter / backTop / affix / tour / watermark)。 +分组:表单(button / textField / inputNumber / textarea / checkbox / checkboxGroup / colorPicker / switchButton / select / multiSelect / radioGroup / slider / rate / autoComplete / datePicker / segmented / upload / form + formItem / transfer / cascader)、反馈(alert / toast / notification / result / spinner / progress / skeleton / badge / empty / statistic)、浮层(tooltip / popover / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree / anchor)、数据展示(dataTable / descriptions / card / list / timeline / tag / avatar / image / carousel)、其他(space / divider / splitter / backTop / affix / tour / watermark)。 ## 可访问性约定 @@ -32,7 +32,7 @@ accordion · affix · alert · autoComplete · avatar · backTop · badge · bre |---|---| | `packages/core/frame` | @ui-arch/frame:signal / computed / effect + hyperscript(无虚拟 DOM) | | `packages/core/table-core` | @ui-arch/table-core:headless 表格(filter → sort → paginate 派生管线) | -| `packages/skin/ui` | @ui-arch/ui:57 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) | +| `packages/skin/ui` | @ui-arch/ui:58 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) | | `packages/design/tokens` | @ui-arch/tokens:设计令牌(暗色只换值不换名) | | `packages/tools/cli` | @ui-arch/cli:`ua` 命令行与 stdio MCP server | | `apps/demo` | 交互演示(esbuild 打包成单文件) | diff --git a/apps/demo/main.ts b/apps/demo/main.ts index d57d59f..a7ac9c6 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -11,6 +11,7 @@ import { accordion, affix, alert, + anchor, autoComplete, avatar, backTop, @@ -1152,6 +1153,25 @@ const content = h( h('p', { class: 'desc' }, '标题行右侧放操作或统计,底部 footer 放主次按钮;hoverable 时整卡悬停浮起。'), ), ), + section( + 'sec-anchor', + '锚点导航', + '竖排区块链接:滚动时按目标位置高亮当前区块并同步 aria-current,点击按 offset 偏移量平滑定位、hash 用 replaceState 回写(不堆历史);禁用项留在列表里不响应,目标缺失的项在判定时自动跳过。', + card( + { title: '跳到本页区块' }, + anchor({ + items: [ + { href: '#sec-anchor', title: '锚点导航(本节)' }, + { href: '#sec-table', title: '数据表格' }, + { href: '#sec-card', title: '卡片容器' }, + { href: '#sec-missing-zone', title: '不存在的区块(自动跳过)' }, + { href: '#sec-affix', title: '吸顶容器(禁用示例)', disabled: true }, + ], + offset: 12, + onChange: (href) => toast.info(`已跳到 ${href}`), + }), + ), + ), ); // 引导漫游实例:根节点需挂到 body(fixed 定位,不参与布局) diff --git a/llms-full.txt b/llms-full.txt index 7cbf642..4e1978a 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T05:16:55.417Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T05:49:43.206Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -1562,6 +1562,31 @@ multiSelect({ value: tagPick, max: 3, placeholder: '选择技术栈', options: [ 现场演示:apps/docs/demo.html#sec-multiselect +### anchor — 锚点导航 Anchor + +参数: + +- items:AnchorItem[] —— 锚点项集合,每项含 href(形如 #sec-x)、title、可选 disabled +- offset:number —— 定位与高亮判定预留的顶部距离,适合有吸顶栏的页面;缺省 0 +- container:HTMLElement | Window —— 监听的滚动容器;缺省 window(整页滚动) +- label:string —— nav 的无障碍名称;缺省「锚点导航」 +- onChange:(href: string) => void —— 跳转后的回调,参数为命中的 href(适合埋点或联动) + +示例: + +``` +anchor({ items: [{ href: '#sec-button', title: '按钮' }, { href: '#sec-card', title: '卡片' }], offset: 72 }) +``` + +手把手: + +1. 在页面任意位置放一个容器,调用 anchor 并把返回的 nav 塞进去(固定侧栏或吸顶栏均可) +2. items 里每条 href 指向页面上真实存在的区块 id,标题即链接文案;目标缺失的项在滚动判定时自动跳过 +3. 页面有吸顶导航时把它的高度传给 offset,跳转与高亮判定都会预留这段距离 +4. 滚动容器不是整页时用 container 传入该元素;点击走平滑滚动,系统开启「减少动效」时改为瞬移,hash 用 replaceState 回写不堆历史 + +现场演示:apps/docs/demo.html#sec-anchor + API 速览: - button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用;type 缺省 button(表单内不隐式提交) @@ -1623,6 +1648,7 @@ API 速览: - checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项 - affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应 - multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用 +- anchor(props):锚点导航,竖排区块链接,滚动时按目标位置高亮当前区块(aria-current 同步),点击按 offset 偏移量平滑定位并回写 hash;container 可指定内滚容器 一行用法: diff --git a/llms.txt b/llms.txt index 97da9e4..70a5223 100644 --- a/llms.txt +++ b/llms.txt @@ -106,6 +106,7 @@ - checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项 - affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应 - multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用 + - anchor(props):锚点导航,竖排区块链接,滚动时按目标位置高亮当前区块(aria-current 同步),点击按 offset 偏移量平滑定位并回写 hash;container 可指定内滚容器 - 用法: `const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')` - 约定: 皮肤不持有业务状态,一律委托 headless 内核或调用方信号 - 约定: 表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调 diff --git a/packages/skin/ui/src/anchor.ts b/packages/skin/ui/src/anchor.ts new file mode 100644 index 0000000..c485467 --- /dev/null +++ b/packages/skin/ui/src/anchor.ts @@ -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; +} diff --git a/packages/skin/ui/src/index.ts b/packages/skin/ui/src/index.ts index b2bf7c3..f53aaa5 100644 --- a/packages/skin/ui/src/index.ts +++ b/packages/skin/ui/src/index.ts @@ -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'; diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index b748b95..41bdda9 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -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 }; // 再导出常用内核类型,方便调用方 diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index 396352d..c5fed4a 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -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 回写不堆历史" + ] } ] } diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index b188d9f..2fa261a 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -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; + } +}