@@ -5,6 +5,7 @@ import { VList, VListItem } from '@/components/VList'
55import { VSheet } from '@/components/VSheet'
66import { VTextarea } from '@/components/VTextarea'
77import { VTextField } from '@/components/VTextField'
8+ import { VTooltip } from '@/components/VTooltip'
89
910// Utilities
1011import { 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 ( ) => {
0 commit comments