Skip to content

Commit 6756494

Browse files
committed
fix(VMenu): collapse submenu only when the root was opened by hover
1 parent 332dbf5 commit 6756494

5 files changed

Lines changed: 325 additions & 26 deletions

File tree

packages/vuetify/src/components/VMenu/VMenu.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,17 @@ export const VMenu = genericComponent<OverlaySlots>()({
8080
const overlay = ref<VOverlay>()
8181

8282
const parent = inject(VMenuSymbol, null)
83-
const openChildren = shallowRef(new Set<string>())
83+
const openChildren = shallowRef(new Map<string, () => void>())
8484
provide(VMenuSymbol, {
85-
register () {
86-
openChildren.value.add(uid)
85+
register (childUid, close) {
86+
// Only one submenu open per level: close any already-open sibling first.
87+
for (const [otherUid, closeOther] of [...openChildren.value]) {
88+
if (otherUid !== childUid) closeOther()
89+
}
90+
openChildren.value.set(childUid, close)
8791
},
88-
unregister () {
89-
openChildren.value.delete(uid)
92+
unregister (childUid) {
93+
openChildren.value.delete(childUid)
9094
},
9195
closeParents (e) {
9296
setTimeout(() => {
@@ -99,15 +103,26 @@ export const VMenu = genericComponent<OverlaySlots>()({
99103
}
100104
}, 40)
101105
},
106+
openOnHover: props.openOnHover,
107+
// Submenus inherit the chain root's hover-open state; a non-submenu menu reports its own,
108+
// so hover-leave collapses a submenu tree only when its root menu was hover-opened.
109+
rootOpenedByHover: props.submenu && parent
110+
? parent.rootOpenedByHover
111+
: () => overlay.value?.openedByHover ?? false,
102112
})
103113

104-
onBeforeUnmount(() => parent?.unregister())
114+
onBeforeUnmount(() => parent?.unregister(uid))
105115
onDeactivated(() => isActive.value = false)
106116

107117
watch(isActive, val => {
108-
val
109-
? parent?.register()
110-
: parent?.unregister()
118+
if (val) {
119+
parent?.register(uid, () => { isActive.value = false })
120+
} else {
121+
parent?.unregister(uid)
122+
// Closing a menu collapses its whole open subtree, so descendants don't
123+
// linger hidden-but-active and reappear when this menu is reopened.
124+
for (const [, closeChild] of [...openChildren.value]) closeChild()
125+
}
111126
}, { immediate: true })
112127

113128
function onClickOutside (e: MouseEvent) {
@@ -212,6 +227,7 @@ export const VMenu = genericComponent<OverlaySlots>()({
212227
{ ...overlayProps }
213228
v-model={ isActive.value }
214229
absolute
230+
_submenu={ props.submenu }
215231
activatorProps={ activatorProps.value }
216232
location={ props.location ?? (props.submenu ? 'end' : 'bottom') }
217233
onClick:outside={ onClickOutside }

packages/vuetify/src/components/VMenu/__tests__/VMenu.spec.browser.tsx

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { VList, VListItem } from '@/components/VList'
55
import { VSheet } from '@/components/VSheet'
66
import { VTextarea } from '@/components/VTextarea'
77
import { VTextField } from '@/components/VTextField'
8+
import { VTooltip } from '@/components/VTooltip'
89

910
// Utilities
1011
import { commands, render, screen, userEvent, wait } from '@test'
@@ -343,6 +344,94 @@ describe('VMenu', () => {
343344

344345
afterEach(() => commands.setReduceMotionEnabled())
345346

347+
it('should keep submenus open on mouse-leave when the root menu was not hover-opened', async () => {
348+
render(() => (
349+
<div>
350+
<VBtn data-testid="top-btn">
351+
Open
352+
<VMenu activator="parent">
353+
<VList>
354+
<VListItem data-testid="l1-item" link>
355+
<span>L1</span>
356+
<VMenu openOnFocus={ false } activator="parent" openOnHover submenu>
357+
<VList>
358+
<VListItem data-testid="l2-item" link>L2</VListItem>
359+
</VList>
360+
</VMenu>
361+
</VListItem>
362+
</VList>
363+
</VMenu>
364+
</VBtn>
365+
<div data-testid="outside" style="height: 40px;">outside</div>
366+
</div>
367+
))
368+
369+
// root opened by click → the whole chain is sticky against hover-leave
370+
await userEvent.click(screen.getByTestId('top-btn'))
371+
await expect.poll(() => screen.queryByTestId('l1-item')).toBeVisible()
372+
373+
// hover-opened submenu, then leave entirely → stays open
374+
await userEvent.hover(screen.getByTestId('l1-item'))
375+
await expect.poll(() => screen.queryByTestId('l2-item')).toBeVisible()
376+
await userEvent.hover(screen.getByTestId('outside'))
377+
await wait(600)
378+
expect(screen.queryByTestId('l2-item')).toBeVisible()
379+
380+
// keyboard-opened submenu, then leave → also stays open
381+
screen.getByTestId('l1-item').focus()
382+
await userEvent.keyboard('{ArrowRight}')
383+
await expect.poll(() => screen.queryByTestId('l2-item')).toBeVisible()
384+
await userEvent.hover(screen.getByTestId('outside'))
385+
await wait(600)
386+
387+
expect(screen.queryByTestId('l2-item')).toBeVisible()
388+
})
389+
390+
it('should collapse the whole chain when the cursor leaves a hover-opened root menu', async () => {
391+
render(() => (
392+
<div>
393+
<VBtn data-testid="top-btn" openOnHover>
394+
Open
395+
<VMenu activator="parent" openOnHover>
396+
<VList>
397+
<VListItem data-testid="l1-item" link>
398+
<span>L1</span>
399+
<VMenu openOnFocus={ false } activator="parent" openOnHover submenu>
400+
<VList>
401+
<VListItem data-testid="l2-item" link>
402+
<span>L2</span>
403+
<VMenu openOnFocus={ false } activator="parent" openOnHover submenu>
404+
<VList>
405+
<VListItem data-testid="l3-item" link>L3</VListItem>
406+
</VList>
407+
</VMenu>
408+
</VListItem>
409+
</VList>
410+
</VMenu>
411+
</VListItem>
412+
</VList>
413+
</VMenu>
414+
</VBtn>
415+
<div data-testid="outside" style="height: 200px;">outside</div>
416+
</div>
417+
))
418+
419+
// hover the whole chain open
420+
await userEvent.hover(screen.getByTestId('top-btn'))
421+
await expect.poll(() => screen.queryByTestId('l1-item')).toBeVisible()
422+
await userEvent.hover(screen.getByTestId('l1-item'))
423+
await expect.poll(() => screen.queryByTestId('l2-item')).toBeVisible()
424+
await userEvent.hover(screen.getByTestId('l2-item'))
425+
await expect.poll(() => screen.queryByTestId('l3-item')).toBeVisible()
426+
427+
// cursor leaves everything → the entire tree collapses (root was hover-opened)
428+
await userEvent.hover(screen.getByTestId('outside'))
429+
430+
await expect.poll(() => screen.queryByTestId('l3-item'), { timeout: 2500 }).toBeNull()
431+
await expect.poll(() => screen.queryByTestId('l2-item'), { timeout: 2500 }).toBeNull()
432+
await expect.poll(() => screen.queryByTestId('l1-item'), { timeout: 2500 }).toBeNull()
433+
})
434+
346435
it('should return focus to the top-level activator after clicking the deepest item', async () => {
347436
render(() => (
348437
<div>
@@ -388,6 +477,159 @@ describe('VMenu', () => {
388477

389478
expect(document.activeElement).toBe(topBtn)
390479
})
480+
481+
// The hover-leave verdict only follows the menu chain for actual submenus. A tooltip
482+
// (or any non-submenu overlay) nested in a click-opened menu must still close on its own.
483+
it('should close a tooltip nested in a click-opened menu when the mouse leaves', async () => {
484+
render(() => (
485+
<div>
486+
<VBtn data-testid="top-btn">
487+
Open
488+
<VMenu activator="parent">
489+
<VList>
490+
<VListItem data-testid="item" link>
491+
<span>Item</span>
492+
<VTooltip activator="parent" openOnHover>
493+
<span data-testid="tip">tip</span>
494+
</VTooltip>
495+
</VListItem>
496+
</VList>
497+
</VMenu>
498+
</VBtn>
499+
<div data-testid="outside" style="height: 40px;">outside</div>
500+
</div>
501+
))
502+
503+
await userEvent.click(screen.getByTestId('top-btn'))
504+
await expect.poll(() => screen.queryByTestId('item')).toBeVisible()
505+
506+
await userEvent.hover(screen.getByTestId('item'))
507+
await expect.poll(() => screen.queryByTestId('tip')).toBeVisible()
508+
509+
// VTooltip is eager, so its content stays mounted — assert it hides, not unmounts
510+
await userEvent.hover(screen.getByTestId('outside'))
511+
await expect.poll(() => screen.queryByTestId('tip')).not.toBeVisible()
512+
})
513+
})
514+
515+
describe('one submenu open per level', () => {
516+
beforeEach(() => commands.setReduceMotionDisabled())
517+
518+
afterEach(() => commands.setReduceMotionEnabled())
519+
520+
function renderSiblings () {
521+
return render(() => (
522+
<div>
523+
<VBtn data-testid="top-btn">
524+
Open
525+
<VMenu activator="parent">
526+
<VList>
527+
<VListItem data-testid="a-item" link>
528+
<span>A</span>
529+
<VMenu openOnFocus={ false } activator="parent" openOnHover submenu>
530+
<VList>
531+
<VListItem data-testid="a-sub" link>A-1</VListItem>
532+
</VList>
533+
</VMenu>
534+
</VListItem>
535+
<VListItem data-testid="b-item" link>
536+
<span>B</span>
537+
<VMenu openOnFocus={ false } activator="parent" openOnHover submenu>
538+
<VList>
539+
<VListItem data-testid="b-sub" link>B-1</VListItem>
540+
</VList>
541+
</VMenu>
542+
</VListItem>
543+
</VList>
544+
</VMenu>
545+
</VBtn>
546+
</div>
547+
))
548+
}
549+
550+
it('should close a hover-opened submenu when a sibling submenu opens on hover', async () => {
551+
renderSiblings()
552+
553+
await userEvent.click(screen.getByTestId('top-btn'))
554+
await expect.poll(() => screen.queryByTestId('a-item')).toBeVisible()
555+
556+
await userEvent.hover(screen.getByTestId('a-item'))
557+
await expect.poll(() => screen.queryByTestId('a-sub')).toBeVisible()
558+
559+
await userEvent.hover(screen.getByTestId('b-item'))
560+
await expect.poll(() => screen.queryByTestId('b-sub')).toBeVisible()
561+
562+
// only one submenu should remain open at this level
563+
await expect.poll(() => screen.queryByTestId('a-sub')).toBeNull()
564+
})
565+
566+
it('should close a keyboard-opened submenu when a sibling submenu opens on hover', async () => {
567+
renderSiblings()
568+
569+
await userEvent.click(screen.getByTestId('top-btn'))
570+
await expect.poll(() => screen.queryByTestId('a-item')).toBeVisible()
571+
572+
screen.getByTestId('a-item').focus()
573+
await userEvent.keyboard('{ArrowRight}')
574+
await expect.poll(() => screen.queryByTestId('a-sub')).toBeVisible()
575+
576+
await userEvent.hover(screen.getByTestId('b-item'))
577+
await expect.poll(() => screen.queryByTestId('b-sub')).toBeVisible()
578+
579+
await expect.poll(() => screen.queryByTestId('a-sub')).toBeNull()
580+
})
581+
582+
it('should close a submenu and its open descendants when a sibling opens', async () => {
583+
render(() => (
584+
<div>
585+
<VBtn data-testid="top-btn">
586+
Open
587+
<VMenu activator="parent">
588+
<VList>
589+
<VListItem data-testid="a-item" link>
590+
<span>A</span>
591+
<VMenu openOnFocus={ false } activator="parent" openOnHover submenu>
592+
<VList>
593+
<VListItem data-testid="a-sub" link>
594+
<span>A-1</span>
595+
<VMenu openOnFocus={ false } activator="parent" openOnHover submenu>
596+
<VList>
597+
<VListItem data-testid="a-sub-sub" link>A-1-a</VListItem>
598+
</VList>
599+
</VMenu>
600+
</VListItem>
601+
</VList>
602+
</VMenu>
603+
</VListItem>
604+
<VListItem data-testid="b-item" link>
605+
<span>B</span>
606+
<VMenu openOnFocus={ false } activator="parent" openOnHover submenu>
607+
<VList>
608+
<VListItem data-testid="b-sub" link>B-1</VListItem>
609+
</VList>
610+
</VMenu>
611+
</VListItem>
612+
</VList>
613+
</VMenu>
614+
</VBtn>
615+
</div>
616+
))
617+
618+
await userEvent.click(screen.getByTestId('top-btn'))
619+
await expect.poll(() => screen.queryByTestId('a-item')).toBeVisible()
620+
621+
await userEvent.hover(screen.getByTestId('a-item'))
622+
await expect.poll(() => screen.queryByTestId('a-sub')).toBeVisible()
623+
await userEvent.hover(screen.getByTestId('a-sub'))
624+
await expect.poll(() => screen.queryByTestId('a-sub-sub')).toBeVisible()
625+
626+
// switching to sibling B should collapse A and its whole open subtree
627+
await userEvent.hover(screen.getByTestId('b-item'))
628+
await expect.poll(() => screen.queryByTestId('b-sub')).toBeVisible()
629+
630+
await expect.poll(() => screen.queryByTestId('a-sub')).toBeNull()
631+
expect(screen.queryByTestId('a-sub-sub')).toBeNull()
632+
})
391633
})
392634

393635
describe('submenu keyboard navigation', () => {
@@ -514,6 +756,7 @@ describe('VMenu', () => {
514756
// exactly when the open keystroke used to drop focus, so run with real motion.
515757
describe('with animations', () => {
516758
beforeEach(() => commands.setReduceMotionDisabled())
759+
517760
afterEach(() => commands.setReduceMotionEnabled())
518761

519762
it('should focus the first submenu item with a single ArrowRight press', async () => {

packages/vuetify/src/components/VMenu/shared.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
import type { InjectionKey } from 'vue'
33

44
interface MenuProvide {
5-
register (): void
6-
unregister (): void
5+
openOnHover: boolean
6+
rootOpenedByHover: () => boolean
7+
register (uid: string, close: () => void): void
8+
unregister (uid: string): void
79
closeParents (e?: MouseEvent): void
810
}
911

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export const VOverlay = genericComponent<OverlaySlots>()({
125125

126126
props: {
127127
_disableGlobalStack: Boolean,
128+
_submenu: Boolean,
128129

129130
...omit(makeVOverlayProps(), ['disableInitialFocus']),
130131
},
@@ -162,7 +163,8 @@ export const VOverlay = genericComponent<OverlaySlots>()({
162163
activatorEvents,
163164
contentEvents,
164165
scrimEvents,
165-
} = useActivator(props, { isActive, isTop: localTop, contentEl })
166+
openedByHover,
167+
} = useActivator(props, { isActive, isTop: localTop, contentEl, isSubmenu: props._submenu })
166168
const { teleportTarget } = useTeleport(() => {
167169
const target = props.attach || props.contained
168170
if (target) return target
@@ -228,8 +230,12 @@ export const VOverlay = genericComponent<OverlaySlots>()({
228230
function returnFocusToActivator () {
229231
const el = activatorEl.value
230232
if (!el || !el.isConnected) return
231-
// Skip submenus; the outermost close in the cascade will restore focus.
232-
if (el.closest('.v-overlay__content')) return
233+
// In a full cascade, the parent overlay's content is also inert (closing),
234+
// so let the outermost close handle focus restoration.
235+
// If the parent is still active (e.g. only this submenu closes via hover-out),
236+
// we must restore focus ourselves so it doesn't fall to document.body.
237+
const parentContent = el.closest<HTMLElement>('.v-overlay__content')
238+
if (parentContent?.inert) return
233239

234240
if (contentEl.value?._clickOutside?.lastMousedownWasOutside) return
235241

@@ -440,6 +446,7 @@ export const VOverlay = genericComponent<OverlaySlots>()({
440446
globalTop,
441447
localTop,
442448
updateLocation,
449+
openedByHover,
443450
}
444451
},
445452
})

0 commit comments

Comments
 (0)