Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions src/aria/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};

Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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!);
Expand All @@ -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!);
Expand Down Expand Up @@ -944,12 +945,12 @@ describe('Menu Bar Pattern', () => {

@Component({
template: `
<div ngMenu (onSelect)="onSelect($event)">
<div ngMenu [expansionDelay]="0" (onSelect)="onSelect($event)">
<div ngMenuItem value='Apple' searchTerm='Apple'>Apple</div>
<div ngMenuItem value='Banana' searchTerm='Banana'>Banana</div>
<div ngMenuItem value='Berries' searchTerm='Berries' [submenu]="berriesMenu">Berries</div>

<div ngMenu #berriesMenu="ngMenu">
<div ngMenu [expansionDelay]="0" #berriesMenu="ngMenu">
<div ngMenuItem value='Blueberry' searchTerm='Blueberry'>Blueberry</div>
<div ngMenuItem value='Blackberry' searchTerm='Blackberry'>Blackberry</div>
<div ngMenuItem value='Strawberry' searchTerm='Strawberry'>Strawberry</div>
Expand All @@ -968,12 +969,12 @@ class StandaloneMenuExample {
template: `
<button ngMenuTrigger [menu]="menu">Open menu</button>

<div ngMenu #menu="ngMenu">
<div ngMenu [expansionDelay]="0" #menu="ngMenu">
<div ngMenuItem value='Apple' searchTerm='Apple'>Apple</div>
<div ngMenuItem value='Banana' searchTerm='Banana'>Banana</div>
<div ngMenuItem value='Berries' searchTerm='Berries' [submenu]="berriesMenu">Berries</div>

<div ngMenu #berriesMenu="ngMenu">
<div ngMenu [expansionDelay]="0" #berriesMenu="ngMenu">
<div ngMenuItem value='Blueberry' searchTerm='Blueberry'>Blueberry</div>
<div ngMenuItem value='Blackberry' searchTerm='Blackberry'>Blackberry</div>
<div ngMenuItem value='Strawberry' searchTerm='Strawberry'>Strawberry</div>
Expand All @@ -992,22 +993,22 @@ class MenuTriggerExample {}
<div ngMenuItem value='File' searchTerm='File'>File</div>
<div ngMenuItem value='Edit' searchTerm='Edit' [submenu]="editMenu">Edit</div>

<div ngMenu #editMenu="ngMenu">
<div ngMenu [expansionDelay]="0" #editMenu="ngMenu">
<div ngMenuItem value='Undo' searchTerm='Undo'>Undo</div>
<div ngMenuItem value='Redo' searchTerm='Redo'>Redo</div>
</div>

<div ngMenuItem [submenu]="viewMenu" value='View' searchTerm='View'>View</div>

<div ngMenu #viewMenu="ngMenu">
<div ngMenu [expansionDelay]="0" #viewMenu="ngMenu">
<div ngMenuItem value='Zoom In' searchTerm='Zoom In'>Zoom In</div>
<div ngMenuItem value='Zoom Out' searchTerm='Zoom Out'>Zoom Out</div>
<div ngMenuItem value='Full Screen' searchTerm='Full Screen'>Full Screen</div>
</div>

<div ngMenuItem [submenu]="helpMenu" value='Help' searchTerm='Help'>Help</div>

<div ngMenu #helpMenu="ngMenu">
<div ngMenu [expansionDelay]="0" #helpMenu="ngMenu">
<div ngMenuItem value='Documentation' searchTerm='Documentation'>Documentation</div>
<div ngMenuItem value='About' searchTerm='About'>About</div>
</div>
Expand Down
5 changes: 4 additions & 1 deletion src/aria/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ export class Menu<V> {
/** A callback function triggered when a menu item is selected. */
onSelect = output<V>();

/** The delay in milliseconds before expanding sub-menus on hover. */
readonly expansionDelay = input<number>(150); // Arbitrarily chosen.

constructor() {
this._pattern = new MenuPattern({
...this,
Expand Down Expand Up @@ -214,7 +217,7 @@ export class Menu<V> {

afterRenderEffect(() => {
if (!this._pattern.hasBeenFocused()) {
this._pattern.setDefaultState();
untracked(() => this._pattern.setDefaultState());
}
});
}
Expand Down
15 changes: 11 additions & 4 deletions src/aria/private/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ function getMenuPattern(
orientation: signal('vertical'),
selectionMode: signal('explicit'),
element: signal(document.createElement('div')),
expansionDelay: signal(0),
});

items.set(
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -385,29 +387,34 @@ 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);

submenu.onFocusOut(new FocusEvent('focusout', {relatedTarget: document.body}));
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);
});
});
Expand Down
79 changes: 74 additions & 5 deletions src/aria/private/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export interface MenuInputs<V>

/** The text direction of the menu bar. */
textDirection: SignalLike<'ltr' | 'rtl'>;

/** The delay in milliseconds before expanding sub-menus on hover. */
expansionDelay: SignalLike<number>;
}

/** The inputs for the MenuTriggerPattern class. */
Expand Down Expand Up @@ -83,6 +86,12 @@ export class MenuPattern<V> {
/** 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();
Expand Down Expand Up @@ -185,23 +194,55 @@ export class MenuPattern<V> {
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<V>) {
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<V>) {
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;
}
Expand Down Expand Up @@ -370,6 +411,28 @@ export class MenuPattern<V> {
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. */
Expand Down Expand Up @@ -685,6 +748,12 @@ export class MenuItemPattern<V> implements ListItem<V> {
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();
}
}
}
}
Loading