From 5685355dc4bd948ab6736a451c70e191845dfd17 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Mon, 10 Nov 2025 10:21:21 -0500 Subject: [PATCH] fix(aria/menu): add expansion delay --- src/aria/menu/menu.spec.ts | 29 +++++------ src/aria/menu/menu.ts | 5 +- src/aria/private/menu/menu.spec.ts | 15 ++++-- src/aria/private/menu/menu.ts | 79 ++++++++++++++++++++++++++++-- 4 files changed, 104 insertions(+), 24 deletions(-) diff --git a/src/aria/menu/menu.spec.ts b/src/aria/menu/menu.spec.ts index 7fd60235bcf1..6e03a5da1e81 100644 --- a/src/aria/menu/menu.spec.ts +++ b/src/aria/menu/menu.spec.ts @@ -18,8 +18,9 @@ describe('Standalone Menu Pattern', () => { fixture.detectChanges(); }; - const mouseover = (element: Element) => { + const mouseover = async (element: Element) => { element.dispatchEvent(new MouseEvent('mouseover', {bubbles: true})); + await new Promise(resolve => setTimeout(resolve, 0)); fixture.detectChanges(); }; @@ -309,9 +310,9 @@ describe('Standalone Menu Pattern', () => { expect(document.activeElement).toBe(berries); }); - it('should open submenu on mouseover', () => { + it('should open submenu on mouseover', async () => { const berries = getItem('Berries'); - mouseover(berries!); + await mouseover(berries!); expect(isSubmenuExpanded()).toBe(true); }); @@ -385,11 +386,11 @@ describe('Standalone Menu Pattern', () => { externalElement.remove(); }); - it('should close an unfocused submenu on mouse out', () => { + it('should close an unfocused submenu on mouse out', async () => { const berries = getItem('Berries'); const submenu = getSubmenu(); - mouseover(berries!); + await mouseover(berries!); expect(isSubmenuExpanded()).toBe(true); mouseout(berries!); @@ -398,11 +399,11 @@ describe('Standalone Menu Pattern', () => { expect(isSubmenuExpanded()).toBe(false); }); - it('should not close an unfocused submenu on mouse out if the parent menu is hovered', () => { + it('should not close an unfocused submenu on mouse out if the parent menu is hovered', async () => { const berries = getItem('Berries'); const submenu = getSubmenu(); - mouseover(berries!); + await mouseover(berries!); expect(isSubmenuExpanded()).toBe(true); mouseout(berries!); @@ -944,12 +945,12 @@ describe('Menu Bar Pattern', () => { @Component({ template: ` -
+
Apple
Banana
Berries
-
+
Blueberry
Blackberry
Strawberry
@@ -968,12 +969,12 @@ class StandaloneMenuExample { template: ` -
+
Apple
Banana
Berries
-
+
Blueberry
Blackberry
Strawberry
@@ -992,14 +993,14 @@ class MenuTriggerExample {}
File
Edit
-
+
Undo
Redo
View
-
+
Zoom In
Zoom Out
Full Screen
@@ -1007,7 +1008,7 @@ class MenuTriggerExample {}
Help
-
+
Documentation
About
diff --git a/src/aria/menu/menu.ts b/src/aria/menu/menu.ts index 61aeda330fe1..c56aa3abab3a 100644 --- a/src/aria/menu/menu.ts +++ b/src/aria/menu/menu.ts @@ -176,6 +176,9 @@ export class Menu { /** A callback function triggered when a menu item is selected. */ onSelect = output(); + /** The delay in milliseconds before expanding sub-menus on hover. */ + readonly expansionDelay = input(150); // Arbitrarily chosen. + constructor() { this._pattern = new MenuPattern({ ...this, @@ -214,7 +217,7 @@ export class Menu { afterRenderEffect(() => { if (!this._pattern.hasBeenFocused()) { - this._pattern.setDefaultState(); + untracked(() => this._pattern.setDefaultState()); } }); } diff --git a/src/aria/private/menu/menu.spec.ts b/src/aria/private/menu/menu.spec.ts index 739e3642d609..d38d2f30e504 100644 --- a/src/aria/private/menu/menu.spec.ts +++ b/src/aria/private/menu/menu.spec.ts @@ -105,6 +105,7 @@ function getMenuPattern( orientation: signal('vertical'), selectionMode: signal('explicit'), element: signal(document.createElement('div')), + expansionDelay: signal(0), }); items.set( @@ -347,9 +348,10 @@ describe('Standalone Menu Pattern', () => { expect(submenu.isVisible()).toBe(false); }); - it('should open submenu on mouseover', () => { + it('should open submenu on mouseover', async () => { const menuItem = menu.inputs.items()[0]; menu.onMouseOver({target: menuItem.element()} as unknown as MouseEvent); + await new Promise(resolve => setTimeout(resolve, 0)); expect(submenu.isVisible()).toBe(true); }); @@ -385,9 +387,10 @@ describe('Standalone Menu Pattern', () => { expect(submenu.isVisible()).toBe(false); }); - it('should close a submenu on focus out', () => { + it('should close a submenu on focus out', async () => { const parentMenuItem = menu.inputs.items()[0]; menu.onMouseOver({target: parentMenuItem.element()} as unknown as MouseEvent); + await new Promise(resolve => setTimeout(resolve, 0)); expect(submenu.isVisible()).toBe(true); expect(submenu.isFocused()).toBe(false); @@ -395,19 +398,23 @@ describe('Standalone Menu Pattern', () => { expect(submenu.isVisible()).toBe(false); }); - it('should close an unfocused submenu on mouse out', () => { + it('should close an unfocused submenu on mouse out', async () => { menu.onMouseOver({target: menu.inputs.items()[0].element()} as unknown as MouseEvent); + await new Promise(resolve => setTimeout(resolve, 0)); expect(submenu.isVisible()).toBe(true); submenu.onMouseOut({relatedTarget: document.body} as unknown as MouseEvent); + await new Promise(resolve => setTimeout(resolve, 0)); expect(submenu.isVisible()).toBe(false); }); - it('should not close an unfocused submenu on mouse out if the parent menu is hovered', () => { + it('should not close an unfocused submenu on mouse out if the parent menu is hovered', async () => { const parentMenuItem = menu.inputs.items()[0]; menu.onMouseOver({target: parentMenuItem.element()} as unknown as MouseEvent); + await new Promise(resolve => setTimeout(resolve, 0)); expect(submenu.isVisible()).toBe(true); submenu.onMouseOut({relatedTarget: parentMenuItem.element()} as unknown as MouseEvent); + await new Promise(resolve => setTimeout(resolve, 0)); expect(submenu.isVisible()).toBe(true); }); }); diff --git a/src/aria/private/menu/menu.ts b/src/aria/private/menu/menu.ts index 9810babee9ea..f9b4cec3c921 100644 --- a/src/aria/private/menu/menu.ts +++ b/src/aria/private/menu/menu.ts @@ -40,6 +40,9 @@ export interface MenuInputs /** The text direction of the menu bar. */ textDirection: SignalLike<'ltr' | 'rtl'>; + + /** The delay in milliseconds before expanding sub-menus on hover. */ + expansionDelay: SignalLike; } /** The inputs for the MenuTriggerPattern class. */ @@ -83,6 +86,12 @@ export class MenuPattern { /** Whether the menu has received focus. */ hasBeenFocused = signal(false); + /** Timeout used to open sub-menus on hover. */ + _openTimeout: any; + + /** Timeout used to close sub-menus on hover out. */ + _closeTimeout: any; + /** Whether the menu should be focused on mouse over. */ shouldFocus = computed(() => { const root = this.root(); @@ -185,23 +194,55 @@ export class MenuPattern { return; } + const parent = this.inputs.parent(); const activeItem = this?.inputs.activeItem(); + if (parent instanceof MenuItemPattern) { + const grandparent = parent.inputs.parent(); + if (grandparent instanceof MenuPattern) { + grandparent._clearTimeouts(); + grandparent.listBehavior.goto(parent, {focusElement: false}); + } + } + if (activeItem && activeItem !== item) { - activeItem.close(); + this._closeItem(activeItem); } - if (item.expanded() && item.submenu()?.inputs.activeItem()) { - item.submenu()?.inputs.activeItem()?.close(); - item.submenu()?.listBehavior.unfocus(); + if (item.expanded()) { + this._clearCloseTimeout(); } - item.open(); + this._openItem(item); this.listBehavior.goto(item, {focusElement: this.shouldFocus()}); } + /** Closes the specified menu item after a delay. */ + private _closeItem(item: MenuItemPattern) { + this._clearOpenTimeout(); + + if (!this._closeTimeout) { + this._closeTimeout = setTimeout(() => { + item.close(); + this._closeTimeout = undefined; + }, this.inputs.expansionDelay()); + } + } + + /** Opens the specified menu item after a delay. */ + private _openItem(item: MenuItemPattern) { + this._clearOpenTimeout(); + + this._openTimeout = setTimeout(() => { + item.open(); + this._openTimeout = undefined; + }, this.inputs.expansionDelay()); + } + /** Handles mouseout events for the menu. */ onMouseOut(event: MouseEvent) { + this._clearOpenTimeout(); + if (this.isFocused()) { return; } @@ -370,6 +411,28 @@ export class MenuPattern { root.inputs.activeItem()?.close({refocus: true}); } } + + /** Clears any open or close timeouts for sub-menus. */ + _clearTimeouts() { + this._clearOpenTimeout(); + this._clearCloseTimeout(); + } + + /** Clears the open timeout. */ + _clearOpenTimeout() { + if (this._openTimeout) { + clearTimeout(this._openTimeout); + this._openTimeout = undefined; + } + } + + /** Clears the close timeout. */ + _clearCloseTimeout() { + if (this._closeTimeout) { + clearTimeout(this._closeTimeout); + this._closeTimeout = undefined; + } + } } /** The menubar ui pattern class. */ @@ -685,6 +748,12 @@ export class MenuItemPattern implements ListItem { menuitem?._expanded.set(false); menuitem?.inputs.parent()?.listBehavior.unfocus(); menuitems = menuitems.concat(menuitem?.submenu()?.inputs.items() ?? []); + + const parent = menuitem?.inputs.parent(); + + if (parent instanceof MenuPattern) { + parent._clearTimeouts(); + } } } }