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
127 changes: 76 additions & 51 deletions src/aria/menu/menu.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {Component, DebugElement} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {Menu, MenuBar, MenuItem, MenuTrigger} from './menu';
import {Menu, MenuBar, MenuContent, MenuItem, MenuTrigger} from './menu';
import {provideFakeDirectionality} from '@angular/cdk/testing/private';

describe('Standalone Menu Pattern', () => {
Expand Down Expand Up @@ -227,25 +227,24 @@ describe('Standalone Menu Pattern', () => {
const apple = getItem('Apple');
const banana = getItem('Banana');
const berries = getItem('Berries');
const blueberry = getItem('Blueberry');

keydown(apple!, 'ArrowDown');
keydown(banana!, 'ArrowDown');
keydown(berries!, 'ArrowRight');

expect(isSubmenuExpanded()).toBe(true);
expect(document.activeElement).toBe(blueberry);
expect(document.activeElement).toBe(getItem('Blueberry'));
});

it('should close submenu on arrow left', () => {
const apple = getItem('Apple');
const banana = getItem('Banana');
const berries = getItem('Berries');
const blueberry = getItem('Blueberry');

keydown(apple!, 'ArrowDown');
keydown(banana!, 'ArrowDown');
keydown(berries!, 'ArrowRight');
const blueberry = getItem('Blueberry');
keydown(blueberry!, 'ArrowLeft');

expect(isSubmenuExpanded()).toBe(false);
Expand All @@ -254,54 +253,51 @@ describe('Standalone Menu Pattern', () => {

it('should close submenu on click outside', () => {
const berries = getItem('Berries');
const blueberry = getItem('Blueberry');

click(berries!);
expect(isSubmenuExpanded()).toBe(true);

focusout(blueberry!, document.body);
focusout(getItem('Blueberry')!, document.body);
expect(isSubmenuExpanded()).toBe(false);
});

it('should expand submenu on enter', () => {
const apple = getItem('Apple');
const banana = getItem('Banana');
const berries = getItem('Berries');
const blueberry = getItem('Blueberry');

keydown(apple!, 'ArrowDown');
keydown(banana!, 'ArrowDown');
keydown(berries!, 'Enter');

expect(isSubmenuExpanded()).toBe(true);
expect(document.activeElement).toBe(blueberry);
expect(document.activeElement).toBe(getItem('Blueberry'));
});

it('should expand submenu on space', () => {
const apple = getItem('Apple');
const banana = getItem('Banana');
const berries = getItem('Berries');
const blueberry = getItem('Blueberry');

keydown(apple!, 'ArrowDown');
keydown(banana!, 'ArrowDown');
keydown(berries!, ' ');

expect(isSubmenuExpanded()).toBe(true);
expect(document.activeElement).toBe(blueberry);
expect(document.activeElement).toBe(getItem('Blueberry'));
});

it('should close submenu on escape', () => {
const apple = getItem('Apple');
const banana = getItem('Banana');
const berries = getItem('Berries');
const blueberry = getItem('Blueberry');

keydown(apple!, 'ArrowDown');
keydown(banana!, 'ArrowDown');
keydown(berries!, 'ArrowRight');

expect(isSubmenuExpanded()).toBe(true);
const blueberry = getItem('Blueberry');
expect(document.activeElement).toBe(blueberry);

keydown(blueberry!, 'Escape');
Expand Down Expand Up @@ -332,11 +328,11 @@ describe('Standalone Menu Pattern', () => {
const apple = getItem('Apple');
const banana = getItem('Banana');
const berries = getItem('Berries');
const blueberry = getItem('Blueberry');

keydown(apple!, 'ArrowDown');
keydown(banana!, 'ArrowDown');
keydown(berries!, 'Enter'); // open submenu
const blueberry = getItem('Blueberry');

expect(document.activeElement).toBe(blueberry);

Expand All @@ -351,11 +347,11 @@ describe('Standalone Menu Pattern', () => {
const apple = getItem('Apple');
const banana = getItem('Banana');
const berries = getItem('Berries');
const blueberry = getItem('Blueberry');

keydown(apple!, 'ArrowDown');
keydown(banana!, 'ArrowDown');
keydown(berries!, ' '); // open submenu
const blueberry = getItem('Blueberry');

expect(document.activeElement).toBe(blueberry);

Expand All @@ -369,12 +365,12 @@ describe('Standalone Menu Pattern', () => {
const apple = getItem('Apple');
const banana = getItem('Banana');
const berries = getItem('Berries');
const blueberry = getItem('Blueberry');

keydown(apple!, 'ArrowDown');
keydown(banana!, 'ArrowDown');
keydown(berries!, 'ArrowRight');
expect(isSubmenuExpanded()).toBe(true);
const blueberry = getItem('Blueberry');
expect(document.activeElement).toBe(blueberry);

const externalElement = document.createElement('button');
Expand Down Expand Up @@ -424,37 +420,52 @@ describe('Standalone Menu Pattern', () => {
const apple = getItem('Apple');
const banana = getItem('Banana');
const berries = getItem('Berries');
const blueberry = getItem('Blueberry');

keydown(apple!, 'ArrowDown');
keydown(banana!, 'ArrowDown');
keydown(berries!, 'ArrowLeft');

expect(isSubmenuExpanded()).toBe(true);
expect(document.activeElement).toBe(blueberry);
expect(document.activeElement).toBe(getItem('Blueberry'));
});

it('should close submenu on arrow right', () => {
const apple = getItem('Apple');
const banana = getItem('Banana');
const berries = getItem('Berries');
const blueberry = getItem('Blueberry');

keydown(apple!, 'ArrowDown');
keydown(banana!, 'ArrowDown');
keydown(berries!, 'ArrowLeft');
const blueberry = getItem('Blueberry');
keydown(blueberry!, 'ArrowRight');

expect(isSubmenuExpanded()).toBe(false);
expect(document.activeElement).toBe(berries);
});
});

it('should not reset default state on hover triggers expansion', async () => {
TestBed.configureTestingModule({});
fixture = TestBed.createComponent(StandaloneMenuExample);
fixture.detectChanges();

const berries = getItem('Berries');
await mouseover(berries!);
expect(berries?.getAttribute('data-active')).toBe('true');
});
});

describe('Menu Trigger Pattern', () => {
let fixture: ComponentFixture<MenuTriggerExample>;

const focusin = (element: Element) => {
element.dispatchEvent(new FocusEvent('focusin', {bubbles: true}));
fixture.detectChanges();
};

const keydown = (element: Element, key: string, modifierKeys: {} = {}) => {
focusin(element);
element.dispatchEvent(
new KeyboardEvent('keydown', {
key,
Expand All @@ -466,6 +477,7 @@ describe('Menu Trigger Pattern', () => {
};

const click = (element: Element, eventInit?: PointerEventInit) => {
focusin(element);
element.dispatchEvent(new PointerEvent('click', {bubbles: true, ...eventInit}));
fixture.detectChanges();
};
Expand All @@ -479,7 +491,6 @@ describe('Menu Trigger Pattern', () => {
TestBed.configureTestingModule({});
fixture = TestBed.createComponent(MenuTriggerExample);
fixture.detectChanges();
getItem('Apple')?.focus();
}

function getTrigger(): HTMLElement {
Expand Down Expand Up @@ -598,7 +609,7 @@ describe('Menu Trigger Pattern', () => {
expect(isExpanded()).toBe(false);
});

it('should close on selecting an item on space', () => {
it('should close on selecting an item on space', async () => {
click(getTrigger());
keydown(getItem('Apple')!, ' ');
expect(isExpanded()).toBe(false);
Expand Down Expand Up @@ -946,20 +957,24 @@ describe('Menu Bar Pattern', () => {
@Component({
template: `
<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 [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>
</div>

<div ngMenuItem value='Cherry' searchTerm='Cherry' [disabled]="true">Cherry</div>
<ng-template ngMenuContent>
<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 [expansionDelay]="0" #berriesMenu="ngMenu">
<ng-template ngMenuContent>
<div ngMenuItem value='Blueberry' searchTerm='Blueberry'>Blueberry</div>
<div ngMenuItem value='Blackberry' searchTerm='Blackberry'>Blackberry</div>
<div ngMenuItem value='Strawberry' searchTerm='Strawberry'>Strawberry</div>
</ng-template>
</div>

<div ngMenuItem value='Cherry' searchTerm='Cherry' [disabled]="true">Cherry</div>
</ng-template>
</div>
`,
imports: [Menu, MenuItem],
imports: [Menu, MenuItem, MenuContent],
})
class StandaloneMenuExample {
onSelect(value: string) {}
Expand All @@ -970,20 +985,24 @@ class StandaloneMenuExample {
<button ngMenuTrigger [menu]="menu">Open menu</button>

<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 [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>
</div>

<div ngMenuItem value='Cherry' searchTerm='Cherry'>Cherry</div>
<ng-template ngMenuContent>
<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 [expansionDelay]="0" #berriesMenu="ngMenu">
<ng-template ngMenuContent>
<div ngMenuItem value='Blueberry' searchTerm='Blueberry'>Blueberry</div>
<div ngMenuItem value='Blackberry' searchTerm='Blackberry'>Blackberry</div>
<div ngMenuItem value='Strawberry' searchTerm='Strawberry'>Strawberry</div>
</ng-template>
</div>

<div ngMenuItem value='Cherry' searchTerm='Cherry'>Cherry</div>
</ng-template>
</div>
`,
imports: [Menu, MenuItem, MenuTrigger],
imports: [Menu, MenuItem, MenuTrigger, MenuContent],
})
class MenuTriggerExample {}

Expand All @@ -994,26 +1013,32 @@ class MenuTriggerExample {}
<div ngMenuItem value='Edit' searchTerm='Edit' [submenu]="editMenu">Edit</div>

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

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

<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>
<ng-template ngMenuContent>
<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>
</ng-template>
</div>

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

<div ngMenu [expansionDelay]="0" #helpMenu="ngMenu">
<div ngMenuItem value='Documentation' searchTerm='Documentation'>Documentation</div>
<div ngMenuItem value='About' searchTerm='About'>About</div>
<ng-template ngMenuContent>
<div ngMenuItem value='Documentation' searchTerm='Documentation'>Documentation</div>
<div ngMenuItem value='About' searchTerm='About'>About</div>
</ng-template>
</div>
</div>
`,
imports: [Menu, MenuBar, MenuItem],
imports: [Menu, MenuBar, MenuItem, MenuContent],
})
class MenuBarExample {}
6 changes: 5 additions & 1 deletion src/aria/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,11 @@ export class Menu<V> {
});

afterRenderEffect(() => {
if (!this._pattern.hasBeenFocused() && this._items().length) {
if (
!this._pattern.hasBeenFocused() &&
!this._pattern.hasBeenHovered() &&
this._items().length
) {
untracked(() => this._pattern.setDefaultState());
}
});
Expand Down
4 changes: 4 additions & 0 deletions src/aria/private/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ export class MenuPattern<V> {
/** Whether the menu has received focus. */
hasBeenFocused = signal(false);

/** Whether the menu trigger has been hovered. */
hasBeenHovered = signal(false);

/** Timeout used to open sub-menus on hover. */
_openTimeout: any;

Expand Down Expand Up @@ -195,6 +198,7 @@ export class MenuPattern<V> {
return;
}

this.hasBeenHovered.set(true);
const item = this.inputs.items().find(i => i.element()?.contains(event.target as Node));

if (!item) {
Expand Down
Loading