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
102 changes: 102 additions & 0 deletions src/aria/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,69 @@ describe('Standalone Menu Pattern', () => {
});
});

describe('Interactive content', () => {
let interactiveFixture: ComponentFixture<MenuWithInteractiveContent>;

const pressKey = async (element: Element, key: string) => {
element.dispatchEvent(new KeyboardEvent('keydown', {key, bubbles: true}));
await interactiveFixture.whenStable();
};

const getItem = (text: string): HTMLElement | null => {
const items = interactiveFixture.debugElement
.queryAll(By.directive(MenuItem))
.map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement);
return items.find(item => item.textContent?.trim() === text) || null;
};

beforeEach(async () => {
TestBed.configureTestingModule({
providers: [provideFakeDirectionality('ltr')],
});
interactiveFixture = TestBed.createComponent(MenuWithInteractiveContent);
await interactiveFixture.whenStable();
interactiveFixture.componentInstance.reset();
getItem('Link')?.focus();
});

it('should activate a link inside a menu item on Enter', async () => {
await pressKey(getItem('Link')!, 'Enter');

expect(interactiveFixture.componentInstance.linkClicked()).toBe(true);
});

it('should still select the item when its link is activated', async () => {
await pressKey(getItem('Link')!, 'Enter');

// Mirrors a mouse click, where the click bubbles up to the menu.
expect(interactiveFixture.componentInstance.selectedValue()).toBe('Link Item');
});

it('should activate a link inside a menu item on Space', async () => {
await pressKey(getItem('Link')!, ' ');

expect(interactiveFixture.componentInstance.linkClicked()).toBe(true);
});

it('should activate a button inside a menu item on Enter', async () => {
await pressKey(getItem('Link')!, 'ArrowDown');
const buttonItem = getItem('Button');

await pressKey(buttonItem!, 'Enter');

expect(interactiveFixture.componentInstance.buttonClicked()).toBe(true);
});

it('should not activate any interactive content for a plain item', async () => {
await pressKey(getItem('Link')!, 'End');
await pressKey(getItem('Text Item')!, 'Enter');

expect(interactiveFixture.componentInstance.linkClicked()).toBe(false);
expect(interactiveFixture.componentInstance.buttonClicked()).toBe(false);
expect(interactiveFixture.componentInstance.selectedValue()).toBe('Text Item');
});
});

describe('Expansion', () => {
function getSubmenu(): HTMLElement | null {
const menus = fixture.debugElement.queryAll(By.directive(Menu));
Expand Down Expand Up @@ -1486,3 +1549,42 @@ class CdkOverlayMenuExample {
class MenuItemRoleOverrideExample {
customRole = signal<'menuitem' | 'menuitemradio' | 'menuitemcheckbox'>('menuitemcheckbox');
}

@Component({
template: `
<div ngMenu (itemSelected)="itemSelected($event)">
<ng-template ngMenuContent>
<div ngMenuItem value="Link Item">
<a href="#link" (click)="onLinkClick($event)">Link</a>
</div>
<div ngMenuItem value="Button Item">
<button (click)="buttonClicked.set(true)">Button</button>
</div>
<div ngMenuItem value="Text Item">Text Item</div>
</ng-template>
</div>
`,
imports: [Menu, MenuItem, MenuContent],
changeDetection: ChangeDetectionStrategy.Eager,
})
class MenuWithInteractiveContent {
linkClicked = signal(false);
buttonClicked = signal(false);
selectedValue = signal<string | undefined>(undefined);

itemSelected(value: string) {
this.selectedValue.set(value);
}

onLinkClick(event: Event) {
// Prevent the test from navigating away from the test page.
event.preventDefault();
this.linkClicked.set(true);
}

reset() {
this.linkClicked.set(false);
this.buttonClicked.set(false);
this.selectedValue.set(undefined);
}
}
66 changes: 61 additions & 5 deletions src/aria/private/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {KeyboardEventManager} from '../behaviors/event-manager';
import {computed, signal, SignalLike} from '../behaviors/signal-like/signal-like';
import {List, ListInputs, ListItem} from '../behaviors/list/list';

/** Selector matching the elements that can be activated from inside a menu item. */
const INTERACTIVE_CONTENT_SELECTOR = 'a[href], button, input, select, textarea, [role="button"]';

/** The inputs for the MenuBarPattern class. */
export interface MenuBarInputs<V> extends ListInputs<MenuItemPattern<V>, V | undefined> {
/** The menu items contained in the menu. */
Expand Down Expand Up @@ -395,9 +398,20 @@ export class MenuPattern<V> {

/** Triggers the active menu item. */
trigger() {
this.inputs.activeItem()?.hasPopup()
? this.inputs.activeItem()?.open({first: true})
: this.submit();
const activeItem = this.inputs.activeItem();

if (!activeItem) {
return;
}

if (activeItem.hasPopup()) {
activeItem.open({first: true});
} else if (!activeItem.clickInteractiveContent()) {
// If the item doesn't wrap an interactive element, fall back to selecting it.
// Otherwise the click bubbles up to the menu's click handler, which selects
// the item the same way a mouse click does.
this.submit();
}
}

/** Submits the menu. */
Expand Down Expand Up @@ -529,17 +543,35 @@ export class MenuBarPattern<V> {
.on(this._previousKey, () => this.prev(), {ignoreRepeat: false})
.on('End', () => this.listBehavior.last())
.on('Home', () => this.listBehavior.first())
.on('Enter', () => this.inputs.activeItem()?.open({first: true}))
.on('Enter', () => this._activateActiveItem())
.on('ArrowUp', () => this.inputs.activeItem()?.open({last: true}))
.on('ArrowDown', () => this.inputs.activeItem()?.open({first: true}))
.on(this.dynamicSpaceKey, () => this.inputs.activeItem()?.open({first: true}))
.on(this.dynamicSpaceKey, () => this._activateActiveItem())
.on(this.typeaheadRegexp, e => this.listBehavior.search(e.key));
});

constructor(readonly inputs: MenuBarInputs<V>) {
this.listBehavior = new List<MenuItemPattern<V>, V | undefined>(inputs);
}

/**
* Activates the active menu bar item, either by opening its submenu or by
* clicking the interactive content that it wraps.
*/
private _activateActiveItem() {
const item = this.inputs.activeItem();

if (!item) {
return;
}

if (item.hasPopup()) {
item.open({first: true});
} else {
item.clickInteractiveContent();
}
}

/** Sets the default state for the menubar. */
setDefaultState() {
const firstFocusable = this.listBehavior.navigationBehavior.peekFirst();
Expand Down Expand Up @@ -827,6 +859,15 @@ export class MenuItemPattern<V> implements ListItem<V | undefined> {
/** Whether the menu item is selectable. */
readonly selectable: SignalLike<boolean>;

/** The interactive element rendered inside the menu item, if any. */
private readonly _interactiveContent = computed<HTMLElement | undefined>(() => {
const el = this.element();

return el
? (el.querySelector<HTMLElement>(INTERACTIVE_CONTENT_SELECTOR) ?? undefined)
: undefined;
});

constructor(readonly inputs: MenuItemInputs<V>) {
this.id = inputs.id;
this.value = inputs.value ?? signal(undefined);
Expand Down Expand Up @@ -876,6 +917,21 @@ export class MenuItemPattern<V> implements ListItem<V | undefined> {
}
}

/**
* Clicks the interactive content inside the menu item, if there is any.
* @returns Whether an interactive element was activated.
*/
clickInteractiveContent(): boolean {
const interactiveEl = this._interactiveContent();

if (this.disabled() || !interactiveEl) {
return false;
}

interactiveEl.click();
return true;
}

/** Handles focusin events for the menu item. */
onFocusIn() {
this.hasBeenInteracted.set(true);
Expand Down
Loading