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.
Files changed (115) hide show
  1. package/README.en.md +526 -0
  2. package/README.md +139 -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 +68 -47
  60. package/src/lib/StackTabs.vue +223 -196
  61. package/src/lib/assets/style/stackTab.scss +84 -12
  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 +45 -16
  69. package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
  70. package/src/lib/components/TabHeader/index.vue +109 -12
  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 +62 -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 +266 -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
@@ -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>
@@ -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
- <!-- <div v-if="!!icon" class="stack-tab__item-icon" :class="icon"></div>-->
12
- <div class="stack-tab__item-title">
13
- <div class="stack-tab__item-title-content" :title="title">
14
- {{ title }}
15
- </div>
16
- </div>
17
- <div
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 '@/lib/model/TabModel'
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
- const eventType = TabEventType.TAB_ACTIVE as unknown as Parameters<typeof emitter.on>[0]
54
- emitter.on(eventType, handleTabActiveEvent as (e: unknown) => void)
72
+ emitter.on(TabEventType.TAB_ACTIVE, handleTabActiveEvent)
55
73
  onUnmounted(() => {
56
- emitter.off(eventType, handleTabActiveEvent as (e: unknown) => void)
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>