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.
- package/README.en.md +526 -0
- package/README.md +141 -39
- 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 +19 -6
- 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 -61
- 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 -72
- 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 +14 -73
- 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 +11 -40
- 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 +0 -15
- 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 -2220
- package/package.json +71 -50
- package/src/lib/StackTabs.vue +223 -196
- package/src/lib/assets/style/stackTab.scss +110 -13
- 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 +74 -17
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
- package/src/lib/components/TabHeader/index.vue +181 -13
- package/src/lib/hooks/stackTabsContext.ts +135 -0
- package/src/lib/hooks/tabPanel/evict.ts +122 -87
- 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 -40
- 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 +64 -30
- package/src/lib/hooks/useTabEventBus.ts +29 -19
- package/src/lib/hooks/useTabLoading.ts +3 -2
- package/src/lib/hooks/useTabPanel.tsx +290 -313
- package/src/lib/hooks/useTabRouter.ts +111 -97
- 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 +27 -82
- 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 +1 -0
- 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 +70 -21
- 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 -32
- 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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 '
|
|
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([
|
|
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
|
-
|
|
54
|
-
emitter.on(eventType, handleTabActiveEvent as (e: unknown) => void)
|
|
90
|
+
emitter.on(TabEventType.TAB_ACTIVE, handleTabActiveEvent)
|
|
55
91
|
onUnmounted(() => {
|
|
56
|
-
emitter.off(
|
|
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 {
|
|
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,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
|
-
|
|
24
|
-
|
|
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
|
|
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 = (
|
|
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
|
-
|
|
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
|
|