fix(components): [tabs] focus may still remain in pane when tabs changed (#19433)

* fix(components): [tabs] focus may still remain in pane when tabs changed

closed #19364

* fix: type

* chore: style

* fix: test
This commit is contained in:
qiang
2025-07-01 17:45:06 +08:00
committed by GitHub
parent b881ef25cb
commit 6eab313cf9
7 changed files with 67 additions and 35 deletions

View File

@@ -6,8 +6,10 @@ import Tabs from '../src/tabs'
import TabPane from '../src/tab-pane.vue'
import TabNav from '../src/tab-nav'
import type { TabPaneName } from '../src/tabs'
import type { TabsPaneContext } from '@element-plus/components/tabs'
import type {
TabPaneName,
TabsPaneContext,
} from '@element-plus/components/tabs'
const Comp = defineComponent({
components: {

View File

@@ -2,19 +2,22 @@ import type { ComputedRef, InjectionKey, Ref, Slots, UnwrapRef } from 'vue'
import type { TabsProps } from './tabs'
import type { TabPaneProps } from './tab-pane'
export type TabPaneName = string | number
export type TabsPaneContext = UnwrapRef<{
uid: number
slots: Slots
props: TabPaneProps
paneName: ComputedRef<string | number | undefined>
paneName: ComputedRef<TabPaneName | undefined>
active: ComputedRef<boolean>
index: Ref<string | undefined>
isClosable: ComputedRef<boolean>
isFocusInsidePane: () => boolean | undefined
}>
export interface TabsRootContext {
props: TabsProps
currentName: Ref<string | number>
currentName: Ref<TabPaneName>
registerPane: (pane: TabsPaneContext) => void
unregisterPane: (pane: TabsPaneContext) => void
}

View File

@@ -1,7 +1,7 @@
import { buildProps, definePropType, mutable } from '@element-plus/utils'
import type { ExtractPropTypes } from 'vue'
import type { TabsPaneContext } from './constants'
import type { TabPaneName, TabsPaneContext } from './constants'
import type TabBar from './tab-bar.vue'
export const tabBarProps = buildProps({
@@ -9,6 +9,10 @@ export const tabBarProps = buildProps({
type: definePropType<TabsPaneContext[]>(Array),
default: () => mutable([] as const),
},
tabRefs: {
type: definePropType<{ [key: TabPaneName]: HTMLDivElement }>(Object),
default: () => mutable({} as const),
},
} as const)
export type TabBarProps = ExtractPropTypes<typeof tabBarProps>

View File

@@ -7,16 +7,9 @@
</template>
<script lang="ts" setup>
import {
getCurrentInstance,
inject,
nextTick,
onBeforeUnmount,
ref,
watch,
} from 'vue'
import { inject, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useResizeObserver } from '@vueuse/core'
import { capitalize, throwError } from '@element-plus/utils'
import { capitalize, isUndefined, throwError } from '@element-plus/utils'
import { useNamespace } from '@element-plus/hooks'
import { tabsRootContextKey } from './constants'
import { tabBarProps } from './tab-bar'
@@ -29,7 +22,6 @@ defineOptions({
})
const props = defineProps(tabBarProps)
const instance = getCurrentInstance()!
const rootTabs = inject(tabsRootContextKey)
if (!rootTabs) throwError(COMPONENT_NAME, '<el-tabs><el-tab-bar /></el-tabs>')
@@ -49,7 +41,8 @@ const getBarStyle = (): CSSProperties => {
const position = sizeDir === 'x' ? 'left' : 'top'
props.tabs.every((tab) => {
const $el = instance.parent?.refs?.[`tab-${tab.uid}`] as HTMLElement
if (isUndefined(tab.paneName)) return false
const $el = props.tabRefs[tab.paneName]
if (!$el) return false
if (!tab.active) {
@@ -82,16 +75,10 @@ const saveObserver = [] as ReturnType<typeof useResizeObserver>[]
const observerTabs = () => {
saveObserver.forEach((observer) => observer.stop())
saveObserver.length = 0
const list = instance.parent?.refs as Record<string, HTMLElement>
if (!list) return
for (const key in list) {
if (key.startsWith('tab-')) {
const _el = list[key]
if (_el) {
saveObserver.push(useResizeObserver(_el, update))
}
}
}
Object.values(props.tabRefs).forEach((tab) => {
saveObserver.push(useResizeObserver(tab, update))
})
}
watch(

View File

@@ -27,10 +27,13 @@ import { useNamespace } from '@element-plus/hooks'
import TabBar from './tab-bar.vue'
import { tabsRootContextKey } from './constants'
import type {
CSSProperties,
ComponentPublicInstance,
ExtractPropTypes,
} from 'vue'
import type { TabBarInstance } from './tab-bar'
import type { CSSProperties, ExtractPropTypes } from 'vue'
import type { TabsPaneContext } from './constants'
import type { TabPaneName } from './tabs'
import type { TabPaneName, TabsPaneContext } from './constants'
interface Scrollable {
next?: boolean
@@ -80,6 +83,7 @@ const TabNav = defineComponent({
const navScroll$ = ref<HTMLDivElement>()
const nav$ = ref<HTMLDivElement>()
const el$ = ref<HTMLDivElement>()
const tabRefsMap = ref<{ [key: TabPaneName]: HTMLDivElement }>({})
const tabBarRef = ref<TabBarInstance>()
@@ -139,7 +143,7 @@ const TabNav = defineComponent({
await nextTick()
const activeTab = el$.value.querySelector('.is-active')
const activeTab = tabRefsMap.value[props.currentName]
if (!activeTab) return
const navScroll = navScroll$.value
@@ -243,6 +247,20 @@ const TabNav = defineComponent({
}
const removeFocus = () => (isFocus.value = false)
const setRefs = (
el: Element | ComponentPublicInstance | null,
key: TabPaneName
) => {
tabRefsMap.value[key] = el as HTMLDivElement
}
const focusActiveTab = async () => {
await nextTick()
const activeTab = tabRefsMap.value[props.currentName]
activeTab?.focus({ preventScroll: true })
}
watch(visibility, (visibility) => {
if (visibility === 'hidden') {
focusable.value = false
@@ -266,6 +284,7 @@ const TabNav = defineComponent({
expose({
scrollToActiveTab,
removeFocus,
focusActiveTab,
tabListRef: nav$,
tabBarRef,
})
@@ -322,7 +341,7 @@ const TabNav = defineComponent({
return (
<div
ref={`tab-${uid}`}
ref={(el) => setRefs(el, tabName)}
class={[
ns.e('item'),
ns.is(rootTabs.props.tabPosition),
@@ -387,7 +406,11 @@ const TabNav = defineComponent({
>
{...[
!props.type ? (
<TabBar ref={tabBarRef} tabs={[...props.panes]} />
<TabBar
ref={tabBarRef}
tabs={[...props.panes]}
tabRefs={tabRefsMap.value}
/>
) : null,
tabs,
]}
@@ -403,6 +426,7 @@ const TabNav = defineComponent({
export type TabNavInstance = InstanceType<typeof TabNav> & {
scrollToActiveTab: () => Promise<void>
removeFocus: () => void
focusActiveTab: () => void
tabListRef: HTMLDivElement | undefined
tabBarRef: TabBarInstance | undefined
}

View File

@@ -3,6 +3,7 @@
v-if="shouldBeRender"
v-show="active"
:id="`pane-${paneName}`"
ref="paneRef"
:class="ns.b()"
role="tabpanel"
:aria-hidden="!active"
@@ -44,6 +45,7 @@ if (!tabsRoot)
const ns = useNamespace('tab-pane')
const paneRef = ref<HTMLDivElement>()
const index = ref<string>()
const isClosable = computed(() => props.closable || tabsRoot.props.closable)
const active = eagerComputed(
@@ -55,6 +57,10 @@ const shouldBeRender = eagerComputed(
() => !props.lazy || loaded.value || active.value
)
const isFocusInsidePane = () => {
return paneRef.value?.contains(document.activeElement)
}
watch(active, (val) => {
if (val) loaded.value = true
})
@@ -67,6 +73,7 @@ const pane = reactive({
active,
index,
isClosable,
isFocusInsidePane,
})
tabsRoot.registerPane(pane)

View File

@@ -26,9 +26,7 @@ import TabNav from './tab-nav'
import type { ExtractPropTypes, VNode } from 'vue'
import type { Awaitable } from '@element-plus/utils'
import type { TabNavInstance } from './tab-nav'
import type { TabsPaneContext } from './constants'
export type TabPaneName = string | number
import type { TabPaneName, TabsPaneContext } from './constants'
export const tabsProps = buildProps({
/**
@@ -134,6 +132,10 @@ const Tabs = defineComponent({
}
if (canLeave !== false) {
const isFocusInsidePane = panes.value
.find((item) => item.paneName === currentName.value)
?.isFocusInsidePane()
currentName.value = value
if (trigger) {
emit(UPDATE_MODEL_EVENT, value)
@@ -141,6 +143,9 @@ const Tabs = defineComponent({
}
nav$.value?.removeFocus?.()
if (isFocusInsidePane) {
nav$.value?.focusActiveTab()
}
}
} catch {}
}