vue-stack-tabs 0.1.50 → 1.0.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.md +717 -3
- package/dist/index.d.ts +3 -0
- package/dist/src/lib/StackTabs.vue.d.ts +33 -161
- package/dist/src/lib/components/ContextMenu/ContextMenuItem.vue.d.ts +6 -6
- package/dist/src/lib/components/ContextMenu/index.vue.d.ts +14 -14
- package/dist/src/lib/components/PageLoading.vue.d.ts +5 -5
- package/dist/src/lib/components/TabHeader/TabHeaderButton.vue.d.ts +24 -15
- package/dist/src/lib/components/TabHeader/TabHeaderItem.vue.d.ts +10 -8
- package/dist/src/lib/components/TabHeader/TabHeaderScroll.vue.d.ts +31 -13
- package/dist/src/lib/components/TabHeader/index.vue.d.ts +172 -20
- package/dist/src/lib/hooks/tabPanel/evict.d.ts +24 -0
- package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +1 -0
- package/dist/src/lib/hooks/tabPanel/scroll.d.ts +8 -0
- package/dist/src/lib/hooks/tabPanel/session.d.ts +9 -0
- package/dist/src/lib/hooks/tabPanel/state.d.ts +77 -0
- package/dist/src/lib/hooks/useContextMenu.d.ts +6 -1
- package/dist/src/lib/hooks/useTabActions.d.ts +68 -0
- package/dist/src/lib/hooks/{useTabMitt.d.ts → useTabEventBus.d.ts} +4 -3
- package/dist/src/lib/hooks/useTabLoading.d.ts +6 -0
- package/dist/src/lib/hooks/{useTabpanel.d.ts → useTabPanel.d.ts} +20 -14
- package/dist/src/lib/hooks/useTabPanel.spec.d.ts +1 -0
- package/dist/src/lib/hooks/useTabRouter.d.ts +14 -5
- package/dist/src/lib/i18n/index.d.ts +1 -1
- package/dist/src/lib/i18n/lang/en.d.ts +1 -0
- package/dist/src/lib/i18n/lang/zh-CN.d.ts +1 -0
- package/dist/src/lib/index.d.ts +5 -4
- package/dist/src/lib/model/TabModel.d.ts +20 -3
- package/dist/src/lib/model/TabModel.spec.d.ts +1 -0
- package/dist/src/lib/utils/iframeBridge.d.ts +31 -0
- package/dist/src/lib/utils/iframeBridge.spec.d.ts +1 -0
- package/dist/src/lib/utils/scrollUtils.d.ts +28 -0
- package/dist/src/lib/utils/tabInfoEncoder.d.ts +19 -0
- package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +1 -0
- package/dist/src/lib/utils/urlParser.d.ts +19 -0
- package/dist/src/lib/utils/urlParser.spec.d.ts +1 -0
- package/dist/src/lib/versionLogger.d.ts +2 -0
- package/dist/style.css +1 -1
- package/dist/vue-stack-tabs.css +1 -0
- package/dist/vue-stack-tabs.es.js +1791 -1681
- package/dist/vue-stack-tabs.umd.js +1 -5
- package/package.json +76 -39
- package/src/lib/StackTabs.vue +222 -47
- package/src/lib/assets/image/open.svg +1 -0
- package/src/lib/assets/style/common/var.scss +1 -1
- package/src/lib/assets/style/icon.scss +6 -0
- package/src/lib/assets/style/stackTab.scss +44 -3
- package/src/lib/assets/style/transition.scss +63 -62
- package/src/lib/components/ContextMenu/ContextMenuItem.vue +7 -0
- package/src/lib/components/ContextMenu/index.vue +50 -38
- package/src/lib/components/PageLoading.vue +30 -10
- package/src/lib/components/TabHeader/TabHeaderButton.vue +12 -3
- package/src/lib/components/TabHeader/TabHeaderItem.vue +31 -27
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +83 -77
- package/src/lib/components/TabHeader/index.vue +45 -40
- package/src/lib/hooks/tabPanel/evict.spec.ts +85 -0
- package/src/lib/hooks/tabPanel/evict.ts +57 -0
- package/src/lib/hooks/tabPanel/scroll.ts +52 -0
- package/src/lib/hooks/tabPanel/session.ts +33 -0
- package/src/lib/hooks/tabPanel/state.ts +52 -0
- package/src/lib/hooks/useContextMenu.ts +22 -20
- package/src/lib/hooks/useTabActions.ts +148 -0
- package/src/lib/hooks/useTabEventBus.ts +31 -0
- package/src/lib/hooks/useTabLoading.ts +12 -4
- package/src/lib/hooks/useTabPanel.spec.ts +34 -0
- package/src/lib/hooks/useTabPanel.tsx +546 -0
- package/src/lib/hooks/useTabRouter.ts +114 -103
- package/src/lib/i18n/index.ts +14 -18
- package/src/lib/i18n/lang/en.ts +2 -1
- package/src/lib/i18n/lang/zh-CN.ts +2 -1
- package/src/lib/iframe.vue +6 -0
- package/src/lib/index.ts +27 -8
- package/src/lib/model/TabModel.spec.ts +40 -0
- package/src/lib/model/TabModel.ts +22 -9
- package/src/lib/nuxt/module.d.ts +19 -0
- package/src/lib/nuxt/module.ts +34 -0
- package/src/lib/nuxt/runtime/plugin.client.ts +11 -0
- package/src/lib/utils/iframeBridge.spec.ts +37 -0
- package/src/lib/utils/iframeBridge.ts +52 -0
- package/src/lib/utils/{TabScrollHelper.ts → scrollUtils.ts} +23 -29
- package/src/lib/utils/tabInfoEncoder.spec.ts +42 -0
- package/src/lib/utils/tabInfoEncoder.ts +65 -0
- package/src/lib/utils/urlParser.spec.ts +45 -0
- package/src/lib/utils/urlParser.ts +42 -0
- package/src/lib/{banner.ts → versionLogger.ts} +4 -5
- package/dist/src/lib/banner.d.ts +0 -2
- package/dist/src/lib/hooks/useStackTab.d.ts +0 -11
- package/dist/src/lib/utils/TabIdHelper.d.ts +0 -11
- package/dist/src/lib/utils/TabScrollHelper.d.ts +0 -33
- package/dist/src/lib/utils/UriHelper.d.ts +0 -4
- package/src/lib/hooks/useStackTab.ts +0 -119
- package/src/lib/hooks/useTabMitt.ts +0 -30
- package/src/lib/hooks/useTabpanel.tsx +0 -705
- package/src/lib/utils/TabIdHelper.ts +0 -70
- package/src/lib/utils/UriHelper.ts +0 -22
|
@@ -1,62 +1,75 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ContextMenu - 标签右键菜单
|
|
3
|
+
|
|
4
|
+
职责:刷新、关闭、关闭左侧/右侧/其他/全部、新窗口打开(iframe),以及自定义菜单项
|
|
5
|
+
扩展:通过 contextMenu prop 传入 IContextMenu[] 添加自定义项
|
|
6
|
+
-->
|
|
1
7
|
<template>
|
|
2
8
|
<div
|
|
3
|
-
ref="
|
|
9
|
+
ref="menuElementRef"
|
|
4
10
|
class="stack-tab__contextmenu"
|
|
5
11
|
:style="{
|
|
6
|
-
left: `${
|
|
7
|
-
top: `${
|
|
12
|
+
left: `${menuPosition.left}px`,
|
|
13
|
+
top: `${menuPosition.top}px`,
|
|
8
14
|
'z-index': getMaxZIndex('.stack-tab,.stack-tab *')
|
|
9
15
|
}"
|
|
10
16
|
>
|
|
11
17
|
<context-menu-item
|
|
12
18
|
icon="stack-tab__icon-reload svg-mask"
|
|
13
19
|
:title="t('VueStackTab.reload')"
|
|
14
|
-
|
|
20
|
+
:disabled="!tabItem.refreshable ? 'disabled' : null"
|
|
21
|
+
@click="handleMenuItemClick(() => refreshTab(tabItem.id))"
|
|
15
22
|
/>
|
|
16
23
|
<context-menu-item
|
|
17
24
|
icon="stack-tab__icon-reload-all svg-mask"
|
|
18
25
|
:title="t('VueStackTab.reloadAll')"
|
|
19
|
-
@click="refreshAllTabs"
|
|
26
|
+
@click="handleMenuItemClick(refreshAllTabs)"
|
|
20
27
|
/>
|
|
21
28
|
<context-menu-item
|
|
22
29
|
icon="stack-tab__icon-close svg-mask"
|
|
23
30
|
:title="t('VueStackTab.close')"
|
|
24
31
|
:disabled="!tabItem.closable ? 'disabled' : null"
|
|
25
|
-
@click="
|
|
32
|
+
@click="handleMenuItemClick(() => closeTab(tabItem.id))"
|
|
26
33
|
/>
|
|
27
34
|
<context-menu-item
|
|
28
35
|
icon="stack-tab__icon-close-lefts svg-mask"
|
|
29
36
|
:title="t('VueStackTab.closeLeft')"
|
|
30
37
|
:disabled="index <= 0 ? 'disabled' : null"
|
|
31
|
-
@click="removeLeftTabs(tabItem.id)"
|
|
38
|
+
@click="handleMenuItemClick(() => removeLeftTabs(tabItem.id))"
|
|
32
39
|
/>
|
|
33
40
|
<context-menu-item
|
|
34
41
|
icon="stack-tab__icon-close-rights svg-mask"
|
|
35
42
|
:title="t('VueStackTab.closeRight')"
|
|
36
43
|
:disabled="index >= max - 1 ? 'disabled' : null"
|
|
37
|
-
@click="removeRightTabs(tabItem.id)"
|
|
44
|
+
@click="handleMenuItemClick(() => removeRightTabs(tabItem.id))"
|
|
38
45
|
/>
|
|
39
46
|
<context-menu-item
|
|
40
47
|
icon="stack-tab__icon-close-others svg-mask"
|
|
41
48
|
:title="t('VueStackTab.closeOthers')"
|
|
42
|
-
@click="removeOtherTabs(tabItem.id)"
|
|
49
|
+
@click="handleMenuItemClick(() => removeOtherTabs(tabItem.id))"
|
|
43
50
|
/>
|
|
44
51
|
<context-menu-item
|
|
45
52
|
icon="stack-tab__icon-close-all svg-mask"
|
|
46
53
|
:title="t('VueStackTab.closeAll')"
|
|
47
|
-
@click="
|
|
54
|
+
@click="handleMenuItemClick(closeAllTabs)"
|
|
55
|
+
/>
|
|
56
|
+
<context-menu-item
|
|
57
|
+
v-if="tabItem.iframe && tabItem.url"
|
|
58
|
+
icon="stack-tab__icon-open svg-mask"
|
|
59
|
+
:title="t('VueStackTab.openInNewWindow')"
|
|
60
|
+
@click="handleMenuItemClick(() => openInNewWindow(tabItem.id))"
|
|
48
61
|
/>
|
|
49
62
|
<div
|
|
50
63
|
v-if="contextMenu !== undefined && contextMenu.length > 0"
|
|
51
64
|
class="divider div-transparent"
|
|
52
65
|
></div>
|
|
53
66
|
<context-menu-item
|
|
54
|
-
v-for="(item,
|
|
55
|
-
:key="
|
|
67
|
+
v-for="(item, menuIndex) in contextMenu"
|
|
68
|
+
:key="menuIndex"
|
|
56
69
|
:icon="item.icon"
|
|
57
70
|
:title="item.title"
|
|
58
71
|
:disabled="item.disabled(tabItem) ? 'disabled' : null"
|
|
59
|
-
@click="!item.disabled && item.callback(tabItem.id)"
|
|
72
|
+
@click="handleMenuItemClick(() => !item.disabled(tabItem) && item.callback(tabItem.id))"
|
|
60
73
|
/>
|
|
61
74
|
</div>
|
|
62
75
|
</template>
|
|
@@ -65,15 +78,16 @@
|
|
|
65
78
|
import { ref, onMounted, reactive } from 'vue'
|
|
66
79
|
import type { ITabItem, IContextMenu } from '../../model/TabModel'
|
|
67
80
|
import { useI18n } from 'vue-i18n-lite'
|
|
68
|
-
import { getMaxZIndex } from '../../utils/
|
|
69
|
-
import
|
|
81
|
+
import { getMaxZIndex } from '../../utils/scrollUtils'
|
|
82
|
+
import useTabActions from '../../hooks/useTabActions'
|
|
83
|
+
import useTabPanel from '../../hooks/useTabPanel'
|
|
70
84
|
import ContextMenuItem from './ContextMenuItem.vue'
|
|
71
|
-
|
|
85
|
+
|
|
72
86
|
const props = withDefaults(
|
|
73
87
|
defineProps<{
|
|
74
88
|
index: number
|
|
75
|
-
left
|
|
76
|
-
top
|
|
89
|
+
left?: number
|
|
90
|
+
top?: number
|
|
77
91
|
contextMenu?: Array<IContextMenu>
|
|
78
92
|
tabItem: ITabItem
|
|
79
93
|
max: number
|
|
@@ -81,30 +95,28 @@ const props = withDefaults(
|
|
|
81
95
|
{
|
|
82
96
|
left: 0,
|
|
83
97
|
top: 0,
|
|
84
|
-
|
|
98
|
+
contextMenu: () => []
|
|
85
99
|
}
|
|
86
100
|
)
|
|
87
|
-
|
|
101
|
+
const emit = defineEmits<{ close: [] }>()
|
|
88
102
|
const { t } = useI18n()
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
})
|
|
94
|
-
const {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
} = useTabpanel()
|
|
103
|
+
/** 菜单根元素,用于计算宽度做右边界溢出修正 */
|
|
104
|
+
const menuElementRef = ref<HTMLElement>()
|
|
105
|
+
/** 菜单定位坐标,onMounted 时根据右边界修正 left */
|
|
106
|
+
const menuPosition = reactive({ left: props.left, top: props.top })
|
|
107
|
+
const { closeTab, closeAllTabs, refreshTab, refreshAllTabs, openInNewWindow } = useTabActions()
|
|
108
|
+
const { removeLeftTabs, removeRightTabs, removeOtherTabs } = useTabPanel()
|
|
109
|
+
|
|
110
|
+
/** 菜单项点击:执行回调并关闭菜单 */
|
|
111
|
+
const handleMenuItemClick = (fn?: () => void) => {
|
|
112
|
+
fn?.()
|
|
113
|
+
emit('close')
|
|
114
|
+
}
|
|
115
|
+
/** 挂载后修正菜单位置,避免超出右边界 */
|
|
103
116
|
onMounted(() => {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
modifyData.left = winWidth - clientWidth - 5
|
|
117
|
+
const menuWidth = menuElementRef.value?.clientWidth ?? 0
|
|
118
|
+
if (menuPosition.left + menuWidth > window.innerWidth) {
|
|
119
|
+
menuPosition.left = window.innerWidth - menuWidth - 5
|
|
108
120
|
}
|
|
109
121
|
})
|
|
110
122
|
</script>
|
|
@@ -1,21 +1,41 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
PageLoading - 页面加载遮罩
|
|
3
|
+
|
|
4
|
+
职责:监听 PAGE_LOADING 事件,按 tabId 显示/隐藏 loading 遮罩
|
|
5
|
+
使用:在 useTabPanel 的 cache 组件内按 tab 渲染
|
|
6
|
+
-->
|
|
1
7
|
<template>
|
|
2
|
-
<div
|
|
8
|
+
<div
|
|
9
|
+
v-if="isLoading"
|
|
10
|
+
class="stack-tab-loading-mask"
|
|
11
|
+
:style="{ zIndex: getMaxZIndex('.cache-page-wrapper *') }"
|
|
12
|
+
>
|
|
3
13
|
<div class="stack-tab-loading--spin turn" />
|
|
4
14
|
</div>
|
|
5
15
|
</template>
|
|
6
16
|
<script setup lang="ts">
|
|
7
|
-
import { ref } from 'vue'
|
|
8
|
-
import {
|
|
9
|
-
import { getMaxZIndex } from '../utils/
|
|
17
|
+
import { ref, onUnmounted } from 'vue'
|
|
18
|
+
import { TabEventType, useTabEmitter } from '../hooks/useTabEventBus'
|
|
19
|
+
import { getMaxZIndex } from '../utils/scrollUtils'
|
|
10
20
|
const props = defineProps<{
|
|
11
|
-
|
|
21
|
+
/** 当前标签 id,用于过滤 PAGE_LOADING 事件 */
|
|
22
|
+
tabId: string
|
|
12
23
|
}>()
|
|
13
|
-
const emitter =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
const emitter = useTabEmitter()
|
|
25
|
+
/** 是否显示 loading 遮罩 */
|
|
26
|
+
const isLoading = ref<boolean>(false)
|
|
27
|
+
/** PAGE_LOADING 事件 payload */
|
|
28
|
+
type PageLoadingPayload = { tId: string; value: boolean }
|
|
29
|
+
/** 处理 PAGE_LOADING 事件,仅当 tId 匹配时更新 */
|
|
30
|
+
const handleLoadingEvent = (payload: PageLoadingPayload) => {
|
|
31
|
+
if (payload.tId === props.tabId) {
|
|
32
|
+
isLoading.value = payload.value
|
|
18
33
|
}
|
|
34
|
+
}
|
|
35
|
+
const eventType = TabEventType.PAGE_LOADING as unknown as Parameters<typeof emitter.on>[0]
|
|
36
|
+
emitter.on(eventType, handleLoadingEvent as (e: unknown) => void)
|
|
37
|
+
onUnmounted(() => {
|
|
38
|
+
emitter.off(eventType, handleLoadingEvent as (e: unknown) => void)
|
|
19
39
|
})
|
|
20
40
|
</script>
|
|
21
41
|
<style scoped></style>
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
TabHeaderButton - 通用图标按钮
|
|
3
|
+
|
|
4
|
+
职责:左右滚动箭头、全屏/还原按钮,支持禁用与阴影样式
|
|
5
|
+
-->
|
|
1
6
|
<template>
|
|
2
7
|
<div
|
|
3
8
|
class="stack-tab__header-button"
|
|
@@ -18,16 +23,20 @@
|
|
|
18
23
|
<script lang="ts" setup>
|
|
19
24
|
withDefaults(
|
|
20
25
|
defineProps<{
|
|
21
|
-
|
|
26
|
+
/** 是否禁用 */
|
|
22
27
|
disabled?: boolean
|
|
23
|
-
|
|
28
|
+
/** 阴影方向,用于滚动到边界时的视觉提示 */
|
|
29
|
+
shadow?: 'left' | 'right' | null
|
|
30
|
+
/** 图标 class(无 slot 时使用) */
|
|
24
31
|
iconClass?: string
|
|
32
|
+
/** 悬停提示 */
|
|
25
33
|
title?: string
|
|
26
34
|
}>(),
|
|
27
35
|
{
|
|
28
36
|
disabled: false,
|
|
29
37
|
shadow: null,
|
|
30
|
-
iconClass: ''
|
|
38
|
+
iconClass: '',
|
|
39
|
+
title: ''
|
|
31
40
|
}
|
|
32
41
|
)
|
|
33
42
|
</script>
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<li
|
|
3
|
-
ref="
|
|
3
|
+
ref="tabElementRef"
|
|
4
4
|
class="stack-tab__item"
|
|
5
5
|
:class="{
|
|
6
6
|
'is-active': item.active,
|
|
7
7
|
'is-icon': item.closable
|
|
8
8
|
}"
|
|
9
|
-
@click="
|
|
9
|
+
@click="handleActivate(true)"
|
|
10
10
|
>
|
|
11
11
|
<!-- <div v-if="!!icon" class="stack-tab__item-icon" :class="icon"></div>-->
|
|
12
12
|
<div class="stack-tab__item-title">
|
|
@@ -18,51 +18,55 @@
|
|
|
18
18
|
v-if="item.closable"
|
|
19
19
|
class="stack-tab__icon-close-fill stack-tab__item-button"
|
|
20
20
|
:title="t('VueStackTab.close')"
|
|
21
|
-
@click.stop="
|
|
21
|
+
@click.stop="handleClose"
|
|
22
22
|
/>
|
|
23
23
|
</li>
|
|
24
24
|
</template>
|
|
25
25
|
|
|
26
|
+
<!--
|
|
27
|
+
TabHeaderItem - 单个标签项
|
|
28
|
+
|
|
29
|
+
职责:展示标签标题、激活/关闭,监听 TAB_ACTIVE 事件
|
|
30
|
+
-->
|
|
26
31
|
<script setup lang="ts">
|
|
27
|
-
import { computed, ref } from 'vue'
|
|
32
|
+
import { computed, ref, onUnmounted } from 'vue'
|
|
28
33
|
import type { ITabItem } from '@/lib/model/TabModel'
|
|
29
|
-
import {
|
|
34
|
+
import { TabEventType, useTabEmitter } from '../../hooks/useTabEventBus'
|
|
30
35
|
import { useI18n } from 'vue-i18n-lite'
|
|
31
|
-
// 消息
|
|
32
36
|
const emit = defineEmits(['close', 'active'])
|
|
33
|
-
const emitter =
|
|
37
|
+
const emitter = useTabEmitter()
|
|
34
38
|
const { t } = useI18n()
|
|
35
|
-
|
|
39
|
+
/** 当前标签 DOM 元素,用于滚动到可视区域 */
|
|
40
|
+
const tabElementRef = ref<HTMLElement>()
|
|
36
41
|
const props = defineProps<{
|
|
37
42
|
item: ITabItem
|
|
38
43
|
}>()
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
// activeTab()
|
|
47
|
-
// }
|
|
48
|
-
// }
|
|
49
|
-
// )
|
|
50
|
-
emitter.on(MittType.TAB_ACTIVE, ({ id, isRoute }: any) => {
|
|
51
|
-
if (props.item.id === id) {
|
|
52
|
-
activeTab(isRoute)
|
|
44
|
+
|
|
45
|
+
/** TAB_ACTIVE 事件 payload */
|
|
46
|
+
type TabActivePayload = { id: string; isRoute?: boolean }
|
|
47
|
+
/** 收到 TAB_ACTIVE 且 id 匹配时,触发激活 */
|
|
48
|
+
const handleTabActiveEvent = (payload: TabActivePayload) => {
|
|
49
|
+
if (props.item.id === payload.id) {
|
|
50
|
+
handleActivate(payload.isRoute ?? true)
|
|
53
51
|
}
|
|
52
|
+
}
|
|
53
|
+
const eventType = TabEventType.TAB_ACTIVE as unknown as Parameters<typeof emitter.on>[0]
|
|
54
|
+
emitter.on(eventType, handleTabActiveEvent as (e: unknown) => void)
|
|
55
|
+
onUnmounted(() => {
|
|
56
|
+
emitter.off(eventType, handleTabActiveEvent as (e: unknown) => void)
|
|
54
57
|
})
|
|
58
|
+
/** 显示标题,空时用 i18n 占位 */
|
|
55
59
|
const title = computed<string>(() => {
|
|
56
60
|
return props.item.title || t('undefined')
|
|
57
61
|
})
|
|
58
62
|
|
|
59
|
-
|
|
63
|
+
/** 关闭按钮点击,向父级 emit close */
|
|
64
|
+
const handleClose = () => {
|
|
60
65
|
emit('close', props.item)
|
|
61
66
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
emit('active', props.item,
|
|
65
|
-
// }
|
|
67
|
+
/** 激活当前标签,向父级 emit active 并传递 DOM 引用 */
|
|
68
|
+
const handleActivate = (isRoute = true) => {
|
|
69
|
+
emit('active', props.item, tabElementRef.value, isRoute)
|
|
66
70
|
}
|
|
67
71
|
</script>
|
|
68
72
|
|
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
TabHeaderScroll - 可横向滚动的标签栏容器
|
|
3
|
+
|
|
4
|
+
职责:左右箭头滚动、滚轮滚动、自定义滚动条拖拽、确保激活标签在可视区域内
|
|
5
|
+
-->
|
|
1
6
|
<template>
|
|
2
7
|
<tab-header-button
|
|
3
|
-
v-if="
|
|
8
|
+
v-if="isScrollButtonVisible"
|
|
4
9
|
icon-class="stack-tab__icon-left-arrow"
|
|
5
10
|
:disabled="isDisabledLeftButton"
|
|
6
|
-
@click="!isDisabledLeftButton &&
|
|
11
|
+
@click="!isDisabledLeftButton && handleScrollButtonClick(-1 * space)"
|
|
7
12
|
/>
|
|
8
|
-
<div ref="headerScroll" class="stack-tab__scroll" @wheel.passive="
|
|
13
|
+
<div ref="headerScroll" class="stack-tab__scroll" @wheel.passive="handleWheelScroll">
|
|
9
14
|
<div
|
|
10
15
|
ref="container"
|
|
11
16
|
class="stack-tab__scroll-container"
|
|
@@ -14,7 +19,7 @@
|
|
|
14
19
|
'stack-tab__shadow-inset-right': !isDisabledRightButton && isDisabledLeftButton,
|
|
15
20
|
'stack-tab__shadow-inset-all': !isDisabledLeftButton && !isDisabledRightButton
|
|
16
21
|
}"
|
|
17
|
-
@scroll.passive="
|
|
22
|
+
@scroll.passive="updateScrollState"
|
|
18
23
|
>
|
|
19
24
|
<slot />
|
|
20
25
|
</div>
|
|
@@ -32,15 +37,15 @@
|
|
|
32
37
|
width: `${thumbWidth}px`,
|
|
33
38
|
transform: `translateX(${thumbLeft}px)`
|
|
34
39
|
}"
|
|
35
|
-
@mousedown.prevent="
|
|
40
|
+
@mousedown.prevent="handleScrollbarDragStart"
|
|
36
41
|
/>
|
|
37
42
|
</div>
|
|
38
43
|
</div>
|
|
39
44
|
<tab-header-button
|
|
40
|
-
v-if="
|
|
45
|
+
v-if="isScrollButtonVisible"
|
|
41
46
|
icon-class="stack-tab__icon-right-arrow"
|
|
42
47
|
:disabled="isDisabledRightButton"
|
|
43
|
-
@click="!isDisabledRightButton &&
|
|
48
|
+
@click="!isDisabledRightButton && handleScrollButtonClick(space!)"
|
|
44
49
|
/>
|
|
45
50
|
</template>
|
|
46
51
|
|
|
@@ -48,23 +53,35 @@
|
|
|
48
53
|
import { computed, ref, reactive, onMounted, watch, onUnmounted } from 'vue'
|
|
49
54
|
import { ResizeObserver } from '@juggle/resize-observer'
|
|
50
55
|
import type { ScrollData, DragData } from '@/lib/model/TabModel'
|
|
51
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
scrollTo as scrollToUtil,
|
|
58
|
+
scrollIntoView as scrollIntoViewUtil
|
|
59
|
+
} from '@/lib/utils/scrollUtils'
|
|
52
60
|
import TabHeaderButton from '@/lib/components/TabHeader/TabHeaderButton.vue'
|
|
53
61
|
|
|
54
|
-
|
|
62
|
+
/** 标签列表滚动容器 */
|
|
55
63
|
const container = ref<HTMLElement | null>(null)
|
|
64
|
+
/** 滚动条轨道 */
|
|
56
65
|
const bar = ref<HTMLElement | null>(null)
|
|
66
|
+
/** 滚动条滑块 */
|
|
57
67
|
const thumb = ref<HTMLElement | null>(null)
|
|
68
|
+
/** 是否正在拖拽滚动条 */
|
|
58
69
|
const scrollDragging = ref<boolean>(false)
|
|
70
|
+
/** 左箭头是否禁用(已到最左) */
|
|
59
71
|
const isDisabledLeftButton = ref<boolean>(false)
|
|
72
|
+
/** 右箭头是否禁用(已到最右) */
|
|
60
73
|
const isDisabledRightButton = ref<boolean>(false)
|
|
61
|
-
|
|
74
|
+
/** 是否有横向滚动条(内容超出时显示左右箭头) */
|
|
75
|
+
const isScrollButtonVisible = ref<boolean>(true)
|
|
76
|
+
/** 外层滚动区域,用于 ResizeObserver */
|
|
62
77
|
const headerScroll = ref<HTMLElement>()
|
|
63
78
|
const props = withDefaults(
|
|
64
79
|
defineProps<{
|
|
65
|
-
|
|
80
|
+
/** 每次点击箭头或滚轮的移动距离(px) */
|
|
66
81
|
space?: number
|
|
82
|
+
/** 是否支持滚轮滚动 */
|
|
67
83
|
isScrollWheel?: boolean
|
|
84
|
+
/** 是否显示左右箭头 */
|
|
68
85
|
isScrollButton?: boolean
|
|
69
86
|
}>(),
|
|
70
87
|
{
|
|
@@ -73,14 +90,13 @@ const props = withDefaults(
|
|
|
73
90
|
isScrollButton: true
|
|
74
91
|
}
|
|
75
92
|
)
|
|
76
|
-
/**
|
|
77
|
-
* 滚动条数据
|
|
78
|
-
*/
|
|
93
|
+
/** 滚动容器尺寸与滚动位置,用于计算滑块和按钮状态 */
|
|
79
94
|
const scrollData = reactive<ScrollData>({
|
|
80
95
|
clientWidth: 0,
|
|
81
96
|
scrollWidth: 0,
|
|
82
97
|
scrollLeft: 0
|
|
83
98
|
})
|
|
99
|
+
/** 拖拽滚动条时的起始状态 */
|
|
84
100
|
const dragData = reactive<DragData>({
|
|
85
101
|
thumbLeft: 0,
|
|
86
102
|
startScrollLeft: 0,
|
|
@@ -88,25 +104,25 @@ const dragData = reactive<DragData>({
|
|
|
88
104
|
startPageX: 0
|
|
89
105
|
})
|
|
90
106
|
|
|
91
|
-
|
|
92
|
-
watch(scrollData, (
|
|
93
|
-
|
|
107
|
+
/** 滚动数据变化时更新箭头与滚动条的启用状态 */
|
|
108
|
+
watch(scrollData, (data) => {
|
|
109
|
+
updateScrollButtonState(data)
|
|
94
110
|
})
|
|
95
111
|
|
|
96
|
-
|
|
97
|
-
|
|
112
|
+
/** 根据滚动位置更新左右箭头禁用状态及滚动条可见性 */
|
|
113
|
+
const updateScrollButtonState = (data: ScrollData) => {
|
|
114
|
+
isDisabledLeftButton.value = data.scrollLeft <= 10
|
|
98
115
|
isDisabledRightButton.value =
|
|
99
|
-
|
|
100
|
-
|
|
116
|
+
data.clientWidth + data.scrollLeft - data.scrollWidth >= -10
|
|
117
|
+
isScrollButtonVisible.value = data.scrollWidth !== data.clientWidth
|
|
101
118
|
}
|
|
102
119
|
|
|
103
|
-
|
|
104
|
-
// 是否拥有滚动条
|
|
120
|
+
/** 内容是否超出容器宽度,需要显示滚动条 */
|
|
105
121
|
const hasScroller = computed<boolean>(() => {
|
|
106
122
|
return scrollData.scrollWidth > scrollData.clientWidth
|
|
107
123
|
})
|
|
108
124
|
|
|
109
|
-
|
|
125
|
+
/** 滚动条滑块宽度(按内容比例计算) */
|
|
110
126
|
const thumbWidth = computed<number>(() => {
|
|
111
127
|
if (!hasScroller.value) return 0
|
|
112
128
|
|
|
@@ -114,7 +130,7 @@ const thumbWidth = computed<number>(() => {
|
|
|
114
130
|
return (clientWidth / scrollWidth) * clientWidth
|
|
115
131
|
})
|
|
116
132
|
|
|
117
|
-
|
|
133
|
+
/** 滚动条滑块 left 偏移(与 scrollLeft 对应) */
|
|
118
134
|
const thumbLeft = computed(() => {
|
|
119
135
|
if (!hasScroller.value) return 0
|
|
120
136
|
|
|
@@ -123,52 +139,45 @@ const thumbLeft = computed(() => {
|
|
|
123
139
|
return (clientWidth - thumbWidth.value) * (scrollLeft / (scrollWidth - clientWidth))
|
|
124
140
|
})
|
|
125
141
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
// 判断 container是否为空,主空直接返回
|
|
130
|
-
if (container.value === null || container.value === undefined) {
|
|
131
|
-
return
|
|
132
|
-
}
|
|
142
|
+
/** 更新滚动条数据 */
|
|
143
|
+
const updateScrollState = () => {
|
|
144
|
+
if (!container.value) return
|
|
133
145
|
const { clientWidth, scrollWidth, scrollLeft } = container.value
|
|
134
|
-
|
|
135
146
|
Object.assign(scrollData, { clientWidth, scrollWidth, scrollLeft })
|
|
136
147
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
148
|
+
|
|
149
|
+
/** 确保激活的标签在可视区域内 */
|
|
150
|
+
const ensureActiveTabInView = () => {
|
|
151
|
+
const activeTabEl = container.value?.querySelector('.stack-tab__item.is-active') as HTMLElement | null
|
|
152
|
+
if (activeTabEl && !isInView(activeTabEl)) {
|
|
153
|
+
scrollIntoView(activeTabEl)
|
|
144
154
|
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const scrollContainerResizeObserver = new ResizeObserver(() => {
|
|
158
|
+
updateScrollState()
|
|
159
|
+
ensureActiveTabInView()
|
|
145
160
|
})
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (!isInView(cur as HTMLElement)) {
|
|
151
|
-
scrollIntoView(cur as HTMLElement)
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
update()
|
|
161
|
+
|
|
162
|
+
const tabListResizeObserver = new ResizeObserver(() => {
|
|
163
|
+
if (!hasScroller.value) ensureActiveTabInView()
|
|
164
|
+
updateScrollState()
|
|
156
165
|
})
|
|
157
|
-
|
|
166
|
+
/** 滚动到指定 left 位置 */
|
|
158
167
|
const scrollTo = (left: number, smooth = true) => {
|
|
159
|
-
|
|
168
|
+
scrollToUtil({ wrap: container.value, left, top: 0, smooth })
|
|
160
169
|
}
|
|
161
170
|
|
|
162
|
-
const
|
|
163
|
-
scrollTo(container.value!.scrollLeft +
|
|
171
|
+
const handleScrollButtonClick = (delta: number) => {
|
|
172
|
+
scrollTo(container.value!.scrollLeft + delta)
|
|
164
173
|
}
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
scrollTo(container.value!.scrollLeft + (
|
|
174
|
+
|
|
175
|
+
const handleWheelScroll = (e: WheelEvent) => {
|
|
176
|
+
const isScrollUp = e.deltaY < 0
|
|
177
|
+
scrollTo(container.value!.scrollLeft + (isScrollUp ? -props.space : props.space))
|
|
169
178
|
}
|
|
170
|
-
|
|
171
|
-
const
|
|
179
|
+
|
|
180
|
+
const handleScrollbarDragStart = (e: MouseEvent) => {
|
|
172
181
|
Object.assign(dragData, {
|
|
173
182
|
startPageX: e.pageX,
|
|
174
183
|
startScrollLeft: container.value?.scrollLeft,
|
|
@@ -176,12 +185,11 @@ const onDragStart = (e: MouseEvent) => {
|
|
|
176
185
|
thumbLeft: thumbLeft.value
|
|
177
186
|
})
|
|
178
187
|
scrollDragging.value = true
|
|
179
|
-
document.addEventListener('mousemove',
|
|
180
|
-
document.addEventListener('mouseup',
|
|
188
|
+
document.addEventListener('mousemove', handleScrollbarDragMove)
|
|
189
|
+
document.addEventListener('mouseup', handleScrollbarDragEnd)
|
|
181
190
|
}
|
|
182
191
|
|
|
183
|
-
|
|
184
|
-
const onDragMove = (e: MouseEvent) => {
|
|
192
|
+
const handleScrollbarDragMove = (e: MouseEvent) => {
|
|
185
193
|
const { clientWidth, scrollWidth } = scrollData
|
|
186
194
|
let thumbLeft = dragData.startThumbLeft + e.pageX - dragData.startPageX
|
|
187
195
|
const maxThumbLeft = clientWidth - thumbWidth.value
|
|
@@ -197,30 +205,28 @@ const onDragMove = (e: MouseEvent) => {
|
|
|
197
205
|
scrollTo((thumbLeft * (scrollWidth - clientWidth)) / (clientWidth - thumbWidth.value), false)
|
|
198
206
|
e.preventDefault()
|
|
199
207
|
}
|
|
200
|
-
// 拖拽结束
|
|
201
|
-
const onDragEnd = (e: MouseEvent) => {
|
|
202
|
-
scrollDragging.value = false
|
|
203
|
-
document.removeEventListener('mousemove', onDragMove)
|
|
204
|
-
document.removeEventListener('mouseup', onDragEnd)
|
|
205
208
|
|
|
206
|
-
|
|
209
|
+
const handleScrollbarDragEnd = () => {
|
|
210
|
+
scrollDragging.value = false
|
|
211
|
+
document.removeEventListener('mousemove', handleScrollbarDragMove)
|
|
212
|
+
document.removeEventListener('mouseup', handleScrollbarDragEnd)
|
|
207
213
|
}
|
|
214
|
+
|
|
208
215
|
onMounted(() => {
|
|
209
|
-
|
|
216
|
+
scrollContainerResizeObserver.observe(headerScroll.value as Element)
|
|
210
217
|
const tabNav = container.value?.querySelector('.stack-tab__nav')
|
|
211
|
-
|
|
218
|
+
tabListResizeObserver.observe(tabNav!)
|
|
212
219
|
})
|
|
213
220
|
onUnmounted(() => {
|
|
214
|
-
|
|
215
|
-
|
|
221
|
+
scrollContainerResizeObserver.disconnect()
|
|
222
|
+
tabListResizeObserver.disconnect()
|
|
216
223
|
})
|
|
217
224
|
|
|
218
|
-
// 滚动到元素
|
|
219
225
|
const scrollIntoView = (el: HTMLElement) => {
|
|
220
|
-
|
|
226
|
+
scrollIntoViewUtil({ el, wrap: container.value, block: 'end', inline: 'center' })
|
|
221
227
|
}
|
|
222
228
|
|
|
223
|
-
|
|
229
|
+
/** 判断元素是否在横向滚动可视区域内 */
|
|
224
230
|
const isInView = (el: HTMLElement) => {
|
|
225
231
|
if (!hasScroller.value) {
|
|
226
232
|
return true
|