vue-stack-tabs 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +526 -0
- package/README.md +139 -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 +68 -47
- package/src/lib/StackTabs.vue +223 -196
- package/src/lib/assets/style/stackTab.scss +84 -12
- 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 +45 -16
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
- package/src/lib/components/TabHeader/index.vue +109 -12
- 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 +62 -30
- package/src/lib/hooks/useTabEventBus.ts +29 -19
- package/src/lib/hooks/useTabLoading.ts +3 -2
- package/src/lib/hooks/useTabPanel.tsx +266 -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
|
@@ -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>
|
|
@@ -1,23 +1,41 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<li
|
|
3
3
|
ref="tabElementRef"
|
|
4
|
+
role="presentation"
|
|
4
5
|
class="stack-tab__item"
|
|
5
6
|
:class="{
|
|
6
7
|
'is-active': item.active,
|
|
7
8
|
'is-icon': item.closable
|
|
8
9
|
}"
|
|
9
|
-
@click="handleActivate(true)"
|
|
10
|
+
@click.self="handleActivate(true)"
|
|
11
|
+
@contextmenu="$emit('contextmenu', $event)"
|
|
10
12
|
>
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
<button
|
|
14
|
+
:id="getStackTabTabId(item.id)"
|
|
15
|
+
type="button"
|
|
16
|
+
role="tab"
|
|
17
|
+
class="stack-tab__item-tab"
|
|
18
|
+
:data-tab-id="item.id"
|
|
19
|
+
:aria-controls="getStackTabPanelId(item.id)"
|
|
20
|
+
:aria-selected="item.active ? 'true' : 'false'"
|
|
21
|
+
:tabindex="item.active ? 0 : -1"
|
|
22
|
+
:title="title"
|
|
23
|
+
@click="handleActivate(true)"
|
|
24
|
+
@keydown="handleTabKeydown"
|
|
25
|
+
>
|
|
26
|
+
<span class="stack-tab__item-title">
|
|
27
|
+
<span class="stack-tab__item-title-content">
|
|
28
|
+
{{ title }}
|
|
29
|
+
</span>
|
|
30
|
+
</span>
|
|
31
|
+
</button>
|
|
32
|
+
<button
|
|
18
33
|
v-if="item.closable"
|
|
34
|
+
type="button"
|
|
19
35
|
class="stack-tab__icon-close-fill stack-tab__item-button"
|
|
20
36
|
:title="t('VueStackTab.close')"
|
|
37
|
+
:aria-label="`${t('VueStackTab.close')} ${title}`"
|
|
38
|
+
:tabindex="item.active ? 0 : -1"
|
|
21
39
|
@click.stop="handleClose"
|
|
22
40
|
/>
|
|
23
41
|
</li>
|
|
@@ -30,10 +48,11 @@
|
|
|
30
48
|
-->
|
|
31
49
|
<script setup lang="ts">
|
|
32
50
|
import { computed, ref, onUnmounted } from 'vue'
|
|
33
|
-
import type { ITabItem } from '
|
|
51
|
+
import type { ITabItem } from '../../model/TabModel'
|
|
34
52
|
import { TabEventType, useTabEmitter } from '../../hooks/useTabEventBus'
|
|
53
|
+
import { getStackTabPanelId, getStackTabTabId } from '../../utils/stackTabsA11y'
|
|
35
54
|
import { useI18n } from 'vue-i18n-lite'
|
|
36
|
-
const emit = defineEmits(['close', 'active'])
|
|
55
|
+
const emit = defineEmits(['close', 'active', 'contextmenu'])
|
|
37
56
|
const emitter = useTabEmitter()
|
|
38
57
|
const { t } = useI18n()
|
|
39
58
|
/** 当前标签 DOM 元素,用于滚动到可视区域 */
|
|
@@ -50,15 +69,12 @@ const handleTabActiveEvent = (payload: TabActivePayload) => {
|
|
|
50
69
|
handleActivate(payload.isRoute ?? true)
|
|
51
70
|
}
|
|
52
71
|
}
|
|
53
|
-
|
|
54
|
-
emitter.on(eventType, handleTabActiveEvent as (e: unknown) => void)
|
|
72
|
+
emitter.on(TabEventType.TAB_ACTIVE, handleTabActiveEvent)
|
|
55
73
|
onUnmounted(() => {
|
|
56
|
-
emitter.off(
|
|
74
|
+
emitter.off(TabEventType.TAB_ACTIVE, handleTabActiveEvent)
|
|
57
75
|
})
|
|
58
76
|
/** 显示标题,空时用 i18n 占位 */
|
|
59
|
-
const title = computed<string>(() =>
|
|
60
|
-
return props.item.title || t('undefined')
|
|
61
|
-
})
|
|
77
|
+
const title = computed<string>(() => props.item.title || t('VueStackTab.undefined'))
|
|
62
78
|
|
|
63
79
|
/** 关闭按钮点击,向父级 emit close */
|
|
64
80
|
const handleClose = () => {
|
|
@@ -68,6 +84,19 @@ const handleClose = () => {
|
|
|
68
84
|
const handleActivate = (isRoute = true) => {
|
|
69
85
|
emit('active', props.item, tabElementRef.value, isRoute)
|
|
70
86
|
}
|
|
87
|
+
|
|
88
|
+
const handleTabKeydown = (event: KeyboardEvent) => {
|
|
89
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
90
|
+
event.preventDefault()
|
|
91
|
+
handleActivate(true)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if ((event.key === 'Delete' || event.key === 'Backspace') && props.item.closable) {
|
|
96
|
+
event.preventDefault()
|
|
97
|
+
handleClose()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
71
100
|
</script>
|
|
72
101
|
|
|
73
102
|
<style lang="scss" scoped></style>
|