vue-stack-tabs 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/StackTabs.vue.d.ts +2 -2
- package/dist/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -2
- package/dist/hooks/stackTabsContext.d.ts +0 -1
- package/dist/hooks/tabPanel/evict.d.ts +2 -2
- package/dist/hooks/tabPanel/scroll.d.ts +0 -2
- package/dist/hooks/tabPanel/state.d.ts +0 -1
- package/dist/hooks/useIframeManager.d.ts +1 -1
- package/dist/hooks/useScrollShadow.d.ts +11 -2
- package/dist/hooks/useTabEventBus.d.ts +5 -0
- package/dist/model/TabModel.d.ts +12 -5
- package/dist/nuxt/module.mjs +1 -1
- package/dist/nuxt/runtime/plugin.mjs +8 -1598
- package/dist/style.css +1 -1
- package/dist/utils/scrollUtils.d.ts +0 -2
- package/dist/utils/tabInfoEncoder.d.ts +0 -2
- package/dist/utils/urlParser.d.ts +6 -7
- package/dist/vue-stack-tabs.es.js +1 -1
- package/package.json +1 -1
- package/src/lib/StackTabs.vue +16 -1
- package/src/lib/assets/style/stack-tab.scss +12 -6
- package/src/lib/components/PageLoading.vue +10 -9
- package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +1 -5
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +164 -90
- package/src/lib/components/TabHeader/index.vue +51 -10
- package/src/lib/hooks/stackTabsContext.ts +0 -1
- package/src/lib/hooks/tabPanel/evict.ts +14 -6
- package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +211 -42
- package/src/lib/hooks/tabPanel/scroll.ts +4 -15
- package/src/lib/hooks/tabPanel/session.ts +11 -4
- package/src/lib/hooks/tabPanel/state.ts +0 -2
- package/src/lib/hooks/tabPanel/tabStore.ts +28 -10
- package/src/lib/hooks/useIframeManager.ts +11 -15
- package/src/lib/hooks/useScrollShadow.ts +74 -19
- package/src/lib/hooks/useTabActions.ts +10 -4
- package/src/lib/hooks/useTabEventBus.ts +3 -0
- package/src/lib/hooks/useTabPanel.tsx +46 -34
- package/src/lib/model/TabModel.ts +14 -5
- package/src/lib/nuxt/module.ts +3 -1
- package/src/lib/nuxt/runtime/plugin.ts +7 -3
- package/src/lib/utils/scrollUtils.ts +0 -18
- package/src/lib/utils/tabInfoEncoder.ts +21 -2
- package/src/lib/utils/urlParser.ts +40 -37
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
TabHeaderScroll - 可横向滚动的标签栏容器
|
|
3
3
|
|
|
4
4
|
职责:左右箭头滚动、滚轮滚动、自定义滚动条拖拽、确保激活标签在可视区域内
|
|
5
|
+
|
|
6
|
+
滚动条是自绘的:原生滚动条隐藏,滚动位置通过 scrollLeft 驱动。
|
|
7
|
+
滚动过程中滑块位置直接写 DOM(不经过响应式),避免每帧触发组件重渲染、
|
|
8
|
+
进而重新执行 slot 并 patch 整个标签列表。
|
|
5
9
|
-->
|
|
6
10
|
<template>
|
|
7
11
|
<tab-header-button
|
|
@@ -35,11 +39,11 @@
|
|
|
35
39
|
ref="thumb"
|
|
36
40
|
class="stack-tab__scrollbar-thumb"
|
|
37
41
|
:class="{ 'stack-tab__scrollbar-thumb--dragging': scrollDragging }"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@
|
|
42
|
+
@pointerdown.prevent="handleScrollbarDragStart"
|
|
43
|
+
@pointermove="handleScrollbarDragMove"
|
|
44
|
+
@pointerup="handleScrollbarDragEnd"
|
|
45
|
+
@pointercancel="handleScrollbarDragEnd"
|
|
46
|
+
@lostpointercapture="handleScrollbarDragEnd"
|
|
43
47
|
/>
|
|
44
48
|
</div>
|
|
45
49
|
</div>
|
|
@@ -55,8 +59,8 @@
|
|
|
55
59
|
|
|
56
60
|
<script lang="ts" setup>
|
|
57
61
|
import { useI18n } from 'vue-i18n-lite'
|
|
58
|
-
import {
|
|
59
|
-
import type { ScrollData
|
|
62
|
+
import { ref, onMounted, watch, onUnmounted } from 'vue'
|
|
63
|
+
import type { ScrollData } from '../../model/TabModel'
|
|
60
64
|
import {
|
|
61
65
|
scrollTo as scrollToUtil,
|
|
62
66
|
scrollIntoView as scrollIntoViewUtil
|
|
@@ -79,6 +83,8 @@ const isDisabledLeftButton = ref<boolean>(false)
|
|
|
79
83
|
const isDisabledRightButton = ref<boolean>(false)
|
|
80
84
|
/** 是否显示左右滚动按钮:必须启用按钮模式且内容横向溢出 */
|
|
81
85
|
const isScrollButtonVisible = ref<boolean>(false)
|
|
86
|
+
/** 内容是否超出容器宽度,需要显示滚动条 */
|
|
87
|
+
const hasScroller = ref<boolean>(false)
|
|
82
88
|
/** 外层滚动区域,用于 ResizeObserver */
|
|
83
89
|
const headerScroll = ref<HTMLElement>()
|
|
84
90
|
const props = withDefaults(
|
|
@@ -96,66 +102,84 @@ const props = withDefaults(
|
|
|
96
102
|
isScrollButton: true
|
|
97
103
|
}
|
|
98
104
|
)
|
|
99
|
-
|
|
100
|
-
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 滚动容器的尺寸与位置。
|
|
108
|
+
* 刻意保持非响应式:滚动时每帧更新它,若走 reactive 会触发组件重渲染。
|
|
109
|
+
*/
|
|
110
|
+
const scrollData: ScrollData = {
|
|
101
111
|
clientWidth: 0,
|
|
102
112
|
scrollWidth: 0,
|
|
103
113
|
scrollLeft: 0
|
|
104
|
-
})
|
|
105
|
-
/** 拖拽滚动条时的起始状态 */
|
|
106
|
-
const dragData = reactive<DragData>({
|
|
107
|
-
thumbLeft: 0,
|
|
108
|
-
startScrollLeft: 0,
|
|
109
|
-
startThumbLeft: 0,
|
|
110
|
-
startPageX: 0
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
/** 滚动数据变化时更新箭头与滚动条的启用状态 */
|
|
114
|
-
watch(scrollData, (data) => {
|
|
115
|
-
updateScrollButtonState(data)
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
watch(
|
|
119
|
-
() => props.isScrollButton,
|
|
120
|
-
() => updateScrollButtonState(scrollData)
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
/** 根据滚动位置更新左右箭头禁用状态及滚动按钮可见性 */
|
|
124
|
-
const updateScrollButtonState = (data: ScrollData) => {
|
|
125
|
-
const hasHorizontalOverflow = data.scrollWidth > data.clientWidth
|
|
126
|
-
|
|
127
|
-
isDisabledLeftButton.value = data.scrollLeft <= 10
|
|
128
|
-
isDisabledRightButton.value = data.clientWidth + data.scrollLeft - data.scrollWidth >= -10
|
|
129
|
-
isScrollButtonVisible.value = props.isScrollButton && hasHorizontalOverflow
|
|
130
114
|
}
|
|
131
115
|
|
|
132
|
-
/**
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
116
|
+
/** 拖拽起始状态(非响应式,避免 pointermove 触发渲染) */
|
|
117
|
+
const dragData = {
|
|
118
|
+
startPageX: 0,
|
|
119
|
+
startThumbLeft: 0
|
|
120
|
+
}
|
|
121
|
+
/** 拖拽指针位置与 rAF 句柄,用于合并高频 pointermove */
|
|
122
|
+
let dragRafId = 0
|
|
123
|
+
let pendingPageX = 0
|
|
124
|
+
let capturedPointerId: number | null = null
|
|
136
125
|
|
|
137
126
|
/** 滚动条滑块宽度(按内容比例计算) */
|
|
138
|
-
const
|
|
139
|
-
if (!hasScroller.value) return 0
|
|
140
|
-
|
|
127
|
+
const getThumbWidth = (): number => {
|
|
141
128
|
const { clientWidth, scrollWidth } = scrollData
|
|
129
|
+
if (scrollWidth <= clientWidth || scrollWidth === 0) return 0
|
|
130
|
+
|
|
142
131
|
return (clientWidth / scrollWidth) * clientWidth
|
|
143
|
-
}
|
|
132
|
+
}
|
|
144
133
|
|
|
145
134
|
/** 滚动条滑块 left 偏移(与 scrollLeft 对应) */
|
|
146
|
-
const
|
|
147
|
-
|
|
135
|
+
const getThumbLeft = (scrollLeft: number): number => {
|
|
136
|
+
const { clientWidth, scrollWidth } = scrollData
|
|
137
|
+
const maxScrollLeft = scrollWidth - clientWidth
|
|
138
|
+
if (maxScrollLeft <= 0) return 0
|
|
148
139
|
|
|
149
|
-
|
|
140
|
+
return (clientWidth - getThumbWidth()) * (scrollLeft / maxScrollLeft)
|
|
141
|
+
}
|
|
150
142
|
|
|
151
|
-
|
|
152
|
-
|
|
143
|
+
/**
|
|
144
|
+
* 直接写滑块样式,绕过模板绑定。
|
|
145
|
+
* 这是滚动路径上唯一的 DOM 写入,不触发组件重渲染。
|
|
146
|
+
*/
|
|
147
|
+
const updateThumb = (scrollLeft: number) => {
|
|
148
|
+
const thumbEl = thumb.value
|
|
149
|
+
if (!thumbEl || !hasScroller.value) return
|
|
150
|
+
|
|
151
|
+
thumbEl.style.width = `${getThumbWidth()}px`
|
|
152
|
+
thumbEl.style.transform = `translateX(${getThumbLeft(scrollLeft)}px)`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 根据滚动位置更新左右箭头禁用状态 */
|
|
156
|
+
const updateScrollButtonState = () => {
|
|
157
|
+
isDisabledLeftButton.value = scrollData.scrollLeft <= 10
|
|
158
|
+
isDisabledRightButton.value =
|
|
159
|
+
scrollData.clientWidth + scrollData.scrollLeft - scrollData.scrollWidth >= -10
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 更新溢出判断与滚动按钮可见性 */
|
|
163
|
+
const updateScrollVisibility = () => {
|
|
164
|
+
hasScroller.value = scrollData.scrollWidth > scrollData.clientWidth
|
|
165
|
+
isScrollButtonVisible.value = props.isScrollButton && hasScroller.value
|
|
166
|
+
}
|
|
153
167
|
|
|
154
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* 同步容器尺寸与滚动位置。
|
|
170
|
+
* scroll 与 ResizeObserver 共用:尺寸只在滚动时顺带读取,不写入响应式状态,
|
|
171
|
+
* 因此不会触发组件重渲染。
|
|
172
|
+
*/
|
|
155
173
|
const updateScrollState = () => {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
174
|
+
const el = container.value
|
|
175
|
+
if (!el) return
|
|
176
|
+
|
|
177
|
+
scrollData.clientWidth = el.clientWidth
|
|
178
|
+
scrollData.scrollWidth = el.scrollWidth
|
|
179
|
+
scrollData.scrollLeft = el.scrollLeft
|
|
180
|
+
updateScrollVisibility()
|
|
181
|
+
updateScrollButtonState()
|
|
182
|
+
updateThumb(scrollData.scrollLeft)
|
|
159
183
|
}
|
|
160
184
|
|
|
161
185
|
/** 确保激活的标签在可视区域内 */
|
|
@@ -170,6 +194,8 @@ const ensureActiveTabInView = () => {
|
|
|
170
194
|
|
|
171
195
|
let scrollContainerResizeObserver: ResizeObserver | null = null
|
|
172
196
|
let tabListResizeObserver: ResizeObserver | null = null
|
|
197
|
+
/** 上一次观察到的标签数量,用于区分“标签增删”和普通的尺寸抖动 */
|
|
198
|
+
let lastTabCount = 0
|
|
173
199
|
/** 滚动到指定 left 位置 */
|
|
174
200
|
const scrollTo = (left: number, smooth = true) => {
|
|
175
201
|
scrollToUtil({ wrap: container.value, left, top: 0, smooth })
|
|
@@ -181,62 +207,103 @@ const handleScrollButtonClick = (delta: number) => {
|
|
|
181
207
|
|
|
182
208
|
const handleWheelScroll = (e: WheelEvent) => {
|
|
183
209
|
if (!props.isScrollWheel || !container.value) return
|
|
210
|
+
// 触控板横向手势交给原生滚动,避免与下面的定距滚动叠加
|
|
211
|
+
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return
|
|
212
|
+
if (!e.deltaY) return
|
|
184
213
|
|
|
185
214
|
const isScrollUp = e.deltaY < 0
|
|
186
215
|
scrollTo(container.value.scrollLeft + (isScrollUp ? -props.space : props.space))
|
|
187
216
|
}
|
|
188
217
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
218
|
+
/** 应用一次拖拽位移(在 rAF 中执行,合并高频 pointermove) */
|
|
219
|
+
const applyDrag = () => {
|
|
220
|
+
dragRafId = 0
|
|
221
|
+
if (!scrollDragging.value || !container.value) return
|
|
222
|
+
|
|
223
|
+
const { clientWidth, scrollWidth } = scrollData
|
|
224
|
+
const maxScrollLeft = scrollWidth - clientWidth
|
|
225
|
+
const maxThumbLeft = clientWidth - getThumbWidth()
|
|
226
|
+
if (maxScrollLeft <= 0 || maxThumbLeft <= 0) return
|
|
227
|
+
|
|
228
|
+
const thumbLeft = Math.min(
|
|
229
|
+
Math.max(dragData.startThumbLeft + pendingPageX - dragData.startPageX, 0),
|
|
230
|
+
maxThumbLeft
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
// 同步滚动与滑块位置,拖拽跟手,不依赖异步 scroll 事件
|
|
234
|
+
scrollToUtil({
|
|
235
|
+
wrap: container.value,
|
|
236
|
+
left: (thumbLeft * maxScrollLeft) / maxThumbLeft,
|
|
237
|
+
top: 0,
|
|
238
|
+
smooth: false
|
|
195
239
|
})
|
|
240
|
+
if (thumb.value) {
|
|
241
|
+
thumb.value.style.transform = `translateX(${thumbLeft}px)`
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const handleScrollbarDragStart = (e: PointerEvent) => {
|
|
246
|
+
const thumbEl = thumb.value
|
|
247
|
+
const isPrimaryButton = e.pointerType !== 'mouse' || e.button === 0
|
|
248
|
+
if (!thumbEl || !isPrimaryButton) return
|
|
249
|
+
|
|
250
|
+
const scrollLeft = container.value?.scrollLeft ?? 0
|
|
251
|
+
dragData.startPageX = e.pageX
|
|
252
|
+
dragData.startThumbLeft = getThumbLeft(scrollLeft)
|
|
253
|
+
pendingPageX = e.pageX
|
|
196
254
|
scrollDragging.value = true
|
|
197
|
-
|
|
198
|
-
|
|
255
|
+
capturedPointerId = e.pointerId
|
|
256
|
+
thumbEl.setPointerCapture?.(e.pointerId)
|
|
199
257
|
}
|
|
200
258
|
|
|
201
|
-
const handleScrollbarDragMove = (e:
|
|
202
|
-
|
|
203
|
-
let thumbLeft = dragData.startThumbLeft + e.pageX - dragData.startPageX
|
|
204
|
-
const maxThumbLeft = clientWidth - thumbWidth.value
|
|
259
|
+
const handleScrollbarDragMove = (e: PointerEvent) => {
|
|
260
|
+
if (!scrollDragging.value) return
|
|
205
261
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
thumbLeft = maxThumbLeft
|
|
210
|
-
}
|
|
211
|
-
// 更新滑块定位
|
|
212
|
-
dragData.thumbLeft = thumbLeft
|
|
213
|
-
// 滚动
|
|
214
|
-
scrollTo((thumbLeft * (scrollWidth - clientWidth)) / (clientWidth - thumbWidth.value), false)
|
|
215
|
-
e.preventDefault()
|
|
262
|
+
pendingPageX = e.pageX
|
|
263
|
+
if (dragRafId) return
|
|
264
|
+
dragRafId = requestAnimationFrame(applyDrag)
|
|
216
265
|
}
|
|
217
266
|
|
|
218
267
|
const handleScrollbarDragEnd = () => {
|
|
268
|
+
if (dragRafId) {
|
|
269
|
+
cancelAnimationFrame(dragRafId)
|
|
270
|
+
dragRafId = 0
|
|
271
|
+
}
|
|
219
272
|
scrollDragging.value = false
|
|
220
|
-
|
|
221
|
-
|
|
273
|
+
|
|
274
|
+
const pointerId = capturedPointerId
|
|
275
|
+
capturedPointerId = null
|
|
276
|
+
const thumbEl = thumb.value
|
|
277
|
+
if (thumbEl && pointerId !== null && thumbEl.hasPointerCapture?.(pointerId)) {
|
|
278
|
+
thumbEl.releasePointerCapture(pointerId)
|
|
279
|
+
}
|
|
222
280
|
}
|
|
223
281
|
|
|
224
282
|
onMounted(() => {
|
|
225
|
-
if (typeof ResizeObserver
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
283
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
284
|
+
scrollContainerResizeObserver = new ResizeObserver(() => {
|
|
285
|
+
updateScrollState()
|
|
286
|
+
ensureActiveTabInView()
|
|
287
|
+
})
|
|
288
|
+
tabListResizeObserver = new ResizeObserver(() => {
|
|
289
|
+
// 只有标签增删(数量变化)才需要纠正滚动位置。
|
|
290
|
+
// hover 展开关闭按钮、标题动态变化等也会改变列表宽度并触发本回调,
|
|
291
|
+
// 若此时无条件 ensureActiveTabInView,会把用户主动滚开的标签栏强行拉回 active 标签。
|
|
292
|
+
const tabCount = container.value?.querySelectorAll('.stack-tab__item').length ?? 0
|
|
293
|
+
const tabCountChanged = tabCount !== lastTabCount
|
|
294
|
+
lastTabCount = tabCount
|
|
295
|
+
|
|
296
|
+
if (hasScroller.value && tabCountChanged) ensureActiveTabInView()
|
|
297
|
+
updateScrollState()
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
if (headerScroll.value) scrollContainerResizeObserver.observe(headerScroll.value)
|
|
301
|
+
const tabNav = container.value?.querySelector('.stack-tab__nav')
|
|
302
|
+
lastTabCount = container.value?.querySelectorAll('.stack-tab__item').length ?? 0
|
|
303
|
+
if (tabNav) tabListResizeObserver.observe(tabNav)
|
|
304
|
+
}
|
|
236
305
|
|
|
237
|
-
|
|
238
|
-
const tabNav = container.value?.querySelector('.stack-tab__nav')
|
|
239
|
-
if (tabNav) tabListResizeObserver.observe(tabNav)
|
|
306
|
+
updateScrollState()
|
|
240
307
|
})
|
|
241
308
|
onUnmounted(() => {
|
|
242
309
|
handleScrollbarDragEnd()
|
|
@@ -246,6 +313,13 @@ onUnmounted(() => {
|
|
|
246
313
|
tabListResizeObserver = null
|
|
247
314
|
})
|
|
248
315
|
|
|
316
|
+
watch(
|
|
317
|
+
() => props.isScrollButton,
|
|
318
|
+
() => {
|
|
319
|
+
isScrollButtonVisible.value = props.isScrollButton && hasScroller.value
|
|
320
|
+
}
|
|
321
|
+
)
|
|
322
|
+
|
|
249
323
|
const scrollIntoView = (el: HTMLElement) => {
|
|
250
324
|
scrollIntoViewUtil({ el, wrap: container.value, block: 'end', inline: 'center' })
|
|
251
325
|
}
|
|
@@ -23,13 +23,10 @@
|
|
|
23
23
|
<tab-header-item
|
|
24
24
|
v-for="(item, index) in tabs"
|
|
25
25
|
:key="item.id"
|
|
26
|
-
:item="item
|
|
26
|
+
:item="item"
|
|
27
27
|
:drop-position="dropIndicator?.tabId === item.id ? dropIndicator.position : undefined"
|
|
28
|
-
@contextmenu="
|
|
29
|
-
|
|
30
|
-
handleTabContextMenu(e, item as unknown as ITabItem, index, tabs.length)
|
|
31
|
-
"
|
|
32
|
-
@click.middle.prevent="handleCloseTab(item as ITabItem)"
|
|
28
|
+
@contextmenu="(e: MouseEvent) => handleTabContextMenu(e, item, index, tabs.length)"
|
|
29
|
+
@click.middle.prevent="handleCloseTab(item)"
|
|
33
30
|
@close="handleCloseTab"
|
|
34
31
|
@active="handleActivateTab"
|
|
35
32
|
@dragstart="handleTabDragStart"
|
|
@@ -75,7 +72,7 @@
|
|
|
75
72
|
依赖:useTabActions(tabs/closeTab/activeTab)、useContextMenu
|
|
76
73
|
-->
|
|
77
74
|
<script lang="ts" setup>
|
|
78
|
-
import { inject, computed, ref, nextTick } from 'vue'
|
|
75
|
+
import { inject, computed, ref, nextTick, watch, onUnmounted } from 'vue'
|
|
79
76
|
import type { TransitionProps } from 'vue'
|
|
80
77
|
import { TabScrollMode } from '../../model/TabModel'
|
|
81
78
|
import type { IContextMenu, ITabItem } from '../../model/TabModel'
|
|
@@ -87,6 +84,7 @@ import TabHeaderScroll from './TabHeaderScroll.vue'
|
|
|
87
84
|
import TabHeaderButton from './TabHeaderButton.vue'
|
|
88
85
|
import { useI18n } from 'vue-i18n-lite'
|
|
89
86
|
import { maximumKey } from '../../hooks/stackTabsContext'
|
|
87
|
+
import { TabEventType, useTabEmitter } from '../../hooks/useTabEventBus'
|
|
90
88
|
const { t } = useI18n()
|
|
91
89
|
/** 是否最大化,由上层 provide */
|
|
92
90
|
const maximum = inject(maximumKey, ref(false))
|
|
@@ -121,6 +119,47 @@ const props = withDefaults(
|
|
|
121
119
|
}
|
|
122
120
|
)
|
|
123
121
|
const { closeTab, activeTab, moveTab, tabs } = useTabActions()
|
|
122
|
+
|
|
123
|
+
/** 当前激活标签 id */
|
|
124
|
+
const activeTabId = computed<string>(() => tabs.value.find((tab) => tab.active)?.id ?? '')
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 将指定标签滚动到可视区域;未指定 id 时使用当前激活标签。
|
|
128
|
+
* 仅在标签确实不可见时才滚动,避免干扰用户的手动滚动。
|
|
129
|
+
*/
|
|
130
|
+
const ensureTabInView = (tabId?: string) => {
|
|
131
|
+
const scrollContainer = scrollContainerRef.value
|
|
132
|
+
if (!scrollContainer) return
|
|
133
|
+
|
|
134
|
+
const target = headerRef.value?.querySelector<HTMLElement>(
|
|
135
|
+
tabId
|
|
136
|
+
? `.stack-tab__nav [role="tab"][data-tab-id="${CSS.escape(tabId)}"]`
|
|
137
|
+
: '.stack-tab__nav .stack-tab__item--active'
|
|
138
|
+
)
|
|
139
|
+
const tabElement = target?.closest<HTMLElement>('.stack-tab__item')
|
|
140
|
+
if (tabElement && !scrollContainer.isInView(tabElement)) {
|
|
141
|
+
scrollContainer.scrollIntoView(tabElement)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 激活标签变化后确保其位于可视区域:
|
|
147
|
+
* 覆盖新开标签、通过 openTab 唤醒已有标签、关闭标签后顺延激活等场景。
|
|
148
|
+
* 仅响应用户主动滚动时不会触发(滚动不改变 active),因此不会把视图强行拉回。
|
|
149
|
+
*/
|
|
150
|
+
watch(activeTabId, () => {
|
|
151
|
+
nextTick(() => ensureTabInView())
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
/** 标签已是 active 但被滚开时,显式打开/激活请求仍需把标签栏滚动到它。 */
|
|
155
|
+
const emitter = useTabEmitter()
|
|
156
|
+
const handleTabScrollTo = ({ id }: { id: string }) => {
|
|
157
|
+
nextTick(() => ensureTabInView(id))
|
|
158
|
+
}
|
|
159
|
+
emitter.on(TabEventType.TAB_SCROLL_TO, handleTabScrollTo)
|
|
160
|
+
onUnmounted(() => {
|
|
161
|
+
emitter.off(TabEventType.TAB_SCROLL_TO, handleTabScrollTo)
|
|
162
|
+
})
|
|
124
163
|
const draggingTabId = ref<string | null>(null)
|
|
125
164
|
type DropPosition = 'before' | 'after'
|
|
126
165
|
const dropIndicator = ref<{ tabId: string; position: DropPosition } | null>(null)
|
|
@@ -219,7 +258,7 @@ const handleTabListDragOver = (event: DragEvent) => {
|
|
|
219
258
|
const handleTabListDrop = (event: DragEvent) => {
|
|
220
259
|
if (isTabItemDragEvent(event)) return
|
|
221
260
|
const lastTab = tabs.value[tabs.value.length - 1]
|
|
222
|
-
if (lastTab) handleTabDrop(lastTab
|
|
261
|
+
if (lastTab) handleTabDrop(lastTab)
|
|
223
262
|
}
|
|
224
263
|
|
|
225
264
|
/** 标签列表 transition 的 props,支持 string 或对象 */
|
|
@@ -248,7 +287,7 @@ const focusTabById = (tabId: string) => {
|
|
|
248
287
|
const activateTabByIndex = (index: number) => {
|
|
249
288
|
const item = tabs.value[index]
|
|
250
289
|
if (!item) return
|
|
251
|
-
handleActivateTab(item
|
|
290
|
+
handleActivateTab(item, undefined, true)
|
|
252
291
|
focusTabById(String(item.id))
|
|
253
292
|
}
|
|
254
293
|
|
|
@@ -317,7 +356,9 @@ const handleActivateTab = (
|
|
|
317
356
|
Promise.resolve(activeTab(item.id, isRoute))
|
|
318
357
|
.then(() => emit('active', item.id))
|
|
319
358
|
.catch((error: unknown) => {
|
|
320
|
-
|
|
359
|
+
if (!import.meta.env.PROD) {
|
|
360
|
+
console.warn('[vue-stack-tabs] Failed to activate tab:', error)
|
|
361
|
+
}
|
|
321
362
|
})
|
|
322
363
|
}
|
|
323
364
|
</script>
|
|
@@ -23,7 +23,6 @@ export const STACK_TABS_DUPLICATE_INSTANCE_MESSAGE =
|
|
|
23
23
|
|
|
24
24
|
export interface StackTabsRuntimeContext {
|
|
25
25
|
tabs: Ref<ITabItem[]>
|
|
26
|
-
defaultTabs: Ref<ITabItem[]>
|
|
27
26
|
caches: ShallowRef<string[]>
|
|
28
27
|
components: Map<string, DefineComponent>
|
|
29
28
|
cacheIdsToEvict: Set<string>
|
|
@@ -9,8 +9,11 @@ import type { StackTabsRuntimeContext } from '../stackTabsContext'
|
|
|
9
9
|
|
|
10
10
|
export interface TabPanelEvictionApi {
|
|
11
11
|
markCacheForEviction: (cacheName: string) => void
|
|
12
|
-
markTabPagesForEviction: (tab: {
|
|
13
|
-
|
|
12
|
+
markTabPagesForEviction: (tab: {
|
|
13
|
+
id: string
|
|
14
|
+
pages: { readonlyList(): readonly ITabPage[] }
|
|
15
|
+
}) => void
|
|
16
|
+
markTabPagesForEvictionOnly: (tab: { pages: { readonlyList(): readonly ITabPage[] } }) => void
|
|
14
17
|
addCache: (cacheName: string) => void
|
|
15
18
|
removeCache: (cacheName: string) => void
|
|
16
19
|
evictPageCache: (cacheName: string) => void
|
|
@@ -39,13 +42,18 @@ export const createTabPanelEviction = (context: StackTabsRuntimeContext): TabPan
|
|
|
39
42
|
cacheIdsToEvict.add(cacheName)
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
const markTabPagesForEviction = (tab: {
|
|
43
|
-
|
|
45
|
+
const markTabPagesForEviction = (tab: {
|
|
46
|
+
id: string
|
|
47
|
+
pages: { readonlyList(): readonly ITabPage[] }
|
|
48
|
+
}): void => {
|
|
49
|
+
for (const page of tab.pages.readonlyList()) markCacheForEviction(page.id)
|
|
44
50
|
tabIdsToEvict.add(tab.id)
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
const markTabPagesForEvictionOnly = (tab: {
|
|
48
|
-
|
|
53
|
+
const markTabPagesForEvictionOnly = (tab: {
|
|
54
|
+
pages: { readonlyList(): readonly ITabPage[] }
|
|
55
|
+
}): void => {
|
|
56
|
+
for (const page of tab.pages.readonlyList()) markCacheForEviction(page.id)
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
const addCache = (cacheName: string): void => {
|