Skip to content
Draft
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
8 changes: 4 additions & 4 deletions goldens/aria/menu/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export class MenuItem<V> implements OnInit, OnDestroy {
close(): void;
readonly disabled: _angular_core.InputSignal<boolean>;
readonly element: HTMLElement;
readonly expanded: _angular_core.Signal<boolean | null>;
readonly expanded: _angular_core.ModelSignal<boolean>;
readonly hasPopup: _angular_core.Signal<boolean>;
readonly id: _angular_core.InputSignal<string>;
// (undocumented)
Expand All @@ -91,7 +91,7 @@ export class MenuItem<V> implements OnInit, OnDestroy {
readonly submenu: _angular_core.InputSignal<Menu<V> | undefined>;
readonly value: _angular_core.InputSignal<V>;
// (undocumented)
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<MenuItem<any>, "[ngMenuItem]", ["ngMenuItem"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": true; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "searchTerm": { "alias": "searchTerm"; "required": false; "isSignal": true; }; "role": { "alias": "role"; "required": false; "isSignal": true; }; "submenu": { "alias": "submenu"; "required": false; "isSignal": true; }; }, { "searchTerm": "searchTermChange"; }, never, never, true, never>;
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<MenuItem<any>, "[ngMenuItem]", ["ngMenuItem"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": true; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "searchTerm": { "alias": "searchTerm"; "required": false; "isSignal": true; }; "role": { "alias": "role"; "required": false; "isSignal": true; }; "submenu": { "alias": "submenu"; "required": false; "isSignal": true; }; "expanded": { "alias": "expanded"; "required": false; "isSignal": true; }; }, { "searchTerm": "searchTermChange"; "expanded": "expandedChange"; }, never, never, true, never>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<MenuItem<any>, never>;
}
Expand All @@ -102,15 +102,15 @@ export class MenuTrigger<V> {
close(): void;
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly element: HTMLElement;
readonly expanded: _angular_core.Signal<boolean>;
readonly expanded: _angular_core.ModelSignal<boolean>;
readonly hasPopup: _angular_core.Signal<boolean>;
readonly menu: _angular_core.InputSignal<Menu<V> | undefined>;
open(): void;
readonly _pattern: MenuTriggerPattern<V>;
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>;
// (undocumented)
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<MenuTrigger<any>, "[ngMenuTrigger]", ["ngMenuTrigger"], { "menu": { "alias": "menu"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<MenuTrigger<any>, "[ngMenuTrigger]", ["ngMenuTrigger"], { "menu": { "alias": "menu"; "required": false; "isSignal": true; }; "expanded": { "alias": "expanded"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; }, { "expanded": "expandedChange"; }, never, never, true, never>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<MenuTrigger<any>, never>;
}
Expand Down
3 changes: 2 additions & 1 deletion goldens/aria/private/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ export interface MenuInputs<V> extends Omit<ListInputs<MenuItemPattern<V>, V>, '

// @public
export interface MenuItemInputs<V> extends Omit<ListItem<V>, 'index' | 'selectable'> {
expanded: WritableSignalLike<boolean>;
parent: SignalLike<MenuPattern<V> | MenuBarPattern<V> | undefined>;
role: SignalLike<'menuitem' | 'menuitemradio' | 'menuitemcheckbox'>;
submenu: SignalLike<MenuPattern<V> | undefined>;
Expand All @@ -403,7 +404,6 @@ export class MenuItemPattern<V> implements ListItem<V> {
readonly disabled: () => boolean;
readonly element: SignalLike<HTMLElement | undefined>;
readonly expanded: SignalLike<boolean | null>;
readonly _expanded: WritableSignalLike<boolean>;
readonly hasBeenInteracted: WritableSignalLike<boolean>;
readonly hasPopup: SignalLike<boolean>;
readonly id: SignalLike<string>;
Expand Down Expand Up @@ -472,6 +472,7 @@ export class MenuPattern<V> {
export interface MenuTriggerInputs<V> {
disabled: SignalLike<boolean>;
element: SignalLike<HTMLElement | undefined>;
expanded: WritableSignalLike<boolean>;
menu: SignalLike<MenuPattern<V> | undefined>;
textDirection: SignalLike<'ltr' | 'rtl'>;
}
Expand Down
12 changes: 10 additions & 2 deletions src/aria/menu/menu-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import type {MenuBar} from './menu-bar';
'[attr.tabindex]': '_pattern.tabIndex()',
'[attr.data-active]': 'active()',
'[attr.aria-haspopup]': 'hasPopup()',
'[attr.aria-expanded]': 'expanded()',
'[attr.aria-expanded]': '_pattern.expanded()',
'[attr.aria-disabled]': '_pattern.disabled()',
'[attr.aria-controls]': '_pattern.submenu()?.id()',
},
Expand Down Expand Up @@ -86,7 +86,7 @@ export class MenuItem<V> implements OnInit, OnDestroy {
readonly active = computed(() => this._pattern.active());

/** Whether the menu is expanded. */
readonly expanded = computed(() => this._pattern.expanded());
readonly expanded = model<boolean>(false);

/** Whether the menu item has a popup. */
readonly hasPopup = computed(() => this._pattern.hasPopup());
Expand All @@ -101,11 +101,19 @@ export class MenuItem<V> implements OnInit, OnDestroy {
parent: computed(() => this.parent?._pattern),
submenu: computed(() => this.submenu()?._pattern),
role: this.role,
expanded: this.expanded,
});

constructor() {
effect(() => this.submenu()?.parent.set(this));

// Minimal support for sharing one submenu across multiple menu items.
effect(() => {
if (this.expanded() && this.submenu()) {
this.submenu()!.parent.set(this);
}
});

// Check for any violations after the DOM has been updated.
if (typeof ngDevMode === 'undefined' || ngDevMode) {
afterRenderEffect({
Expand Down
11 changes: 10 additions & 1 deletion src/aria/menu/menu-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ElementRef,
inject,
input,
model,
} from '@angular/core';
import {MenuTriggerPattern} from '../private';
import {Directionality} from '@angular/cdk/bidi';
Expand Down Expand Up @@ -68,7 +69,7 @@ export class MenuTrigger<V> {
readonly menu = input<Menu<V> | undefined>(undefined);

/** Whether the menu is expanded. */
readonly expanded = computed(() => this._pattern.expanded());
readonly expanded = model<boolean>(false);

/** Whether the menu trigger has a popup. */
readonly hasPopup = computed(() => this._pattern.hasPopup());
Expand All @@ -85,12 +86,20 @@ export class MenuTrigger<V> {
element: computed(() => this._elementRef.nativeElement),
menu: computed(() => this.menu()?._pattern),
disabled: () => this.disabled(),
expanded: this.expanded,
});

constructor() {
effect(() => this.menu()?.parent.set(this));
effect(() => this._pattern.pendingFocusEffect());

// Minimal support for sharing one menu across multiple menu triggers.
effect(() => {
if (this.expanded() && this.menu()) {
this.menu()!.parent.set(this);
}
});

// Automatically prevent form submission.
if (this.element.tagName === 'BUTTON' && !this.element.hasAttribute('type')) {
this.element.setAttribute('type', 'button');
Expand Down
55 changes: 53 additions & 2 deletions src/aria/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,55 @@ describe('Menu Trigger Pattern', () => {
await focusout(getMenu()!, document.body);
expect(isExpanded()).toBe(false);
});

it('should update trigger model when opened via click', async () => {
expect(fixture.componentInstance.expanded()).toBe(false);
await click(getTrigger());
expect(fixture.componentInstance.expanded()).toBe(true);
});

it('should update trigger model when closed', async () => {
await click(getTrigger());
expect(fixture.componentInstance.expanded()).toBe(true);
await click(getTrigger());
expect(fixture.componentInstance.expanded()).toBe(false);
});

it('should update submenu model when opened via click', async () => {
await click(getTrigger());
const berries = getItem('Berries');
expect(fixture.componentInstance.berriesExpanded()).toBe(false);
await click(berries!);
expect(fixture.componentInstance.berriesExpanded()).toBe(true);
});

it('should open menu programmatically when trigger model is updated', async () => {
expect(isExpanded()).toBe(false);
fixture.componentInstance.expanded.set(true);
fixture.detectChanges();
await fixture.whenStable();
expect(isExpanded()).toBe(true);
});

it('should close menu programmatically when trigger model is updated', async () => {
await click(getTrigger());
expect(isExpanded()).toBe(true);
fixture.componentInstance.expanded.set(false);
fixture.detectChanges();
await fixture.whenStable();
expect(isExpanded()).toBe(false);
});

it('should open submenu programmatically when submenu model is updated', async () => {
await click(getTrigger());
const berries = getItem('Berries');
expect(berries!.getAttribute('aria-expanded')).toBe('false');

fixture.componentInstance.berriesExpanded.set(true);
fixture.detectChanges();
await fixture.whenStable();
expect(berries!.getAttribute('aria-expanded')).toBe('true');
});
});

describe('Selection', () => {
Expand Down Expand Up @@ -1263,13 +1312,13 @@ class StandaloneMenuExample {

@Component({
template: `
<button ngMenuTrigger [menu]="menu">Open menu</button>
<button ngMenuTrigger [(expanded)]="expanded" [menu]="menu">Open menu</button>

<div ngMenu [expansionDelay]="0" #menu="ngMenu" (itemSelected)="itemSelected($event)">
<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 ngMenuItem value='Berries' searchTerm='Berries' [(expanded)]="berriesExpanded" [submenu]="berriesMenu">Berries</div>

<div ngMenu [expansionDelay]="0" #berriesMenu="ngMenu">
<ng-template ngMenuContent>
Expand All @@ -1287,6 +1336,8 @@ class StandaloneMenuExample {
changeDetection: ChangeDetectionStrategy.Eager,
})
class MenuTriggerExample {
expanded = signal(false);
berriesExpanded = signal(false);
itemSelected(value: string) {}
}

Expand Down
3 changes: 3 additions & 0 deletions src/aria/private/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function getMenuTriggerPattern(opts?: {textDirection: 'ltr' | 'rtl'}) {
element,
menu: submenu,
disabled: signal(false),
expanded: signal(false),
});

const originalOnClick = trigger.onClick.bind(trigger);
Expand Down Expand Up @@ -94,6 +95,7 @@ function getMenuBarPattern(values: string[], opts?: {textDirection: 'ltr' | 'rtl
element: signal(element),
submenu: signal(undefined),
role: signal('menuitem'),
expanded: signal(false),
}) as TestMenuItem;
}),
);
Expand Down Expand Up @@ -140,6 +142,7 @@ function getMenuPattern(
element: signal(element),
submenu: signal(undefined),
role: signal('menuitem'),
expanded: signal(false),
}) as TestMenuItem;
}),
);
Expand Down
29 changes: 19 additions & 10 deletions src/aria/private/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
*/

import {KeyboardEventManager} from '../behaviors/event-manager';
import {computed, signal, SignalLike} from '../behaviors/signal-like/signal-like';
import {
computed,
signal,
SignalLike,
WritableSignalLike,
} from '../behaviors/signal-like/signal-like';
import {List, ListInputs, ListItem} from '../behaviors/list/list';

/** The inputs for the MenuBarPattern class. */
Expand Down Expand Up @@ -56,6 +61,9 @@ export interface MenuTriggerInputs<V> {

/** Whether the menu trigger is disabled. */
disabled: SignalLike<boolean>;

/** Whether the menu trigger is expanded. */
expanded: WritableSignalLike<boolean>;
}

/** The inputs for the MenuItemPattern class. */
Expand All @@ -68,6 +76,9 @@ export interface MenuItemInputs<V> extends Omit<ListItem<V>, 'index' | 'selectab

/** The role of the menu item. */
role: SignalLike<'menuitem' | 'menuitemradio' | 'menuitemcheckbox'>;

/** Whether the menu item is expanded. */
expanded: WritableSignalLike<boolean>;
}

/** The menu ui pattern class. */
Expand Down Expand Up @@ -636,7 +647,7 @@ export class MenuBarPattern<V> {
/** The menu trigger ui pattern class. */
export class MenuTriggerPattern<V> {
/** Whether the menu trigger is expanded. */
readonly expanded = signal(false);
readonly expanded: WritableSignalLike<boolean>;

/** Whether the menu trigger has received interaction. */
readonly hasBeenInteracted = signal(false);
Expand Down Expand Up @@ -672,6 +683,7 @@ export class MenuTriggerPattern<V> {
});

constructor(readonly inputs: MenuTriggerInputs<V>) {
this.expanded = this.inputs.expanded;
this.menu = this.inputs.menu;
}

Expand Down Expand Up @@ -748,7 +760,7 @@ export class MenuTriggerPattern<V> {

while (menuitems.length) {
const menuitem = menuitems.pop();
menuitem?._expanded.set(false);
menuitem?.inputs.expanded.set(false);
menuitem?.inputs.parent()?.listBehavior.unfocus();
menuitems = menuitems.concat(menuitem?.submenu()?.inputs.items() ?? []);
}
Expand Down Expand Up @@ -790,10 +802,7 @@ export class MenuItemPattern<V> implements ListItem<V> {
readonly index = computed(() => this.inputs.parent()?.inputs.items().indexOf(this) ?? -1);

/** Whether the menu item is expanded. */
readonly expanded = computed(() => (this.submenu() ? this._expanded() : null));

/** Whether the menu item is expanded. */
readonly _expanded = signal(false);
readonly expanded = computed(() => (this.submenu() ? this.inputs.expanded() : null));

/** The ID of the menu that the menu item controls. */
readonly controls = signal<string | undefined>(undefined);
Expand Down Expand Up @@ -825,7 +834,7 @@ export class MenuItemPattern<V> implements ListItem<V> {
return;
}

this._expanded.set(true);
this.inputs.expanded.set(true);

if (opts?.first) {
this.submenu()?.first();
Expand All @@ -837,7 +846,7 @@ export class MenuItemPattern<V> implements ListItem<V> {

/** Closes the submenu. */
close(opts: {refocus?: boolean} = {}) {
this._expanded.set(false);
this.inputs.expanded.set(false);

if (opts.refocus) {
this.inputs.parent()?.listBehavior.goto(this);
Expand All @@ -847,7 +856,7 @@ export class MenuItemPattern<V> implements ListItem<V> {

while (menuitems.length) {
const menuitem = menuitems.pop();
menuitem?._expanded.set(false);
menuitem?.inputs.expanded.set(false);
menuitem?.inputs.parent()?.listBehavior.unfocus();
menuitems = menuitems.concat(menuitem?.submenu()?.inputs.items() ?? []);

Expand Down
1 change: 1 addition & 0 deletions src/components-examples/aria/menu/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ ng_project(
"//src/aria/menu",
"//src/cdk/a11y",
"//src/cdk/overlay",
"//src/material/snack-bar",
],
)

Expand Down
1 change: 1 addition & 0 deletions src/components-examples/aria/menu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export {MenuTriggerDisabledExample} from './menu-trigger-disabled/menu-trigger-d
export {MenuStandaloneExample} from './menu-standalone/menu-standalone-example';
export {MenuStandaloneDisabledExample} from './menu-standalone-disabled/menu-standalone-disabled-example';
export {MenuCdkOverlayExample} from './menu-cdk-overlay/menu-cdk-overlay-example';
export {SharedMenuExample} from './shared-menu/shared-menu-example';
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
.example-shared-menu-list {
list-style: none;
padding: 0;
margin: 0;
max-width: 20rem;
border: 1px solid var(--mat-sys-outline);
border-radius: var(--mat-sys-corner-extra-small);
background: var(--mat-sys-surface);
}

.example-shared-menu-list-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid color-mix(in srgb, var(--mat-sys-outline) 20%, transparent);
}

.example-shared-menu-list-item:last-child {
border-bottom: none;
}

.example-shared-menu-item-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
}

.example-shared-menu-trigger-btn {
padding: 0.25rem;
}

.example-shared-menu-trigger-icon {
font-size: 1.25rem;
}
Loading
Loading