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.
Files changed (94) hide show
  1. package/README.md +717 -3
  2. package/dist/index.d.ts +3 -0
  3. package/dist/src/lib/StackTabs.vue.d.ts +33 -161
  4. package/dist/src/lib/components/ContextMenu/ContextMenuItem.vue.d.ts +6 -6
  5. package/dist/src/lib/components/ContextMenu/index.vue.d.ts +14 -14
  6. package/dist/src/lib/components/PageLoading.vue.d.ts +5 -5
  7. package/dist/src/lib/components/TabHeader/TabHeaderButton.vue.d.ts +24 -15
  8. package/dist/src/lib/components/TabHeader/TabHeaderItem.vue.d.ts +10 -8
  9. package/dist/src/lib/components/TabHeader/TabHeaderScroll.vue.d.ts +31 -13
  10. package/dist/src/lib/components/TabHeader/index.vue.d.ts +172 -20
  11. package/dist/src/lib/hooks/tabPanel/evict.d.ts +24 -0
  12. package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +1 -0
  13. package/dist/src/lib/hooks/tabPanel/scroll.d.ts +8 -0
  14. package/dist/src/lib/hooks/tabPanel/session.d.ts +9 -0
  15. package/dist/src/lib/hooks/tabPanel/state.d.ts +77 -0
  16. package/dist/src/lib/hooks/useContextMenu.d.ts +6 -1
  17. package/dist/src/lib/hooks/useTabActions.d.ts +68 -0
  18. package/dist/src/lib/hooks/{useTabMitt.d.ts → useTabEventBus.d.ts} +4 -3
  19. package/dist/src/lib/hooks/useTabLoading.d.ts +6 -0
  20. package/dist/src/lib/hooks/{useTabpanel.d.ts → useTabPanel.d.ts} +20 -14
  21. package/dist/src/lib/hooks/useTabPanel.spec.d.ts +1 -0
  22. package/dist/src/lib/hooks/useTabRouter.d.ts +14 -5
  23. package/dist/src/lib/i18n/index.d.ts +1 -1
  24. package/dist/src/lib/i18n/lang/en.d.ts +1 -0
  25. package/dist/src/lib/i18n/lang/zh-CN.d.ts +1 -0
  26. package/dist/src/lib/index.d.ts +5 -4
  27. package/dist/src/lib/model/TabModel.d.ts +20 -3
  28. package/dist/src/lib/model/TabModel.spec.d.ts +1 -0
  29. package/dist/src/lib/utils/iframeBridge.d.ts +31 -0
  30. package/dist/src/lib/utils/iframeBridge.spec.d.ts +1 -0
  31. package/dist/src/lib/utils/scrollUtils.d.ts +28 -0
  32. package/dist/src/lib/utils/tabInfoEncoder.d.ts +19 -0
  33. package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +1 -0
  34. package/dist/src/lib/utils/urlParser.d.ts +19 -0
  35. package/dist/src/lib/utils/urlParser.spec.d.ts +1 -0
  36. package/dist/src/lib/versionLogger.d.ts +2 -0
  37. package/dist/style.css +1 -1
  38. package/dist/vue-stack-tabs.css +1 -0
  39. package/dist/vue-stack-tabs.es.js +1791 -1681
  40. package/dist/vue-stack-tabs.umd.js +1 -5
  41. package/package.json +76 -39
  42. package/src/lib/StackTabs.vue +222 -47
  43. package/src/lib/assets/image/open.svg +1 -0
  44. package/src/lib/assets/style/common/var.scss +1 -1
  45. package/src/lib/assets/style/icon.scss +6 -0
  46. package/src/lib/assets/style/stackTab.scss +44 -3
  47. package/src/lib/assets/style/transition.scss +63 -62
  48. package/src/lib/components/ContextMenu/ContextMenuItem.vue +7 -0
  49. package/src/lib/components/ContextMenu/index.vue +50 -38
  50. package/src/lib/components/PageLoading.vue +30 -10
  51. package/src/lib/components/TabHeader/TabHeaderButton.vue +12 -3
  52. package/src/lib/components/TabHeader/TabHeaderItem.vue +31 -27
  53. package/src/lib/components/TabHeader/TabHeaderScroll.vue +83 -77
  54. package/src/lib/components/TabHeader/index.vue +45 -40
  55. package/src/lib/hooks/tabPanel/evict.spec.ts +85 -0
  56. package/src/lib/hooks/tabPanel/evict.ts +57 -0
  57. package/src/lib/hooks/tabPanel/scroll.ts +52 -0
  58. package/src/lib/hooks/tabPanel/session.ts +33 -0
  59. package/src/lib/hooks/tabPanel/state.ts +52 -0
  60. package/src/lib/hooks/useContextMenu.ts +22 -20
  61. package/src/lib/hooks/useTabActions.ts +148 -0
  62. package/src/lib/hooks/useTabEventBus.ts +31 -0
  63. package/src/lib/hooks/useTabLoading.ts +12 -4
  64. package/src/lib/hooks/useTabPanel.spec.ts +34 -0
  65. package/src/lib/hooks/useTabPanel.tsx +546 -0
  66. package/src/lib/hooks/useTabRouter.ts +114 -103
  67. package/src/lib/i18n/index.ts +14 -18
  68. package/src/lib/i18n/lang/en.ts +2 -1
  69. package/src/lib/i18n/lang/zh-CN.ts +2 -1
  70. package/src/lib/iframe.vue +6 -0
  71. package/src/lib/index.ts +27 -8
  72. package/src/lib/model/TabModel.spec.ts +40 -0
  73. package/src/lib/model/TabModel.ts +22 -9
  74. package/src/lib/nuxt/module.d.ts +19 -0
  75. package/src/lib/nuxt/module.ts +34 -0
  76. package/src/lib/nuxt/runtime/plugin.client.ts +11 -0
  77. package/src/lib/utils/iframeBridge.spec.ts +37 -0
  78. package/src/lib/utils/iframeBridge.ts +52 -0
  79. package/src/lib/utils/{TabScrollHelper.ts → scrollUtils.ts} +23 -29
  80. package/src/lib/utils/tabInfoEncoder.spec.ts +42 -0
  81. package/src/lib/utils/tabInfoEncoder.ts +65 -0
  82. package/src/lib/utils/urlParser.spec.ts +45 -0
  83. package/src/lib/utils/urlParser.ts +42 -0
  84. package/src/lib/{banner.ts → versionLogger.ts} +4 -5
  85. package/dist/src/lib/banner.d.ts +0 -2
  86. package/dist/src/lib/hooks/useStackTab.d.ts +0 -11
  87. package/dist/src/lib/utils/TabIdHelper.d.ts +0 -11
  88. package/dist/src/lib/utils/TabScrollHelper.d.ts +0 -33
  89. package/dist/src/lib/utils/UriHelper.d.ts +0 -4
  90. package/src/lib/hooks/useStackTab.ts +0 -119
  91. package/src/lib/hooks/useTabMitt.ts +0 -30
  92. package/src/lib/hooks/useTabpanel.tsx +0 -705
  93. package/src/lib/utils/TabIdHelper.ts +0 -70
  94. 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="contextmenu"
9
+ ref="menuElementRef"
4
10
  class="stack-tab__contextmenu"
5
11
  :style="{
6
- left: `${modifyData.left}px`,
7
- top: `${modifyData.top}px`,
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
- @click="refreshTab(tabItem.id)"
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="removeTab(tabItem.id)"
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="removeAllTabs"
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, index) in contextMenu"
55
- :key="index"
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/TabScrollHelper'
69
- import useTabpanel from '../../hooks/useTabpanel'
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
- // const emit = defineEmits(['close', 'closeAll', 'closeLeft', 'closeRight', 'refresh', 'refreshAll'])
85
+
72
86
  const props = withDefaults(
73
87
  defineProps<{
74
88
  index: number
75
- left: number
76
- top: number
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
- contextMen: []
98
+ contextMenu: () => []
85
99
  }
86
100
  )
87
- // @ts-ignore
101
+ const emit = defineEmits<{ close: [] }>()
88
102
  const { t } = useI18n()
89
- const contextmenu = ref<HTMLElement>()
90
- const modifyData = reactive({
91
- left: props.left,
92
- top: props.top
93
- })
94
- const {
95
- removeTab,
96
- removeAllTabs,
97
- removeLeftTabs,
98
- removeRightTabs,
99
- refreshTab,
100
- refreshAllTabs,
101
- removeOtherTabs
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 clientWidth = contextmenu.value ? contextmenu.value.clientWidth : 0
105
- const winWidth = window.innerWidth
106
- if (modifyData.left + clientWidth > winWidth) {
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 v-if="loading" class="stack-tab-loading-mask" :style="{zIndex: getMaxZIndex('.cache-page-wrapper *')}">
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 { MittType, useEmitter } from '../hooks/useTabMitt'
9
- import { getMaxZIndex } from '../utils/TabScrollHelper'
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
- tId: string
21
+ /** 当前标签 id,用于过滤 PAGE_LOADING 事件 */
22
+ tabId: string
12
23
  }>()
13
- const emitter = useEmitter()
14
- const loading = ref<boolean>(false)
15
- emitter.on(MittType.PAGE_LOADING, ({ tId, value }: any) => {
16
- if (tId === props.tId) {
17
- loading.value = value
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
- shadow?: string | null
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="curtab"
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="activeTab(true)"
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="closeTab"
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 { MittType, useEmitter } from '../../hooks/useTabMitt'
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 = useEmitter()
37
+ const emitter = useTabEmitter()
34
38
  const { t } = useI18n()
35
- const curtab = ref<HTMLElement>()
39
+ /** 当前标签 DOM 元素,用于滚动到可视区域 */
40
+ const tabElementRef = ref<HTMLElement>()
36
41
  const props = defineProps<{
37
42
  item: ITabItem
38
43
  }>()
39
- /**
40
- * 如果Active为true,就看看
41
- */
42
- // watch(
43
- // () => props.item.active,
44
- // (val) => {
45
- // if (val) {
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
- const closeTab = () => {
63
+ /** 关闭按钮点击,向父级 emit close */
64
+ const handleClose = () => {
60
65
  emit('close', props.item)
61
66
  }
62
- const activeTab = (isRoute: boolean = true) => {
63
- // if (!props.active) {
64
- emit('active', props.item, curtab, isRoute)
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="isButtonVisibled"
8
+ v-if="isScrollButtonVisible"
4
9
  icon-class="stack-tab__icon-left-arrow"
5
10
  :disabled="isDisabledLeftButton"
6
- @click="!isDisabledLeftButton && onButton(-1 * space)"
11
+ @click="!isDisabledLeftButton && handleScrollButtonClick(-1 * space)"
7
12
  />
8
- <div ref="headerScroll" class="stack-tab__scroll" @wheel.passive="onWheel">
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="update"
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="onDragStart"
40
+ @mousedown.prevent="handleScrollbarDragStart"
36
41
  />
37
42
  </div>
38
43
  </div>
39
44
  <tab-header-button
40
- v-if="isButtonVisibled"
45
+ v-if="isScrollButtonVisible"
41
46
  icon-class="stack-tab__icon-right-arrow"
42
47
  :disabled="isDisabledRightButton"
43
- @click="!isDisabledRightButton && onButton(space!)"
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 { _scrollTo, _scrollIntoView } from '@/lib/utils/TabScrollHelper'
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
- // ref
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
- const isButtonVisibled = ref<boolean>(true)
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, (newValue) => {
93
- setScrollButton(newValue)
107
+ /** 滚动数据变化时更新箭头与滚动条的启用状态 */
108
+ watch(scrollData, (data) => {
109
+ updateScrollButtonState(data)
94
110
  })
95
111
 
96
- const setScrollButton = (scrollData: ScrollData) => {
97
- isDisabledLeftButton.value = scrollData.scrollLeft <= 10
112
+ /** 根据滚动位置更新左右箭头禁用状态及滚动条可见性 */
113
+ const updateScrollButtonState = (data: ScrollData) => {
114
+ isDisabledLeftButton.value = data.scrollLeft <= 10
98
115
  isDisabledRightButton.value =
99
- scrollData.clientWidth + scrollData.scrollLeft - scrollData.scrollWidth >= -10
100
- isButtonVisibled.value = scrollData.scrollWidth !== scrollData.clientWidth
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
- // 滑块 left
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
- const update = () => {
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
- const resizeUpdate = new ResizeObserver(() => {
138
- update()
139
- const cur = container.value?.querySelector('.stack-tab__item.is-active')
140
- if (cur) {
141
- if (!isInView(cur as HTMLElement)) {
142
- scrollIntoView(cur as HTMLElement)
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
- const tabsUpdate = new ResizeObserver(() => {
147
- if (!hasScroller.value) {
148
- const cur = container.value?.querySelector('.stack-tab__item.is-active')
149
- if (cur) {
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
- _scrollTo({ wrap: container.value, left, smooth })
168
+ scrollToUtil({ wrap: container.value, left, top: 0, smooth })
160
169
  }
161
170
 
162
- const onButton = (space: number) => {
163
- scrollTo(container.value!.scrollLeft + space)
171
+ const handleScrollButtonClick = (delta: number) => {
172
+ scrollTo(container.value!.scrollLeft + delta)
164
173
  }
165
- // 页签鼠标滚动
166
- const onWheel = (e: WheelEvent) => {
167
- const isWheelUp = e.deltaY < 0
168
- scrollTo(container.value!.scrollLeft + (isWheelUp ? -props.space : props.space))
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 onDragStart = (e: MouseEvent) => {
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', onDragMove)
180
- document.addEventListener('mouseup', onDragEnd)
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
- e.preventDefault()
209
+ const handleScrollbarDragEnd = () => {
210
+ scrollDragging.value = false
211
+ document.removeEventListener('mousemove', handleScrollbarDragMove)
212
+ document.removeEventListener('mouseup', handleScrollbarDragEnd)
207
213
  }
214
+
208
215
  onMounted(() => {
209
- resizeUpdate.observe(headerScroll.value as Element)
216
+ scrollContainerResizeObserver.observe(headerScroll.value as Element)
210
217
  const tabNav = container.value?.querySelector('.stack-tab__nav')
211
- tabsUpdate.observe(tabNav!)
218
+ tabListResizeObserver.observe(tabNav!)
212
219
  })
213
220
  onUnmounted(() => {
214
- resizeUpdate.disconnect()
215
- tabsUpdate.disconnect()
221
+ scrollContainerResizeObserver.disconnect()
222
+ tabListResizeObserver.disconnect()
216
223
  })
217
224
 
218
- // 滚动到元素
219
225
  const scrollIntoView = (el: HTMLElement) => {
220
- _scrollIntoView({ el, wrap: container, block: 'end', inline: 'center' })
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