Skip to content

Commit 77d1a8a

Browse files
committed
fix(VTooltip): avoid stealing focus
fixes #22891
1 parent ecabcc5 commit 77d1a8a

2 files changed

Lines changed: 90 additions & 4 deletions

File tree

packages/vuetify/src/components/VOverlay/VOverlay.tsx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,22 @@ export const VOverlay = genericComponent<OverlaySlots>()({
209209

210210
useFocusTrap(props, { isActive, localTop, contentEl })
211211

212+
let openedWithActivatorFocus = false
213+
214+
function ownsFocus (activeElement: Element | null): boolean {
215+
let current = activeElement
216+
const visited = new Set<Element>()
217+
while (current) {
218+
const el = current.closest('.v-overlay__content')
219+
if (!el || visited.has(el)) return false
220+
if (el === contentEl.value) return true
221+
visited.add(el)
222+
const ownerId = el.closest('.v-overlay')?.id
223+
current = ownerId ? document.querySelector(`[aria-owns~="${CSS.escape(ownerId)}"]`) : null
224+
}
225+
return false
226+
}
227+
212228
function returnFocusToActivator () {
213229
const el = activatorEl.value
214230
if (!el || !el.isConnected) return
@@ -219,11 +235,10 @@ export const VOverlay = genericComponent<OverlaySlots>()({
219235

220236
const activeEl = document.activeElement
221237
const focusWasInOverlay =
222-
!activeEl ||
223-
activeEl === document.body ||
238+
((!activeEl || activeEl === document.body) && openedWithActivatorFocus) ||
224239
activeEl === el ||
225240
el.contains(activeEl) ||
226-
!!activeEl.closest('.v-overlay__content')
241+
ownsFocus(activeEl)
227242
if (!focusWasInOverlay) return
228243

229244
const parent = el.parentElement
@@ -239,7 +254,13 @@ export const VOverlay = genericComponent<OverlaySlots>()({
239254
}
240255

241256
watch(isActive, val => {
242-
if (!val) returnFocusToActivator()
257+
if (val) {
258+
const activeEl = document.activeElement
259+
const el = activatorEl.value
260+
openedWithActivatorFocus = !!el && (activeEl === el || el.contains(activeEl))
261+
} else {
262+
returnFocusToActivator()
263+
}
243264
}, { flush: 'post' })
244265

245266
IN_BROWSER && watch(isActive, val => {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Components
2+
import { VTooltip } from '../VTooltip'
3+
import { VBtn } from '@/components/VBtn'
4+
import { VList, VListItem } from '@/components/VList'
5+
import { VMenu } from '@/components/VMenu'
6+
7+
// Utilities
8+
import { render, screen, userEvent, wait } from '@test'
9+
10+
describe('VTooltip', () => {
11+
it('should not focus the activator after closing on mouseleave', async () => {
12+
render(() => (
13+
<VList>
14+
<VListItem data-testid="item" link title="Item">
15+
<VTooltip activator="parent" openDelay={ 0 } closeDelay={ 0 }>Tooltip</VTooltip>
16+
</VListItem>
17+
<VListItem link title="Other" data-testid="other" />
18+
</VList>
19+
))
20+
21+
const item = screen.getByTestId('item')
22+
await userEvent.hover(item)
23+
await expect.poll(() => screen.queryByCSS('.v-tooltip .v-overlay__content')).toBeVisible()
24+
25+
await userEvent.unhover(item)
26+
await wait(100)
27+
28+
expect(screen.queryByCSS('.v-tooltip .v-overlay__content')).not.toBeVisible()
29+
expect(document.activeElement).not.toBe(item)
30+
})
31+
32+
it('should not focus the activator after closing on mouseleave while another menu is open', async () => {
33+
render(() => (
34+
<div>
35+
<VBtn data-testid="tooltip-btn">
36+
Hover me
37+
<VTooltip activator="parent" openDelay={ 0 } closeDelay={ 0 }>Tooltip</VTooltip>
38+
</VBtn>
39+
<VBtn data-testid="menu-btn">
40+
Open menu
41+
<VMenu activator="parent">
42+
<VList>
43+
<VListItem link title="Item" />
44+
</VList>
45+
</VMenu>
46+
</VBtn>
47+
</div>
48+
))
49+
50+
await userEvent.click(screen.getByTestId('menu-btn'))
51+
await expect.poll(() => screen.queryByCSS('.v-menu .v-overlay__content')).toBeVisible()
52+
53+
const tooltipBtn = screen.getByTestId('tooltip-btn')
54+
await userEvent.hover(tooltipBtn)
55+
await expect.poll(() => screen.queryByCSS('.v-tooltip .v-overlay__content')).toBeVisible()
56+
57+
await userEvent.unhover(tooltipBtn)
58+
await wait(100)
59+
60+
expect(screen.queryByCSS('.v-tooltip .v-overlay__content')).not.toBeVisible()
61+
expect(document.activeElement).not.toBe(tooltipBtn)
62+
// the menu should remain open
63+
expect(screen.queryByCSS('.v-menu .v-overlay__content')).toBeVisible()
64+
})
65+
})

0 commit comments

Comments
 (0)