vue-stack-tabs 1.0.1 → 1.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/README.en.md +526 -0
- package/README.md +337 -520
- package/README.ru.md +526 -0
- package/dist/iframe-bridge.d.ts +8 -0
- package/dist/iframe-bridge.html +77 -0
- package/dist/iframe-bridge.mjs +20 -0
- package/dist/iframe-child.html +88 -0
- package/dist/nuxt/favicon.svg +10 -0
- package/dist/nuxt/iframe-bridge.html +77 -0
- package/dist/nuxt/iframe-child.html +88 -0
- package/dist/nuxt/module.d.ts +6 -0
- package/dist/nuxt/module.mjs +28 -0
- package/dist/nuxt/plugin.mjs +9 -0
- package/dist/nuxt/runtime/plugin.d.ts +2 -0
- package/dist/nuxt/runtime/plugin.mjs +9 -0
- package/dist/src/lib/StackTabs.vue.d.ts +30 -16
- package/dist/src/lib/components/ContextMenu/ContextMenuItem.vue.d.ts +7 -1
- package/dist/src/lib/components/ContextMenu/index.vue.d.ts +5 -4
- package/dist/src/lib/components/PageLoading.vue.d.ts +2 -1
- package/dist/src/lib/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +25 -0
- package/dist/src/lib/components/StackKeepAlive/StackKeepAlive.vue.d.ts +12 -0
- package/dist/src/lib/components/TabHeader/TabHeaderButton.vue.d.ts +11 -12
- package/dist/src/lib/components/TabHeader/TabHeaderItem.vue.d.ts +5 -4
- package/dist/src/lib/components/TabHeader/TabHeaderScroll.vue.d.ts +8 -22
- package/dist/src/lib/components/TabHeader/index.vue.d.ts +11 -164
- package/dist/src/lib/hooks/stackTabsContext.d.ts +49 -0
- package/dist/src/lib/hooks/tabPanel/evict.d.ts +21 -23
- package/dist/src/lib/hooks/tabPanel/navigationTransaction.d.ts +35 -0
- package/dist/src/lib/hooks/tabPanel/refresh.d.ts +17 -0
- package/dist/src/lib/hooks/tabPanel/scroll.d.ts +8 -8
- package/dist/src/lib/hooks/tabPanel/session.d.ts +9 -8
- package/dist/src/lib/hooks/tabPanel/state.d.ts +20 -73
- package/dist/src/lib/hooks/useIframeManager.d.ts +35 -0
- package/dist/src/lib/hooks/useTabActions.d.ts +5 -46
- package/dist/src/lib/hooks/useTabEventBus.d.ts +22 -9
- package/dist/src/lib/hooks/useTabPanel.d.ts +17 -74
- package/dist/src/lib/hooks/useTabRouter.d.ts +5 -5
- package/dist/src/lib/i18n/index.d.ts +6 -4
- package/dist/src/lib/i18n/lang/en.d.ts +5 -0
- package/dist/src/lib/i18n/lang/zh-CN.d.ts +5 -0
- package/dist/src/lib/i18n/stackTabsLocale.d.ts +2 -0
- package/dist/src/lib/iframe-bridge.d.ts +8 -0
- package/dist/src/lib/iframe.vue.d.ts +2 -1
- package/dist/src/lib/index.d.ts +4 -5
- package/dist/src/lib/model/TabModel.d.ts +14 -33
- package/dist/src/lib/nuxt/module.d.ts +6 -0
- package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +2 -0
- package/dist/src/lib/nuxt/runtime/plugin.d.ts +2 -0
- package/dist/src/lib/utils/iframeBridge.d.ts +11 -3
- package/dist/src/lib/utils/stackTabsA11y.d.ts +8 -0
- package/dist/src/lib/utils/stackTabsMessage.d.ts +7 -0
- package/dist/src/lib/utils/tabInfoEncoder.d.ts +1 -16
- package/dist/src/lib/utils/typeGuards.d.ts +2 -0
- package/dist/src/lib/utils/urlParser.d.ts +24 -3
- package/dist/src/lib/versionLogger.d.ts +1 -0
- package/dist/style.css +2 -1
- package/dist/utils/iframeBridge.d.ts +39 -0
- package/dist/vue-stack-tabs.css +2 -1
- package/dist/vue-stack-tabs.es.js +1 -2411
- package/package.json +68 -47
- package/src/lib/StackTabs.vue +216 -192
- package/src/lib/assets/style/stackTab.scss +84 -12
- package/src/lib/components/ContextMenu/ContextMenuItem.vue +23 -11
- package/src/lib/components/ContextMenu/index.vue +94 -10
- package/src/lib/components/PageLoading.vue +9 -7
- package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +70 -0
- package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +57 -0
- package/src/lib/components/TabHeader/TabHeaderButton.vue +19 -8
- package/src/lib/components/TabHeader/TabHeaderItem.vue +45 -16
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
- package/src/lib/components/TabHeader/index.vue +109 -12
- package/src/lib/hooks/stackTabsContext.ts +135 -0
- package/src/lib/hooks/tabPanel/evict.ts +123 -41
- package/src/lib/hooks/tabPanel/navigationTransaction.ts +61 -0
- package/src/lib/hooks/tabPanel/refresh.ts +109 -0
- package/src/lib/hooks/tabPanel/scroll.ts +57 -36
- package/src/lib/hooks/tabPanel/session.ts +84 -22
- package/src/lib/hooks/tabPanel/state.ts +41 -44
- package/src/lib/hooks/useIframeManager.ts +253 -0
- package/src/lib/hooks/useTabActions.ts +62 -30
- package/src/lib/hooks/useTabEventBus.ts +29 -19
- package/src/lib/hooks/useTabLoading.ts +3 -2
- package/src/lib/hooks/useTabPanel.tsx +415 -213
- package/src/lib/hooks/useTabRouter.ts +172 -43
- package/src/lib/i18n/index.ts +53 -45
- package/src/lib/i18n/lang/en.ts +6 -1
- package/src/lib/i18n/lang/zh-CN.ts +6 -1
- package/src/lib/i18n/stackTabsLocale.ts +8 -0
- package/src/lib/iframe-bridge.ts +12 -0
- package/src/lib/index.ts +14 -19
- package/src/lib/model/TabModel.ts +34 -86
- package/src/lib/nuxt/module.d.ts +4 -0
- package/src/lib/nuxt/module.ts +8 -3
- package/src/lib/nuxt/runtime/plugin.client.ts +2 -3
- package/src/lib/nuxt/runtime/plugin.ts +11 -0
- package/src/lib/utils/iframeBridge.ts +29 -5
- package/src/lib/utils/scrollUtils.ts +9 -4
- package/src/lib/utils/stackTabsA11y.ts +12 -0
- package/src/lib/utils/stackTabsMessage.ts +99 -0
- package/src/lib/utils/tabInfoEncoder.ts +69 -42
- package/src/lib/utils/typeGuards.ts +3 -0
- package/src/lib/utils/urlParser.ts +144 -14
- package/src/lib/versionLogger.ts +7 -19
- package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +0 -1
- package/dist/src/lib/hooks/useTabPanel.spec.d.ts +0 -1
- package/dist/src/lib/model/TabModel.spec.d.ts +0 -1
- package/dist/src/lib/utils/iframeBridge.spec.d.ts +0 -1
- package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +0 -1
- package/dist/src/lib/utils/urlParser.spec.d.ts +0 -1
- package/dist/vue-stack-tabs.umd.js +0 -1
- package/src/lib/hooks/tabPanel/evict.spec.ts +0 -85
- package/src/lib/hooks/useTabPanel.spec.ts +0 -34
- package/src/lib/model/TabModel.spec.ts +0 -40
- package/src/lib/utils/iframeBridge.spec.ts +0 -37
- package/src/lib/utils/tabInfoEncoder.spec.ts +0 -42
- package/src/lib/utils/urlParser.spec.ts +0 -45
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
v-if="isScrollButtonVisible"
|
|
9
9
|
icon-class="stack-tab__icon-left-arrow"
|
|
10
10
|
:disabled="isDisabledLeftButton"
|
|
11
|
+
:title="t('VueStackTab.scrollLeft')"
|
|
12
|
+
:aria-label="t('VueStackTab.scrollLeft')"
|
|
11
13
|
@click="!isDisabledLeftButton && handleScrollButtonClick(-1 * space)"
|
|
12
14
|
/>
|
|
13
15
|
<div ref="headerScroll" class="stack-tab__scroll" @wheel.passive="handleWheelScroll">
|
|
@@ -45,19 +47,23 @@
|
|
|
45
47
|
v-if="isScrollButtonVisible"
|
|
46
48
|
icon-class="stack-tab__icon-right-arrow"
|
|
47
49
|
:disabled="isDisabledRightButton"
|
|
50
|
+
:title="t('VueStackTab.scrollRight')"
|
|
51
|
+
:aria-label="t('VueStackTab.scrollRight')"
|
|
48
52
|
@click="!isDisabledRightButton && handleScrollButtonClick(space!)"
|
|
49
53
|
/>
|
|
50
54
|
</template>
|
|
51
55
|
|
|
52
56
|
<script lang="ts" setup>
|
|
57
|
+
import { useI18n } from 'vue-i18n-lite'
|
|
53
58
|
import { computed, ref, reactive, onMounted, watch, onUnmounted } from 'vue'
|
|
54
|
-
import {
|
|
55
|
-
import type { ScrollData, DragData } from '@/lib/model/TabModel'
|
|
59
|
+
import type { ScrollData, DragData } from '../../model/TabModel'
|
|
56
60
|
import {
|
|
57
61
|
scrollTo as scrollToUtil,
|
|
58
62
|
scrollIntoView as scrollIntoViewUtil
|
|
59
|
-
} from '
|
|
60
|
-
import TabHeaderButton from '
|
|
63
|
+
} from '../../utils/scrollUtils'
|
|
64
|
+
import TabHeaderButton from './TabHeaderButton.vue'
|
|
65
|
+
|
|
66
|
+
const { t } = useI18n()
|
|
61
67
|
|
|
62
68
|
/** 标签列表滚动容器 */
|
|
63
69
|
const container = ref<HTMLElement | null>(null)
|
|
@@ -71,8 +77,8 @@ const scrollDragging = ref<boolean>(false)
|
|
|
71
77
|
const isDisabledLeftButton = ref<boolean>(false)
|
|
72
78
|
/** 右箭头是否禁用(已到最右) */
|
|
73
79
|
const isDisabledRightButton = ref<boolean>(false)
|
|
74
|
-
/**
|
|
75
|
-
const isScrollButtonVisible = ref<boolean>(
|
|
80
|
+
/** 是否显示左右滚动按钮:必须启用按钮模式且内容横向溢出 */
|
|
81
|
+
const isScrollButtonVisible = ref<boolean>(false)
|
|
76
82
|
/** 外层滚动区域,用于 ResizeObserver */
|
|
77
83
|
const headerScroll = ref<HTMLElement>()
|
|
78
84
|
const props = withDefaults(
|
|
@@ -109,12 +115,18 @@ watch(scrollData, (data) => {
|
|
|
109
115
|
updateScrollButtonState(data)
|
|
110
116
|
})
|
|
111
117
|
|
|
112
|
-
|
|
118
|
+
watch(
|
|
119
|
+
() => props.isScrollButton,
|
|
120
|
+
() => updateScrollButtonState(scrollData)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
/** 根据滚动位置更新左右箭头禁用状态及滚动按钮可见性 */
|
|
113
124
|
const updateScrollButtonState = (data: ScrollData) => {
|
|
125
|
+
const hasHorizontalOverflow = data.scrollWidth > data.clientWidth
|
|
126
|
+
|
|
114
127
|
isDisabledLeftButton.value = data.scrollLeft <= 10
|
|
115
|
-
isDisabledRightButton.value =
|
|
116
|
-
|
|
117
|
-
isScrollButtonVisible.value = data.scrollWidth !== data.clientWidth
|
|
128
|
+
isDisabledRightButton.value = data.clientWidth + data.scrollLeft - data.scrollWidth >= -10
|
|
129
|
+
isScrollButtonVisible.value = props.isScrollButton && hasHorizontalOverflow
|
|
118
130
|
}
|
|
119
131
|
|
|
120
132
|
/** 内容是否超出容器宽度,需要显示滚动条 */
|
|
@@ -148,21 +160,16 @@ const updateScrollState = () => {
|
|
|
148
160
|
|
|
149
161
|
/** 确保激活的标签在可视区域内 */
|
|
150
162
|
const ensureActiveTabInView = () => {
|
|
151
|
-
const activeTabEl = container.value?.querySelector(
|
|
163
|
+
const activeTabEl = container.value?.querySelector(
|
|
164
|
+
'.stack-tab__item.is-active'
|
|
165
|
+
) as HTMLElement | null
|
|
152
166
|
if (activeTabEl && !isInView(activeTabEl)) {
|
|
153
167
|
scrollIntoView(activeTabEl)
|
|
154
168
|
}
|
|
155
169
|
}
|
|
156
170
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
ensureActiveTabInView()
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
const tabListResizeObserver = new ResizeObserver(() => {
|
|
163
|
-
if (!hasScroller.value) ensureActiveTabInView()
|
|
164
|
-
updateScrollState()
|
|
165
|
-
})
|
|
171
|
+
let scrollContainerResizeObserver: ResizeObserver | null = null
|
|
172
|
+
let tabListResizeObserver: ResizeObserver | null = null
|
|
166
173
|
/** 滚动到指定 left 位置 */
|
|
167
174
|
const scrollTo = (left: number, smooth = true) => {
|
|
168
175
|
scrollToUtil({ wrap: container.value, left, top: 0, smooth })
|
|
@@ -173,8 +180,10 @@ const handleScrollButtonClick = (delta: number) => {
|
|
|
173
180
|
}
|
|
174
181
|
|
|
175
182
|
const handleWheelScroll = (e: WheelEvent) => {
|
|
183
|
+
if (!props.isScrollWheel || !container.value) return
|
|
184
|
+
|
|
176
185
|
const isScrollUp = e.deltaY < 0
|
|
177
|
-
scrollTo(container.value
|
|
186
|
+
scrollTo(container.value.scrollLeft + (isScrollUp ? -props.space : props.space))
|
|
178
187
|
}
|
|
179
188
|
|
|
180
189
|
const handleScrollbarDragStart = (e: MouseEvent) => {
|
|
@@ -213,13 +222,26 @@ const handleScrollbarDragEnd = () => {
|
|
|
213
222
|
}
|
|
214
223
|
|
|
215
224
|
onMounted(() => {
|
|
216
|
-
|
|
225
|
+
if (typeof ResizeObserver === 'undefined') return
|
|
226
|
+
|
|
227
|
+
scrollContainerResizeObserver = new ResizeObserver(() => {
|
|
228
|
+
updateScrollState()
|
|
229
|
+
ensureActiveTabInView()
|
|
230
|
+
})
|
|
231
|
+
tabListResizeObserver = new ResizeObserver(() => {
|
|
232
|
+
if (!hasScroller.value) ensureActiveTabInView()
|
|
233
|
+
updateScrollState()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
if (headerScroll.value) scrollContainerResizeObserver.observe(headerScroll.value)
|
|
217
237
|
const tabNav = container.value?.querySelector('.stack-tab__nav')
|
|
218
|
-
tabListResizeObserver.observe(tabNav
|
|
238
|
+
if (tabNav) tabListResizeObserver.observe(tabNav)
|
|
219
239
|
})
|
|
220
240
|
onUnmounted(() => {
|
|
221
|
-
scrollContainerResizeObserver
|
|
222
|
-
tabListResizeObserver
|
|
241
|
+
scrollContainerResizeObserver?.disconnect()
|
|
242
|
+
tabListResizeObserver?.disconnect()
|
|
243
|
+
scrollContainerResizeObserver = null
|
|
244
|
+
tabListResizeObserver = null
|
|
223
245
|
})
|
|
224
246
|
|
|
225
247
|
const scrollIntoView = (el: HTMLElement) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="stack-tab__header">
|
|
2
|
+
<div ref="headerRef" class="stack-tab__header">
|
|
3
3
|
<slot name="leftButton" />
|
|
4
4
|
<tab-header-scroll
|
|
5
5
|
ref="scrollContainerRef"
|
|
@@ -12,16 +12,19 @@
|
|
|
12
12
|
key="tab-list-transition"
|
|
13
13
|
tag="ul"
|
|
14
14
|
class="stack-tab__nav"
|
|
15
|
+
role="tablist"
|
|
16
|
+
aria-orientation="horizontal"
|
|
15
17
|
v-bind="tabTransitionProps"
|
|
16
18
|
appear
|
|
19
|
+
@keydown="handleTabListKeydown"
|
|
17
20
|
>
|
|
18
21
|
<tab-header-item
|
|
19
22
|
v-for="(item, index) in tabs"
|
|
20
23
|
:key="item.id"
|
|
21
|
-
:contextmenu="contextmenu"
|
|
22
24
|
:item="item as ITabItem"
|
|
23
|
-
@contextmenu
|
|
24
|
-
(e: MouseEvent) =>
|
|
25
|
+
@contextmenu="
|
|
26
|
+
(e: MouseEvent) =>
|
|
27
|
+
handleTabContextMenu(e, item as unknown as ITabItem, index, tabs.length)
|
|
25
28
|
"
|
|
26
29
|
@click.middle.prevent="handleCloseTab(item as ITabItem)"
|
|
27
30
|
@close="handleCloseTab"
|
|
@@ -33,7 +36,8 @@
|
|
|
33
36
|
<slot name="rightButton" />
|
|
34
37
|
<tab-header-button
|
|
35
38
|
:icon-class="maximum ? 'stack-tab__icon-restore' : 'stack-tab__icon-fullscreen'"
|
|
36
|
-
:title="t('VueStackTab.maximum')"
|
|
39
|
+
:title="maximum ? t('VueStackTab.restore') : t('VueStackTab.maximum')"
|
|
40
|
+
:aria-label="maximum ? t('VueStackTab.restore') : t('VueStackTab.maximum')"
|
|
37
41
|
@click="maximum = !maximum"
|
|
38
42
|
/>
|
|
39
43
|
<transition name="stack-tab-zoom" appear>
|
|
@@ -45,6 +49,8 @@
|
|
|
45
49
|
:tab-item="contextMenuData.item as ITabItem"
|
|
46
50
|
:max="contextMenuData.max"
|
|
47
51
|
:index="contextMenuData.index"
|
|
52
|
+
:context-menu="normalizedContextMenu"
|
|
53
|
+
:restore-focus-element="contextMenuTriggerElement"
|
|
48
54
|
@close="handleCloseContextMenu"
|
|
49
55
|
/>
|
|
50
56
|
</transition>
|
|
@@ -61,7 +67,7 @@
|
|
|
61
67
|
import { inject, computed, ref, nextTick } from 'vue'
|
|
62
68
|
import type { TransitionProps } from 'vue'
|
|
63
69
|
import { TabScrollMode } from '../../model/TabModel'
|
|
64
|
-
import type { ITabItem } from '../../model/TabModel'
|
|
70
|
+
import type { IContextMenu, ITabItem } from '../../model/TabModel'
|
|
65
71
|
import ContextMenu from '../ContextMenu/index.vue'
|
|
66
72
|
import useContextMenu from '../../hooks/useContextMenu'
|
|
67
73
|
import useTabActions from '../../hooks/useTabActions'
|
|
@@ -69,13 +75,16 @@ import TabHeaderItem from './TabHeaderItem.vue'
|
|
|
69
75
|
import TabHeaderScroll from './TabHeaderScroll.vue'
|
|
70
76
|
import TabHeaderButton from './TabHeaderButton.vue'
|
|
71
77
|
import { useI18n } from 'vue-i18n-lite'
|
|
78
|
+
import { maximumKey } from '../../hooks/stackTabsContext'
|
|
72
79
|
const { t } = useI18n()
|
|
73
80
|
/** 是否最大化,由上层 provide */
|
|
74
|
-
const maximum = inject
|
|
81
|
+
const maximum = inject(maximumKey, ref(false))
|
|
75
82
|
const emit = defineEmits(['active'])
|
|
76
83
|
/** 滚动容器 ref,用于调用 scrollIntoView / isInView */
|
|
77
84
|
const scrollContainerRef = ref<InstanceType<typeof TabHeaderScroll>>()
|
|
85
|
+
const headerRef = ref<HTMLElement>()
|
|
78
86
|
const { shown: contextMenuShown, contextMenuData, showContextMenu } = useContextMenu()
|
|
87
|
+
const contextMenuTriggerElement = ref<HTMLElement | null>(null)
|
|
79
88
|
/** 关闭右键菜单 */
|
|
80
89
|
const handleCloseContextMenu = () => {
|
|
81
90
|
contextMenuShown.value = false
|
|
@@ -102,6 +111,32 @@ const props = withDefaults(
|
|
|
102
111
|
)
|
|
103
112
|
const { closeTab, activeTab, tabs } = useTabActions()
|
|
104
113
|
|
|
114
|
+
/** 右键菜单是否启用;仅 false 明确禁用,其余旧对象值保持启用以兼容历史写法 */
|
|
115
|
+
const isContextMenuEnabled = computed<boolean>(() => props.contextmenu !== false)
|
|
116
|
+
|
|
117
|
+
/** 自定义菜单数组;非数组对象按历史宽类型兼容为默认菜单,不抛错 */
|
|
118
|
+
const normalizedContextMenu = computed<IContextMenu[]>(() => {
|
|
119
|
+
return Array.isArray(props.contextmenu) ? props.contextmenu : []
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
/** 右键标签:禁用时不阻止浏览器默认菜单,启用时打开组件菜单 */
|
|
123
|
+
const getContextMenuFocusElement = (event: MouseEvent): HTMLElement | null => {
|
|
124
|
+
const eventTarget = event.target instanceof Element ? event.target : null
|
|
125
|
+
const targetTab = eventTarget?.closest<HTMLElement>('[role="tab"]')
|
|
126
|
+
if (targetTab) return targetTab
|
|
127
|
+
|
|
128
|
+
const currentTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null
|
|
129
|
+
return currentTarget?.querySelector<HTMLElement>('[role="tab"]') ?? null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const handleTabContextMenu = (e: MouseEvent, item: ITabItem, index: number, max: number) => {
|
|
133
|
+
if (!isContextMenuEnabled.value) return
|
|
134
|
+
|
|
135
|
+
e.preventDefault()
|
|
136
|
+
contextMenuTriggerElement.value = getContextMenuFocusElement(e)
|
|
137
|
+
showContextMenu(e, item, index, max)
|
|
138
|
+
}
|
|
139
|
+
|
|
105
140
|
/** 标签列表 transition 的 props,支持 string 或对象 */
|
|
106
141
|
const tabTransitionProps = computed<TransitionProps>(() =>
|
|
107
142
|
typeof props.tabTransition === 'string'
|
|
@@ -109,13 +144,68 @@ const tabTransitionProps = computed<TransitionProps>(() =>
|
|
|
109
144
|
: (props.tabTransition as TransitionProps)
|
|
110
145
|
)
|
|
111
146
|
|
|
147
|
+
const getActiveTabIndex = (): number => tabs.value.findIndex((tab) => tab.active)
|
|
148
|
+
|
|
149
|
+
const focusTabById = (tabId: string) => {
|
|
150
|
+
nextTick(() => {
|
|
151
|
+
const activeTabElement = headerRef.value?.querySelector<HTMLElement>(
|
|
152
|
+
`.stack-tab__nav [role="tab"][data-tab-id="${CSS.escape(tabId)}"]`
|
|
153
|
+
)
|
|
154
|
+
if (!activeTabElement) return
|
|
155
|
+
|
|
156
|
+
activeTabElement.focus()
|
|
157
|
+
if (scrollContainerRef.value && !scrollContainerRef.value.isInView(activeTabElement)) {
|
|
158
|
+
scrollContainerRef.value.scrollIntoView(activeTabElement)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const activateTabByIndex = (index: number) => {
|
|
164
|
+
const item = tabs.value[index]
|
|
165
|
+
if (!item) return
|
|
166
|
+
handleActivateTab(item as ITabItem, undefined, true)
|
|
167
|
+
focusTabById(String(item.id))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const handleTabListKeydown = (event: KeyboardEvent) => {
|
|
171
|
+
const count = tabs.value.length
|
|
172
|
+
if (count <= 0) return
|
|
173
|
+
|
|
174
|
+
const currentIndex = getActiveTabIndex()
|
|
175
|
+
const safeIndex = currentIndex >= 0 ? currentIndex : 0
|
|
176
|
+
|
|
177
|
+
if (event.key === 'ArrowRight') {
|
|
178
|
+
event.preventDefault()
|
|
179
|
+
activateTabByIndex((safeIndex + 1) % count)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (event.key === 'ArrowLeft') {
|
|
184
|
+
event.preventDefault()
|
|
185
|
+
activateTabByIndex((safeIndex - 1 + count) % count)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (event.key === 'Home') {
|
|
190
|
+
event.preventDefault()
|
|
191
|
+
activateTabByIndex(0)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (event.key === 'End') {
|
|
196
|
+
event.preventDefault()
|
|
197
|
+
activateTabByIndex(count - 1)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
112
201
|
/** 是否显示左右滚动按钮 */
|
|
113
202
|
const isScrollButton = computed<boolean>(() => {
|
|
114
203
|
return props.tabScrollMode === TabScrollMode.BOTH || props.tabScrollMode === TabScrollMode.BUTTON
|
|
115
204
|
})
|
|
116
205
|
/** 是否支持滚轮滚动 */
|
|
117
|
-
const isScrollWheel =
|
|
118
|
-
props.tabScrollMode === TabScrollMode.BOTH || props.tabScrollMode === TabScrollMode.WHEEL
|
|
206
|
+
const isScrollWheel = computed<boolean>(() => {
|
|
207
|
+
return props.tabScrollMode === TabScrollMode.BOTH || props.tabScrollMode === TabScrollMode.WHEEL
|
|
208
|
+
})
|
|
119
209
|
|
|
120
210
|
/** 关闭指定标签,并关闭右键菜单 */
|
|
121
211
|
const handleCloseTab = (item: ITabItem) => {
|
|
@@ -126,7 +216,11 @@ const handleCloseTab = (item: ITabItem) => {
|
|
|
126
216
|
}
|
|
127
217
|
|
|
128
218
|
/** 激活指定标签,滚动到可视区域,并触发路由跳转 */
|
|
129
|
-
const handleActivateTab = (
|
|
219
|
+
const handleActivateTab = (
|
|
220
|
+
item: ITabItem,
|
|
221
|
+
clickedTabElement: HTMLElement | undefined,
|
|
222
|
+
isRoute: boolean
|
|
223
|
+
) => {
|
|
130
224
|
if (scrollContainerRef.value && clickedTabElement) {
|
|
131
225
|
nextTick(() => {
|
|
132
226
|
if (!scrollContainerRef.value!.isInView(clickedTabElement)) {
|
|
@@ -134,8 +228,11 @@ const handleActivateTab = (item: ITabItem, clickedTabElement: HTMLElement | unde
|
|
|
134
228
|
}
|
|
135
229
|
})
|
|
136
230
|
}
|
|
137
|
-
activeTab(item.id, isRoute)
|
|
138
|
-
|
|
231
|
+
Promise.resolve(activeTab(item.id, isRoute))
|
|
232
|
+
.then(() => emit('active', item.id))
|
|
233
|
+
.catch((error: unknown) => {
|
|
234
|
+
console.warn('[vue-stack-tabs] Failed to activate tab:', error)
|
|
235
|
+
})
|
|
139
236
|
}
|
|
140
237
|
</script>
|
|
141
238
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VueStackTabs 全局运行时上下文管理。
|
|
3
|
+
*
|
|
4
|
+
* 负责创建、注册、解析全局唯一的 StackTabsRuntimeContext,
|
|
5
|
+
* 支持通过 Vue provide/inject 或模块级单例两种方式访问。
|
|
6
|
+
* 每个 Vue app 只允许注册一个 <VueStackTabs> 实例。
|
|
7
|
+
*/
|
|
8
|
+
import type { App, DefineComponent, InjectionKey, Ref, ShallowRef } from 'vue'
|
|
9
|
+
import { hasInjectionContext, inject, ref } from 'vue'
|
|
10
|
+
import type { Emitter } from 'mitt'
|
|
11
|
+
import mitt from 'mitt'
|
|
12
|
+
import type { ITabItem } from '../model/TabModel'
|
|
13
|
+
import type { TabEventPayloadMap } from './useTabEventBus'
|
|
14
|
+
import { createTabPanelRuntimeState } from './tabPanel/state'
|
|
15
|
+
import type { ScrollPosition } from './tabPanel/state'
|
|
16
|
+
|
|
17
|
+
export const STACK_TABS_CONTEXT_UNAVAILABLE_MESSAGE =
|
|
18
|
+
'VueStackTabs runtime context is not available. Render <VueStackTabs> before using vue-stack-tabs composables.'
|
|
19
|
+
|
|
20
|
+
export const STACK_TABS_DUPLICATE_INSTANCE_MESSAGE =
|
|
21
|
+
'VueStackTabs only supports one <VueStackTabs> instance per Vue app.'
|
|
22
|
+
|
|
23
|
+
export interface StackTabsRuntimeContext {
|
|
24
|
+
tabs: Ref<ITabItem[]>
|
|
25
|
+
defaultTabs: Ref<ITabItem[]>
|
|
26
|
+
caches: ShallowRef<string[]>
|
|
27
|
+
components: Map<string, DefineComponent>
|
|
28
|
+
cacheIdsToEvict: Set<string>
|
|
29
|
+
tabIdsToEvict: Set<string>
|
|
30
|
+
refreshKey: Ref<number>
|
|
31
|
+
scrollPositionsByPageId: Map<string, Map<string, ScrollPosition>>
|
|
32
|
+
isInitialized: Ref<boolean>
|
|
33
|
+
iframeRefreshKeys: Ref<Record<string, number>>
|
|
34
|
+
maxTabCount: Ref<number>
|
|
35
|
+
useGlobalScroll: Ref<boolean>
|
|
36
|
+
sessionPrefix: Ref<string>
|
|
37
|
+
iframePath: Ref<string>
|
|
38
|
+
eventBus: Emitter<TabEventPayloadMap>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CreateStackTabsRuntimeContextOptions {
|
|
42
|
+
iframePath?: string
|
|
43
|
+
maxTabCount?: number
|
|
44
|
+
useGlobalScroll?: boolean
|
|
45
|
+
sessionPrefix?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RegisterStackTabsRuntimeContextOptions {
|
|
49
|
+
app?: App
|
|
50
|
+
isProduction?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface UnregisterStackTabsRuntimeContextOptions {
|
|
54
|
+
app?: App
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const stackTabsContextKey: InjectionKey<StackTabsRuntimeContext> = Symbol('stackTabsContext')
|
|
58
|
+
export const maximumKey: InjectionKey<Ref<boolean>> = Symbol('stackTabsMaximum')
|
|
59
|
+
|
|
60
|
+
let activeRuntimeContext: StackTabsRuntimeContext | null = null
|
|
61
|
+
let isFallbackRuntimeContextOwnerRegistered = false
|
|
62
|
+
const runtimeContextOwnersByApp = new WeakSet<App>()
|
|
63
|
+
|
|
64
|
+
/** 创建一个新的运行时上下文实例 */
|
|
65
|
+
export const createStackTabsRuntimeContext = (
|
|
66
|
+
options: CreateStackTabsRuntimeContextOptions = {}
|
|
67
|
+
): StackTabsRuntimeContext => {
|
|
68
|
+
const state = createTabPanelRuntimeState()
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
...state,
|
|
72
|
+
maxTabCount: ref(options.maxTabCount ?? state.maxTabCount.value),
|
|
73
|
+
useGlobalScroll: ref(options.useGlobalScroll ?? state.useGlobalScroll.value),
|
|
74
|
+
sessionPrefix: ref(options.sessionPrefix ?? state.sessionPrefix.value),
|
|
75
|
+
iframePath: ref(options.iframePath ?? ''),
|
|
76
|
+
eventBus: mitt<TabEventPayloadMap>()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 获取当前活跃的运行时上下文(模块级单例) */
|
|
81
|
+
export const getActiveStackTabsRuntimeContext = (): StackTabsRuntimeContext | null =>
|
|
82
|
+
activeRuntimeContext
|
|
83
|
+
|
|
84
|
+
/** 注册运行时上下文,重复注册会在开发环境抛出错误 */
|
|
85
|
+
export const registerStackTabsRuntimeContext = (
|
|
86
|
+
context: StackTabsRuntimeContext,
|
|
87
|
+
options: RegisterStackTabsRuntimeContextOptions = {}
|
|
88
|
+
): boolean => {
|
|
89
|
+
if (!activeRuntimeContext) {
|
|
90
|
+
activeRuntimeContext = context
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (options.app && !runtimeContextOwnersByApp.has(options.app)) {
|
|
94
|
+
runtimeContextOwnersByApp.add(options.app)
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (
|
|
99
|
+
!options.app &&
|
|
100
|
+
activeRuntimeContext === context &&
|
|
101
|
+
!isFallbackRuntimeContextOwnerRegistered
|
|
102
|
+
) {
|
|
103
|
+
isFallbackRuntimeContextOwnerRegistered = true
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const isProduction = options.isProduction ?? import.meta.env.PROD
|
|
108
|
+
if (isProduction) {
|
|
109
|
+
console.warn(STACK_TABS_DUPLICATE_INSTANCE_MESSAGE)
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
throw new Error(STACK_TABS_DUPLICATE_INSTANCE_MESSAGE)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 注销运行时上下文,组件卸载时调用 */
|
|
117
|
+
export const unregisterStackTabsRuntimeContext = (
|
|
118
|
+
context: StackTabsRuntimeContext,
|
|
119
|
+
options: UnregisterStackTabsRuntimeContextOptions = {}
|
|
120
|
+
): void => {
|
|
121
|
+
if (options.app) runtimeContextOwnersByApp.delete(options.app)
|
|
122
|
+
if (!options.app && activeRuntimeContext === context)
|
|
123
|
+
isFallbackRuntimeContextOwnerRegistered = false
|
|
124
|
+
if (activeRuntimeContext === context) activeRuntimeContext = null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 解析当前可用的运行时上下文,优先使用 inject,回退到模块单例 */
|
|
128
|
+
export const resolveStackTabsRuntimeContext = (): StackTabsRuntimeContext => {
|
|
129
|
+
const injected = hasInjectionContext() ? inject(stackTabsContextKey, null) : null
|
|
130
|
+
const context = injected ?? activeRuntimeContext
|
|
131
|
+
|
|
132
|
+
if (!context) throw new Error(STACK_TABS_CONTEXT_UNAVAILABLE_MESSAGE)
|
|
133
|
+
|
|
134
|
+
return context
|
|
135
|
+
}
|
|
@@ -1,57 +1,139 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* tabPanel/evict - 缓存驱逐逻辑
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 职责:基于当前 StackTabsRuntimeContext 标记待驱逐缓存、执行驱逐、维护 caches / components。
|
|
5
5
|
*/
|
|
6
6
|
import type { ITabPage } from '../../model/TabModel'
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
components,
|
|
10
|
-
cacheIdsToEvict,
|
|
11
|
-
tabIdsToEvict
|
|
12
|
-
} from './state'
|
|
13
|
-
|
|
14
|
-
/** 标记缓存为待驱逐 */
|
|
15
|
-
export const markCacheForEviction = (cacheName: string) => {
|
|
16
|
-
cacheIdsToEvict.add(cacheName)
|
|
17
|
-
}
|
|
7
|
+
import { triggerRef } from 'vue'
|
|
8
|
+
import type { StackTabsRuntimeContext } from '../stackTabsContext'
|
|
18
9
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
10
|
+
export interface TabPanelEvictionApi {
|
|
11
|
+
markCacheForEviction: (cacheName: string) => void
|
|
12
|
+
markTabPagesForEviction: (tab: { id: string; pages: { list(): ITabPage[] } }) => void
|
|
13
|
+
markTabPagesForEvictionOnly: (tab: { pages: { list(): ITabPage[] } }) => void
|
|
14
|
+
addCache: (cacheName: string) => void
|
|
15
|
+
removeCache: (cacheName: string) => void
|
|
16
|
+
evictPageCache: (cacheName: string) => void
|
|
17
|
+
replacePageCaches: (
|
|
18
|
+
cacheNamesToEvict: Iterable<string>,
|
|
19
|
+
cacheNamesToAdd: Iterable<string>
|
|
20
|
+
) => void
|
|
21
|
+
evictMarkedCaches: () => void
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
for (const page of tab.pages.list()) markCacheForEviction(page.id)
|
|
28
|
-
}
|
|
24
|
+
export const createTabPanelEviction = (context: StackTabsRuntimeContext): TabPanelEvictionApi => {
|
|
25
|
+
const { caches, components, cacheIdsToEvict, tabIdsToEvict } = context
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (!caches.value.includes(cacheName)) caches.value.push(cacheName)
|
|
33
|
-
}
|
|
27
|
+
const cacheSet = new Set<string>(caches.value)
|
|
28
|
+
let cacheList = caches.value
|
|
34
29
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
30
|
+
/** 外部重置 caches(例如 destroy)后,同步内部 Set,避免后续 addCache 被错误跳过。 */
|
|
31
|
+
const syncCacheSet = () => {
|
|
32
|
+
if (cacheList === caches.value) return
|
|
33
|
+
cacheList = caches.value
|
|
34
|
+
cacheSet.clear()
|
|
35
|
+
for (const cacheName of cacheList) cacheSet.add(cacheName)
|
|
36
|
+
}
|
|
40
37
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
38
|
+
const markCacheForEviction = (cacheName: string): void => {
|
|
39
|
+
cacheIdsToEvict.add(cacheName)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const markTabPagesForEviction = (tab: { id: string; pages: { list(): ITabPage[] } }): void => {
|
|
43
|
+
for (const page of tab.pages.list()) markCacheForEviction(page.id)
|
|
44
|
+
tabIdsToEvict.add(tab.id)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const markTabPagesForEvictionOnly = (tab: { pages: { list(): ITabPage[] } }): void => {
|
|
48
|
+
for (const page of tab.pages.list()) markCacheForEviction(page.id)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const addCache = (cacheName: string): void => {
|
|
52
|
+
syncCacheSet()
|
|
53
|
+
if (!cacheSet.has(cacheName)) {
|
|
54
|
+
cacheSet.add(cacheName)
|
|
55
|
+
cacheList.push(cacheName)
|
|
56
|
+
triggerRef(caches)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const removeCache = (cacheName: string): void => {
|
|
61
|
+
syncCacheSet()
|
|
62
|
+
if (cacheSet.has(cacheName)) {
|
|
63
|
+
cacheSet.delete(cacheName)
|
|
64
|
+
const index = cacheList.indexOf(cacheName)
|
|
65
|
+
if (index >= 0) cacheList.splice(index, 1)
|
|
66
|
+
triggerRef(caches)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const evictPageCache = (cacheName: string): void => {
|
|
71
|
+
if (!cacheName) return
|
|
72
|
+
cacheIdsToEvict.delete(cacheName)
|
|
73
|
+
removeCache(cacheName)
|
|
74
|
+
components.delete(cacheName)
|
|
75
|
+
}
|
|
48
76
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
77
|
+
/** 批量替换缓存 id,仅在所有变更完成后通知一次,供刷新全部等操作使用。 */
|
|
78
|
+
const replacePageCaches = (
|
|
79
|
+
cacheNamesToEvict: Iterable<string>,
|
|
80
|
+
cacheNamesToAdd: Iterable<string>
|
|
81
|
+
): void => {
|
|
82
|
+
syncCacheSet()
|
|
83
|
+
const toEvict = new Set(cacheNamesToEvict)
|
|
84
|
+
let changed = false
|
|
85
|
+
|
|
86
|
+
if (toEvict.size > 0) {
|
|
87
|
+
for (let index = cacheList.length - 1; index >= 0; index--) {
|
|
88
|
+
const cacheName = cacheList[index]
|
|
89
|
+
if (cacheName && toEvict.has(cacheName)) {
|
|
90
|
+
cacheList.splice(index, 1)
|
|
91
|
+
changed = true
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const cacheName of toEvict) {
|
|
95
|
+
cacheSet.delete(cacheName)
|
|
96
|
+
cacheIdsToEvict.delete(cacheName)
|
|
97
|
+
components.delete(cacheName)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const cacheName of cacheNamesToAdd) {
|
|
102
|
+
if (!cacheSet.has(cacheName)) {
|
|
103
|
+
cacheSet.add(cacheName)
|
|
104
|
+
cacheList.push(cacheName)
|
|
105
|
+
changed = true
|
|
106
|
+
}
|
|
54
107
|
}
|
|
108
|
+
|
|
109
|
+
if (changed) triggerRef(caches)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const evictMarkedCaches = (): void => {
|
|
113
|
+
if (cacheIdsToEvict.size <= 0) return
|
|
114
|
+
|
|
115
|
+
syncCacheSet()
|
|
116
|
+
const toEvict = new Set(cacheIdsToEvict)
|
|
117
|
+
for (let index = cacheList.length - 1; index >= 0; index--) {
|
|
118
|
+
const cacheName = cacheList[index]
|
|
119
|
+
if (cacheName && toEvict.has(cacheName)) cacheList.splice(index, 1)
|
|
120
|
+
}
|
|
121
|
+
for (const cacheName of toEvict) {
|
|
122
|
+
cacheSet.delete(cacheName)
|
|
123
|
+
components.delete(cacheName)
|
|
124
|
+
}
|
|
125
|
+
triggerRef(caches)
|
|
55
126
|
cacheIdsToEvict.clear()
|
|
56
127
|
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
markCacheForEviction,
|
|
131
|
+
markTabPagesForEviction,
|
|
132
|
+
markTabPagesForEvictionOnly,
|
|
133
|
+
addCache,
|
|
134
|
+
removeCache,
|
|
135
|
+
evictPageCache,
|
|
136
|
+
replacePageCaches,
|
|
137
|
+
evictMarkedCaches
|
|
138
|
+
}
|
|
57
139
|
}
|