vue-stack-tabs 1.1.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.en.md +526 -0
  2. package/README.md +141 -39
  3. package/README.ru.md +526 -0
  4. package/dist/iframe-bridge.d.ts +8 -0
  5. package/dist/iframe-bridge.html +77 -0
  6. package/dist/iframe-bridge.mjs +20 -0
  7. package/dist/iframe-child.html +19 -6
  8. package/dist/nuxt/favicon.svg +10 -0
  9. package/dist/nuxt/iframe-bridge.html +77 -0
  10. package/dist/nuxt/iframe-child.html +88 -0
  11. package/dist/nuxt/module.d.ts +6 -0
  12. package/dist/nuxt/module.mjs +28 -0
  13. package/dist/nuxt/plugin.mjs +9 -0
  14. package/dist/nuxt/runtime/plugin.d.ts +2 -0
  15. package/dist/nuxt/runtime/plugin.mjs +9 -0
  16. package/dist/src/lib/StackTabs.vue.d.ts +30 -16
  17. package/dist/src/lib/components/ContextMenu/ContextMenuItem.vue.d.ts +7 -1
  18. package/dist/src/lib/components/ContextMenu/index.vue.d.ts +5 -4
  19. package/dist/src/lib/components/PageLoading.vue.d.ts +2 -1
  20. package/dist/src/lib/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +25 -0
  21. package/dist/src/lib/components/StackKeepAlive/StackKeepAlive.vue.d.ts +12 -0
  22. package/dist/src/lib/components/TabHeader/TabHeaderButton.vue.d.ts +11 -12
  23. package/dist/src/lib/components/TabHeader/TabHeaderItem.vue.d.ts +5 -4
  24. package/dist/src/lib/components/TabHeader/TabHeaderScroll.vue.d.ts +8 -22
  25. package/dist/src/lib/components/TabHeader/index.vue.d.ts +11 -164
  26. package/dist/src/lib/hooks/stackTabsContext.d.ts +49 -0
  27. package/dist/src/lib/hooks/tabPanel/evict.d.ts +21 -61
  28. package/dist/src/lib/hooks/tabPanel/navigationTransaction.d.ts +35 -0
  29. package/dist/src/lib/hooks/tabPanel/refresh.d.ts +17 -0
  30. package/dist/src/lib/hooks/tabPanel/scroll.d.ts +8 -8
  31. package/dist/src/lib/hooks/tabPanel/session.d.ts +9 -8
  32. package/dist/src/lib/hooks/tabPanel/state.d.ts +20 -72
  33. package/dist/src/lib/hooks/useIframeManager.d.ts +35 -0
  34. package/dist/src/lib/hooks/useTabActions.d.ts +5 -46
  35. package/dist/src/lib/hooks/useTabEventBus.d.ts +22 -9
  36. package/dist/src/lib/hooks/useTabPanel.d.ts +14 -73
  37. package/dist/src/lib/i18n/index.d.ts +6 -4
  38. package/dist/src/lib/i18n/lang/en.d.ts +5 -0
  39. package/dist/src/lib/i18n/lang/zh-CN.d.ts +5 -0
  40. package/dist/src/lib/i18n/stackTabsLocale.d.ts +2 -0
  41. package/dist/src/lib/iframe-bridge.d.ts +8 -0
  42. package/dist/src/lib/iframe.vue.d.ts +2 -1
  43. package/dist/src/lib/index.d.ts +4 -5
  44. package/dist/src/lib/model/TabModel.d.ts +11 -40
  45. package/dist/src/lib/nuxt/module.d.ts +6 -0
  46. package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +2 -0
  47. package/dist/src/lib/nuxt/runtime/plugin.d.ts +2 -0
  48. package/dist/src/lib/utils/iframeBridge.d.ts +11 -3
  49. package/dist/src/lib/utils/stackTabsA11y.d.ts +8 -0
  50. package/dist/src/lib/utils/stackTabsMessage.d.ts +7 -0
  51. package/dist/src/lib/utils/tabInfoEncoder.d.ts +0 -15
  52. package/dist/src/lib/utils/typeGuards.d.ts +2 -0
  53. package/dist/src/lib/utils/urlParser.d.ts +24 -3
  54. package/dist/src/lib/versionLogger.d.ts +1 -0
  55. package/dist/style.css +2 -1
  56. package/dist/utils/iframeBridge.d.ts +39 -0
  57. package/dist/vue-stack-tabs.css +2 -1
  58. package/dist/vue-stack-tabs.es.js +1 -2220
  59. package/package.json +71 -50
  60. package/src/lib/StackTabs.vue +223 -196
  61. package/src/lib/assets/style/stackTab.scss +110 -13
  62. package/src/lib/components/ContextMenu/ContextMenuItem.vue +23 -11
  63. package/src/lib/components/ContextMenu/index.vue +94 -10
  64. package/src/lib/components/PageLoading.vue +9 -7
  65. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +70 -0
  66. package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +57 -0
  67. package/src/lib/components/TabHeader/TabHeaderButton.vue +19 -8
  68. package/src/lib/components/TabHeader/TabHeaderItem.vue +74 -17
  69. package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
  70. package/src/lib/components/TabHeader/index.vue +181 -13
  71. package/src/lib/hooks/stackTabsContext.ts +135 -0
  72. package/src/lib/hooks/tabPanel/evict.ts +122 -87
  73. package/src/lib/hooks/tabPanel/navigationTransaction.ts +61 -0
  74. package/src/lib/hooks/tabPanel/refresh.ts +109 -0
  75. package/src/lib/hooks/tabPanel/scroll.ts +57 -40
  76. package/src/lib/hooks/tabPanel/session.ts +84 -22
  77. package/src/lib/hooks/tabPanel/state.ts +41 -44
  78. package/src/lib/hooks/useIframeManager.ts +253 -0
  79. package/src/lib/hooks/useTabActions.ts +64 -30
  80. package/src/lib/hooks/useTabEventBus.ts +29 -19
  81. package/src/lib/hooks/useTabLoading.ts +3 -2
  82. package/src/lib/hooks/useTabPanel.tsx +290 -313
  83. package/src/lib/hooks/useTabRouter.ts +111 -97
  84. package/src/lib/i18n/index.ts +53 -45
  85. package/src/lib/i18n/lang/en.ts +6 -1
  86. package/src/lib/i18n/lang/zh-CN.ts +6 -1
  87. package/src/lib/i18n/stackTabsLocale.ts +8 -0
  88. package/src/lib/iframe-bridge.ts +12 -0
  89. package/src/lib/index.ts +14 -19
  90. package/src/lib/model/TabModel.ts +27 -82
  91. package/src/lib/nuxt/module.d.ts +4 -0
  92. package/src/lib/nuxt/module.ts +8 -3
  93. package/src/lib/nuxt/runtime/plugin.client.ts +1 -0
  94. package/src/lib/nuxt/runtime/plugin.ts +11 -0
  95. package/src/lib/utils/iframeBridge.ts +29 -5
  96. package/src/lib/utils/scrollUtils.ts +9 -4
  97. package/src/lib/utils/stackTabsA11y.ts +12 -0
  98. package/src/lib/utils/stackTabsMessage.ts +99 -0
  99. package/src/lib/utils/tabInfoEncoder.ts +70 -21
  100. package/src/lib/utils/typeGuards.ts +3 -0
  101. package/src/lib/utils/urlParser.ts +144 -14
  102. package/src/lib/versionLogger.ts +7 -19
  103. package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +0 -1
  104. package/dist/src/lib/hooks/useTabPanel.spec.d.ts +0 -1
  105. package/dist/src/lib/model/TabModel.spec.d.ts +0 -1
  106. package/dist/src/lib/utils/iframeBridge.spec.d.ts +0 -1
  107. package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +0 -1
  108. package/dist/src/lib/utils/urlParser.spec.d.ts +0 -1
  109. package/dist/vue-stack-tabs.umd.js +0 -1
  110. package/src/lib/hooks/tabPanel/evict.spec.ts +0 -85
  111. package/src/lib/hooks/useTabPanel.spec.ts +0 -34
  112. package/src/lib/model/TabModel.spec.ts +0 -40
  113. package/src/lib/utils/iframeBridge.spec.ts +0 -37
  114. package/src/lib/utils/tabInfoEncoder.spec.ts +0 -32
  115. package/src/lib/utils/urlParser.spec.ts +0 -45
@@ -14,6 +14,14 @@
14
14
  position: relative;
15
15
  overflow: hidden;
16
16
  }
17
+ &__keep-alive-panel {
18
+ position: absolute;
19
+ top: 0;
20
+ left: 0;
21
+ width: 100%;
22
+ height: 100%;
23
+ }
24
+
17
25
  /*缓存页面wrapper*/
18
26
  .cache-page-wrapper {
19
27
  width: 100%;
@@ -75,6 +83,12 @@
75
83
  cursor: pointer;
76
84
  display: flex;
77
85
  align-items: center;
86
+ border: 0;
87
+ padding: 0;
88
+ color: inherit;
89
+ font: inherit;
90
+ text-align: inherit;
91
+ appearance: none;
78
92
 
79
93
  //按钮禁用样式
80
94
  &[disabled] {
@@ -150,7 +164,7 @@
150
164
  position: relative;
151
165
  display: inline-flex;
152
166
  height: 100%;
153
- padding: 0;
167
+ padding: 0 16px 0 0;
154
168
  margin: 0;
155
169
  list-style: none;
156
170
  flex-wrap: nowrap;
@@ -183,6 +197,31 @@
183
197
  border-left: $stack-tab-border;
184
198
  }
185
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
+
186
225
  /*
187
226
  激活右键菜单的页签
188
227
  */
@@ -216,6 +255,22 @@
216
255
  }
217
256
  }
218
257
 
258
+ &-tab {
259
+ display: flex;
260
+ flex: 1 1 auto;
261
+ min-width: 0;
262
+ height: 100%;
263
+ padding: 0;
264
+ color: inherit;
265
+ cursor: inherit;
266
+ background: transparent;
267
+ border: 0;
268
+ font: inherit;
269
+ text-align: inherit;
270
+ appearance: none;
271
+ align-items: center;
272
+ }
273
+
219
274
  /*
220
275
  页签标题
221
276
  */
@@ -247,6 +302,10 @@
247
302
  width: 0;
248
303
  background-color: $stack-tab-text-color;
249
304
  transition: $stack-tab__trans;
305
+ border: 0;
306
+ font: inherit;
307
+ appearance: none;
308
+ padding: 0;
250
309
 
251
310
  &:hover {
252
311
  background-color: $stack-tab-color-primary;
@@ -299,17 +358,7 @@
299
358
  left: 0;
300
359
  width: 100%;
301
360
  height: 100%;
302
- pointer-events: none; /* 允许鼠标事件穿透到下层,避免遮挡其他标签 */
303
- /* 进入的 iframe 置于上层;延迟进入,等待其他 tab 离开动画完成后再播放入场 */
304
- .stack-tab-swap-enter-active {
305
- z-index: 1;
306
- transition-duration: 0.5s;
307
- transition-delay: 0.3s;
308
- }
309
- .stack-tab-swap-leave-active {
310
- z-index: 0;
311
- position: absolute;
312
- }
361
+ pointer-events: none;
313
362
  }
314
363
 
315
364
  &__iframe-wrapper {
@@ -347,6 +396,39 @@
347
396
  font-size: var(--stack-tab-font-size, 14px);
348
397
  }
349
398
  }
399
+
400
+ &__iframe-error {
401
+ position: absolute;
402
+ top: 0;
403
+ left: 0;
404
+ z-index: 2;
405
+ display: flex;
406
+ gap: 8px;
407
+ align-items: center;
408
+ justify-content: center;
409
+ width: 100%;
410
+ height: 100%;
411
+ padding: 16px;
412
+ pointer-events: auto;
413
+ background: var(--stack-tab-iframe-error-bg, rgb(255 255 255 / 92%));
414
+ box-sizing: border-box;
415
+
416
+ &-text {
417
+ color: var(--stack-tab-iframe-error-color, #666);
418
+ font-size: var(--stack-tab-font-size, 14px);
419
+ }
420
+
421
+ &-retry,
422
+ &-open {
423
+ border: $stack-tab-border;
424
+ padding: 4px 10px;
425
+ color: $stack-tab-text-color;
426
+ cursor: pointer;
427
+ background: $stack-tab-background-color-base;
428
+ border-radius: 6px;
429
+ font: inherit;
430
+ }
431
+ }
350
432
  /*
351
433
  右键菜单
352
434
  */
@@ -367,16 +449,27 @@
367
449
  display: flex;
368
450
  position: relative;
369
451
  align-items: center;
452
+ justify-content: flex-start;
453
+ width: 100%;
454
+ min-height: 30px;
455
+ margin: 0;
370
456
  padding: 0 10px;
371
457
  line-height: 30px;
458
+ font: inherit;
459
+ text-align: inherit;
460
+ appearance: none;
461
+ box-sizing: border-box;
372
462
  color: $stack-tab-text-color;
373
463
  cursor: pointer;
464
+ background: transparent;
465
+ border: 0;
374
466
  border-radius: 10px 0;
375
467
  transition: all 0.2s ease-in-out;
376
468
  user-select: none;
377
469
 
378
470
  &:hover,
379
- &:active {
471
+ &:active,
472
+ &:focus-visible {
380
473
  color: $stack-tab-color-white;
381
474
  background-color: $stack-tab-color-primary;
382
475
 
@@ -385,6 +478,10 @@
385
478
  }
386
479
  }
387
480
 
481
+ &:focus {
482
+ outline: none;
483
+ }
484
+
388
485
  &[disabled] {
389
486
  color: $stack-tab-color-disabled;
390
487
  pointer-events: none;
@@ -4,19 +4,31 @@
4
4
  职责:展示图标和标题,支持 disabled 状态
5
5
  -->
6
6
  <template>
7
- <div class="stack-tab__contextmenu-item">
8
- <div class="stack-tab__contextmenu-icon" :class="icon"></div>
9
- <div style="margin-left: 8px" @contextmenu.prevent>{{ title }}</div>
10
- </div>
7
+ <button type="button" role="menuitem" class="stack-tab__contextmenu-item" :disabled="disabled">
8
+ <span class="stack-tab__contextmenu-icon" :class="icon" aria-hidden="true" />
9
+ <span class="stack-tab__contextmenu-title" @contextmenu.prevent>{{ title }}</span>
10
+ </button>
11
11
  </template>
12
12
 
13
13
  <script setup lang="ts">
14
- defineProps<{
15
- /** 图标 class,如 stack-tab__icon-reload svg-mask */
16
- icon?: string
17
- /** 显示文本 */
18
- title: string
19
- }>()
14
+ withDefaults(
15
+ defineProps<{
16
+ /** 图标 class,如 stack-tab__icon-reload svg-mask */
17
+ icon?: string
18
+ /** 显示文本 */
19
+ title: string
20
+ /** 是否禁用 */
21
+ disabled?: boolean
22
+ }>(),
23
+ {
24
+ icon: '',
25
+ disabled: false
26
+ }
27
+ )
20
28
  </script>
21
29
 
22
- <style lang="scss" scoped></style>
30
+ <style lang="scss" scoped>
31
+ .stack-tab__contextmenu-title {
32
+ margin-left: 8px;
33
+ }
34
+ </style>
@@ -8,16 +8,19 @@
8
8
  <div
9
9
  ref="menuElementRef"
10
10
  class="stack-tab__contextmenu"
11
+ role="menu"
12
+ tabindex="-1"
11
13
  :style="{
12
14
  left: `${menuPosition.left}px`,
13
15
  top: `${menuPosition.top}px`,
14
16
  'z-index': getMaxZIndex('.stack-tab,.stack-tab *')
15
17
  }"
18
+ @keydown="handleMenuKeydown"
16
19
  >
17
20
  <context-menu-item
18
21
  icon="stack-tab__icon-reload svg-mask"
19
22
  :title="t('VueStackTab.reload')"
20
- :disabled="!tabItem.refreshable ? 'disabled' : null"
23
+ :disabled="!tabItem.refreshable"
21
24
  @click="handleMenuItemClick(() => refreshTab(tabItem.id))"
22
25
  />
23
26
  <context-menu-item
@@ -28,19 +31,19 @@
28
31
  <context-menu-item
29
32
  icon="stack-tab__icon-close svg-mask"
30
33
  :title="t('VueStackTab.close')"
31
- :disabled="!tabItem.closable ? 'disabled' : null"
34
+ :disabled="!tabItem.closable"
32
35
  @click="handleMenuItemClick(() => closeTab(tabItem.id))"
33
36
  />
34
37
  <context-menu-item
35
38
  icon="stack-tab__icon-close-lefts svg-mask"
36
39
  :title="t('VueStackTab.closeLeft')"
37
- :disabled="index <= 0 ? 'disabled' : null"
40
+ :disabled="index <= 0"
38
41
  @click="handleMenuItemClick(() => removeLeftTabs(tabItem.id))"
39
42
  />
40
43
  <context-menu-item
41
44
  icon="stack-tab__icon-close-rights svg-mask"
42
45
  :title="t('VueStackTab.closeRight')"
43
- :disabled="index >= max - 1 ? 'disabled' : null"
46
+ :disabled="index >= max - 1"
44
47
  @click="handleMenuItemClick(() => removeRightTabs(tabItem.id))"
45
48
  />
46
49
  <context-menu-item
@@ -62,20 +65,23 @@
62
65
  <div
63
66
  v-if="contextMenu !== undefined && contextMenu.length > 0"
64
67
  class="divider div-transparent"
68
+ role="separator"
69
+ aria-orientation="horizontal"
65
70
  ></div>
66
71
  <context-menu-item
67
- v-for="(item, menuIndex) in contextMenu"
68
- :key="menuIndex"
72
+ v-for="item in contextMenu"
73
+ :key="getCustomMenuKey(item)"
74
+ :data-menu-key="getCustomMenuKey(item)"
69
75
  :icon="item.icon"
70
76
  :title="item.title"
71
- :disabled="item.disabled(tabItem) ? 'disabled' : null"
77
+ :disabled="item.disabled(tabItem)"
72
78
  @click="handleMenuItemClick(() => !item.disabled(tabItem) && item.callback(tabItem.id))"
73
79
  />
74
80
  </div>
75
81
  </template>
76
82
 
77
83
  <script setup lang="ts">
78
- import { ref, onMounted, reactive } from 'vue'
84
+ import { ref, onMounted, reactive, nextTick } from 'vue'
79
85
  import type { ITabItem, IContextMenu } from '../../model/TabModel'
80
86
  import { useI18n } from 'vue-i18n-lite'
81
87
  import { getMaxZIndex } from '../../utils/scrollUtils'
@@ -89,13 +95,15 @@ const props = withDefaults(
89
95
  left?: number
90
96
  top?: number
91
97
  contextMenu?: Array<IContextMenu>
98
+ restoreFocusElement?: HTMLElement | null
92
99
  tabItem: ITabItem
93
100
  max: number
94
101
  }>(),
95
102
  {
96
103
  left: 0,
97
104
  top: 0,
98
- contextMenu: () => []
105
+ contextMenu: () => [],
106
+ restoreFocusElement: null
99
107
  }
100
108
  )
101
109
  const emit = defineEmits<{ close: [] }>()
@@ -107,17 +115,93 @@ const menuPosition = reactive({ left: props.left, top: props.top })
107
115
  const { closeTab, closeAllTabs, refreshTab, refreshAllTabs, openInNewWindow } = useTabActions()
108
116
  const { removeLeftTabs, removeRightTabs, removeOtherTabs } = useTabPanel()
109
117
 
118
+ const focusActiveTabFallback = () => {
119
+ const activeTab = document.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
120
+ activeTab?.focus()
121
+ }
122
+
123
+ const restoreFocusToTrigger = () => {
124
+ if (props.restoreFocusElement && document.contains(props.restoreFocusElement)) {
125
+ props.restoreFocusElement.focus()
126
+ return
127
+ }
128
+
129
+ focusActiveTabFallback()
130
+ }
131
+
132
+ const closeMenu = () => {
133
+ emit('close')
134
+ void nextTick(restoreFocusToTrigger)
135
+ }
136
+
110
137
  /** 菜单项点击:执行回调并关闭菜单 */
111
138
  const handleMenuItemClick = (fn?: () => void) => {
112
139
  fn?.()
113
- emit('close')
140
+ closeMenu()
141
+ }
142
+
143
+ const getMenuButtons = (): HTMLButtonElement[] => {
144
+ const root = menuElementRef.value
145
+ if (!root) return []
146
+ return Array.from(root.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')).filter(
147
+ (button) => !button.disabled
148
+ )
114
149
  }
150
+
151
+ const focusMenuItem = (index: number) => {
152
+ const buttons = getMenuButtons()
153
+ if (buttons.length <= 0) return
154
+ const safeIndex = (index + buttons.length) % buttons.length
155
+ buttons[safeIndex]?.focus()
156
+ }
157
+
158
+ const getFocusedMenuItemIndex = (): number => {
159
+ const buttons = getMenuButtons()
160
+ return buttons.findIndex((button) => button === document.activeElement)
161
+ }
162
+
163
+ const handleMenuKeydown = (event: KeyboardEvent) => {
164
+ if (event.key === 'Escape') {
165
+ event.preventDefault()
166
+ closeMenu()
167
+ return
168
+ }
169
+
170
+ if (event.key === 'ArrowDown') {
171
+ event.preventDefault()
172
+ const focusedIndex = getFocusedMenuItemIndex()
173
+ focusMenuItem(focusedIndex === -1 ? 0 : focusedIndex + 1)
174
+ return
175
+ }
176
+
177
+ if (event.key === 'ArrowUp') {
178
+ event.preventDefault()
179
+ const focusedIndex = getFocusedMenuItemIndex()
180
+ focusMenuItem(focusedIndex === -1 ? -1 : focusedIndex - 1)
181
+ return
182
+ }
183
+
184
+ if (event.key === 'Home') {
185
+ event.preventDefault()
186
+ focusMenuItem(0)
187
+ return
188
+ }
189
+
190
+ if (event.key === 'End') {
191
+ event.preventDefault()
192
+ focusMenuItem(getMenuButtons().length - 1)
193
+ }
194
+ }
195
+
196
+ const getCustomMenuKey = (item: IContextMenu): string => item.key ?? `${item.title}-${item.icon ?? ''}`
197
+
115
198
  /** 挂载后修正菜单位置,避免超出右边界 */
116
199
  onMounted(() => {
117
200
  const menuWidth = menuElementRef.value?.clientWidth ?? 0
118
201
  if (menuPosition.left + menuWidth > window.innerWidth) {
119
202
  menuPosition.left = window.innerWidth - menuWidth - 5
120
203
  }
204
+ focusMenuItem(0)
121
205
  })
122
206
  </script>
123
207
 
@@ -8,34 +8,36 @@
8
8
  <div
9
9
  v-if="isLoading"
10
10
  class="stack-tab-loading-mask"
11
+ role="status"
12
+ aria-live="polite"
13
+ :aria-label="t('VueStackTab.loading')"
11
14
  :style="{ zIndex: getMaxZIndex('.cache-page-wrapper *') }"
12
15
  >
13
- <div class="stack-tab-loading--spin turn" />
16
+ <div class="stack-tab-loading--spin turn" aria-hidden="true" />
14
17
  </div>
15
18
  </template>
16
19
  <script setup lang="ts">
17
20
  import { ref, onUnmounted } from 'vue'
21
+ import { useI18n } from 'vue-i18n-lite'
18
22
  import { TabEventType, useTabEmitter } from '../hooks/useTabEventBus'
19
23
  import { getMaxZIndex } from '../utils/scrollUtils'
20
24
  const props = defineProps<{
21
25
  /** 当前标签 id,用于过滤 PAGE_LOADING 事件 */
22
26
  tabId: string
23
27
  }>()
28
+ const { t } = useI18n()
24
29
  const emitter = useTabEmitter()
25
30
  /** 是否显示 loading 遮罩 */
26
31
  const isLoading = ref<boolean>(false)
27
- /** PAGE_LOADING 事件 payload */
28
- type PageLoadingPayload = { tId: string; value: boolean }
29
32
  /** 处理 PAGE_LOADING 事件,仅当 tId 匹配时更新 */
30
- const handleLoadingEvent = (payload: PageLoadingPayload) => {
33
+ const handleLoadingEvent = (payload: { tId: string; value: boolean }) => {
31
34
  if (payload.tId === props.tabId) {
32
35
  isLoading.value = payload.value
33
36
  }
34
37
  }
35
- const eventType = TabEventType.PAGE_LOADING as unknown as Parameters<typeof emitter.on>[0]
36
- emitter.on(eventType, handleLoadingEvent as (e: unknown) => void)
38
+ emitter.on(TabEventType.PAGE_LOADING, handleLoadingEvent)
37
39
  onUnmounted(() => {
38
- emitter.off(eventType, handleLoadingEvent as (e: unknown) => void)
40
+ emitter.off(TabEventType.PAGE_LOADING, handleLoadingEvent)
39
41
  })
40
42
  </script>
41
43
  <style scoped></style>
@@ -0,0 +1,70 @@
1
+ <!--
2
+ StackCacheRenderer - 内部缓存组件解析层
3
+
4
+ 职责:
5
+ - 接收 router-view slot 提供的 route 与 Component
6
+ - 仅在 route/component 身份变化时调用 useTabPanel.addPage
7
+ - 通过 scoped slot 暴露包装组件,确保真正的缓存页仍是 keep-alive 的直接子节点
8
+
9
+ 注意:
10
+ - 这是内部组件,暂不从 src/lib/index.ts 导出
11
+ - 不负责 transition、keep-alive、TabHeader、iframe 或 session 初始化
12
+ -->
13
+ <script lang="ts" setup>
14
+ import { defineComponent, shallowRef, watch } from 'vue'
15
+ import type { DefineComponent, VNode } from 'vue'
16
+ import type { RouteLocationNormalizedLoaded } from 'vue-router'
17
+ import useTabPanel from '../../hooks/useTabPanel'
18
+
19
+ interface StackCacheRendererProps {
20
+ route: RouteLocationNormalizedLoaded
21
+ component?: VNode | null
22
+ }
23
+
24
+ interface StackCacheRendererSlotProps {
25
+ wrappedComponent: DefineComponent
26
+ activeCacheKey: string
27
+ refreshKey: number
28
+ activePageRefreshVersion: number
29
+ component?: VNode | null
30
+ }
31
+
32
+ const props = defineProps<StackCacheRendererProps>()
33
+
34
+ defineSlots<{
35
+ default(props: StackCacheRendererSlotProps): unknown
36
+ }>()
37
+
38
+ const { refreshKey, activeCacheKey, activePageRefreshVersion, addPage } = useTabPanel()
39
+
40
+ const EmptyRendererComponent = defineComponent({
41
+ name: 'StackCacheRendererEmpty',
42
+ setup() {
43
+ return () => null
44
+ }
45
+ }) as DefineComponent
46
+
47
+ const wrappedComponent = shallowRef<DefineComponent>(EmptyRendererComponent)
48
+
49
+ const getComponentIdentity = (component?: VNode | null): unknown => {
50
+ return component?.type ?? component
51
+ }
52
+
53
+ watch(
54
+ [() => props.route.fullPath, () => getComponentIdentity(props.component), () => refreshKey.value],
55
+ () => {
56
+ wrappedComponent.value = addPage(props.route, props.component)
57
+ },
58
+ { immediate: true }
59
+ )
60
+ </script>
61
+
62
+ <template>
63
+ <slot
64
+ :wrapped-component="wrappedComponent"
65
+ :active-cache-key="activeCacheKey"
66
+ :refresh-key="refreshKey"
67
+ :active-page-refresh-version="activePageRefreshVersion"
68
+ :component="component"
69
+ />
70
+ </template>
@@ -0,0 +1,57 @@
1
+ <!--
2
+ StackKeepAlive - 内部页面缓存渲染层
3
+
4
+ 职责:
5
+ - 渲染 router-view 当前页面
6
+ - 维持 transition -> keep-alive -> dynamic component 的原有结构
7
+ - 通过 StackCacheRenderer 注册并解析栈式缓存页
8
+ - 将缓存页 on-loaded 事件转发为 loaded
9
+
10
+ 注意:
11
+ - 这是内部组件,暂不从 src/lib/index.ts 导出
12
+ - 不负责 TabHeader、iframe、session 初始化或 openTab/closeTab API
13
+ -->
14
+ <script lang="ts" setup>
15
+ import useTabPanel from '../../hooks/useTabPanel'
16
+ import StackCacheRenderer from './StackCacheRenderer.vue'
17
+
18
+ interface StackKeepAliveProps {
19
+ transitionName?: string
20
+ }
21
+
22
+ withDefaults(defineProps<StackKeepAliveProps>(), {
23
+ transitionName: 'stack-tab-swap'
24
+ })
25
+
26
+ const emit = defineEmits<{
27
+ loaded: []
28
+ }>()
29
+
30
+ const { caches } = useTabPanel()
31
+
32
+ /** 页面组件加载完成时向父组件转发 */
33
+ const emitLoaded = () => {
34
+ emit('loaded')
35
+ }
36
+ </script>
37
+
38
+ <template>
39
+ <router-view v-slot="{ Component, route }">
40
+ <StackCacheRenderer
41
+ v-slot="{ wrappedComponent, activeCacheKey, activePageRefreshVersion, component }"
42
+ :route="route"
43
+ :component="Component"
44
+ >
45
+ <transition :name="transitionName" appear mode="out-in">
46
+ <keep-alive :include="caches">
47
+ <component
48
+ :is="wrappedComponent"
49
+ :key="`${activeCacheKey}-${activePageRefreshVersion}`"
50
+ :vnode="component"
51
+ @on-loaded="emitLoaded"
52
+ />
53
+ </keep-alive>
54
+ </transition>
55
+ </StackCacheRenderer>
56
+ </router-view>
57
+ </template>
@@ -4,24 +4,28 @@
4
4
  职责:左右滚动箭头、全屏/还原按钮,支持禁用与阴影样式
5
5
  -->
6
6
  <template>
7
- <div
7
+ <button
8
+ type="button"
8
9
  class="stack-tab__header-button"
9
10
  :class="{
10
11
  'stack-tab__shadow-right': shadow === 'right',
11
12
  'stack-tab__shadow-left': shadow === 'left'
12
13
  }"
13
- :disabled="disabled ? 'disabled' : null"
14
+ :disabled="disabled"
14
15
  :title="title"
16
+ :aria-label="resolvedAriaLabel"
15
17
  >
16
- <div v-if="!('icon' in $slots)" class="stack-tab__mask-button" :class="iconClass" />
17
- <div v-if="'icon' in $slots" class="stack-tab__button">
18
+ <span v-if="!('icon' in $slots)" class="stack-tab__mask-button" :class="iconClass" />
19
+ <span v-if="'icon' in $slots" class="stack-tab__button">
18
20
  <slot name="icon" />
19
- </div>
20
- </div>
21
+ </span>
22
+ </button>
21
23
  </template>
22
24
 
23
25
  <script lang="ts" setup>
24
- withDefaults(
26
+ import { computed } from 'vue'
27
+
28
+ const props = withDefaults(
25
29
  defineProps<{
26
30
  /** 是否禁用 */
27
31
  disabled?: boolean
@@ -31,14 +35,21 @@ withDefaults(
31
35
  iconClass?: string
32
36
  /** 悬停提示 */
33
37
  title?: string
38
+ /** 辅助技术标签,未传时使用 title */
39
+ ariaLabel?: string
34
40
  }>(),
35
41
  {
36
42
  disabled: false,
37
43
  shadow: null,
38
44
  iconClass: '',
39
- title: ''
45
+ title: '',
46
+ ariaLabel: ''
40
47
  }
41
48
  )
49
+
50
+ const resolvedAriaLabel = computed<string>(() => {
51
+ return props.ariaLabel || props.title
52
+ })
42
53
  </script>
43
54
 
44
55
  <style scoped></style>