Skip to content
Open
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
1 change: 1 addition & 0 deletions src/aria/menu/menu-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {MENU_COMPONENT} from './menu-tokens';
'[attr.tabindex]': '_pattern.tabIndex()',
'(keydown)': '_pattern.onKeydown($event)',
'(mouseover)': '_pattern.onMouseOver($event)',
'(mouseout)': '_pattern.onMouseOut($event)',
'(click)': '_pattern.onClick($event)',
'(focusin)': '_pattern.onFocusIn()',
'(focusout)': '_pattern.onFocusOut($event)',
Expand Down
99 changes: 99 additions & 0 deletions src/aria/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,23 @@ describe('Menu Trigger Pattern', () => {
focusout(getMenu()!, document.body);
expect(isExpanded()).toBe(false);
});

it('should close programmatically without refocusing the trigger', () => {
const trigger = getTrigger();
const triggerDirective = fixture.debugElement
.query(By.directive(MenuTrigger))
.injector.get(MenuTrigger);

click(trigger);
expect(isExpanded()).toBe(true);
expect(document.activeElement).toBe(getItem('Apple'));

triggerDirective.close();
fixture.detectChanges();

expect(isExpanded()).toBe(false);
expect(document.activeElement).not.toBe(trigger);
});
});

describe('Selection', () => {
Expand Down Expand Up @@ -1221,6 +1238,88 @@ describe('Menu Bar Pattern', () => {
expect(document.activeElement).toBe(undo);
});
});

describe('Active state', () => {
const mouseout = (element: Element, relatedTarget?: EventTarget) => {
element.dispatchEvent(new MouseEvent('mouseout', {bubbles: true, relatedTarget} as any));
fixture.detectChanges();
};

function isActive(text: string): boolean {
return getMenuBarItem(text)?.getAttribute('data-active') === 'true';
}

it('should not mark any menubar item as active on initial render', () => {
TestBed.configureTestingModule({providers: [provideFakeDirectionality('ltr')]});
fixture = TestBed.createComponent(MenuBarExample);
fixture.detectChanges();

expect(isActive('File')).toBe(false);
expect(isActive('Edit')).toBe(false);
expect(isActive('View')).toBe(false);
});

it('should mark a menubar item as active when hovered', () => {
TestBed.configureTestingModule({providers: [provideFakeDirectionality('ltr')]});
fixture = TestBed.createComponent(MenuBarExample);
fixture.detectChanges();

mouseover(getMenuBarItem('Edit')!);

expect(isActive('Edit')).toBe(true);
expect(isActive('File')).toBe(false);
});

it('should clear the active state when the pointer leaves the menubar', () => {
TestBed.configureTestingModule({providers: [provideFakeDirectionality('ltr')]});
fixture = TestBed.createComponent(MenuBarExample);
fixture.detectChanges();

const edit = getMenuBarItem('Edit')!;
mouseover(edit);
expect(isActive('Edit')).toBe(true);

mouseout(edit, document.body);

expect(isActive('Edit')).toBe(false);
expect(isActive('File')).toBe(false);
expect(isActive('View')).toBe(false);
});

it('should keep the active state while the pointer moves between menubar items', () => {
TestBed.configureTestingModule({providers: [provideFakeDirectionality('ltr')]});
fixture = TestBed.createComponent(MenuBarExample);
fixture.detectChanges();

const file = getMenuBarItem('File')!;
const edit = getMenuBarItem('Edit')!;

mouseover(file);
expect(isActive('File')).toBe(true);

mouseout(file, edit);
mouseover(edit);

expect(isActive('Edit')).toBe(true);
});

it('should mark the focused menubar item as active', () => {
setupMenu();
fixture.detectChanges();

expect(isActive('File')).toBe(true);
});

it('should clear the active state when focus leaves the menubar', () => {
setupMenu();
fixture.detectChanges();
expect(isActive('File')).toBe(true);

focusout(getMenuBarItem('File')!, document.body);

expect(isActive('File')).toBe(false);
});
});
});

@Component({
Expand Down
24 changes: 23 additions & 1 deletion src/aria/private/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,9 @@ export class MenuBarPattern<V> {
/** Whether the menubar or any of its children are currently focused. */
readonly isFocused = signal(false);

/** Whether the pointer is currently over a menubar item. */
readonly isHovered = signal(false);

/** Whether the menubar has been interacted with. */
readonly hasBeenInteracted = signal(false);

Expand Down Expand Up @@ -567,10 +570,20 @@ export class MenuBarPattern<V> {
const item = this.inputs.items().find(i => i.element()?.contains(event.target as Node));

if (item) {
this.isHovered.set(true);
this.goto(item, {focusElement: this.isFocused()});
}
}

/** Handles mouseout events for the menu bar. */
onMouseOut(event: MouseEvent) {
const relatedTarget = event.relatedTarget as Node | null;

if (!this.inputs.element()?.contains(relatedTarget)) {
this.isHovered.set(false);
}
}

/** Handles focusin events for the menu bar. */
onFocusIn() {
this.isFocused.set(true);
Expand Down Expand Up @@ -773,7 +786,16 @@ export class MenuItemPattern<V> implements ListItem<V> {
readonly element: SignalLike<HTMLElement | undefined>;

/** Whether the menu item is active. */
readonly active = computed(() => this.inputs.parent()?.inputs.activeItem() === this);
readonly active = computed(() => {
const parent = this.inputs.parent();
if (parent?.inputs.activeItem() !== this) {
return false;
}
if (parent instanceof MenuBarPattern) {
return parent.isFocused() || parent.isHovered() || this._expanded();
}
return true;
});

/** Whether the menu item has received interaction. */
readonly hasBeenInteracted = signal(false);
Expand Down
Loading