vue-stack-tabs 1.1.0 → 1.3.1

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 +141 -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 +71 -50
  60. package/src/lib/StackTabs.vue +223 -196
  61. package/src/lib/assets/style/stackTab.scss +110 -13
  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 +74 -17
  69. package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
  70. package/src/lib/components/TabHeader/index.vue +181 -13
  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 +64 -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 +290 -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
@@ -1,23 +1,49 @@
1
1
  <template>
2
2
  <li
3
3
  ref="tabElementRef"
4
+ role="presentation"
4
5
  class="stack-tab__item"
5
6
  :class="{
6
7
  'is-active': item.active,
7
- 'is-icon': item.closable
8
+ 'is-icon': item.closable,
9
+ 'is-dragging': dragging,
10
+ 'is-drop-before': dropPosition === 'before',
11
+ 'is-drop-after': dropPosition === 'after'
8
12
  }"
9
- @click="handleActivate(true)"
13
+ :draggable="true"
14
+ @click.self="handleActivate(true)"
15
+ @contextmenu="$emit('contextmenu', $event)"
16
+ @dragstart="handleDragStart"
17
+ @dragend="handleDragEnd"
18
+ @dragover.prevent="$emit('dragover', item, $event)"
19
+ @drop.prevent="$emit('drop', item, $event)"
10
20
  >
11
- <!-- <div v-if="!!icon" class="stack-tab__item-icon" :class="icon"></div>-->
12
- <div class="stack-tab__item-title">
13
- <div class="stack-tab__item-title-content" :title="title">
14
- {{ title }}
15
- </div>
16
- </div>
17
- <div
21
+ <button
22
+ :id="getStackTabTabId(item.id)"
23
+ type="button"
24
+ role="tab"
25
+ class="stack-tab__item-tab"
26
+ :data-tab-id="item.id"
27
+ :aria-controls="getStackTabPanelId(item.id)"
28
+ :aria-selected="item.active ? 'true' : 'false'"
29
+ :tabindex="item.active ? 0 : -1"
30
+ :title="title"
31
+ @click="handleActivate(true)"
32
+ @keydown="handleTabKeydown"
33
+ >
34
+ <span class="stack-tab__item-title">
35
+ <span class="stack-tab__item-title-content">
36
+ {{ title }}
37
+ </span>
38
+ </span>
39
+ </button>
40
+ <button
18
41
  v-if="item.closable"
42
+ type="button"
19
43
  class="stack-tab__icon-close-fill stack-tab__item-button"
20
44
  :title="t('VueStackTab.close')"
45
+ :aria-label="`${t('VueStackTab.close')} ${title}`"
46
+ :tabindex="item.active ? 0 : -1"
21
47
  @click.stop="handleClose"
22
48
  />
23
49
  </li>
@@ -30,16 +56,27 @@
30
56
  -->
31
57
  <script setup lang="ts">
32
58
  import { computed, ref, onUnmounted } from 'vue'
33
- import type { ITabItem } from '@/lib/model/TabModel'
59
+ import type { ITabItem } from '../../model/TabModel'
34
60
  import { TabEventType, useTabEmitter } from '../../hooks/useTabEventBus'
61
+ import { getStackTabPanelId, getStackTabTabId } from '../../utils/stackTabsA11y'
35
62
  import { useI18n } from 'vue-i18n-lite'
36
- const emit = defineEmits(['close', 'active'])
63
+ const emit = defineEmits([
64
+ 'close',
65
+ 'active',
66
+ 'contextmenu',
67
+ 'dragstart',
68
+ 'dragover',
69
+ 'drop',
70
+ 'dragend'
71
+ ])
37
72
  const emitter = useTabEmitter()
38
73
  const { t } = useI18n()
39
74
  /** 当前标签 DOM 元素,用于滚动到可视区域 */
40
75
  const tabElementRef = ref<HTMLElement>()
76
+ const dragging = ref(false)
41
77
  const props = defineProps<{
42
78
  item: ITabItem
79
+ dropPosition?: 'before' | 'after'
43
80
  }>()
44
81
 
45
82
  /** TAB_ACTIVE 事件 payload */
@@ -50,24 +87,44 @@ const handleTabActiveEvent = (payload: TabActivePayload) => {
50
87
  handleActivate(payload.isRoute ?? true)
51
88
  }
52
89
  }
53
- const eventType = TabEventType.TAB_ACTIVE as unknown as Parameters<typeof emitter.on>[0]
54
- emitter.on(eventType, handleTabActiveEvent as (e: unknown) => void)
90
+ emitter.on(TabEventType.TAB_ACTIVE, handleTabActiveEvent)
55
91
  onUnmounted(() => {
56
- emitter.off(eventType, handleTabActiveEvent as (e: unknown) => void)
92
+ emitter.off(TabEventType.TAB_ACTIVE, handleTabActiveEvent)
57
93
  })
58
94
  /** 显示标题,空时用 i18n 占位 */
59
- const title = computed<string>(() => {
60
- return props.item.title || t('undefined')
61
- })
95
+ const title = computed<string>(() => props.item.title || t('VueStackTab.undefined'))
62
96
 
63
97
  /** 关闭按钮点击,向父级 emit close */
64
98
  const handleClose = () => {
65
99
  emit('close', props.item)
66
100
  }
101
+ const handleDragStart = (event: DragEvent) => {
102
+ dragging.value = true
103
+ event.dataTransfer?.setData('text/plain', props.item.id)
104
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
105
+ emit('dragstart', props.item, event)
106
+ }
107
+ const handleDragEnd = (event: DragEvent) => {
108
+ dragging.value = false
109
+ emit('dragend', props.item, event)
110
+ }
67
111
  /** 激活当前标签,向父级 emit active 并传递 DOM 引用 */
68
112
  const handleActivate = (isRoute = true) => {
69
113
  emit('active', props.item, tabElementRef.value, isRoute)
70
114
  }
115
+
116
+ const handleTabKeydown = (event: KeyboardEvent) => {
117
+ if (event.key === 'Enter' || event.key === ' ') {
118
+ event.preventDefault()
119
+ handleActivate(true)
120
+ return
121
+ }
122
+
123
+ if ((event.key === 'Delete' || event.key === 'Backspace') && props.item.closable) {
124
+ event.preventDefault()
125
+ handleClose()
126
+ }
127
+ }
71
128
  </script>
72
129
 
73
130
  <style lang="scss" scoped></style>
@@ -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,20 +12,30 @@
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"
20
+ @dragover.prevent="handleTabListDragOver"
21
+ @drop.prevent="handleTabListDrop"
17
22
  >
18
23
  <tab-header-item
19
24
  v-for="(item, index) in tabs"
20
25
  :key="item.id"
21
- :contextmenu="contextmenu"
22
26
  :item="item as ITabItem"
23
- @contextmenu.prevent="
24
- (e: MouseEvent) => showContextMenu(e, item as unknown as ITabItem, index, tabs.length)
27
+ :drop-position="dropIndicator?.tabId === item.id ? dropIndicator.position : undefined"
28
+ @contextmenu="
29
+ (e: MouseEvent) =>
30
+ handleTabContextMenu(e, item as unknown as ITabItem, index, tabs.length)
25
31
  "
26
32
  @click.middle.prevent="handleCloseTab(item as ITabItem)"
27
33
  @close="handleCloseTab"
28
34
  @active="handleActivateTab"
35
+ @dragstart="handleTabDragStart"
36
+ @dragover="handleTabDragOver"
37
+ @drop="handleTabDrop"
38
+ @dragend="handleTabDragEnd"
29
39
  />
30
40
  </transition-group>
31
41
  </template>
@@ -33,7 +43,8 @@
33
43
  <slot name="rightButton" />
34
44
  <tab-header-button
35
45
  :icon-class="maximum ? 'stack-tab__icon-restore' : 'stack-tab__icon-fullscreen'"
36
- :title="t('VueStackTab.maximum')"
46
+ :title="maximum ? t('VueStackTab.restore') : t('VueStackTab.maximum')"
47
+ :aria-label="maximum ? t('VueStackTab.restore') : t('VueStackTab.maximum')"
37
48
  @click="maximum = !maximum"
38
49
  />
39
50
  <transition name="stack-tab-zoom" appear>
@@ -45,6 +56,8 @@
45
56
  :tab-item="contextMenuData.item as ITabItem"
46
57
  :max="contextMenuData.max"
47
58
  :index="contextMenuData.index"
59
+ :context-menu="normalizedContextMenu"
60
+ :restore-focus-element="contextMenuTriggerElement"
48
61
  @close="handleCloseContextMenu"
49
62
  />
50
63
  </transition>
@@ -61,7 +74,7 @@
61
74
  import { inject, computed, ref, nextTick } from 'vue'
62
75
  import type { TransitionProps } from 'vue'
63
76
  import { TabScrollMode } from '../../model/TabModel'
64
- import type { ITabItem } from '../../model/TabModel'
77
+ import type { IContextMenu, ITabItem } from '../../model/TabModel'
65
78
  import ContextMenu from '../ContextMenu/index.vue'
66
79
  import useContextMenu from '../../hooks/useContextMenu'
67
80
  import useTabActions from '../../hooks/useTabActions'
@@ -69,13 +82,16 @@ import TabHeaderItem from './TabHeaderItem.vue'
69
82
  import TabHeaderScroll from './TabHeaderScroll.vue'
70
83
  import TabHeaderButton from './TabHeaderButton.vue'
71
84
  import { useI18n } from 'vue-i18n-lite'
85
+ import { maximumKey } from '../../hooks/stackTabsContext'
72
86
  const { t } = useI18n()
73
87
  /** 是否最大化,由上层 provide */
74
- const maximum = inject<boolean>('maximum')
88
+ const maximum = inject(maximumKey, ref(false))
75
89
  const emit = defineEmits(['active'])
76
90
  /** 滚动容器 ref,用于调用 scrollIntoView / isInView */
77
91
  const scrollContainerRef = ref<InstanceType<typeof TabHeaderScroll>>()
92
+ const headerRef = ref<HTMLElement>()
78
93
  const { shown: contextMenuShown, contextMenuData, showContextMenu } = useContextMenu()
94
+ const contextMenuTriggerElement = ref<HTMLElement | null>(null)
79
95
  /** 关闭右键菜单 */
80
96
  const handleCloseContextMenu = () => {
81
97
  contextMenuShown.value = false
@@ -100,7 +116,97 @@ const props = withDefaults(
100
116
  max: 20
101
117
  }
102
118
  )
103
- const { closeTab, activeTab, tabs } = useTabActions()
119
+ const { closeTab, activeTab, moveTab, tabs } = useTabActions()
120
+ const draggingTabId = ref<string | null>(null)
121
+ type DropPosition = 'before' | 'after'
122
+ const dropIndicator = ref<{ tabId: string; position: DropPosition } | null>(null)
123
+
124
+ /** 右键菜单是否启用;仅 false 明确禁用,其余旧对象值保持启用以兼容历史写法 */
125
+ const isContextMenuEnabled = computed<boolean>(() => props.contextmenu !== false)
126
+
127
+ /** 自定义菜单数组;非数组对象按历史宽类型兼容为默认菜单,不抛错 */
128
+ const normalizedContextMenu = computed<IContextMenu[]>(() => {
129
+ return Array.isArray(props.contextmenu) ? props.contextmenu : []
130
+ })
131
+
132
+ /** 右键标签:禁用时不阻止浏览器默认菜单,启用时打开组件菜单 */
133
+ const getContextMenuFocusElement = (event: MouseEvent): HTMLElement | null => {
134
+ const eventTarget = event.target instanceof Element ? event.target : null
135
+ const targetTab = eventTarget?.closest<HTMLElement>('[role="tab"]')
136
+ if (targetTab) return targetTab
137
+
138
+ const currentTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null
139
+ return currentTarget?.querySelector<HTMLElement>('[role="tab"]') ?? null
140
+ }
141
+
142
+ const handleTabContextMenu = (e: MouseEvent, item: ITabItem, index: number, max: number) => {
143
+ if (!isContextMenuEnabled.value) return
144
+
145
+ e.preventDefault()
146
+ contextMenuTriggerElement.value = getContextMenuFocusElement(e)
147
+ showContextMenu(e, item, index, max)
148
+ }
149
+
150
+ /** 原生拖放只在释放鼠标时更新顺序,避免拖动经过标签时反复变更响应式列表。 */
151
+ const handleTabDragStart = (item: ITabItem) => {
152
+ draggingTabId.value = item.id
153
+ }
154
+
155
+ const handleTabDragOver = (item: ITabItem, event: DragEvent) => {
156
+ if (!draggingTabId.value || draggingTabId.value === item.id) {
157
+ dropIndicator.value = null
158
+ return
159
+ }
160
+ const tabElement = event.currentTarget instanceof HTMLElement ? event.currentTarget : null
161
+ if (!tabElement) return
162
+
163
+ const { left, width } = tabElement.getBoundingClientRect()
164
+ dropIndicator.value = {
165
+ tabId: item.id,
166
+ position: event.clientX < left + width / 2 ? 'before' : 'after'
167
+ }
168
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
169
+ }
170
+
171
+ const handleTabDrop = (target: ITabItem) => {
172
+ const sourceId = draggingTabId.value
173
+ const indicator = dropIndicator.value
174
+ draggingTabId.value = null
175
+ dropIndicator.value = null
176
+ if (!sourceId || sourceId === target.id || indicator?.tabId !== target.id) return
177
+ const sourceIndex = tabs.value.findIndex((tab) => tab.id === sourceId)
178
+ const targetIndex = tabs.value.findIndex((tab) => tab.id === target.id)
179
+ if (sourceIndex < 0 || targetIndex < 0) return
180
+
181
+ let destinationIndex = targetIndex
182
+ if (indicator.position === 'before' && sourceIndex < targetIndex) destinationIndex--
183
+ if (indicator.position === 'after' && sourceIndex > targetIndex) destinationIndex++
184
+ moveTab(sourceId, destinationIndex)
185
+ }
186
+
187
+ const handleTabDragEnd = () => {
188
+ draggingTabId.value = null
189
+ dropIndicator.value = null
190
+ }
191
+
192
+ /** 标签列表末端的留白也是投放区,便于明确地将标签移动到最后。 */
193
+ const isTabItemDragEvent = (event: DragEvent) =>
194
+ event.target instanceof Element && Boolean(event.target.closest('.stack-tab__item'))
195
+
196
+ const handleTabListDragOver = (event: DragEvent) => {
197
+ if (!draggingTabId.value || isTabItemDragEvent(event)) return
198
+ const lastTab = tabs.value[tabs.value.length - 1]
199
+ if (!lastTab || lastTab.id === draggingTabId.value) return
200
+
201
+ dropIndicator.value = { tabId: lastTab.id, position: 'after' }
202
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
203
+ }
204
+
205
+ const handleTabListDrop = (event: DragEvent) => {
206
+ if (isTabItemDragEvent(event)) return
207
+ const lastTab = tabs.value[tabs.value.length - 1]
208
+ if (lastTab) handleTabDrop(lastTab as ITabItem)
209
+ }
104
210
 
105
211
  /** 标签列表 transition 的 props,支持 string 或对象 */
106
212
  const tabTransitionProps = computed<TransitionProps>(() =>
@@ -109,13 +215,68 @@ const tabTransitionProps = computed<TransitionProps>(() =>
109
215
  : (props.tabTransition as TransitionProps)
110
216
  )
111
217
 
218
+ const getActiveTabIndex = (): number => tabs.value.findIndex((tab) => tab.active)
219
+
220
+ const focusTabById = (tabId: string) => {
221
+ nextTick(() => {
222
+ const activeTabElement = headerRef.value?.querySelector<HTMLElement>(
223
+ `.stack-tab__nav [role="tab"][data-tab-id="${CSS.escape(tabId)}"]`
224
+ )
225
+ if (!activeTabElement) return
226
+
227
+ activeTabElement.focus()
228
+ if (scrollContainerRef.value && !scrollContainerRef.value.isInView(activeTabElement)) {
229
+ scrollContainerRef.value.scrollIntoView(activeTabElement)
230
+ }
231
+ })
232
+ }
233
+
234
+ const activateTabByIndex = (index: number) => {
235
+ const item = tabs.value[index]
236
+ if (!item) return
237
+ handleActivateTab(item as ITabItem, undefined, true)
238
+ focusTabById(String(item.id))
239
+ }
240
+
241
+ const handleTabListKeydown = (event: KeyboardEvent) => {
242
+ const count = tabs.value.length
243
+ if (count <= 0) return
244
+
245
+ const currentIndex = getActiveTabIndex()
246
+ const safeIndex = currentIndex >= 0 ? currentIndex : 0
247
+
248
+ if (event.key === 'ArrowRight') {
249
+ event.preventDefault()
250
+ activateTabByIndex((safeIndex + 1) % count)
251
+ return
252
+ }
253
+
254
+ if (event.key === 'ArrowLeft') {
255
+ event.preventDefault()
256
+ activateTabByIndex((safeIndex - 1 + count) % count)
257
+ return
258
+ }
259
+
260
+ if (event.key === 'Home') {
261
+ event.preventDefault()
262
+ activateTabByIndex(0)
263
+ return
264
+ }
265
+
266
+ if (event.key === 'End') {
267
+ event.preventDefault()
268
+ activateTabByIndex(count - 1)
269
+ }
270
+ }
271
+
112
272
  /** 是否显示左右滚动按钮 */
113
273
  const isScrollButton = computed<boolean>(() => {
114
274
  return props.tabScrollMode === TabScrollMode.BOTH || props.tabScrollMode === TabScrollMode.BUTTON
115
275
  })
116
276
  /** 是否支持滚轮滚动 */
117
- const isScrollWheel =
118
- props.tabScrollMode === TabScrollMode.BOTH || props.tabScrollMode === TabScrollMode.WHEEL
277
+ const isScrollWheel = computed<boolean>(() => {
278
+ return props.tabScrollMode === TabScrollMode.BOTH || props.tabScrollMode === TabScrollMode.WHEEL
279
+ })
119
280
 
120
281
  /** 关闭指定标签,并关闭右键菜单 */
121
282
  const handleCloseTab = (item: ITabItem) => {
@@ -126,7 +287,11 @@ const handleCloseTab = (item: ITabItem) => {
126
287
  }
127
288
 
128
289
  /** 激活指定标签,滚动到可视区域,并触发路由跳转 */
129
- const handleActivateTab = (item: ITabItem, clickedTabElement: HTMLElement | undefined, isRoute: boolean) => {
290
+ const handleActivateTab = (
291
+ item: ITabItem,
292
+ clickedTabElement: HTMLElement | undefined,
293
+ isRoute: boolean
294
+ ) => {
130
295
  if (scrollContainerRef.value && clickedTabElement) {
131
296
  nextTick(() => {
132
297
  if (!scrollContainerRef.value!.isInView(clickedTabElement)) {
@@ -134,8 +299,11 @@ const handleActivateTab = (item: ITabItem, clickedTabElement: HTMLElement | unde
134
299
  }
135
300
  })
136
301
  }
137
- activeTab(item.id, isRoute)
138
- emit('active', item.id)
302
+ Promise.resolve(activeTab(item.id, isRoute))
303
+ .then(() => emit('active', item.id))
304
+ .catch((error: unknown) => {
305
+ console.warn('[vue-stack-tabs] Failed to activate tab:', error)
306
+ })
139
307
  }
140
308
  </script>
141
309