vue-stack-tabs 1.3.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.md CHANGED
@@ -247,6 +247,7 @@ const {
247
247
  refreshTab, // (id: string) => void
248
248
  refreshAllTabs, // () => void
249
249
  activeTab, // (id: string, isRoute?: boolean) => void
250
+ moveTab, // (id: string, targetIndex: number) => boolean
250
251
  reset, // () => void
251
252
  tabs // Ref<ITabItem[]>
252
253
  } = useTabActions()
@@ -260,6 +261,7 @@ const {
260
261
  | `refreshTab(id)` | 刷新指定标签(替换缓存 ID,重建组件实例) |
261
262
  | `refreshAllTabs()` | 刷新所有标签 |
262
263
  | `activeTab(id)` | 激活指定标签(切换 Tab) |
264
+ | `moveTab(id, targetIndex)` | 将标签移动到指定索引;标签栏也支持鼠标拖放排序 |
263
265
  | `reset()` | 关闭所有标签并重置状态 |
264
266
 
265
267
  ---
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vue-stack-tabs",
3
- "version": "1.3.0",
4
- "packageManager": "pnpm@11.5.0+sha512.dbfcc4f81cf48597afd4bc391ffdf12c11f1a9fb83a395bfa6b0a2d9cc2fd8ffebafdb1ccbd529632153f793904c2615b7f09fe1a345473fd1c35845172a8eb1",
3
+ "version": "1.3.1",
4
+ "packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "files": [
@@ -74,73 +74,73 @@
74
74
  "dependencies": {
75
75
  "defu": "^6.1.7",
76
76
  "mitt": "^3.0.1",
77
+ "nostics": "^1.2.0",
77
78
  "throttle-debounce": "^5.0.2",
78
- "vue": "^3.5.35",
79
+ "vue": "^3.5.42",
79
80
  "vue-i18n-lite": "^1.0.2",
80
- "vue-router": "^5.1.0"
81
+ "vue-router": "^5.3.0"
81
82
  },
82
83
  "devDependencies": {
83
84
  "@ctrl/tinycolor": "^4.2.0",
84
85
  "@element-plus/icons-vue": "^2.3.2",
85
86
  "@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",
87
+ "@floating-ui/core": "^1.8.0",
88
+ "@floating-ui/dom": "^1.8.0",
89
+ "@floating-ui/utils": "^0.2.12",
90
+ "@mdit/plugin-tasklist": "^1.1.0",
90
91
  "@popperjs/core": "^2.11.8",
91
92
  "@rushstack/eslint-patch": "^1.16.1",
92
- "@tsconfig/node20": "^20.1.9",
93
+ "@tsconfig/node26": "^26.0.1",
93
94
  "@types/lodash-es": "^4.17.12",
94
- "@types/node": "^25.9.1",
95
+ "@types/node": "^26.4.0",
95
96
  "@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",
97
+ "@vitejs/plugin-vue": "^6.0.8",
98
+ "@vitejs/plugin-vue-jsx": "^5.1.6",
99
+ "@vitest/coverage-v8": "4.1.10",
100
+ "@vue/compiler-core": "^3.5.42",
101
+ "@vue/compiler-dom": "^3.5.42",
102
+ "@vue/devtools-api": "^8.2.1",
103
+ "@vue/devtools-kit": "^8.2.1",
104
+ "@vue/devtools-shared": "^8.2.1",
104
105
  "@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",
106
+ "@vue/eslint-config-typescript": "^14.9.0",
107
+ "@vue/reactivity": "^3.5.42",
108
+ "@vue/runtime-core": "^3.5.42",
109
+ "@vue/runtime-dom": "^3.5.42",
110
+ "@vue/shared": "^3.5.42",
111
+ "@vue/test-utils": "^2.5.0",
111
112
  "@vue/tsconfig": "^0.9.1",
112
- "@vueuse/core": "^14.3.0",
113
- "@vueuse/shared": "^14.3.0",
113
+ "@vueuse/core": "^14.4.0",
114
+ "@vueuse/shared": "^14.4.0",
114
115
  "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",
116
+ "birpc": "^4.2.0",
117
+ "dayjs": "^1.11.23",
118
+ "element-plus": "^2.14.5",
119
+ "eslint": "^10.9.1",
119
120
  "eslint-config-prettier": "^10.1.8",
120
- "eslint-plugin-vue": "^10.9.1",
121
- "globals": "^17.6.0",
122
- "happy-dom": "^20.9.0",
121
+ "eslint-plugin-vue": "^10.10.0",
122
+ "globals": "^17.11.0",
123
+ "happy-dom": "^20.12.0",
123
124
  "hookable": "^6.1.1",
124
125
  "lodash-es": "^4.18.1",
125
126
  "lodash-unified": "^1.0.3",
126
127
  "memoize-one": "^6.0.0",
127
128
  "normalize-wheel-es": "^1.2.0",
128
129
  "normalize.css": "^8.0.1",
129
- "npm-run-all2": "^9.0.1",
130
+ "npm-run-all2": "^9.0.3",
130
131
  "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",
132
+ "prettier": "^3.9.6",
133
+ "sass-embedded": "^1.103.1",
134
+ "terser": "^5.51.2",
135
+ "typescript": "~7.0.2",
136
+ "typescript-eslint": "^8.69.0",
137
+ "unplugin-auto-import": "^21.1.0",
138
138
  "unplugin-vue-components": "^32.1.0",
139
- "vite": "^8.0.14",
140
- "vite-plugin-dts": "5.0.1",
139
+ "vite": "^8.2.2",
140
+ "vite-plugin-dts": "5.0.3",
141
141
  "vite-plugin-package-version": "^1.1.0",
142
142
  "vitepress": "^1.6.4",
143
- "vitest": "^4.1.7",
144
- "vue-tsc": "^3.3.3"
143
+ "vitest": "^4.1.11",
144
+ "vue-tsc": "^3.3.11"
145
145
  }
146
146
  }
@@ -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
  */
@@ -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'
@@ -38,6 +38,7 @@ export default function useTabActions() {
38
38
  renewTab,
39
39
  getTab,
40
40
  tabs,
41
+ moveTab,
41
42
  removeTab,
42
43
  removeAllTabs,
43
44
  refreshTab,
@@ -169,6 +170,7 @@ export default function useTabActions() {
169
170
 
170
171
  // 激活与重置
171
172
  activeTab: active,
173
+ moveTab,
172
174
  reset,
173
175
 
174
176
  // 数据与配置
@@ -189,6 +189,29 @@ export default () => {
189
189
 
190
190
  const getTab = (id: string) => tabs.value.find((t) => t.id === id) ?? null
191
191
 
192
+ /**
193
+ * 将指定标签移动到目标索引。标签对象本身不变,因此其页面栈和缓存关联保持不受影响。
194
+ * @returns 是否实际调整了顺序
195
+ */
196
+ const moveTab = (id: string, targetIndex: number): boolean => {
197
+ const sourceIndex = tabs.value.findIndex((tab) => tab.id === id)
198
+ if (
199
+ sourceIndex < 0 ||
200
+ targetIndex < 0 ||
201
+ targetIndex >= tabs.value.length ||
202
+ sourceIndex === targetIndex
203
+ ) {
204
+ return false
205
+ }
206
+
207
+ const reorderedTabs = tabs.value.slice()
208
+ const [tab] = reorderedTabs.splice(sourceIndex, 1)
209
+ if (!tab) return false
210
+ reorderedTabs.splice(targetIndex, 0, tab)
211
+ tabs.value = reorderedTabs
212
+ return true
213
+ }
214
+
192
215
  /** 从路由解析 tabInfo,若无则创建默认信息,调用方负责把编码信息写入内部 page.query */
193
216
  const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase => {
194
217
  if (typeof route.query.__tab === 'string') {
@@ -718,6 +741,7 @@ export default () => {
718
741
  addCache,
719
742
  removeCache,
720
743
  getTab,
744
+ moveTab,
721
745
  addTab,
722
746
  removeTab,
723
747
  removeAllTabs,