Skip to content

Commit fe79e98

Browse files
committed
fix(aria/menu): add expansion delay (#32293)
(cherry picked from commit ea191c7)
1 parent eb90c28 commit fe79e98

File tree

4 files changed

+104
-24
lines changed

4 files changed

+104
-24
lines changed

src/aria/menu/menu.spec.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ describe('Standalone Menu Pattern', () => {
1818
fixture.detectChanges();
1919
};
2020

21-
const mouseover = (element: Element) => {
21+
const mouseover = async (element: Element) => {
2222
element.dispatchEvent(new MouseEvent('mouseover', {bubbles: true}));
23+
await new Promise(resolve => setTimeout(resolve, 0));
2324
fixture.detectChanges();
2425
};
2526

@@ -309,9 +310,9 @@ describe('Standalone Menu Pattern', () => {
309310
expect(document.activeElement).toBe(berries);
310311
});
311312

312-
it('should open submenu on mouseover', () => {
313+
it('should open submenu on mouseover', async () => {
313314
const berries = getItem('Berries');
314-
mouseover(berries!);
315+
await mouseover(berries!);
315316
expect(isSubmenuExpanded()).toBe(true);
316317
});
317318

@@ -385,11 +386,11 @@ describe('Standalone Menu Pattern', () => {
385386
externalElement.remove();
386387
});
387388

388-
it('should close an unfocused submenu on mouse out', () => {
389+
it('should close an unfocused submenu on mouse out', async () => {
389390
const berries = getItem('Berries');
390391
const submenu = getSubmenu();
391392

392-
mouseover(berries!);
393+
await mouseover(berries!);
393394
expect(isSubmenuExpanded()).toBe(true);
394395

395396
mouseout(berries!);
@@ -398,11 +399,11 @@ describe('Standalone Menu Pattern', () => {
398399
expect(isSubmenuExpanded()).toBe(false);
399400
});
400401

401-
it('should not close an unfocused submenu on mouse out if the parent menu is hovered', () => {
402+
it('should not close an unfocused submenu on mouse out if the parent menu is hovered', async () => {
402403
const berries = getItem('Berries');
403404
const submenu = getSubmenu();
404405

405-
mouseover(berries!);
406+
await mouseover(berries!);
406407
expect(isSubmenuExpanded()).toBe(true);
407408

408409
mouseout(berries!);
@@ -944,12 +945,12 @@ describe('Menu Bar Pattern', () => {
944945

945946
@Component({
946947
template: `
947-
<div ngMenu (onSelect)="onSelect($event)">
948+
<div ngMenu [expansionDelay]="0" (onSelect)="onSelect($event)">
948949
<div ngMenuItem value='Apple' searchTerm='Apple'>Apple</div>
949950
<div ngMenuItem value='Banana' searchTerm='Banana'>Banana</div>
950951
<div ngMenuItem value='Berries' searchTerm='Berries' [submenu]="berriesMenu">Berries</div>
951952
952-
<div ngMenu #berriesMenu="ngMenu">
953+
<div ngMenu [expansionDelay]="0" #berriesMenu="ngMenu">
953954
<div ngMenuItem value='Blueberry' searchTerm='Blueberry'>Blueberry</div>
954955
<div ngMenuItem value='Blackberry' searchTerm='Blackberry'>Blackberry</div>
955956
<div ngMenuItem value='Strawberry' searchTerm='Strawberry'>Strawberry</div>
@@ -968,12 +969,12 @@ class StandaloneMenuExample {
968969
template: `
969970
<button ngMenuTrigger [menu]="menu">Open menu</button>
970971
971-
<div ngMenu #menu="ngMenu">
972+
<div ngMenu [expansionDelay]="0" #menu="ngMenu">
972973
<div ngMenuItem value='Apple' searchTerm='Apple'>Apple</div>
973974
<div ngMenuItem value='Banana' searchTerm='Banana'>Banana</div>
974975
<div ngMenuItem value='Berries' searchTerm='Berries' [submenu]="berriesMenu">Berries</div>
975976
976-
<div ngMenu #berriesMenu="ngMenu">
977+
<div ngMenu [expansionDelay]="0" #berriesMenu="ngMenu">
977978
<div ngMenuItem value='Blueberry' searchTerm='Blueberry'>Blueberry</div>
978979
<div ngMenuItem value='Blackberry' searchTerm='Blackberry'>Blackberry</div>
979980
<div ngMenuItem value='Strawberry' searchTerm='Strawberry'>Strawberry</div>
@@ -992,22 +993,22 @@ class MenuTriggerExample {}
992993
<div ngMenuItem value='File' searchTerm='File'>File</div>
993994
<div ngMenuItem value='Edit' searchTerm='Edit' [submenu]="editMenu">Edit</div>
994995
995-
<div ngMenu #editMenu="ngMenu">
996+
<div ngMenu [expansionDelay]="0" #editMenu="ngMenu">
996997
<div ngMenuItem value='Undo' searchTerm='Undo'>Undo</div>
997998
<div ngMenuItem value='Redo' searchTerm='Redo'>Redo</div>
998999
</div>
9991000
10001001
<div ngMenuItem [submenu]="viewMenu" value='View' searchTerm='View'>View</div>
10011002
1002-
<div ngMenu #viewMenu="ngMenu">
1003+
<div ngMenu [expansionDelay]="0" #viewMenu="ngMenu">
10031004
<div ngMenuItem value='Zoom In' searchTerm='Zoom In'>Zoom In</div>
10041005
<div ngMenuItem value='Zoom Out' searchTerm='Zoom Out'>Zoom Out</div>
10051006
<div ngMenuItem value='Full Screen' searchTerm='Full Screen'>Full Screen</div>
10061007
</div>
10071008
10081009
<div ngMenuItem [submenu]="helpMenu" value='Help' searchTerm='Help'>Help</div>
10091010
1010-
<div ngMenu #helpMenu="ngMenu">
1011+
<div ngMenu [expansionDelay]="0" #helpMenu="ngMenu">
10111012
<div ngMenuItem value='Documentation' searchTerm='Documentation'>Documentation</div>
10121013
<div ngMenuItem value='About' searchTerm='About'>About</div>
10131014
</div>

src/aria/menu/menu.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@ export class Menu<V> {
176176
/** A callback function triggered when a menu item is selected. */
177177
onSelect = output<V>();
178178

179+
/** The delay in milliseconds before expanding sub-menus on hover. */
180+
readonly expansionDelay = input<number>(150); // Arbitrarily chosen.
181+
179182
constructor() {
180183
this._pattern = new MenuPattern({
181184
...this,
@@ -214,7 +217,7 @@ export class Menu<V> {
214217

215218
afterRenderEffect(() => {
216219
if (!this._pattern.hasBeenFocused()) {
217-
this._pattern.setDefaultState();
220+
untracked(() => this._pattern.setDefaultState());
218221
}
219222
});
220223
}

src/aria/private/menu/menu.spec.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ function getMenuPattern(
105105
orientation: signal('vertical'),
106106
selectionMode: signal('explicit'),
107107
element: signal(document.createElement('div')),
108+
expansionDelay: signal(0),
108109
});
109110

110111
items.set(
@@ -347,9 +348,10 @@ describe('Standalone Menu Pattern', () => {
347348
expect(submenu.isVisible()).toBe(false);
348349
});
349350

350-
it('should open submenu on mouseover', () => {
351+
it('should open submenu on mouseover', async () => {
351352
const menuItem = menu.inputs.items()[0];
352353
menu.onMouseOver({target: menuItem.element()} as unknown as MouseEvent);
354+
await new Promise(resolve => setTimeout(resolve, 0));
353355
expect(submenu.isVisible()).toBe(true);
354356
});
355357

@@ -385,29 +387,34 @@ describe('Standalone Menu Pattern', () => {
385387
expect(submenu.isVisible()).toBe(false);
386388
});
387389

388-
it('should close a submenu on focus out', () => {
390+
it('should close a submenu on focus out', async () => {
389391
const parentMenuItem = menu.inputs.items()[0];
390392
menu.onMouseOver({target: parentMenuItem.element()} as unknown as MouseEvent);
393+
await new Promise(resolve => setTimeout(resolve, 0));
391394
expect(submenu.isVisible()).toBe(true);
392395
expect(submenu.isFocused()).toBe(false);
393396

394397
submenu.onFocusOut(new FocusEvent('focusout', {relatedTarget: document.body}));
395398
expect(submenu.isVisible()).toBe(false);
396399
});
397400

398-
it('should close an unfocused submenu on mouse out', () => {
401+
it('should close an unfocused submenu on mouse out', async () => {
399402
menu.onMouseOver({target: menu.inputs.items()[0].element()} as unknown as MouseEvent);
403+
await new Promise(resolve => setTimeout(resolve, 0));
400404
expect(submenu.isVisible()).toBe(true);
401405

402406
submenu.onMouseOut({relatedTarget: document.body} as unknown as MouseEvent);
407+
await new Promise(resolve => setTimeout(resolve, 0));
403408
expect(submenu.isVisible()).toBe(false);
404409
});
405410

406-
it('should not close an unfocused submenu on mouse out if the parent menu is hovered', () => {
411+
it('should not close an unfocused submenu on mouse out if the parent menu is hovered', async () => {
407412
const parentMenuItem = menu.inputs.items()[0];
408413
menu.onMouseOver({target: parentMenuItem.element()} as unknown as MouseEvent);
414+
await new Promise(resolve => setTimeout(resolve, 0));
409415
expect(submenu.isVisible()).toBe(true);
410416
submenu.onMouseOut({relatedTarget: parentMenuItem.element()} as unknown as MouseEvent);
417+
await new Promise(resolve => setTimeout(resolve, 0));
411418
expect(submenu.isVisible()).toBe(true);
412419
});
413420
});

src/aria/private/menu/menu.ts

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export interface MenuInputs<V>
4040

4141
/** The text direction of the menu bar. */
4242
textDirection: SignalLike<'ltr' | 'rtl'>;
43+
44+
/** The delay in milliseconds before expanding sub-menus on hover. */
45+
expansionDelay: SignalLike<number>;
4346
}
4447

4548
/** The inputs for the MenuTriggerPattern class. */
@@ -83,6 +86,12 @@ export class MenuPattern<V> {
8386
/** Whether the menu has received focus. */
8487
hasBeenFocused = signal(false);
8588

89+
/** Timeout used to open sub-menus on hover. */
90+
_openTimeout: any;
91+
92+
/** Timeout used to close sub-menus on hover out. */
93+
_closeTimeout: any;
94+
8695
/** Whether the menu should be focused on mouse over. */
8796
shouldFocus = computed(() => {
8897
const root = this.root();
@@ -185,23 +194,55 @@ export class MenuPattern<V> {
185194
return;
186195
}
187196

197+
const parent = this.inputs.parent();
188198
const activeItem = this?.inputs.activeItem();
189199

200+
if (parent instanceof MenuItemPattern) {
201+
const grandparent = parent.inputs.parent();
202+
if (grandparent instanceof MenuPattern) {
203+
grandparent._clearTimeouts();
204+
grandparent.listBehavior.goto(parent, {focusElement: false});
205+
}
206+
}
207+
190208
if (activeItem && activeItem !== item) {
191-
activeItem.close();
209+
this._closeItem(activeItem);
192210
}
193211

194-
if (item.expanded() && item.submenu()?.inputs.activeItem()) {
195-
item.submenu()?.inputs.activeItem()?.close();
196-
item.submenu()?.listBehavior.unfocus();
212+
if (item.expanded()) {
213+
this._clearCloseTimeout();
197214
}
198215

199-
item.open();
216+
this._openItem(item);
200217
this.listBehavior.goto(item, {focusElement: this.shouldFocus()});
201218
}
202219

220+
/** Closes the specified menu item after a delay. */
221+
private _closeItem(item: MenuItemPattern<V>) {
222+
this._clearOpenTimeout();
223+
224+
if (!this._closeTimeout) {
225+
this._closeTimeout = setTimeout(() => {
226+
item.close();
227+
this._closeTimeout = undefined;
228+
}, this.inputs.expansionDelay());
229+
}
230+
}
231+
232+
/** Opens the specified menu item after a delay. */
233+
private _openItem(item: MenuItemPattern<V>) {
234+
this._clearOpenTimeout();
235+
236+
this._openTimeout = setTimeout(() => {
237+
item.open();
238+
this._openTimeout = undefined;
239+
}, this.inputs.expansionDelay());
240+
}
241+
203242
/** Handles mouseout events for the menu. */
204243
onMouseOut(event: MouseEvent) {
244+
this._clearOpenTimeout();
245+
205246
if (this.isFocused()) {
206247
return;
207248
}
@@ -370,6 +411,28 @@ export class MenuPattern<V> {
370411
root.inputs.activeItem()?.close({refocus: true});
371412
}
372413
}
414+
415+
/** Clears any open or close timeouts for sub-menus. */
416+
_clearTimeouts() {
417+
this._clearOpenTimeout();
418+
this._clearCloseTimeout();
419+
}
420+
421+
/** Clears the open timeout. */
422+
_clearOpenTimeout() {
423+
if (this._openTimeout) {
424+
clearTimeout(this._openTimeout);
425+
this._openTimeout = undefined;
426+
}
427+
}
428+
429+
/** Clears the close timeout. */
430+
_clearCloseTimeout() {
431+
if (this._closeTimeout) {
432+
clearTimeout(this._closeTimeout);
433+
this._closeTimeout = undefined;
434+
}
435+
}
373436
}
374437

375438
/** The menubar ui pattern class. */
@@ -685,6 +748,12 @@ export class MenuItemPattern<V> implements ListItem<V> {
685748
menuitem?._expanded.set(false);
686749
menuitem?.inputs.parent()?.listBehavior.unfocus();
687750
menuitems = menuitems.concat(menuitem?.submenu()?.inputs.items() ?? []);
751+
752+
const parent = menuitem?.inputs.parent();
753+
754+
if (parent instanceof MenuPattern) {
755+
parent._clearTimeouts();
756+
}
688757
}
689758
}
690759
}

0 commit comments

Comments
 (0)