vue-stack-tabs 1.1.0 → 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.
Files changed (115) hide show
  1. package/README.en.md +526 -0
  2. package/README.md +139 -39
  3. package/README.ru.md +526 -0
  4. package/dist/iframe-bridge.d.ts +8 -0
  5. package/dist/iframe-bridge.html +77 -0
  6. package/dist/iframe-bridge.mjs +20 -0
  7. package/dist/iframe-child.html +19 -6
  8. package/dist/nuxt/favicon.svg +10 -0
  9. package/dist/nuxt/iframe-bridge.html +77 -0
  10. package/dist/nuxt/iframe-child.html +88 -0
  11. package/dist/nuxt/module.d.ts +6 -0
  12. package/dist/nuxt/module.mjs +28 -0
  13. package/dist/nuxt/plugin.mjs +9 -0
  14. package/dist/nuxt/runtime/plugin.d.ts +2 -0
  15. package/dist/nuxt/runtime/plugin.mjs +9 -0
  16. package/dist/src/lib/StackTabs.vue.d.ts +30 -16
  17. package/dist/src/lib/components/ContextMenu/ContextMenuItem.vue.d.ts +7 -1
  18. package/dist/src/lib/components/ContextMenu/index.vue.d.ts +5 -4
  19. package/dist/src/lib/components/PageLoading.vue.d.ts +2 -1
  20. package/dist/src/lib/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +25 -0
  21. package/dist/src/lib/components/StackKeepAlive/StackKeepAlive.vue.d.ts +12 -0
  22. package/dist/src/lib/components/TabHeader/TabHeaderButton.vue.d.ts +11 -12
  23. package/dist/src/lib/components/TabHeader/TabHeaderItem.vue.d.ts +5 -4
  24. package/dist/src/lib/components/TabHeader/TabHeaderScroll.vue.d.ts +8 -22
  25. package/dist/src/lib/components/TabHeader/index.vue.d.ts +11 -164
  26. package/dist/src/lib/hooks/stackTabsContext.d.ts +49 -0
  27. package/dist/src/lib/hooks/tabPanel/evict.d.ts +21 -61
  28. package/dist/src/lib/hooks/tabPanel/navigationTransaction.d.ts +35 -0
  29. package/dist/src/lib/hooks/tabPanel/refresh.d.ts +17 -0
  30. package/dist/src/lib/hooks/tabPanel/scroll.d.ts +8 -8
  31. package/dist/src/lib/hooks/tabPanel/session.d.ts +9 -8
  32. package/dist/src/lib/hooks/tabPanel/state.d.ts +20 -72
  33. package/dist/src/lib/hooks/useIframeManager.d.ts +35 -0
  34. package/dist/src/lib/hooks/useTabActions.d.ts +5 -46
  35. package/dist/src/lib/hooks/useTabEventBus.d.ts +22 -9
  36. package/dist/src/lib/hooks/useTabPanel.d.ts +14 -73
  37. package/dist/src/lib/i18n/index.d.ts +6 -4
  38. package/dist/src/lib/i18n/lang/en.d.ts +5 -0
  39. package/dist/src/lib/i18n/lang/zh-CN.d.ts +5 -0
  40. package/dist/src/lib/i18n/stackTabsLocale.d.ts +2 -0
  41. package/dist/src/lib/iframe-bridge.d.ts +8 -0
  42. package/dist/src/lib/iframe.vue.d.ts +2 -1
  43. package/dist/src/lib/index.d.ts +4 -5
  44. package/dist/src/lib/model/TabModel.d.ts +11 -40
  45. package/dist/src/lib/nuxt/module.d.ts +6 -0
  46. package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +2 -0
  47. package/dist/src/lib/nuxt/runtime/plugin.d.ts +2 -0
  48. package/dist/src/lib/utils/iframeBridge.d.ts +11 -3
  49. package/dist/src/lib/utils/stackTabsA11y.d.ts +8 -0
  50. package/dist/src/lib/utils/stackTabsMessage.d.ts +7 -0
  51. package/dist/src/lib/utils/tabInfoEncoder.d.ts +0 -15
  52. package/dist/src/lib/utils/typeGuards.d.ts +2 -0
  53. package/dist/src/lib/utils/urlParser.d.ts +24 -3
  54. package/dist/src/lib/versionLogger.d.ts +1 -0
  55. package/dist/style.css +2 -1
  56. package/dist/utils/iframeBridge.d.ts +39 -0
  57. package/dist/vue-stack-tabs.css +2 -1
  58. package/dist/vue-stack-tabs.es.js +1 -2220
  59. package/package.json +68 -47
  60. package/src/lib/StackTabs.vue +223 -196
  61. package/src/lib/assets/style/stackTab.scss +84 -12
  62. package/src/lib/components/ContextMenu/ContextMenuItem.vue +23 -11
  63. package/src/lib/components/ContextMenu/index.vue +94 -10
  64. package/src/lib/components/PageLoading.vue +9 -7
  65. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +70 -0
  66. package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +57 -0
  67. package/src/lib/components/TabHeader/TabHeaderButton.vue +19 -8
  68. package/src/lib/components/TabHeader/TabHeaderItem.vue +45 -16
  69. package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
  70. package/src/lib/components/TabHeader/index.vue +109 -12
  71. package/src/lib/hooks/stackTabsContext.ts +135 -0
  72. package/src/lib/hooks/tabPanel/evict.ts +122 -87
  73. package/src/lib/hooks/tabPanel/navigationTransaction.ts +61 -0
  74. package/src/lib/hooks/tabPanel/refresh.ts +109 -0
  75. package/src/lib/hooks/tabPanel/scroll.ts +57 -40
  76. package/src/lib/hooks/tabPanel/session.ts +84 -22
  77. package/src/lib/hooks/tabPanel/state.ts +41 -44
  78. package/src/lib/hooks/useIframeManager.ts +253 -0
  79. package/src/lib/hooks/useTabActions.ts +62 -30
  80. package/src/lib/hooks/useTabEventBus.ts +29 -19
  81. package/src/lib/hooks/useTabLoading.ts +3 -2
  82. package/src/lib/hooks/useTabPanel.tsx +266 -313
  83. package/src/lib/hooks/useTabRouter.ts +111 -97
  84. package/src/lib/i18n/index.ts +53 -45
  85. package/src/lib/i18n/lang/en.ts +6 -1
  86. package/src/lib/i18n/lang/zh-CN.ts +6 -1
  87. package/src/lib/i18n/stackTabsLocale.ts +8 -0
  88. package/src/lib/iframe-bridge.ts +12 -0
  89. package/src/lib/index.ts +14 -19
  90. package/src/lib/model/TabModel.ts +27 -82
  91. package/src/lib/nuxt/module.d.ts +4 -0
  92. package/src/lib/nuxt/module.ts +8 -3
  93. package/src/lib/nuxt/runtime/plugin.client.ts +1 -0
  94. package/src/lib/nuxt/runtime/plugin.ts +11 -0
  95. package/src/lib/utils/iframeBridge.ts +29 -5
  96. package/src/lib/utils/scrollUtils.ts +9 -4
  97. package/src/lib/utils/stackTabsA11y.ts +12 -0
  98. package/src/lib/utils/stackTabsMessage.ts +99 -0
  99. package/src/lib/utils/tabInfoEncoder.ts +70 -21
  100. package/src/lib/utils/typeGuards.ts +3 -0
  101. package/src/lib/utils/urlParser.ts +144 -14
  102. package/src/lib/versionLogger.ts +7 -19
  103. package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +0 -1
  104. package/dist/src/lib/hooks/useTabPanel.spec.d.ts +0 -1
  105. package/dist/src/lib/model/TabModel.spec.d.ts +0 -1
  106. package/dist/src/lib/utils/iframeBridge.spec.d.ts +0 -1
  107. package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +0 -1
  108. package/dist/src/lib/utils/urlParser.spec.d.ts +0 -1
  109. package/dist/vue-stack-tabs.umd.js +0 -1
  110. package/src/lib/hooks/tabPanel/evict.spec.ts +0 -85
  111. package/src/lib/hooks/useTabPanel.spec.ts +0 -34
  112. package/src/lib/model/TabModel.spec.ts +0 -40
  113. package/src/lib/utils/iframeBridge.spec.ts +0 -37
  114. package/src/lib/utils/tabInfoEncoder.spec.ts +0 -32
  115. 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 { ResizeObserver } from '@juggle/resize-observer'
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 '@/lib/utils/scrollUtils'
60
- import TabHeaderButton from '@/lib/components/TabHeader/TabHeaderButton.vue'
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>(true)
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
- data.clientWidth + data.scrollLeft - data.scrollWidth >= -10
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('.stack-tab__item.is-active') as HTMLElement | null
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
- const scrollContainerResizeObserver = new ResizeObserver(() => {
158
- updateScrollState()
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!.scrollLeft + (isScrollUp ? -props.space : props.space))
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
- scrollContainerResizeObserver.observe(headerScroll.value as Element)
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.disconnect()
222
- tabListResizeObserver.disconnect()
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.prevent="
24
- (e: MouseEvent) => showContextMenu(e, item as unknown as ITabItem, index, tabs.length)
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<boolean>('maximum')
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 = (item: ITabItem, clickedTabElement: HTMLElement | undefined, isRoute: boolean) => {
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
- emit('active', item.id)
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
+ }