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
-
+
View
-
+
Zoom In
Zoom Out
Full Screen
@@ -1007,7 +1008,7 @@ class MenuTriggerExample {}
Help
-
+
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();
+ }
}
}
}