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.
- package/README.en.md +526 -0
- package/README.md +141 -39
- package/README.ru.md +526 -0
- package/dist/iframe-bridge.d.ts +8 -0
- package/dist/iframe-bridge.html +77 -0
- package/dist/iframe-bridge.mjs +20 -0
- package/dist/iframe-child.html +19 -6
- package/dist/nuxt/favicon.svg +10 -0
- package/dist/nuxt/iframe-bridge.html +77 -0
- package/dist/nuxt/iframe-child.html +88 -0
- package/dist/nuxt/module.d.ts +6 -0
- package/dist/nuxt/module.mjs +28 -0
- package/dist/nuxt/plugin.mjs +9 -0
- package/dist/nuxt/runtime/plugin.d.ts +2 -0
- package/dist/nuxt/runtime/plugin.mjs +9 -0
- package/dist/src/lib/StackTabs.vue.d.ts +30 -16
- package/dist/src/lib/components/ContextMenu/ContextMenuItem.vue.d.ts +7 -1
- package/dist/src/lib/components/ContextMenu/index.vue.d.ts +5 -4
- package/dist/src/lib/components/PageLoading.vue.d.ts +2 -1
- package/dist/src/lib/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +25 -0
- package/dist/src/lib/components/StackKeepAlive/StackKeepAlive.vue.d.ts +12 -0
- package/dist/src/lib/components/TabHeader/TabHeaderButton.vue.d.ts +11 -12
- package/dist/src/lib/components/TabHeader/TabHeaderItem.vue.d.ts +5 -4
- package/dist/src/lib/components/TabHeader/TabHeaderScroll.vue.d.ts +8 -22
- package/dist/src/lib/components/TabHeader/index.vue.d.ts +11 -164
- package/dist/src/lib/hooks/stackTabsContext.d.ts +49 -0
- package/dist/src/lib/hooks/tabPanel/evict.d.ts +21 -61
- package/dist/src/lib/hooks/tabPanel/navigationTransaction.d.ts +35 -0
- package/dist/src/lib/hooks/tabPanel/refresh.d.ts +17 -0
- package/dist/src/lib/hooks/tabPanel/scroll.d.ts +8 -8
- package/dist/src/lib/hooks/tabPanel/session.d.ts +9 -8
- package/dist/src/lib/hooks/tabPanel/state.d.ts +20 -72
- package/dist/src/lib/hooks/useIframeManager.d.ts +35 -0
- package/dist/src/lib/hooks/useTabActions.d.ts +5 -46
- package/dist/src/lib/hooks/useTabEventBus.d.ts +22 -9
- package/dist/src/lib/hooks/useTabPanel.d.ts +14 -73
- package/dist/src/lib/i18n/index.d.ts +6 -4
- package/dist/src/lib/i18n/lang/en.d.ts +5 -0
- package/dist/src/lib/i18n/lang/zh-CN.d.ts +5 -0
- package/dist/src/lib/i18n/stackTabsLocale.d.ts +2 -0
- package/dist/src/lib/iframe-bridge.d.ts +8 -0
- package/dist/src/lib/iframe.vue.d.ts +2 -1
- package/dist/src/lib/index.d.ts +4 -5
- package/dist/src/lib/model/TabModel.d.ts +11 -40
- package/dist/src/lib/nuxt/module.d.ts +6 -0
- package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +2 -0
- package/dist/src/lib/nuxt/runtime/plugin.d.ts +2 -0
- package/dist/src/lib/utils/iframeBridge.d.ts +11 -3
- package/dist/src/lib/utils/stackTabsA11y.d.ts +8 -0
- package/dist/src/lib/utils/stackTabsMessage.d.ts +7 -0
- package/dist/src/lib/utils/tabInfoEncoder.d.ts +0 -15
- package/dist/src/lib/utils/typeGuards.d.ts +2 -0
- package/dist/src/lib/utils/urlParser.d.ts +24 -3
- package/dist/src/lib/versionLogger.d.ts +1 -0
- package/dist/style.css +2 -1
- package/dist/utils/iframeBridge.d.ts +39 -0
- package/dist/vue-stack-tabs.css +2 -1
- package/dist/vue-stack-tabs.es.js +1 -2220
- package/package.json +71 -50
- package/src/lib/StackTabs.vue +223 -196
- package/src/lib/assets/style/stackTab.scss +110 -13
- package/src/lib/components/ContextMenu/ContextMenuItem.vue +23 -11
- package/src/lib/components/ContextMenu/index.vue +94 -10
- package/src/lib/components/PageLoading.vue +9 -7
- package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +70 -0
- package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +57 -0
- package/src/lib/components/TabHeader/TabHeaderButton.vue +19 -8
- package/src/lib/components/TabHeader/TabHeaderItem.vue +74 -17
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
- package/src/lib/components/TabHeader/index.vue +181 -13
- package/src/lib/hooks/stackTabsContext.ts +135 -0
- package/src/lib/hooks/tabPanel/evict.ts +122 -87
- package/src/lib/hooks/tabPanel/navigationTransaction.ts +61 -0
- package/src/lib/hooks/tabPanel/refresh.ts +109 -0
- package/src/lib/hooks/tabPanel/scroll.ts +57 -40
- package/src/lib/hooks/tabPanel/session.ts +84 -22
- package/src/lib/hooks/tabPanel/state.ts +41 -44
- package/src/lib/hooks/useIframeManager.ts +253 -0
- package/src/lib/hooks/useTabActions.ts +64 -30
- package/src/lib/hooks/useTabEventBus.ts +29 -19
- package/src/lib/hooks/useTabLoading.ts +3 -2
- package/src/lib/hooks/useTabPanel.tsx +290 -313
- package/src/lib/hooks/useTabRouter.ts +111 -97
- package/src/lib/i18n/index.ts +53 -45
- package/src/lib/i18n/lang/en.ts +6 -1
- package/src/lib/i18n/lang/zh-CN.ts +6 -1
- package/src/lib/i18n/stackTabsLocale.ts +8 -0
- package/src/lib/iframe-bridge.ts +12 -0
- package/src/lib/index.ts +14 -19
- package/src/lib/model/TabModel.ts +27 -82
- package/src/lib/nuxt/module.d.ts +4 -0
- package/src/lib/nuxt/module.ts +8 -3
- package/src/lib/nuxt/runtime/plugin.client.ts +1 -0
- package/src/lib/nuxt/runtime/plugin.ts +11 -0
- package/src/lib/utils/iframeBridge.ts +29 -5
- package/src/lib/utils/scrollUtils.ts +9 -4
- package/src/lib/utils/stackTabsA11y.ts +12 -0
- package/src/lib/utils/stackTabsMessage.ts +99 -0
- package/src/lib/utils/tabInfoEncoder.ts +70 -21
- package/src/lib/utils/typeGuards.ts +3 -0
- package/src/lib/utils/urlParser.ts +144 -14
- package/src/lib/versionLogger.ts +7 -19
- package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +0 -1
- package/dist/src/lib/hooks/useTabPanel.spec.d.ts +0 -1
- package/dist/src/lib/model/TabModel.spec.d.ts +0 -1
- package/dist/src/lib/utils/iframeBridge.spec.d.ts +0 -1
- package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +0 -1
- package/dist/src/lib/utils/urlParser.spec.d.ts +0 -1
- package/dist/vue-stack-tabs.umd.js +0 -1
- package/src/lib/hooks/tabPanel/evict.spec.ts +0 -85
- package/src/lib/hooks/useTabPanel.spec.ts +0 -34
- package/src/lib/model/TabModel.spec.ts +0 -40
- package/src/lib/utils/iframeBridge.spec.ts +0 -37
- package/src/lib/utils/tabInfoEncoder.spec.ts +0 -32
- 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
|
-
<
|
|
8
|
-
<
|
|
9
|
-
<
|
|
10
|
-
</
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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="
|
|
68
|
-
:key="
|
|
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)
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
36
|
-
emitter.on(eventType, handleLoadingEvent as (e: unknown) => void)
|
|
38
|
+
emitter.on(TabEventType.PAGE_LOADING, handleLoadingEvent)
|
|
37
39
|
onUnmounted(() => {
|
|
38
|
-
emitter.off(
|
|
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
|
-
<
|
|
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
|
|
14
|
+
:disabled="disabled"
|
|
14
15
|
:title="title"
|
|
16
|
+
:aria-label="resolvedAriaLabel"
|
|
15
17
|
>
|
|
16
|
-
<
|
|
17
|
-
<
|
|
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
|
-
</
|
|
20
|
-
</
|
|
21
|
+
</span>
|
|
22
|
+
</button>
|
|
21
23
|
</template>
|
|
22
24
|
|
|
23
25
|
<script lang="ts" setup>
|
|
24
|
-
|
|
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>
|