diff --git a/goldens/aria/menu/index.api.md b/goldens/aria/menu/index.api.md index 79630b052dba..fc4ff2a4a577 100644 --- a/goldens/aria/menu/index.api.md +++ b/goldens/aria/menu/index.api.md @@ -76,7 +76,7 @@ export class MenuItem implements OnInit, OnDestroy { close(): void; readonly disabled: _angular_core.InputSignal; readonly element: HTMLElement; - readonly expanded: _angular_core.Signal; + readonly expanded: _angular_core.ModelSignal; readonly hasPopup: _angular_core.Signal; readonly id: _angular_core.InputSignal; // (undocumented) @@ -91,7 +91,7 @@ export class MenuItem implements OnInit, OnDestroy { readonly submenu: _angular_core.InputSignal | undefined>; readonly value: _angular_core.InputSignal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[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, "[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, never>; } @@ -102,7 +102,7 @@ export class MenuTrigger { close(): void; readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; - readonly expanded: _angular_core.Signal; + readonly expanded: _angular_core.ModelSignal; readonly hasPopup: _angular_core.Signal; readonly menu: _angular_core.InputSignal | undefined>; open(): void; @@ -110,7 +110,7 @@ export class MenuTrigger { readonly softDisabled: _angular_core.InputSignalWithTransform; readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[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, "[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, never>; } diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index 9b459d94906c..910865003c89 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -387,6 +387,7 @@ export interface MenuInputs extends Omit, V>, ' // @public export interface MenuItemInputs extends Omit, 'index' | 'selectable'> { + expanded: WritableSignalLike; parent: SignalLike | MenuBarPattern | undefined>; role: SignalLike<'menuitem' | 'menuitemradio' | 'menuitemcheckbox'>; submenu: SignalLike | undefined>; @@ -403,7 +404,6 @@ export class MenuItemPattern implements ListItem { readonly disabled: () => boolean; readonly element: SignalLike; readonly expanded: SignalLike; - readonly _expanded: WritableSignalLike; readonly hasBeenInteracted: WritableSignalLike; readonly hasPopup: SignalLike; readonly id: SignalLike; @@ -472,6 +472,7 @@ export class MenuPattern { export interface MenuTriggerInputs { disabled: SignalLike; element: SignalLike; + expanded: WritableSignalLike; menu: SignalLike | undefined>; textDirection: SignalLike<'ltr' | 'rtl'>; } diff --git a/src/aria/menu/menu-item.ts b/src/aria/menu/menu-item.ts index f19e11a25e82..15ef735d259d 100644 --- a/src/aria/menu/menu-item.ts +++ b/src/aria/menu/menu-item.ts @@ -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()', }, @@ -86,7 +86,7 @@ export class MenuItem implements OnInit, OnDestroy { readonly active = computed(() => this._pattern.active()); /** Whether the menu is expanded. */ - readonly expanded = computed(() => this._pattern.expanded()); + readonly expanded = model(false); /** Whether the menu item has a popup. */ readonly hasPopup = computed(() => this._pattern.hasPopup()); @@ -101,11 +101,19 @@ export class MenuItem 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({ diff --git a/src/aria/menu/menu-trigger.ts b/src/aria/menu/menu-trigger.ts index 7ed57871e656..d45887bed58e 100644 --- a/src/aria/menu/menu-trigger.ts +++ b/src/aria/menu/menu-trigger.ts @@ -14,6 +14,7 @@ import { ElementRef, inject, input, + model, } from '@angular/core'; import {MenuTriggerPattern} from '../private'; import {Directionality} from '@angular/cdk/bidi'; @@ -68,7 +69,7 @@ export class MenuTrigger { readonly menu = input | undefined>(undefined); /** Whether the menu is expanded. */ - readonly expanded = computed(() => this._pattern.expanded()); + readonly expanded = model(false); /** Whether the menu trigger has a popup. */ readonly hasPopup = computed(() => this._pattern.hasPopup()); @@ -85,12 +86,20 @@ export class MenuTrigger { 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'); diff --git a/src/aria/menu/menu.spec.ts b/src/aria/menu/menu.spec.ts index c95273b1ce00..bedb7917c653 100644 --- a/src/aria/menu/menu.spec.ts +++ b/src/aria/menu/menu.spec.ts @@ -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', () => { @@ -1263,13 +1312,13 @@ class StandaloneMenuExample { @Component({ template: ` - +
Apple
Banana
-
Berries
+
Berries
@@ -1287,6 +1336,8 @@ class StandaloneMenuExample { changeDetection: ChangeDetectionStrategy.Eager, }) class MenuTriggerExample { + expanded = signal(false); + berriesExpanded = signal(false); itemSelected(value: string) {} } diff --git a/src/aria/private/menu/menu.spec.ts b/src/aria/private/menu/menu.spec.ts index 026ca1dfff62..c3d95b3198e8 100644 --- a/src/aria/private/menu/menu.spec.ts +++ b/src/aria/private/menu/menu.spec.ts @@ -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); @@ -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; }), ); @@ -140,6 +142,7 @@ function getMenuPattern( element: signal(element), submenu: signal(undefined), role: signal('menuitem'), + expanded: signal(false), }) as TestMenuItem; }), ); diff --git a/src/aria/private/menu/menu.ts b/src/aria/private/menu/menu.ts index 70657833c7ec..c53a46ef6ef9 100644 --- a/src/aria/private/menu/menu.ts +++ b/src/aria/private/menu/menu.ts @@ -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. */ @@ -56,6 +61,9 @@ export interface MenuTriggerInputs { /** Whether the menu trigger is disabled. */ disabled: SignalLike; + + /** Whether the menu trigger is expanded. */ + expanded: WritableSignalLike; } /** The inputs for the MenuItemPattern class. */ @@ -68,6 +76,9 @@ export interface MenuItemInputs extends Omit, 'index' | 'selectab /** The role of the menu item. */ role: SignalLike<'menuitem' | 'menuitemradio' | 'menuitemcheckbox'>; + + /** Whether the menu item is expanded. */ + expanded: WritableSignalLike; } /** The menu ui pattern class. */ @@ -636,7 +647,7 @@ export class MenuBarPattern { /** The menu trigger ui pattern class. */ export class MenuTriggerPattern { /** Whether the menu trigger is expanded. */ - readonly expanded = signal(false); + readonly expanded: WritableSignalLike; /** Whether the menu trigger has received interaction. */ readonly hasBeenInteracted = signal(false); @@ -672,6 +683,7 @@ export class MenuTriggerPattern { }); constructor(readonly inputs: MenuTriggerInputs) { + this.expanded = this.inputs.expanded; this.menu = this.inputs.menu; } @@ -748,7 +760,7 @@ export class MenuTriggerPattern { 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() ?? []); } @@ -790,10 +802,7 @@ export class MenuItemPattern implements ListItem { 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(undefined); @@ -825,7 +834,7 @@ export class MenuItemPattern implements ListItem { return; } - this._expanded.set(true); + this.inputs.expanded.set(true); if (opts?.first) { this.submenu()?.first(); @@ -837,7 +846,7 @@ export class MenuItemPattern implements ListItem { /** 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); @@ -847,7 +856,7 @@ export class MenuItemPattern implements ListItem { 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() ?? []); diff --git a/src/components-examples/aria/menu/BUILD.bazel b/src/components-examples/aria/menu/BUILD.bazel index cc55d838e4f2..f273ebb8c745 100644 --- a/src/components-examples/aria/menu/BUILD.bazel +++ b/src/components-examples/aria/menu/BUILD.bazel @@ -15,6 +15,7 @@ ng_project( "//src/aria/menu", "//src/cdk/a11y", "//src/cdk/overlay", + "//src/material/snack-bar", ], ) diff --git a/src/components-examples/aria/menu/index.ts b/src/components-examples/aria/menu/index.ts index 02e202e13e7a..d23d4ff52680 100644 --- a/src/components-examples/aria/menu/index.ts +++ b/src/components-examples/aria/menu/index.ts @@ -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'; diff --git a/src/components-examples/aria/menu/shared-menu/shared-menu-example.css b/src/components-examples/aria/menu/shared-menu/shared-menu-example.css new file mode 100644 index 000000000000..6620383045c5 --- /dev/null +++ b/src/components-examples/aria/menu/shared-menu/shared-menu-example.css @@ -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; +} diff --git a/src/components-examples/aria/menu/shared-menu/shared-menu-example.html b/src/components-examples/aria/menu/shared-menu/shared-menu-example.html new file mode 100644 index 000000000000..592d0f0c9a68 --- /dev/null +++ b/src/components-examples/aria/menu/shared-menu/shared-menu-example.html @@ -0,0 +1,30 @@ +
    + @for (item of items; track item.id) { +
  • + {{ item.name }} + +
  • + } +
+ +
+ +
+ delete + Delete {{ activeItem()?.name }} +
+ +
+ archive + Archive {{ activeItem()?.name }} +
+
+
diff --git a/src/components-examples/aria/menu/shared-menu/shared-menu-example.ts b/src/components-examples/aria/menu/shared-menu/shared-menu-example.ts new file mode 100644 index 000000000000..357e50f4b3d2 --- /dev/null +++ b/src/components-examples/aria/menu/shared-menu/shared-menu-example.ts @@ -0,0 +1,55 @@ +import {Component, inject, signal} from '@angular/core'; +import {MenuTrigger, MenuContent} from '@angular/aria/menu'; +import {MatSnackBar, MatSnackBarModule} from '@angular/material/snack-bar'; +import {SimpleMenu, SimpleMenuItem, SimpleMenuItemIcon, SimpleMenuItemText} from '../simple-menu'; + +interface LoopItem { + id: number; + name: string; +} + +/** @title Shared Menu Example. */ +@Component({ + selector: 'shared-menu-example', + templateUrl: 'shared-menu-example.html', + styleUrls: ['../menu-example.css', 'shared-menu-example.css'], + imports: [ + MenuContent, + MenuTrigger, + SimpleMenu, + SimpleMenuItem, + SimpleMenuItemIcon, + SimpleMenuItemText, + MatSnackBarModule, + ], +}) +export class SharedMenuExample { + private readonly _snackBar = inject(MatSnackBar); + + items: LoopItem[] = [ + {id: 1, name: 'Document A'}, + {id: 2, name: 'Image B'}, + {id: 3, name: 'Spreadsheet C'}, + ]; + + readonly activeItem = signal(null); + + onExpandedChange(expanded: boolean, item: LoopItem) { + if (expanded) { + this.activeItem.set(item); + } + } + + onItemSelected(value: string) { + const item = this.activeItem(); + if (!item) { + return; + } + + if (value === 'Delete') { + this._snackBar.open(`Deleted: ${item.name}`, 'Dismiss', {duration: 3000}); + } else if (value === 'Archive') { + this._snackBar.open(`Archived: ${item.name}`, 'Dismiss', {duration: 3000}); + } + } +} diff --git a/src/components-examples/aria/menu/simple-menu.ts b/src/components-examples/aria/menu/simple-menu.ts index 87a71501ff71..f6b799fd2a40 100644 --- a/src/components-examples/aria/menu/simple-menu.ts +++ b/src/components-examples/aria/menu/simple-menu.ts @@ -3,7 +3,12 @@ import {afterRenderEffect, Directive, effect, inject} from '@angular/core'; @Directive({ selector: '[ng-menu]', - hostDirectives: [{directive: Menu}], + hostDirectives: [ + { + directive: Menu, + outputs: ['itemSelected'], + }, + ], host: { class: 'example-menu', popover: 'manual', diff --git a/src/dev-app/aria-menu/menu-demo.html b/src/dev-app/aria-menu/menu-demo.html index ead6faddd1a2..6daa5f2e8327 100644 --- a/src/dev-app/aria-menu/menu-demo.html +++ b/src/dev-app/aria-menu/menu-demo.html @@ -29,5 +29,10 @@

Context Menu Example

Menu CDK Overlay Example

+ +
+

Shared Menu Example

+ +
diff --git a/src/dev-app/aria-menu/menu-demo.ts b/src/dev-app/aria-menu/menu-demo.ts index 00c962aa88c1..5531ce310d48 100644 --- a/src/dev-app/aria-menu/menu-demo.ts +++ b/src/dev-app/aria-menu/menu-demo.ts @@ -14,6 +14,7 @@ import { MenuStandaloneDisabledExample, MenuTriggerDisabledExample, MenuCdkOverlayExample, + SharedMenuExample, } from '@angular/components-examples/aria/menu'; @Component({ @@ -27,6 +28,7 @@ import { MenuStandaloneExample, MenuStandaloneDisabledExample, MenuCdkOverlayExample, + SharedMenuExample, ], }) export class MenuDemo {}