fix(tabs): 修复 showMoreTabs 模式下 overflow 计算滞后导致下拉菜单未及时更新(#4279) (#4272)

* fix(tabs): fix showMoreTabs overflow calculation lag caused by CSS transition

The nav element has CSS transition: transform 0.3s, which causes
getBoundingClientRect() to return pre-transition positions when
calcMorePanes runs in requestAnimationFrame (before the transition
has advanced).

Fix: use tab-relative-to-nav position delta (transition-invariant
since both share the same transform coordinate space) plus navOffset
to compute the final expected positions, bypassing the transition
intermediate state entirely.

Also replace nextTick with requestAnimationFrame in deferred
calcMorePanes calls to ensure all cascading DOM updates from
scrollToActiveTab -> updated() chains have settled.

* fix(tab-nav): trigger calcMorePanes after scrollPrev/scrollNext arrow click

* test(tabs): 修正 custom-more-icon 溢出下拉数量断言

showMoreTabs 溢出计算修正后按真实布局计算,容器 400px 减去
更多按钮占位后仅 5 个 tab 完整可见,下拉应为 Tab 6/7/8 共 3 项,
原断言 2 是基于旧实现 mount 早期量宽了 navScroll 的错误结果。
This commit is contained in:
vitlot-Hongchujie 2026-08-05 09:19:25 +08:00 committed by GitHub
parent f83a76b7c3
commit fdb2129f86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 65 additions and 22 deletions

View File

@ -18,7 +18,8 @@ test('"定义更多按钮"', async ({ page }) => {
await expect(listWidth).toBeGreaterThan(boxWidth)
await expect(tabItems).toHaveCount(8)
await expect(dropdownItems).toHaveCount(2)
// 溢出计算修正后按真实布局计算:容器 400px 减去"更多"按钮占位后,仅 5 个 tab 完整可见,下拉为 Tab 6/7/8 共 3 项
await expect(dropdownItems).toHaveCount(3)
await expect(headerBox).toHaveCSS('overflow', 'hidden')
await moreIcon.hover()
await dropdownLastItem.click()

View File

@ -229,6 +229,11 @@ export const scrollPrev =
const newOffset = currentOffset > containerSize ? currentOffset - containerSize : 0
state.navOffset = newOffset
// 箭头滚动后通知父级 Tabs 重新计算溢出,用 rAF 等待级联 DOM 更新落定
requestAnimationFrame(() => {
;(state.rootTabs as Record<string, unknown>)?.calcMorePanes?.()
})
}
export const scrollNext =
@ -246,6 +251,11 @@ export const scrollNext =
navSize - currentOffset > containerSize * 2 ? currentOffset + containerSize : navSize - containerSize
state.navOffset = newOffset
// 箭头滚动后通知父级 Tabs 重新计算溢出,用 rAF 等待级联 DOM 更新落定
requestAnimationFrame(() => {
;(state.rootTabs as Record<string, unknown>)?.calcMorePanes?.()
})
}
/* istanbul ignore next */

View File

@ -121,7 +121,7 @@ export const calcMorePanes =
const el = parent.$el
const tabs = el.querySelectorAll('.tiny-tabs__item')
const tabNavRefs = refs.nav.$refs
const navScrollEl = refs.nav.$refs.navScroll as HTMLElement
// 此处不同步aui。新规范适配
if (props.moreShowAll) {
@ -129,29 +129,40 @@ export const calcMorePanes =
return
}
if (tabs && tabs.length) {
let tabsAllWidth = 0
if (!tabs || !tabs.length) {
return
}
if (state.currentIndex === -1) {
state.currentIndex = state.panes.findIndex((item) => item.state.paneName === state.currentName)
}
const currentIndex = state.currentIndex < 0 ? 0 : state.currentIndex
const tabsHeaderWidth = tabNavRefs.navScroll.offsetWidth
if (state.currentIndex === -1) {
state.currentIndex = state.panes.findIndex((item) => item.state.paneName === state.currentName)
}
for (let i = 0; i < tabs.length; i++) {
const tabItem = tabs[i] as HTMLElement
// 遮住元素一半则隐藏
tabsAllWidth = tabItem.offsetLeft + tabItem.offsetWidth / 2
if (tabsAllWidth > tabsHeaderWidth && currentIndex >= 0) {
if (currentIndex >= i + 1) {
state.showPanesCount = currentIndex
} else {
state.showPanesCount = i
}
break
}
const navScrollRect = navScrollEl.getBoundingClientRect()
const navScrollLeft = navScrollRect.left
const navScrollRight = navScrollRect.right
// nav 元素上有 CSS transition: transform 0.3s
// 导致 scrollToActiveTab 改变 navOffset 后 getBoundingClientRect 返回过渡前的坐标。
// 解决方案:用 tab 相对于 nav 的位置差(不受 transition 影响)+ navOffset 推算最终位置。
const navEl = refs.nav.$refs.nav as HTMLElement
const navRect = navEl.getBoundingClientRect()
const stateNavOffset = ((refs.nav?.state as Record<string, unknown>)?.navOffset as number) || 0
for (let i = 0; i < tabs.length; i++) {
const tabRect = tabs[i].getBoundingClientRect()
// tabOffsetInNav 不受 CSS transition 影响,因为 tab 和 nav 共享同一个 transform 坐标系
const tabOffsetInNav = tabRect.left - navRect.left
// 计算 navOffset 完全生效后的最终视口中心位置
const finalCenter = navScrollLeft + tabOffsetInNav + tabRect.width / 2 - stateNavOffset
// 遮住元素一半则隐藏
if (finalCenter > navScrollRight) {
state.showPanesCount = i
return
}
}
// 所有 tab 都在可视范围内,无需下拉菜单
state.showPanesCount = tabs.length
}
export const calcExpandPanes =

View File

@ -38,6 +38,7 @@ export const api = [
'state',
'handleTabAdd',
'calcPaneInstances',
'calcMorePanes',
'handleTabRemove',
'handleTabClick',
'handleTabDragStart',
@ -83,6 +84,11 @@ const initWatcher = ({
() => {
nextTick(() => {
refs.nav.scrollToActiveTab()
// scrollToActiveTab → updated 调整 navOffset 可能触发多轮 DOM 更新,
// 用 rAF 确保所有布局计算完成后再读取 getBoundingClientRect消除滞后
requestAnimationFrame(() => {
api.calcMorePanes()
})
})
},
{ deep: true }
@ -137,12 +143,21 @@ export const renderless = (
api.calcPaneInstances()
api.calcMorePanes()
api.calcExpandPanes()
// TabNav 在 render 中通过 $nextTick 异步 $forceUpdate
// 用 rAF 确保所有级联 DOM 更新完成后再重算
requestAnimationFrame(() => {
api.calcMorePanes()
})
})
onUpdated(() => {
api.calcPaneInstances()
api.calcMorePanes()
api.calcExpandPanes()
// 同上
requestAnimationFrame(() => {
api.calcMorePanes()
})
})
onUnmounted(() => {

View File

@ -145,7 +145,13 @@ export default defineComponent({
const TabNavComponent = h(TabNav, { ...navData })
this.$nextTick(() => {
this.$refs.nav && this.$refs.nav.$forceUpdate()
if (this.$refs.nav) {
this.$refs.nav.$forceUpdate()
// rAF DOM
requestAnimationFrame(() => {
this.calcMorePanes()
})
}
})
const header = (