vue-stack-tabs 1.3.0 → 1.4.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 (96) hide show
  1. package/README.en.md +119 -52
  2. package/README.md +104 -41
  3. package/README.ru.md +125 -54
  4. package/dist/{src/lib/StackTabs.vue.d.ts → StackTabs.vue.d.ts} +5 -5
  5. package/dist/{src/lib/components → components}/ContextMenu/index.vue.d.ts +1 -1
  6. package/dist/components/StackKeepAlive/emptyPlaceholder.d.ts +3 -0
  7. package/dist/{src/lib/components → components}/TabHeader/TabHeaderItem.vue.d.ts +9 -0
  8. package/dist/{src/lib/components → components}/TabHeader/index.vue.d.ts +3 -3
  9. package/dist/{src/lib/hooks → hooks}/stackTabsContext.d.ts +5 -2
  10. package/dist/hooks/tabPanel/pageComponentFactory.d.ts +20 -0
  11. package/dist/{src/lib/hooks → hooks}/tabPanel/scroll.d.ts +2 -0
  12. package/dist/hooks/useScrollShadow.d.ts +20 -0
  13. package/dist/{src/lib/hooks → hooks}/useTabActions.d.ts +7 -2
  14. package/dist/{src/lib/hooks → hooks}/useTabEventBus.d.ts +2 -1
  15. package/dist/{src/lib/hooks → hooks}/useTabPanel.d.ts +3 -1
  16. package/dist/iframe-bridge.mjs +1 -1
  17. package/dist/index.d.ts +22 -6
  18. package/dist/{src/lib/model → model}/TabModel.d.ts +21 -0
  19. package/dist/nuxt/module.mjs +10 -6
  20. package/dist/nuxt/runtime/plugin.mjs +1600 -4
  21. package/dist/style.css +1 -1
  22. package/dist/utils/iframeBridge.d.ts +7 -1
  23. package/dist/{src/lib/utils → utils}/scrollUtils.d.ts +7 -1
  24. package/dist/{src/lib/versionLogger.d.ts → versionLogger.d.ts} +1 -1
  25. package/dist/vue-stack-tabs.css +1 -1
  26. package/dist/vue-stack-tabs.es.js +1 -1
  27. package/package.json +83 -59
  28. package/src/lib/StackTabs.vue +16 -3
  29. package/src/lib/assets/style/common/var.scss +2 -0
  30. package/src/lib/assets/style/stackTab.scss +43 -4
  31. package/src/lib/components/ContextMenu/index.vue +12 -3
  32. package/src/lib/components/PageLoading.vue +10 -2
  33. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +3 -9
  34. package/src/lib/components/StackKeepAlive/emptyPlaceholder.tsx +18 -0
  35. package/src/lib/components/TabHeader/TabHeaderItem.vue +30 -2
  36. package/src/lib/components/TabHeader/index.vue +76 -4
  37. package/src/lib/hooks/stackTabsContext.ts +23 -18
  38. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +142 -0
  39. package/src/lib/hooks/tabPanel/refresh.ts +3 -0
  40. package/src/lib/hooks/tabPanel/scroll.ts +110 -7
  41. package/src/lib/hooks/tabPanel/session.ts +29 -3
  42. package/src/lib/hooks/useContextMenu.ts +57 -11
  43. package/src/lib/hooks/useIframeManager.ts +18 -2
  44. package/src/lib/hooks/useScrollShadow.ts +124 -0
  45. package/src/lib/hooks/useTabActions.ts +20 -8
  46. package/src/lib/hooks/useTabEventBus.ts +5 -3
  47. package/src/lib/hooks/useTabPanel.tsx +132 -180
  48. package/src/lib/hooks/useTabRouter.ts +11 -2
  49. package/src/lib/i18n/index.ts +3 -3
  50. package/src/lib/model/TabModel.ts +40 -1
  51. package/src/lib/nuxt/module.ts +10 -3
  52. package/src/lib/nuxt/runtime/plugin.ts +13 -2
  53. package/src/lib/utils/iframeBridge.ts +9 -1
  54. package/src/lib/utils/scrollUtils.ts +35 -2
  55. package/src/lib/utils/tabInfoEncoder.ts +21 -8
  56. package/src/lib/utils/urlParser.ts +3 -1
  57. package/src/lib/versionLogger.ts +6 -2
  58. package/dist/favicon.svg +0 -10
  59. package/dist/iframe-bridge.html +0 -77
  60. package/dist/iframe-child.html +0 -88
  61. package/dist/nuxt/favicon.svg +0 -10
  62. package/dist/nuxt/iframe-bridge.html +0 -77
  63. package/dist/nuxt/iframe-child.html +0 -88
  64. package/dist/nuxt/plugin.mjs +0 -9
  65. package/dist/src/lib/iframe-bridge.d.ts +0 -8
  66. package/dist/src/lib/index.d.ts +0 -22
  67. package/dist/src/lib/nuxt/module.d.ts +0 -6
  68. package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +0 -2
  69. package/dist/src/lib/nuxt/runtime/plugin.d.ts +0 -2
  70. package/dist/src/lib/utils/iframeBridge.d.ts +0 -39
  71. package/src/lib/nuxt/runtime/plugin.client.ts +0 -10
  72. /package/dist/{src/lib/components → components}/ContextMenu/ContextMenuItem.vue.d.ts +0 -0
  73. /package/dist/{src/lib/components → components}/PageLoading.vue.d.ts +0 -0
  74. /package/dist/{src/lib/components → components}/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -0
  75. /package/dist/{src/lib/components → components}/StackKeepAlive/StackKeepAlive.vue.d.ts +0 -0
  76. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderButton.vue.d.ts +0 -0
  77. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderScroll.vue.d.ts +0 -0
  78. /package/dist/{src/lib/hooks → hooks}/tabPanel/evict.d.ts +0 -0
  79. /package/dist/{src/lib/hooks → hooks}/tabPanel/navigationTransaction.d.ts +0 -0
  80. /package/dist/{src/lib/hooks → hooks}/tabPanel/refresh.d.ts +0 -0
  81. /package/dist/{src/lib/hooks → hooks}/tabPanel/session.d.ts +0 -0
  82. /package/dist/{src/lib/hooks → hooks}/tabPanel/state.d.ts +0 -0
  83. /package/dist/{src/lib/hooks → hooks}/useContextMenu.d.ts +0 -0
  84. /package/dist/{src/lib/hooks → hooks}/useIframeManager.d.ts +0 -0
  85. /package/dist/{src/lib/hooks → hooks}/useTabLoading.d.ts +0 -0
  86. /package/dist/{src/lib/hooks → hooks}/useTabRouter.d.ts +0 -0
  87. /package/dist/{src/lib/i18n → i18n}/index.d.ts +0 -0
  88. /package/dist/{src/lib/i18n → i18n}/lang/en.d.ts +0 -0
  89. /package/dist/{src/lib/i18n → i18n}/lang/zh-CN.d.ts +0 -0
  90. /package/dist/{src/lib/i18n → i18n}/stackTabsLocale.d.ts +0 -0
  91. /package/dist/{src/lib/iframe.vue.d.ts → iframe.vue.d.ts} +0 -0
  92. /package/dist/{src/lib/utils → utils}/stackTabsA11y.d.ts +0 -0
  93. /package/dist/{src/lib/utils → utils}/stackTabsMessage.d.ts +0 -0
  94. /package/dist/{src/lib/utils → utils}/tabInfoEncoder.d.ts +0 -0
  95. /package/dist/{src/lib/utils → utils}/typeGuards.d.ts +0 -0
  96. /package/dist/{src/lib/utils → utils}/urlParser.d.ts +0 -0
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "vue-stack-tabs",
3
- "version": "1.3.0",
4
- "packageManager": "pnpm@11.5.0+sha512.dbfcc4f81cf48597afd4bc391ffdf12c11f1a9fb83a395bfa6b0a2d9cc2fd8ffebafdb1ccbd529632153f793904c2615b7f09fe1a345473fd1c35845172a8eb1",
3
+ "version": "1.4.1",
5
4
  "private": false,
6
5
  "type": "module",
6
+ "description": "Vue 3 multi-tab manager built on Vue Router with iframe-like tab isolation: per-tab component scope, stack-based forward/backward navigation, keep-alive page caching, scroll position memory, i18n and a Nuxt 3/4 module.",
7
7
  "files": [
8
8
  "src/lib",
9
9
  "dist"
10
10
  ],
11
+ "sideEffects": [
12
+ "**/*.css",
13
+ "**/*.scss"
14
+ ],
11
15
  "exports": {
12
16
  ".": {
13
17
  "types": "./dist/index.d.ts",
@@ -25,7 +29,9 @@
25
29
  "./dist/vue-stack-tabs.css": "./dist/vue-stack-tabs.css"
26
30
  },
27
31
  "peerDependencies": {
28
- "nuxt": "^3.0.0 || ^4.0.0"
32
+ "nuxt": "^3.0.0 || ^4.0.0",
33
+ "vue": "^3.5.43",
34
+ "vue-router": "^5.4.0"
29
35
  },
30
36
  "peerDependenciesMeta": {
31
37
  "nuxt": {
@@ -34,6 +40,7 @@
34
40
  },
35
41
  "main": "./dist/vue-stack-tabs.es.js",
36
42
  "module": "./dist/vue-stack-tabs.es.js",
43
+ "types": "./dist/index.d.ts",
37
44
  "typings": "./dist/index.d.ts",
38
45
  "repository": {
39
46
  "type": "git",
@@ -41,20 +48,39 @@
41
48
  },
42
49
  "keywords": [
43
50
  "vue",
44
- "vue router",
51
+ "vue3",
52
+ "vue-router",
53
+ "router",
45
54
  "tabs",
46
- "nuxt"
55
+ "tab",
56
+ "tab-panel",
57
+ "multi-tab",
58
+ "tabs-router",
59
+ "keep-alive",
60
+ "cache",
61
+ "iframe",
62
+ "nuxt",
63
+ "nuxt3",
64
+ "nuxt-module",
65
+ "navigation",
66
+ "vue-component",
67
+ "typescript"
47
68
  ],
48
69
  "author": "tantan",
49
- "license": "Apache License, Version 2.0",
70
+ "license": "Apache-2.0",
50
71
  "bugs": {
51
72
  "url": "https://github.com/jktantan/vue-stack-tabs/issues"
52
73
  },
53
- "homepage": "https://github.com/jktantan/vue-stack-tabs.git",
74
+ "homepage": "https://jktantan.github.io/vue-stack-tabs/",
75
+ "publishConfig": {
76
+ "access": "public"
77
+ },
54
78
  "scripts": {
55
79
  "clean:ports": "node -e \"const { execSync } = require('child_process'); try { if (process.platform === 'win32') { execSync('netstat -ano | findstr \":5173\" | findstr \"LISTENING\"').toString().split('\\n').forEach(line => { const pid = line.trim().split(/\\s+/)[4]; if (pid) try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' }); } catch(e) {} }); execSync('netstat -ano | findstr \":5174\" | findstr \"LISTENING\"').toString().split('\\n').forEach(line => { const pid = line.trim().split(/\\s+/)[4]; if (pid) try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' }); } catch(e) {} }); } else { execSync('lsof -ti:5173 | xargs kill -9 || true', { shell: '/bin/bash' }); execSync('lsof -ti:5174 | xargs kill -9 || true', { shell: '/bin/bash' }); } console.log('✅ 端口清理完成'); } catch(e) { console.log('✅ 端口已经空闲或清理完成'); }\"",
56
- "docs:dev": "vitepress dev docs",
57
- "docs:build": "vitepress build docs && vite build --config vite.config.docs-demo.ts && node -e \"const fs=require('fs');const p='docs/.vitepress/dist/demo';fs.mkdirSync(p+'/demo',{recursive:true});fs.copyFileSync(p+'/index.html',p+'/demo/index.html');\"",
80
+ "docs:dev": "run-p docs:dev:docs docs:dev:demo",
81
+ "docs:dev:docs": "vitepress dev docs",
82
+ "docs:dev:demo": "vite --config vite.config.docs-demo.ts",
83
+ "docs:build": "vitepress build docs && vite build --config vite.config.docs-demo.ts",
58
84
  "dev": "vite --host ",
59
85
  "build": "run-p type-check \"build-only {@}\" --",
60
86
  "preview": "vite preview",
@@ -64,83 +90,81 @@
64
90
  "lint:check": "eslint .",
65
91
  "format": "prettier --write .",
66
92
  "format:check": "prettier --check .",
67
- "lib:build": "vite --config vite.config.lib.ts build && vite --config vite.config.iframe-bridge.ts build && vite --config vite.config.nuxt.ts build && node -e \"const fs=require('fs');fs.copyFileSync('dist/vue-stack-tabs.css','dist/style.css');fs.copyFileSync('dist/src/lib/iframe-bridge.d.ts','dist/iframe-bridge.d.ts');fs.mkdirSync('dist/utils',{recursive:true});fs.copyFileSync('dist/src/lib/utils/iframeBridge.d.ts','dist/utils/iframeBridge.d.ts');fs.mkdirSync('dist/nuxt',{recursive:true});fs.copyFileSync('dist/src/lib/nuxt/module.d.ts','dist/nuxt/module.d.ts');fs.mkdirSync('dist/nuxt/runtime',{recursive:true});fs.copyFileSync('dist/src/lib/nuxt/runtime/plugin.d.ts','dist/nuxt/runtime/plugin.d.ts');fs.copyFileSync('dist/nuxt/plugin.mjs','dist/nuxt/runtime/plugin.mjs')\"",
93
+ "lib:build": "vite --config vite.config.lib.ts build && vite --config vite.config.iframe-bridge.ts build && vite --config vite.config.nuxt.ts build && node -e \"const fs=require('fs');fs.copyFileSync('dist/vue-stack-tabs.css','dist/style.css');fs.rmSync('dist/src',{recursive:true,force:true});\"",
68
94
  "test": "vitest run",
69
95
  "test:watch": "vitest",
70
- "test:coverage": "vitest run --coverage",
71
- "test:prepack": "node scripts/verify-prepack.mjs",
72
- "test:packaged": "pnpm run lib:build && node scripts/verify-packaged.mjs"
96
+ "test:coverage": "vitest run --coverage"
73
97
  },
74
98
  "dependencies": {
75
99
  "defu": "^6.1.7",
76
100
  "mitt": "^3.0.1",
101
+ "nostics": "^1.3.0",
77
102
  "throttle-debounce": "^5.0.2",
78
- "vue": "^3.5.35",
79
- "vue-i18n-lite": "^1.0.2",
80
- "vue-router": "^5.1.0"
103
+ "vue-i18n-lite": "^1.0.2"
81
104
  },
82
105
  "devDependencies": {
83
- "@ctrl/tinycolor": "^4.2.0",
106
+ "@ctrl/tinycolor": "^4.2.1",
84
107
  "@element-plus/icons-vue": "^2.3.2",
85
108
  "@eslint/js": "^10.0.1",
86
- "@floating-ui/core": "^1.7.5",
87
- "@floating-ui/dom": "^1.7.6",
88
- "@floating-ui/utils": "^0.2.11",
89
- "@mdit/plugin-tasklist": "^0.23.2",
109
+ "@floating-ui/core": "^1.8.0",
110
+ "@floating-ui/dom": "^1.8.0",
111
+ "@floating-ui/utils": "^0.2.12",
112
+ "@mdit/plugin-tasklist": "^1.1.3",
90
113
  "@popperjs/core": "^2.11.8",
91
114
  "@rushstack/eslint-patch": "^1.16.1",
92
- "@tsconfig/node20": "^20.1.9",
115
+ "@tsconfig/node26": "^26.0.1",
93
116
  "@types/lodash-es": "^4.17.12",
94
- "@types/node": "^25.9.1",
117
+ "@types/node": "^26.6.5",
95
118
  "@types/throttle-debounce": "^5.0.2",
96
- "@vitejs/plugin-vue": "^6.0.7",
97
- "@vitejs/plugin-vue-jsx": "^5.1.5",
98
- "@vitest/coverage-v8": "4.1.7",
99
- "@vue/compiler-core": "^3.5.35",
100
- "@vue/compiler-dom": "^3.5.35",
101
- "@vue/devtools-api": "^8.1.2",
102
- "@vue/devtools-kit": "^8.1.2",
103
- "@vue/devtools-shared": "^8.1.2",
119
+ "@vitejs/plugin-vue": "^6.0.10",
120
+ "@vitejs/plugin-vue-jsx": "^5.1.6",
121
+ "@vitest/coverage-v8": "4.1.10",
122
+ "@vue/compiler-core": "^3.5.43",
123
+ "@vue/compiler-dom": "^3.5.43",
124
+ "@vue/devtools-api": "^8.2.1",
125
+ "@vue/devtools-kit": "^8.2.1",
126
+ "@vue/devtools-shared": "^8.2.1",
104
127
  "@vue/eslint-config-prettier": "^10.2.0",
105
- "@vue/eslint-config-typescript": "^14.7.0",
106
- "@vue/reactivity": "^3.5.35",
107
- "@vue/runtime-core": "^3.5.35",
108
- "@vue/runtime-dom": "^3.5.35",
109
- "@vue/shared": "^3.5.35",
110
- "@vue/test-utils": "^2.4.10",
128
+ "@vue/eslint-config-typescript": "^14.9.0",
129
+ "@vue/reactivity": "^3.5.43",
130
+ "@vue/runtime-core": "^3.5.43",
131
+ "@vue/runtime-dom": "^3.5.43",
132
+ "@vue/shared": "^3.5.43",
133
+ "@vue/test-utils": "^2.5.1",
111
134
  "@vue/tsconfig": "^0.9.1",
112
- "@vueuse/core": "^14.3.0",
113
- "@vueuse/shared": "^14.3.0",
135
+ "@vueuse/core": "^14.4.0",
136
+ "@vueuse/shared": "^14.4.0",
114
137
  "async-validator": "^4.2.5",
115
- "birpc": "^4.0.0",
116
- "dayjs": "^1.11.21",
117
- "element-plus": "^2.14.1",
118
- "eslint": "^10.4.1",
138
+ "birpc": "^4.2.0",
139
+ "dayjs": "^1.11.23",
140
+ "element-plus": "^2.14.7",
141
+ "eslint": "^10.12.0",
119
142
  "eslint-config-prettier": "^10.1.8",
120
- "eslint-plugin-vue": "^10.9.1",
121
- "globals": "^17.6.0",
122
- "happy-dom": "^20.9.0",
123
- "hookable": "^6.1.1",
143
+ "eslint-plugin-vue": "^10.11.1",
144
+ "globals": "^17.13.0",
145
+ "happy-dom": "^20.14.6",
146
+ "hookable": "^6.1.2",
124
147
  "lodash-es": "^4.18.1",
125
148
  "lodash-unified": "^1.0.3",
126
149
  "memoize-one": "^6.0.0",
127
150
  "normalize-wheel-es": "^1.2.0",
128
151
  "normalize.css": "^8.0.1",
129
- "npm-run-all2": "^9.0.1",
152
+ "npm-run-all2": "^9.0.3",
130
153
  "perfect-debounce": "^2.1.0",
131
- "prettier": "^3.8.3",
132
- "sass": "^1.100.0",
133
- "sass-loader": "^17.0.0",
134
- "terser": "^5.48.0",
135
- "typescript": "~6.0.3",
136
- "typescript-eslint": "^8.60.0",
137
- "unplugin-auto-import": "^21.0.0",
154
+ "prettier": "^3.9.10",
155
+ "sass-embedded": "^1.105.1",
156
+ "terser": "^5.51.2",
157
+ "typescript": "^6.0.3",
158
+ "typescript-eslint": "^8.71.1",
159
+ "unplugin-auto-import": "^21.1.0",
138
160
  "unplugin-vue-components": "^32.1.0",
139
- "vite": "^8.0.14",
140
- "vite-plugin-dts": "5.0.1",
161
+ "vite": "^8.3.4",
162
+ "vite-plugin-dts": "5.0.3",
141
163
  "vite-plugin-package-version": "^1.1.0",
142
164
  "vitepress": "^1.6.4",
143
- "vitest": "^4.1.7",
144
- "vue-tsc": "^3.3.3"
165
+ "vitest": "^4.1.11",
166
+ "vue": "^3.5.43",
167
+ "vue-router": "^5.4.0",
168
+ "vue-tsc": "^3.3.13"
145
169
  }
146
170
  }
@@ -22,7 +22,7 @@ import {
22
22
  watch
23
23
  } from 'vue'
24
24
  import type { TransitionProps } from 'vue'
25
- import { getMaxZIndex } from './utils/scrollUtils'
25
+ import { getMaxZIndex, invalidateZIndexCache } from './utils/scrollUtils'
26
26
  import { isAllowedTabUrl } from './utils/urlParser'
27
27
  import { applyStackTabsLocale } from './i18n/stackTabsLocale'
28
28
  import { type ITabData, TabScrollMode } from './model/TabModel'
@@ -96,6 +96,17 @@ const props = withDefaults(
96
96
  iframeLoadTimeout: 15000
97
97
  }
98
98
  )
99
+
100
+ // iframePath 必填:用于 iframe 占位页(StackTabs 把 iframe 标签路由到这个 path 渲染)。
101
+ // 这里在开发期给出明确错误,避免空字符串导致 router.push({ path: '' }) 跳到首页的隐式行为。
102
+ if (typeof props.iframePath !== 'string' || props.iframePath.trim() === '') {
103
+ const message = '[vue-stack-tabs] <VueStackTabs> requires the "iframePath" prop (e.g. "/iframe").'
104
+ if (!import.meta.env.PROD) {
105
+ throw new Error(message)
106
+ }
107
+ console.error(message)
108
+ }
109
+
99
110
  const { changeLocale, t } = useI18n()
100
111
  const runtimeContext =
101
112
  inject(stackTabsContextKey, null) ??
@@ -120,10 +131,12 @@ if (isRuntimeContextOwner) {
120
131
  provide(maximumKey, maximum)
121
132
  }
122
133
  watch(maximum, (isMaximum) => {
134
+ // 最大化切换会显著改变 body 下元素层叠顺序,先失效缓存再读取避免过期结果。
135
+ invalidateZIndexCache()
123
136
  maximumZIndex.value = isMaximum ? getMaxZIndex('body *:not(.stack-tab,.stack-tab *)') : undefined
124
137
  })
125
138
  const panelApi = isRuntimeContextOwner
126
- ? useTabPanel()
139
+ ? useTabPanel(runtimeContext)
127
140
  : {
128
141
  tabs: runtimeContext.tabs,
129
142
  iframeRefreshKeys: runtimeContext.iframeRefreshKeys,
@@ -143,7 +156,7 @@ const {
143
156
  setSessionPrefix
144
157
  } = panelApi
145
158
  const tabActions = isRuntimeContextOwner
146
- ? useTabActions()
159
+ ? useTabActions(runtimeContext)
147
160
  : {
148
161
  setIFramePath: () => undefined,
149
162
  openTab: () => Promise.resolve(undefined),
@@ -63,4 +63,6 @@ $stack-tab-shadow-left: -5px 0 5px -5px rgb(0 0 0 / 60%); //#ccc;
63
63
  $stack-tab-shadow-right: 5px 0 5px -5px rgb(0 0 0 / 60%); //#ccc;
64
64
  $stack-tab-shadow-right-inset: inset -5px 0 5px -5px rgb(0 0 0 / 60%); //#ccc;
65
65
  $stack-tab-shadow-left-inset: inset 5px 0 5px -5px rgb(0 0 0 / 60%); //#ccc;
66
+ $stack-tab-shadow-top-inset: inset 0 5px 5px -5px rgb(0 0 0 / 60%); //#ccc;
67
+ $stack-tab-shadow-bottom-inset: inset 0 -5px 5px -5px rgb(0 0 0 / 60%); //#ccc;
66
68
  $stack-tab-shadow-context-menu: 5px 5px 5px rgb(0 0 0 / 10%);
@@ -164,7 +164,7 @@
164
164
  position: relative;
165
165
  display: inline-flex;
166
166
  height: 100%;
167
- padding: 0;
167
+ padding: 0 16px 0 0;
168
168
  margin: 0;
169
169
  list-style: none;
170
170
  flex-wrap: nowrap;
@@ -197,6 +197,31 @@
197
197
  border-left: $stack-tab-border;
198
198
  }
199
199
 
200
+ &.is-dragging {
201
+ opacity: 0.55;
202
+ }
203
+
204
+ &.is-drop-before::before,
205
+ &.is-drop-after::after {
206
+ position: absolute;
207
+ z-index: 2;
208
+ top: 2px;
209
+ bottom: 2px;
210
+ width: 5px;
211
+ content: '';
212
+ background: $stack-tab-color-primary;
213
+ border-radius: 2px;
214
+ box-shadow: 0 0 6px rgba($stack-tab-color-primary, 0.8);
215
+ }
216
+
217
+ &.is-drop-before::before {
218
+ left: -3px;
219
+ }
220
+
221
+ &.is-drop-after::after {
222
+ right: -3px;
223
+ }
224
+
200
225
  /*
201
226
  激活右键菜单的页签
202
227
  */
@@ -418,7 +443,6 @@
418
443
  border-radius: 10px 0;
419
444
  box-shadow: $stack-tab-shadow-context-menu;
420
445
  transform-origin: left top;
421
- transition: all 0.25s ease-in;
422
446
 
423
447
  &-item {
424
448
  display: flex;
@@ -439,7 +463,10 @@
439
463
  background: transparent;
440
464
  border: 0;
441
465
  border-radius: 10px 0;
442
- transition: all 0.2s ease-in-out;
466
+ // 仅过渡悬停态实际会改变的颜色,避免触发菜单布局与合成层的无关动画。
467
+ transition:
468
+ color 0.2s ease-in-out,
469
+ background-color 0.2s ease-in-out;
443
470
  user-select: none;
444
471
 
445
472
  &:hover,
@@ -472,7 +499,7 @@
472
499
  &-icon {
473
500
  width: $stack-tab-font-size;
474
501
  height: $stack-tab-font-size;
475
- transition: all 0.2s ease-in-out;
502
+ transition: background-color 0.2s ease-in-out;
476
503
 
477
504
  &.svg-mask {
478
505
  width: $stack-tab-font-size;
@@ -507,4 +534,16 @@
507
534
  @include mixins.insetShadow();
508
535
  box-shadow: $stack-tab-shadow-left-inset, $stack-tab-shadow-right-inset;
509
536
  }
537
+ &-top::after {
538
+ @include mixins.insetShadow();
539
+ box-shadow: $stack-tab-shadow-top-inset;
540
+ }
541
+ &-bottom::after {
542
+ @include mixins.insetShadow();
543
+ box-shadow: $stack-tab-shadow-bottom-inset;
544
+ }
545
+ &-vertical::after {
546
+ @include mixins.insetShadow();
547
+ box-shadow: $stack-tab-shadow-top-inset, $stack-tab-shadow-bottom-inset;
548
+ }
510
549
  }
@@ -13,7 +13,7 @@
13
13
  :style="{
14
14
  left: `${menuPosition.left}px`,
15
15
  top: `${menuPosition.top}px`,
16
- 'z-index': getMaxZIndex('.stack-tab,.stack-tab *')
16
+ 'z-index': menuZIndex()
17
17
  }"
18
18
  @keydown="handleMenuKeydown"
19
19
  >
@@ -84,7 +84,7 @@
84
84
  import { ref, onMounted, reactive, nextTick } from 'vue'
85
85
  import type { ITabItem, IContextMenu } from '../../model/TabModel'
86
86
  import { useI18n } from 'vue-i18n-lite'
87
- import { getMaxZIndex } from '../../utils/scrollUtils'
87
+ import { getMaxZIndex, invalidateZIndexCache } from '../../utils/scrollUtils'
88
88
  import useTabActions from '../../hooks/useTabActions'
89
89
  import useTabPanel from '../../hooks/useTabPanel'
90
90
  import ContextMenuItem from './ContextMenuItem.vue'
@@ -115,6 +115,14 @@ const menuPosition = reactive({ left: props.left, top: props.top })
115
115
  const { closeTab, closeAllTabs, refreshTab, refreshAllTabs, openInNewWindow } = useTabActions()
116
116
  const { removeLeftTabs, removeRightTabs, removeOtherTabs } = useTabPanel()
117
117
 
118
+ /**
119
+ * 菜单挂载时 z-index 必须重新计算(DOM 层叠已变化),先用 invalidateZIndexCache 失效再读取。
120
+ */
121
+ const menuZIndex = () => {
122
+ invalidateZIndexCache('.stack-tab,.stack-tab *')
123
+ return getMaxZIndex('.stack-tab,.stack-tab *')
124
+ }
125
+
118
126
  const focusActiveTabFallback = () => {
119
127
  const activeTab = document.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
120
128
  activeTab?.focus()
@@ -193,7 +201,8 @@ const handleMenuKeydown = (event: KeyboardEvent) => {
193
201
  }
194
202
  }
195
203
 
196
- const getCustomMenuKey = (item: IContextMenu): string => item.key ?? `${item.title}-${item.icon ?? ''}`
204
+ const getCustomMenuKey = (item: IContextMenu): string =>
205
+ item.key ?? `${item.title}-${item.icon ?? ''}`
197
206
 
198
207
  /** 挂载后修正菜单位置,避免超出右边界 */
199
208
  onMounted(() => {
@@ -11,7 +11,7 @@
11
11
  role="status"
12
12
  aria-live="polite"
13
13
  :aria-label="t('VueStackTab.loading')"
14
- :style="{ zIndex: getMaxZIndex('.cache-page-wrapper *') }"
14
+ :style="{ zIndex: loadingZIndex() }"
15
15
  >
16
16
  <div class="stack-tab-loading--spin turn" aria-hidden="true" />
17
17
  </div>
@@ -20,7 +20,15 @@
20
20
  import { ref, onUnmounted } from 'vue'
21
21
  import { useI18n } from 'vue-i18n-lite'
22
22
  import { TabEventType, useTabEmitter } from '../hooks/useTabEventBus'
23
- import { getMaxZIndex } from '../utils/scrollUtils'
23
+ import { getMaxZIndex, invalidateZIndexCache } from '../utils/scrollUtils'
24
+
25
+ /**
26
+ * loading 遮罩每次显隐都会改变 .cache-page-wrapper 子树层叠,先失效缓存再读取避免旧值。
27
+ */
28
+ const loadingZIndex = () => {
29
+ invalidateZIndexCache('.cache-page-wrapper *')
30
+ return getMaxZIndex('.cache-page-wrapper *')
31
+ }
24
32
  const props = defineProps<{
25
33
  /** 当前标签 id,用于过滤 PAGE_LOADING 事件 */
26
34
  tabId: string
@@ -11,10 +11,11 @@
11
11
  - 不负责 transition、keep-alive、TabHeader、iframe 或 session 初始化
12
12
  -->
13
13
  <script lang="ts" setup>
14
- import { defineComponent, shallowRef, watch } from 'vue'
14
+ import { shallowRef, watch } from 'vue'
15
15
  import type { DefineComponent, VNode } from 'vue'
16
16
  import type { RouteLocationNormalizedLoaded } from 'vue-router'
17
17
  import useTabPanel from '../../hooks/useTabPanel'
18
+ import { getEmptyPlaceholderComponent } from './emptyPlaceholder'
18
19
 
19
20
  interface StackCacheRendererProps {
20
21
  route: RouteLocationNormalizedLoaded
@@ -37,14 +38,7 @@ defineSlots<{
37
38
 
38
39
  const { refreshKey, activeCacheKey, activePageRefreshVersion, addPage } = useTabPanel()
39
40
 
40
- const EmptyRendererComponent = defineComponent({
41
- name: 'StackCacheRendererEmpty',
42
- setup() {
43
- return () => null
44
- }
45
- }) as DefineComponent
46
-
47
- const wrappedComponent = shallowRef<DefineComponent>(EmptyRendererComponent)
41
+ const wrappedComponent = shallowRef<DefineComponent>(getEmptyPlaceholderComponent())
48
42
 
49
43
  const getComponentIdentity = (component?: VNode | null): unknown => {
50
44
  return component?.type ?? component
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 共享的 Empty 占位组件。
3
+ *
4
+ * useTabPanel 在「empty」/「recovered」分支会返回一个不渲染任何内容的占位组件。
5
+ * 原先在 pageComponentFactory.tsx 和 StackCacheRenderer.vue 各自定义了一份,
6
+ * 这里抽出唯一来源,避免两份略有差异的副本带来维护负担。
7
+ */
8
+ import { defineComponent } from 'vue'
9
+ import type { DefineComponent } from 'vue'
10
+
11
+ export const EmptyPlaceholderComponent = defineComponent({
12
+ name: 'StackTabEmptyPlaceholder',
13
+ setup() {
14
+ return () => null
15
+ }
16
+ }) as DefineComponent
17
+
18
+ export const getEmptyPlaceholderComponent = (): DefineComponent => EmptyPlaceholderComponent
@@ -5,10 +5,18 @@
5
5
  class="stack-tab__item"
6
6
  :class="{
7
7
  'is-active': item.active,
8
- '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'
9
12
  }"
13
+ :draggable="true"
10
14
  @click.self="handleActivate(true)"
11
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)"
12
20
  >
13
21
  <button
14
22
  :id="getStackTabTabId(item.id)"
@@ -52,13 +60,23 @@ import type { ITabItem } from '../../model/TabModel'
52
60
  import { TabEventType, useTabEmitter } from '../../hooks/useTabEventBus'
53
61
  import { getStackTabPanelId, getStackTabTabId } from '../../utils/stackTabsA11y'
54
62
  import { useI18n } from 'vue-i18n-lite'
55
- const emit = defineEmits(['close', 'active', 'contextmenu'])
63
+ const emit = defineEmits([
64
+ 'close',
65
+ 'active',
66
+ 'contextmenu',
67
+ 'dragstart',
68
+ 'dragover',
69
+ 'drop',
70
+ 'dragend'
71
+ ])
56
72
  const emitter = useTabEmitter()
57
73
  const { t } = useI18n()
58
74
  /** 当前标签 DOM 元素,用于滚动到可视区域 */
59
75
  const tabElementRef = ref<HTMLElement>()
76
+ const dragging = ref(false)
60
77
  const props = defineProps<{
61
78
  item: ITabItem
79
+ dropPosition?: 'before' | 'after'
62
80
  }>()
63
81
 
64
82
  /** TAB_ACTIVE 事件 payload */
@@ -80,6 +98,16 @@ const title = computed<string>(() => props.item.title || t('VueStackTab.undefine
80
98
  const handleClose = () => {
81
99
  emit('close', props.item)
82
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
+ }
83
111
  /** 激活当前标签,向父级 emit active 并传递 DOM 引用 */
84
112
  const handleActivate = (isRoute = true) => {
85
113
  emit('active', props.item, tabElementRef.value, isRoute)
@@ -17,11 +17,14 @@
17
17
  v-bind="tabTransitionProps"
18
18
  appear
19
19
  @keydown="handleTabListKeydown"
20
+ @dragover.prevent="handleTabListDragOver"
21
+ @drop.prevent="handleTabListDrop"
20
22
  >
21
23
  <tab-header-item
22
24
  v-for="(item, index) in tabs"
23
25
  :key="item.id"
24
26
  :item="item as ITabItem"
27
+ :drop-position="dropIndicator?.tabId === item.id ? dropIndicator.position : undefined"
25
28
  @contextmenu="
26
29
  (e: MouseEvent) =>
27
30
  handleTabContextMenu(e, item as unknown as ITabItem, index, tabs.length)
@@ -29,6 +32,10 @@
29
32
  @click.middle.prevent="handleCloseTab(item as ITabItem)"
30
33
  @close="handleCloseTab"
31
34
  @active="handleActivateTab"
35
+ @dragstart="handleTabDragStart"
36
+ @dragover="handleTabDragOver"
37
+ @drop="handleTabDrop"
38
+ @dragend="handleTabDragEnd"
32
39
  />
33
40
  </transition-group>
34
41
  </template>
@@ -109,7 +116,10 @@ const props = withDefaults(
109
116
  max: 20
110
117
  }
111
118
  )
112
- 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)
113
123
 
114
124
  /** 右键菜单是否启用;仅 false 明确禁用,其余旧对象值保持启用以兼容历史写法 */
115
125
  const isContextMenuEnabled = computed<boolean>(() => props.contextmenu !== false)
@@ -137,6 +147,67 @@ const handleTabContextMenu = (e: MouseEvent, item: ITabItem, index: number, max:
137
147
  showContextMenu(e, item, index, max)
138
148
  }
139
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
+ }
210
+
140
211
  /** 标签列表 transition 的 props,支持 string 或对象 */
141
212
  const tabTransitionProps = computed<TransitionProps>(() =>
142
213
  typeof props.tabTransition === 'string'
@@ -172,17 +243,18 @@ const handleTabListKeydown = (event: KeyboardEvent) => {
172
243
  if (count <= 0) return
173
244
 
174
245
  const currentIndex = getActiveTabIndex()
175
- const safeIndex = currentIndex >= 0 ? currentIndex : 0
176
246
 
177
247
  if (event.key === 'ArrowRight') {
178
248
  event.preventDefault()
179
- activateTabByIndex((safeIndex + 1) % count)
249
+ // 无 active 标签时从 -1 起步,按右方向键跳到第 0 个;正常情况环绕到下一个。
250
+ activateTabByIndex(currentIndex === -1 ? 0 : (currentIndex + 1) % count)
180
251
  return
181
252
  }
182
253
 
183
254
  if (event.key === 'ArrowLeft') {
184
255
  event.preventDefault()
185
- activateTabByIndex((safeIndex - 1 + count) % count)
256
+ // 无 active 标签时按左方向键跳到最后一个;正常情况环绕到上一个。
257
+ activateTabByIndex(currentIndex === -1 ? count - 1 : (currentIndex - 1 + count) % count)
186
258
  return
187
259
  }
188
260