Skip to content

feat(content-nav): add content navigation component#322

Open
SonyLeo wants to merge 5 commits intoopentiny:developfrom
SonyLeo:feat/content-nav-comp
Open

feat(content-nav): add content navigation component#322
SonyLeo wants to merge 5 commits intoopentiny:developfrom
SonyLeo:feat/content-nav-comp

Conversation

@SonyLeo
Copy link
Copy Markdown
Collaborator

@SonyLeo SonyLeo commented Apr 14, 2026

变更概述

文档在线预览 🔗

本次 PR 新增 TrContentNav 内容导航组件,用于长内容、结构化内容和多段内容场景下的快速定位与阅读辅助。

组件支持激活项同步、悬浮展开 / 手动展开、搜索过滤、键盘导航、目标内容反馈等能力,并补充了组件导出、样式入口,以及少量 Bubble 渲染能力上的基础支持。

背景

在长文本、结构化内容或多段内容展示场景中,用户通常需要一个轻量、低接入成本的导航入口来:

  • 快速定位到指定内容段落
  • 在滚动阅读过程中同步当前所在位置
  • 在紧凑态与展开态之间切换
  • 支持搜索和键盘操作,提升可用性

因此,这次将内容导航能力抽离为独立组件 TrContentNav,并通过清晰的类型定义、内部 composables 和工具模块来组织实现。

主要变更

1. 新增 content-nav 组件模块

新增 packages/components/src/content-nav/ 目录,主要包含以下内容:

  • index.vue
    • 组件主入口,负责编排状态、激活项同步、交互联动与目标反馈
  • components/ContentNavOverlay.vue
    • 负责导航浮层容器、展开态面板与搜索区域布局
  • components/ContentNavList.vue
    • 负责导航项列表渲染
  • components/ContentNavItem.vue
    • 负责单项渲染、激活态 / 高亮态展示,以及文本截断后的 tooltip 展示
  • components/ContentNavSearch.vue
    • 提供内置搜索输入框
  • index.type.ts
    • 对外暴露的组件类型定义
  • internal.type.ts
    • 组件内部使用的类型定义
  • defaults.ts
    • 默认搜索匹配逻辑及相关辅助实现
  • utils/scroll.ts
    • 滚动容器 / 视口相关的工具函数
  • utils/target.ts
    • 内容目标节点查找工具

2. 将核心逻辑拆分为 composables

为降低主组件复杂度并提升可维护性,本次将核心行为拆分为多个 composables:

  • useNavController

    • 管理展开状态
    • 管理查询词状态
    • 生成过滤后的导航项
    • 管理键盘高亮项及上下移动逻辑
  • useActiveSync

    • 根据滚动位置同步当前激活项
    • 支持点击导航项后滚动到目标位置
    • 处理程序化滚动期间的同步节制,避免状态抖动
  • useFloatingOffset

    • 计算导航浮层在容器中的垂直偏移
    • 让导航浮层在滚动容器视口内保持更合理的位置
  • useOverlayInteractions

    • 处理键盘交互
    • 处理 hover / focus 下的展开与收起逻辑
    • 支持 Enter / Space 选中当前高亮项
  • useTargetFeedback

    • 点击目录项后,为目标内容节点添加临时反馈 class
    • 支持业务侧自定义反馈样式和持续时间

3. 实现内容导航的核心能力

当前 TrContentNav 支持以下能力:

  • 支持 left / right 两种放置方向
  • 支持 hover / manual 两种展开触发模式
  • 支持 activeId 的受控 / 非受控使用方式
  • 支持 expanded 的受控 / 非受控使用方式
  • 支持 query 的受控 / 非受控使用方式
  • 支持搜索过滤及自定义 matcher
  • 支持折叠时清空搜索条件
  • 支持根据滚动位置自动同步当前激活项
  • 支持点击导航项后滚动到对应目标内容
  • 支持键盘导航
  • 支持空状态展示
  • 支持 item / marker / search / empty 插槽扩展
  • 支持文本截断时显示 tooltip
  • 支持通过 targetActiveClass / targetActiveDuration 为目标内容提供跳转反馈
  • 支持通过 tooltipDelay 控制 tooltip 显示时机

4. 增加内容目标关联机制

组件通过 data-content-nav-id 与内容区域中的目标节点建立关联。

这种方式保持了接入方式的轻量性,不要求业务额外包裹复杂结构,也便于后续在长文档、普通内容区、Bubble 等不同渲染场景中复用。

5. 新增 content-nav 样式及变量

新增 packages/components/src/styles/components/content-nav.less,并接入统一样式入口。

样式层面主要包括:

  • 折叠态 / 展开态宽度变量
  • 容器、marker、搜索框、focus ring 等视觉变量
  • tooltip 样式
  • 搜索高亮样式
  • 目标节点反馈动画辅助样式(flash / outline)

6. 对外导出组件与类型

更新组件库导出入口和样式入口,使 ContentNav / TrContentNav 及相关公开类型可以从组件库根入口导出。

7. 补充 Bubble 侧最小共享支持

为了支持后续将内容导航能力接入 Bubble 场景,本次同步补充了 Bubble 侧一处较小但必要的基础能力:

  • BubbleBoxRendererMatch.attributes 从仅支持静态对象,扩展为支持:
    • 静态 attributes
    • 动态 resolver 函数
  • useBubbleBoxRenderer 增加了对动态 attributes 的解析逻辑

这部分改动范围较小,但为后续按消息 / 内容动态生成属性提供了更灵活的基础。

对外 API

Props

  • items
  • scrollContainer
  • activeId
  • expanded
  • query
  • placement
  • expandTrigger
  • search
  • tooltipDelay
  • targetActiveClass
  • targetActiveDuration
  • emptyText

Emits

  • update:activeId
  • update:expanded
  • update:query
  • select
  • activate

Slots

  • item
  • marker
  • search
  • empty

Summary by CodeRabbit

Release Notes

  • New Features

    • Added ContentNav component for navigable content listings with integrated search, keyboard navigation, automatic scroll tracking, configurable placement (left/right), tooltip support, and interactive feedback animations.
  • Styling

    • Added comprehensive theme-aware styling for ContentNav with animations, transitions, and responsive behaviors.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

Walkthrough

This pull request introduces a complete content navigation component system (ContentNav) with search filtering, keyboard navigation, scroll synchronization, floating positioning, and target feedback animations, alongside enhancing bubble component attribute resolution to support dynamic computation.

Changes

Cohort / File(s) Summary
Bubble Component Enhancement
packages/components/src/bubble/composables/useBubbleBoxRenderer.ts, packages/components/src/bubble/index.type.ts
Extended bubble box renderer to support dynamic attribute resolution via BubbleBoxRendererAttributesResolver function, enabling computed attributes at render time rather than static values only.
ContentNav Type Definitions
packages/components/src/content-nav/index.type.ts, packages/components/src/content-nav/internal.type.ts
Introduced comprehensive public and internal type contracts for content navigation items, search highlighting, component props/emits/slots, and behavioral options.
ContentNav UI Components
packages/components/src/content-nav/components/ContentNavItem.vue, packages/components/src/content-nav/components/ContentNavList.vue, packages/components/src/content-nav/components/ContentNavOverlay.vue, packages/components/src/content-nav/components/ContentNavSearch.vue
Added four Vue components: item-level navigation entry with hover/tooltip support, list container with empty-state handling, floating overlay with positioning/transitions, and search input with query binding.
ContentNav Composables
packages/components/src/content-nav/composables/useNavController.ts, packages/components/src/content-nav/composables/useActiveSync.ts, packages/components/src/content-nav/composables/useFloatingOffset.ts, packages/components/src/content-nav/composables/useOverlayInteractions.ts, packages/components/src/content-nav/composables/useTargetFeedback.ts, packages/components/src/content-nav/composables/index.ts
Implemented five composables managing state (expansion, query, filtering), scroll synchronization with viewport tracking, floating positioning offset calculation, overlay keyboard/mouse interactions, and target highlight animations.
ContentNav Utilities & Configuration
packages/components/src/content-nav/utils/scroll.ts, packages/components/src/content-nav/utils/target.ts, packages/components/src/content-nav/defaults.ts
Added scroll metrics helpers, DOM query utilities for navigation targets/items, and default search matching/active resolver/segment normalization logic.
ContentNav Main Component & Plugin
packages/components/src/content-nav/index.vue, packages/components/src/content-nav/index.ts
Created main component orchestrating all composables and sub-components with full scroll/keyboard/selection/feedback behavior, and registered as Vue plugin with automatic component installation.
Styling & Library Integration
packages/components/src/styles/components/content-nav.less, packages/components/src/styles/components/index.css, packages/components/src/index.ts
Added theming variables, animations (flash/outline target feedback), and integrated ContentNav into library exports.

Sequence Diagram

sequenceDiagram
    participant User
    participant ContentNav as ContentNav<br/>(Main)
    participant NavController as useNavController<br/>(State)
    participant ActiveSync as useActiveSync<br/>(Scroll Sync)
    participant Interactions as useOverlayInteractions<br/>(Keyboard/Mouse)
    participant TargetFeedback as useTargetFeedback<br/>(Animations)
    
    User->>ContentNav: Scroll viewport
    activate ContentNav
    ContentNav->>ActiveSync: sync()
    activate ActiveSync
    ActiveSync->>ActiveSync: Read scroll position,<br/>resolve anchor positions
    ActiveSync->>NavController: Update activeId
    deactivate ActiveSync
    deactivate ContentNav
    
    User->>Interactions: Press arrow key / click item
    activate Interactions
    Interactions->>NavController: setQuery() or<br/>handleNavigationKeydown()
    activate NavController
    NavController->>NavController: Filter items by search,<br/>compute highlighted index
    deactivate NavController
    deactivate Interactions
    
    User->>Interactions: Press Enter / click to select
    activate Interactions
    Interactions->>ContentNav: handleSelect(itemId)
    activate ContentNav
    ContentNav->>ActiveSync: scrollTo(id)
    activate ActiveSync
    ActiveSync->>ActiveSync: Calculate target offset,<br/>smooth scroll
    deactivate ActiveSync
    ContentNav->>TargetFeedback: activate(id)
    activate TargetFeedback
    TargetFeedback->>TargetFeedback: Apply animation class,<br/>schedule removal
    deactivate TargetFeedback
    ContentNav->>ContentNav: Emit select & activate
    deactivate ContentNav
    deactivate Interactions
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 Hop-skip through content with nav so keen,
Scroll syncs smooth, the finest I've seen!
With keyboard grace and search that gleams,
Floating overlays fulfill nav dreams.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: adding a new content navigation component to the library.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 14, 2026

✅ Preview build completed successfully!

Click the image above to preview.
Preview will be automatically removed when this PR is closed.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 14, 2026

@SonyLeo SonyLeo marked this pull request as ready for review April 16, 2026 07:55
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
packages/components/src/content-nav/composables/useFloatingOffset.ts (2)

31-48: Consider reactivity limitations of resizeTargets.

The resizeTargets computed calls getFloatingNodes() which queries the DOM via firstElementChild and querySelector. Since these DOM queries aren't reactive dependencies, the computed won't automatically update if the overlay's internal DOM structure changes after initial render.

This is likely fine if the overlay structure is stable once mounted, but worth noting if dynamic slot content could affect the measured elements.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/components/src/content-nav/composables/useFloatingOffset.ts` around
lines 31 - 48, The computed resizeTargets relies on getFloatingNodes() which
performs DOM queries (firstElementChild/querySelector) so it won't react to
later DOM changes; to fix, make the dependency explicit by tracking a reactive
ref that changes when the overlay's internal DOM updates (e.g., expose or watch
the measured/floating/host refs from getFloatingNodes or emit an updateRef), or
attach a MutationObserver inside useFloatingOffset (or getFloatingNodes) to
update a local reactive token so resizeTargets recomputes; update references to
resizeTargets/getFloatingNodes accordingly so dynamic slot content triggers
recomputation.

73-74: Consider extracting the padding constant.

The 24 pixel padding appears twice. A named constant would clarify intent and simplify future adjustments.

♻️ Optional refactor
+const FLOATING_PADDING = 24
+
 function sync() {
   // ...
-  const minTop = containerTop + 24
-  const maxTop = Math.max(minTop, containerBottom - floatingHeight - 24)
+  const minTop = containerTop + FLOATING_PADDING
+  const maxTop = Math.max(minTop, containerBottom - floatingHeight - FLOATING_PADDING)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/components/src/content-nav/composables/useFloatingOffset.ts` around
lines 73 - 74, Extract the repeated numeric padding (24) into a named constant
(e.g., const FLOATING_PADDING = 24) at the top of useFloatingOffset.ts and
replace the two literal occurrences in the calculations for minTop and maxTop
(where minTop = containerTop + 24 and maxTop = Math.max(minTop, containerBottom
- floatingHeight - 24)) with that constant to clarify intent and make future
adjustments easier.
packages/components/src/content-nav/components/ContentNavSearch.vue (1)

15-16: Consider aligning aria-label with placeholder for consistency.

When options?.placeholder is customized, the aria-label falls back to a different default ("Search content navigation") than the placeholder ("Search"). This could create a mismatch where screen reader users hear different text than what's visually displayed.

♻️ Optional alignment
-    :placeholder="props.options?.placeholder ?? 'Search'"
-    :aria-label="props.options?.placeholder ?? 'Search content navigation'"
+    :placeholder="props.options?.placeholder ?? 'Search'"
+    :aria-label="props.options?.ariaLabel ?? props.options?.placeholder ?? 'Search'"

Alternatively, if ariaLabel isn't in the options type, you could use the same fallback for both or derive a more descriptive label from the placeholder.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/components/src/content-nav/components/ContentNavSearch.vue` around
lines 15 - 16, The aria-label fallback is inconsistent with the placeholder in
ContentNavSearch.vue — update the binding for aria-label (currently using
props.options?.placeholder ?? 'Search content navigation') to match the
placeholder fallback (props.options?.placeholder ?? 'Search') or add an explicit
options.ariaLabel and use props.options?.ariaLabel ?? props.options?.placeholder
?? 'Search' so screen readers always hear the same text shown in the
placeholder; adjust the template binding accordingly (reference
props.options?.placeholder and aria-label).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/components/src/content-nav/composables/useActiveSync.ts`:
- Around line 54-58: sync() and scrollTo() currently return early when
options.container.value is falsy, preventing auto-activation and programmatic
scrolling at the document level; update both functions (sync() and scrollTo())
to use a document-level fallback when options.container.value is missing (e.g.,
treat missing container as window/document.documentElement for bounding/scroll
calculations) so targets resolved by index.vue still auto-activate and scroll
correctly; ensure you reference options.container in sync() and scrollTo() and
branch to the fallback logic for calculating target positions and performing
scroll behavior instead of bailing out.

In `@packages/components/src/content-nav/composables/useNavState.ts`:
- Around line 27-35: The matcher currently treats an empty array as a truthy
match, so update the filter logic in useNavState (around matcher.value and the
segments variable) to treat empty arrays as non-matches: when keyword is
present, return null if segments is falsy OR segments.length === 0, and only
call ensureContentNavSegments(item, segments) after confirming segments is a
non-empty array.
- Around line 16-18: The computed expanded currently chooses state based on
isManualExpandTrigger, which ties controlled/uncontrolled ownership to
expandTrigger and causes manual+uncontrolled to default false and
hover+controlled to ignore options.expanded; change expanded (and the similar
block at lines ~69-72) to derive value first from the external control if
provided, otherwise fall back to internal state—i.e. use options.expanded.value
(when defined) else localExpanded.value—so update the computed using the symbols
expanded, isManualExpandTrigger/expandTrigger, options.expanded, and
localExpanded to respect controlled vs uncontrolled regardless of trigger mode.

In `@packages/components/src/content-nav/composables/useOverlayInteractions.ts`:
- Around line 25-26: The selector-building using escapedId is unsafe when
CSS.escape is missing; update the focus logic in useOverlayInteractions (the
line using
options.overlay.value?.navEl?.querySelector<HTMLElement>(`[data-item-id="${escapedId}"]`)?.focus())
to use a safe fallback: when CSS.escape is unavailable, call
navEl.querySelectorAll('[data-item-id]'), convert to an array (Array.from), find
the element whose dataset.itemId (or dataset['itemId']) strictly equals the raw
id, and call focus() on that element if found; keep the existing path that uses
CSS.escape when available.

---

Nitpick comments:
In `@packages/components/src/content-nav/components/ContentNavSearch.vue`:
- Around line 15-16: The aria-label fallback is inconsistent with the
placeholder in ContentNavSearch.vue — update the binding for aria-label
(currently using props.options?.placeholder ?? 'Search content navigation') to
match the placeholder fallback (props.options?.placeholder ?? 'Search') or add
an explicit options.ariaLabel and use props.options?.ariaLabel ??
props.options?.placeholder ?? 'Search' so screen readers always hear the same
text shown in the placeholder; adjust the template binding accordingly
(reference props.options?.placeholder and aria-label).

In `@packages/components/src/content-nav/composables/useFloatingOffset.ts`:
- Around line 31-48: The computed resizeTargets relies on getFloatingNodes()
which performs DOM queries (firstElementChild/querySelector) so it won't react
to later DOM changes; to fix, make the dependency explicit by tracking a
reactive ref that changes when the overlay's internal DOM updates (e.g., expose
or watch the measured/floating/host refs from getFloatingNodes or emit an
updateRef), or attach a MutationObserver inside useFloatingOffset (or
getFloatingNodes) to update a local reactive token so resizeTargets recomputes;
update references to resizeTargets/getFloatingNodes accordingly so dynamic slot
content triggers recomputation.
- Around line 73-74: Extract the repeated numeric padding (24) into a named
constant (e.g., const FLOATING_PADDING = 24) at the top of useFloatingOffset.ts
and replace the two literal occurrences in the calculations for minTop and
maxTop (where minTop = containerTop + 24 and maxTop = Math.max(minTop,
containerBottom - floatingHeight - 24)) with that constant to clarify intent and
make future adjustments easier.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a7e5993e-a506-4def-9c28-295b2300f5a8

📥 Commits

Reviewing files that changed from the base of the PR and between 6a8bf29 and 64537e5.

📒 Files selected for processing (20)
  • packages/components/src/bubble/composables/useBubbleBoxRenderer.ts
  • packages/components/src/bubble/index.type.ts
  • packages/components/src/content-nav/components/ContentNavItem.vue
  • packages/components/src/content-nav/components/ContentNavList.vue
  • packages/components/src/content-nav/components/ContentNavOverlay.vue
  • packages/components/src/content-nav/components/ContentNavSearch.vue
  • packages/components/src/content-nav/composables/index.ts
  • packages/components/src/content-nav/composables/useActiveSync.ts
  • packages/components/src/content-nav/composables/useFloatingOffset.ts
  • packages/components/src/content-nav/composables/useNavState.ts
  • packages/components/src/content-nav/composables/useOverlayInteractions.ts
  • packages/components/src/content-nav/defaults.ts
  • packages/components/src/content-nav/index.ts
  • packages/components/src/content-nav/index.type.ts
  • packages/components/src/content-nav/index.vue
  • packages/components/src/content-nav/internal.type.ts
  • packages/components/src/content-nav/target.ts
  • packages/components/src/index.ts
  • packages/components/src/styles/components/content-nav.less
  • packages/components/src/styles/components/index.css

Comment thread packages/components/src/content-nav/composables/useActiveSync.ts
Comment thread packages/components/src/content-nav/composables/useNavController.ts
Comment thread packages/components/src/content-nav/composables/useNavController.ts
Comment thread packages/components/src/content-nav/composables/useOverlayInteractions.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/components/src/content-nav/index.vue (1)

45-45: Redundant computed wrapper.

items from toRefs(props) is already a Ref<ContentNavItem[]>. Wrapping it in computed(() => items.value) creates an unnecessary intermediate reactive layer.

🛠️ Suggested fix
-const itemsRef = computed(() => items.value)
+const itemsRef = items
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/components/src/content-nav/index.vue` at line 45, The computed
wrapper itemsRef is redundant because items (from toRefs(props)) is already a
Ref<ContentNavItem[]>; remove the computed(() => items.value) declaration
(itemsRef) and replace its usages with the original items ref (or items.value
where a raw array is needed), updating any references to itemsRef in the
component (template bindings, watchers, methods) to use items so you eliminate
the unnecessary intermediate reactive layer.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/components/src/content-nav/composables/useActiveSync.ts`:
- Around line 126-133: The watcher on options.activeId?.value in
useActiveSync.ts ignores transitions to undefined, leaving localActiveId stale;
change the watcher callback to always assign localActiveId.value = value (or
explicitly set localActiveId.value = undefined when value === undefined) so that
localActiveId is synced even when options.activeId becomes undefined, and keep
the watcher signature using watch(() => options.activeId?.value, (value) => {
... }) referencing options.activeId and localActiveId.

In `@packages/components/src/content-nav/target.ts`:
- Around line 7-8: Replace the CSS.escape + attribute selector branch with the
dataset-comparison fallback so we never construct a quoted attribute selector
containing unescaped quotes; specifically, remove or bypass the block that does
root.querySelector<HTMLElement>(`[${attribute}="${CSS.escape(id)}"]`) and always
use the dataset comparison approach (the same logic used in the existing
fallback path that reads element.dataset[...] or compares getAttribute values
safely) to locate the element by id/attribute, referencing the same variables
id, attribute, and root used in target.ts.

---

Nitpick comments:
In `@packages/components/src/content-nav/index.vue`:
- Line 45: The computed wrapper itemsRef is redundant because items (from
toRefs(props)) is already a Ref<ContentNavItem[]>; remove the computed(() =>
items.value) declaration (itemsRef) and replace its usages with the original
items ref (or items.value where a raw array is needed), updating any references
to itemsRef in the component (template bindings, watchers, methods) to use items
so you eliminate the unnecessary intermediate reactive layer.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c89e5dc3-a07c-4b35-9bec-77a2cb35fd42

📥 Commits

Reviewing files that changed from the base of the PR and between 64537e5 and 8af83d5.

📒 Files selected for processing (7)
  • packages/components/src/content-nav/composables/useActiveSync.ts
  • packages/components/src/content-nav/composables/useNavState.ts
  • packages/components/src/content-nav/composables/useOverlayInteractions.ts
  • packages/components/src/content-nav/defaults.ts
  • packages/components/src/content-nav/index.vue
  • packages/components/src/content-nav/scroll.ts
  • packages/components/src/content-nav/target.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/components/src/content-nav/composables/useOverlayInteractions.ts
  • packages/components/src/content-nav/composables/useNavState.ts

Comment thread packages/components/src/content-nav/composables/useActiveSync.ts
Comment thread packages/components/src/content-nav/target.ts Outdated
@SonyLeo SonyLeo force-pushed the feat/content-nav-comp branch from e62885f to ad5db97 Compare April 16, 2026 10:01
Copy link
Copy Markdown
Collaborator

@gene9831 gene9831 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image 如果第一次展开导航组件,并且悬停到未超长的文本项上,也会显示toolTip

Comment thread packages/components/src/content-nav/composables/useNavController.ts
Comment thread packages/components/src/content-nav/utils/scroll.ts
Comment thread packages/components/src/content-nav/index.type.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/components/src/content-nav/composables/useNavController.ts (1)

15-18: ⚠️ Potential issue | 🟠 Major

Controlled/uncontrolled expanded still tied to expandTrigger — not addressed from previous review.

Line 17 still returns options.expanded.value ?? false only when expandTrigger === 'manual', and Line 71 only writes localExpanded when not manual. This means:

  • expandTrigger: 'manual' + uncontrolled → expanded is stuck at false (no writer).
  • expandTrigger: 'hover' + controlled → external options.expanded is ignored.

Ownership should be decided by whether a controlled value is provided, independent of trigger mode — the same pattern already used by setQuery at Line 83.

💡 Proposed fix
-  const expanded = computed(() =>
-    isManualExpandTrigger.value ? (options.expanded.value ?? false) : localExpanded.value,
-  )
+  const expanded = computed(() => options.expanded.value ?? localExpanded.value)
@@
   function setExpanded(value: boolean) {
-    if (!isManualExpandTrigger.value) {
+    if (options.expanded.value === undefined) {
       localExpanded.value = value
     }

     options.onUpdateExpanded?.(value)

Also applies to: 70-80

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/components/src/content-nav/composables/useNavController.ts` around
lines 15 - 18, The computed ownership for expanded is wrong: decide control
based on whether options.expanded is provided, not on expandTrigger; change the
logic in isManualExpandTrigger/expanded so expanded returns
options.expanded.value when options.expanded is defined (controlled), otherwise
uses localExpanded (uncontrolled), and update any writers so setLocalExpanded
(or localExpanded mutation) is used when uncontrolled regardless of
expandTrigger — mirror the ownership pattern used by setQuery to detect
controlled vs uncontrolled and apply it to the expanded computed and its
writers.
🧹 Nitpick comments (4)
packages/components/src/content-nav/index.vue (2)

66-66: Minor: itemsRef is redundant.

items from toRefs(props) is already a Ref<ContentNavItem[]>; wrapping it in another computed only adds indirection. You can pass items directly to useActiveSync, useNavController, and use items.value in shouldRender / handleSelect.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/components/src/content-nav/index.vue` at line 66, Remove the
redundant computed wrapper itemsRef and use the original Ref from toRefs(props)
directly: replace usages of itemsRef with items (pass items into useActiveSync
and useNavController, and use items.value inside shouldRender and handleSelect)
so you eliminate the extra indirection introduced by const itemsRef =
computed(() => items.value).

171-178: Edge case: id-list key via join(',') can false-positive / false-negative.

Joining with ',' means items [{id:'a,b'}] and [{id:'a'},{id:'b'}] produce the same key, and comma-free id reorders still trigger correctly. Low risk in practice, but using JSON.stringify(items.value.map(i => i.id)) or a separator that cannot appear in ids (e.g. '\u0000') avoids the ambiguity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/components/src/content-nav/index.vue` around lines 171 - 178, The
current watch key uses itemsRef.value.map(item => item.id).join(',') which can
collide for ids containing commas or different shapes producing the same string;
update the watch key to a collision-safe serialization such as
JSON.stringify(itemsRef.value.map(i => i.id)) (or use a non-possible separator
like '\u0000') while keeping the same callback (active.clearPendingScroll and
scheduleMeasure) and the { immediate: true } option so the reactive behavior of
the watch, the referenced itemsRef, and the actions in the callback remain
unchanged.
packages/components/src/styles/components/content-nav.less (1)

49-77: Honor prefers-reduced-motion for target-flash/outline animations.

Users with reduced-motion preferences will still see the flash/outline pulse on navigation. The useActiveSync composable already respects this for scroll; the CSS target feedback should match.

♻️ Suggested addition
 `@keyframes` tr-content-nav-target-outline {
   0% {
     box-shadow: 0 0 0 2px color-mix(in srgb, var(--tr-color-primary) 42%, transparent);
   }

   100% {
     box-shadow: none;
   }
 }
+
+@media (prefers-reduced-motion: reduce) {
+  .tr-content-nav-target--flash,
+  .tr-content-nav-target--outline {
+    animation: none;
+  }
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/components/src/styles/components/content-nav.less` around lines 49 -
77, The target flash/outline animations (.tr-content-nav-target--flash,
.tr-content-nav-target--outline) and their keyframes
(tr-content-nav-target-flash, tr-content-nav-target-outline) do not respect
users' prefers-reduced-motion setting; wrap a media query for
prefers-reduced-motion: reduce that disables these animations (set animation:
none and remove any transitional box-shadow/background effects) so the classes
produce no motion for reduced-motion users, ensuring the CSS feedback matches
the existing useActiveSync behavior.
packages/components/src/content-nav/utils/target.ts (1)

6-16: Optional: use attribute-value selectors instead of scanning all matches.

querySelectorAll(selector) enumerates every element carrying the attribute before .find filters by value, which is O(N) per lookup. A direct attribute-equality selector with CSS.escape lets the browser use its indexed lookup and removes the dataset-key duplication.

♻️ Suggested refactor
-function queryByDataAttribute(root: ParentNode, selector: string, datasetKey: string, id: string) {
-  return Array.from(root.querySelectorAll<HTMLElement>(selector)).find((entry) => entry.dataset[datasetKey] === id)
-}
-
-export function queryContentNavTargetById(root: ParentNode, id: string) {
-  return queryByDataAttribute(root, CONTENT_NAV_TARGET_SELECTOR, 'contentNavId', id)
-}
-
-export function queryContentNavItemById(root: ParentNode, id: string) {
-  return queryByDataAttribute(root, CONTENT_NAV_ITEM_SELECTOR, 'itemId', id)
-}
+function queryByAttributeValue(root: ParentNode, attribute: string, id: string) {
+  return root.querySelector<HTMLElement>(`[${attribute}="${CSS.escape(id)}"]`) ?? undefined
+}
+
+export function queryContentNavTargetById(root: ParentNode, id: string) {
+  return queryByAttributeValue(root, CONTENT_NAV_TARGET_ATTRIBUTE, id)
+}
+
+export function queryContentNavItemById(root: ParentNode, id: string) {
+  return queryByAttributeValue(root, CONTENT_NAV_ITEM_ATTRIBUTE, id)
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/components/src/content-nav/utils/target.ts` around lines 6 - 16,
queryByDataAttribute currently enumerates all elements then filters by dataset
value; change it to use a direct attribute-equality selector with CSS.escape to
let the browser do the lookup. Update queryByDataAttribute(root, selector,
datasetKey, id) to compute the corresponding data-attribute name by converting
datasetKey from camelCase to kebab-case (e.g., contentNavId -> content-nav-id),
build a selector like `${selector}[data-${attrName}="${CSS.escape(id)}"]`, and
call root.querySelector<HTMLElement>(thatSelector) (removing
Array.from(...).find). Keep the existing callers queryContentNavTargetById and
queryContentNavItemById unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/components/src/content-nav/index.vue`:
- Around line 54-55: resolvedSearchOptions can be boolean true when consumers
pass search: true, so searchSlotOptions currently becomes true (because true ??
{} === true) and that boolean is passed to the options prop of
<ContentNavSearch> and the scoped-slot payload; change the computed for
searchSlotOptions to coerce non-object truthy values into an empty object (e.g.,
check typeof resolvedSearchOptions.value !== 'object' ||
resolvedSearchOptions.value === null and return {} in that case) so that
searchSlotOptions always yields a ContentNavSearchOptions object for use by the
options prop and slot.

In `@packages/components/src/styles/components/content-nav.less`:
- Around line 39-45: Stylelint is failing to parse Less's each(`@vars`, { … })
syntax; install and add postcss-less to the project and update your Stylelint
config to use it (set customSyntax: "postcss-less" in .stylelintrc.json) or
replace stylelint-config-standard-scss with stylelint-config-standard-less so
constructs like each(`@vars`, { … }) and variable interpolation
(--@{prefix}-@{key}) are recognized; update the config and rerun linting.

---

Duplicate comments:
In `@packages/components/src/content-nav/composables/useNavController.ts`:
- Around line 15-18: The computed ownership for expanded is wrong: decide
control based on whether options.expanded is provided, not on expandTrigger;
change the logic in isManualExpandTrigger/expanded so expanded returns
options.expanded.value when options.expanded is defined (controlled), otherwise
uses localExpanded (uncontrolled), and update any writers so setLocalExpanded
(or localExpanded mutation) is used when uncontrolled regardless of
expandTrigger — mirror the ownership pattern used by setQuery to detect
controlled vs uncontrolled and apply it to the expanded computed and its
writers.

---

Nitpick comments:
In `@packages/components/src/content-nav/index.vue`:
- Line 66: Remove the redundant computed wrapper itemsRef and use the original
Ref from toRefs(props) directly: replace usages of itemsRef with items (pass
items into useActiveSync and useNavController, and use items.value inside
shouldRender and handleSelect) so you eliminate the extra indirection introduced
by const itemsRef = computed(() => items.value).
- Around line 171-178: The current watch key uses itemsRef.value.map(item =>
item.id).join(',') which can collide for ids containing commas or different
shapes producing the same string; update the watch key to a collision-safe
serialization such as JSON.stringify(itemsRef.value.map(i => i.id)) (or use a
non-possible separator like '\u0000') while keeping the same callback
(active.clearPendingScroll and scheduleMeasure) and the { immediate: true }
option so the reactive behavior of the watch, the referenced itemsRef, and the
actions in the callback remain unchanged.

In `@packages/components/src/content-nav/utils/target.ts`:
- Around line 6-16: queryByDataAttribute currently enumerates all elements then
filters by dataset value; change it to use a direct attribute-equality selector
with CSS.escape to let the browser do the lookup. Update
queryByDataAttribute(root, selector, datasetKey, id) to compute the
corresponding data-attribute name by converting datasetKey from camelCase to
kebab-case (e.g., contentNavId -> content-nav-id), build a selector like
`${selector}[data-${attrName}="${CSS.escape(id)}"]`, and call
root.querySelector<HTMLElement>(thatSelector) (removing Array.from(...).find).
Keep the existing callers queryContentNavTargetById and queryContentNavItemById
unchanged.

In `@packages/components/src/styles/components/content-nav.less`:
- Around line 49-77: The target flash/outline animations
(.tr-content-nav-target--flash, .tr-content-nav-target--outline) and their
keyframes (tr-content-nav-target-flash, tr-content-nav-target-outline) do not
respect users' prefers-reduced-motion setting; wrap a media query for
prefers-reduced-motion: reduce that disables these animations (set animation:
none and remove any transitional box-shadow/background effects) so the classes
produce no motion for reduced-motion users, ensuring the CSS feedback matches
the existing useActiveSync behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c71b86d6-b177-460f-9d3d-00dd81028aff

📥 Commits

Reviewing files that changed from the base of the PR and between 8af83d5 and ce681fc.

📒 Files selected for processing (13)
  • packages/components/src/content-nav/components/ContentNavItem.vue
  • packages/components/src/content-nav/components/ContentNavList.vue
  • packages/components/src/content-nav/composables/index.ts
  • packages/components/src/content-nav/composables/useActiveSync.ts
  • packages/components/src/content-nav/composables/useNavController.ts
  • packages/components/src/content-nav/composables/useOverlayInteractions.ts
  • packages/components/src/content-nav/composables/useTargetFeedback.ts
  • packages/components/src/content-nav/index.type.ts
  • packages/components/src/content-nav/index.vue
  • packages/components/src/content-nav/internal.type.ts
  • packages/components/src/content-nav/utils/scroll.ts
  • packages/components/src/content-nav/utils/target.ts
  • packages/components/src/styles/components/content-nav.less
✅ Files skipped from review due to trivial changes (1)
  • packages/components/src/content-nav/index.type.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/components/src/content-nav/composables/index.ts
  • packages/components/src/content-nav/composables/useOverlayInteractions.ts
  • packages/components/src/content-nav/components/ContentNavList.vue
  • packages/components/src/content-nav/components/ContentNavItem.vue
  • packages/components/src/content-nav/internal.type.ts
  • packages/components/src/content-nav/composables/useActiveSync.ts

Comment thread packages/components/src/content-nav/index.vue Outdated
Comment thread packages/components/src/styles/components/content-nav.less
@SonyLeo
Copy link
Copy Markdown
Collaborator Author

SonyLeo commented Apr 21, 2026

Image 如果第一次展开导航组件,并且悬停到未超长的文本项上,也会显示toolTip

该问题已经解决,详见在线示例

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants