|
1 | | -import {Component, DebugElement, ChangeDetectionStrategy, signal} from '@angular/core'; |
| 1 | +import { |
| 2 | + Component, |
| 3 | + DebugElement, |
| 4 | + ChangeDetectionStrategy, |
| 5 | + signal, |
| 6 | + ViewChild, |
| 7 | + inject, |
| 8 | + ChangeDetectorRef, |
| 9 | +} from '@angular/core'; |
| 10 | +import {CommonModule} from '@angular/common'; |
| 11 | +import {OverlayModule} from '@angular/cdk/overlay'; |
2 | 12 | import {ComponentFixture, TestBed} from '@angular/core/testing'; |
3 | 13 | import {By} from '@angular/platform-browser'; |
4 | 14 | import {provideFakeDirectionality} from '@angular/cdk/testing/private'; |
@@ -741,6 +751,96 @@ describe('Menu Trigger Pattern', () => { |
741 | 751 | }); |
742 | 752 | }); |
743 | 753 |
|
| 754 | +describe('CDK Overlay Menu Pattern', () => { |
| 755 | + let fixture: ComponentFixture<CdkOverlayMenuExample>; |
| 756 | + |
| 757 | + const focusin = (element: Element) => { |
| 758 | + element.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); |
| 759 | + fixture.detectChanges(); |
| 760 | + }; |
| 761 | + |
| 762 | + const keydown = async (element: Element, key: string, modifierKeys: {} = {}) => { |
| 763 | + focusin(element); |
| 764 | + element.dispatchEvent( |
| 765 | + new KeyboardEvent('keydown', { |
| 766 | + key, |
| 767 | + bubbles: true, |
| 768 | + ...modifierKeys, |
| 769 | + }), |
| 770 | + ); |
| 771 | + fixture.detectChanges(); |
| 772 | + await waitForMicrotasks(); |
| 773 | + fixture.detectChanges(); |
| 774 | + }; |
| 775 | + |
| 776 | + const click = async (element: Element, eventInit?: PointerEventInit) => { |
| 777 | + focusin(element); |
| 778 | + element.dispatchEvent(new PointerEvent('click', {bubbles: true, ...eventInit})); |
| 779 | + fixture.detectChanges(); |
| 780 | + await waitForMicrotasks(); |
| 781 | + fixture.detectChanges(); |
| 782 | + }; |
| 783 | + |
| 784 | + function setupMenu() { |
| 785 | + fixture = TestBed.createComponent(CdkOverlayMenuExample); |
| 786 | + fixture.detectChanges(); |
| 787 | + } |
| 788 | + |
| 789 | + function getTrigger(): HTMLElement { |
| 790 | + return fixture.debugElement.query(By.directive(MenuTrigger)).nativeElement as HTMLElement; |
| 791 | + } |
| 792 | + |
| 793 | + function getItem(text: string): HTMLElement | null { |
| 794 | + const items = fixture.debugElement |
| 795 | + .queryAll(By.directive(MenuItem)) |
| 796 | + .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement); |
| 797 | + return items.find(item => item.textContent?.trim() === text) || null; |
| 798 | + } |
| 799 | + |
| 800 | + beforeEach(() => setupMenu()); |
| 801 | + |
| 802 | + it('should focus the first item when opened via arrow down', async () => { |
| 803 | + await keydown(getTrigger(), 'ArrowDown'); |
| 804 | + expect(document.activeElement).toBe(getItem('Apple')); |
| 805 | + }); |
| 806 | + |
| 807 | + it('should focus the first item when opened via enter', async () => { |
| 808 | + await keydown(getTrigger(), 'Enter'); |
| 809 | + expect(document.activeElement).toBe(getItem('Apple')); |
| 810 | + }); |
| 811 | + |
| 812 | + it('should focus the first item when opened via space', async () => { |
| 813 | + await keydown(getTrigger(), ' '); |
| 814 | + expect(document.activeElement).toBe(getItem('Apple')); |
| 815 | + }); |
| 816 | + |
| 817 | + it('should focus the first item when opened via click', async () => { |
| 818 | + await click(getTrigger()); |
| 819 | + expect(document.activeElement).toBe(getItem('Apple')); |
| 820 | + }); |
| 821 | + |
| 822 | + it('should focus the first item stably when opened, closed via escape, and opened again', async () => { |
| 823 | + const trigger = getTrigger(); |
| 824 | + |
| 825 | + // First open |
| 826 | + await keydown(trigger, 'Enter'); |
| 827 | + expect(document.activeElement).toBe(getItem('Apple')); |
| 828 | + |
| 829 | + // Close via escape |
| 830 | + await keydown(getItem('Apple')!, 'Escape'); |
| 831 | + expect(trigger.getAttribute('aria-expanded')).toBe('false'); |
| 832 | + expect(document.activeElement).toBe(trigger); |
| 833 | + |
| 834 | + // Explicitly clear cached menu before second open |
| 835 | + fixture.componentInstance.clearMenu(); |
| 836 | + fixture.detectChanges(); |
| 837 | + |
| 838 | + // Second open |
| 839 | + await keydown(trigger, 'Enter'); |
| 840 | + expect(document.activeElement).toBe(getItem('Apple')); |
| 841 | + }); |
| 842 | +}); |
| 843 | + |
744 | 844 | describe('Menu Bar Pattern', () => { |
745 | 845 | let fixture: ComponentFixture<MenuBarExample>; |
746 | 846 |
|
@@ -1254,3 +1354,53 @@ class MenuWithDuplicateValues {} |
1254 | 1354 | changeDetection: ChangeDetectionStrategy.Eager, |
1255 | 1355 | }) |
1256 | 1356 | class MenuItemOutsideMenu {} |
| 1357 | + |
| 1358 | +@Component({ |
| 1359 | + template: ` |
| 1360 | + <ng-container *ngTemplateOutlet="menuTemplate"></ng-container> |
| 1361 | +
|
| 1362 | + <ng-template #menuTemplate> |
| 1363 | + <button |
| 1364 | + ngMenuTrigger |
| 1365 | + #menuTrigger="ngMenuTrigger" |
| 1366 | + [menu]="myMenu" |
| 1367 | + cdkOverlayOrigin |
| 1368 | + #origin="cdkOverlayOrigin" |
| 1369 | + > |
| 1370 | + Open Menu |
| 1371 | + </button> |
| 1372 | +
|
| 1373 | + <ng-template |
| 1374 | + cdkConnectedOverlay |
| 1375 | + [cdkConnectedOverlayOrigin]="origin" |
| 1376 | + [cdkConnectedOverlayOpen]="menuTrigger.expanded()" |
| 1377 | + > |
| 1378 | + <div ngMenu #overlayMenu="ngMenu"> |
| 1379 | + <ng-template ngMenuContent> |
| 1380 | + <div ngMenuItem value="Apple" searchTerm="Apple">Apple</div> |
| 1381 | + <div ngMenuItem value="Banana" searchTerm="Banana">Banana</div> |
| 1382 | + </ng-template> |
| 1383 | + </div> |
| 1384 | + </ng-template> |
| 1385 | + </ng-template> |
| 1386 | + `, |
| 1387 | + imports: [CommonModule, OverlayModule, Menu, MenuTrigger, MenuItem, MenuContent], |
| 1388 | + changeDetection: ChangeDetectionStrategy.Eager, |
| 1389 | +}) |
| 1390 | +class CdkOverlayMenuExample { |
| 1391 | + @ViewChild('overlayMenu') _myMenu!: Menu<any>; |
| 1392 | + private _cachedMenu?: Menu<any>; |
| 1393 | + private readonly _cdr = inject(ChangeDetectorRef); |
| 1394 | + |
| 1395 | + get myMenu() { |
| 1396 | + if (this._myMenu) { |
| 1397 | + this._cachedMenu = this._myMenu; |
| 1398 | + } |
| 1399 | + return this._cachedMenu; |
| 1400 | + } |
| 1401 | + |
| 1402 | + clearMenu() { |
| 1403 | + this._cachedMenu = undefined; |
| 1404 | + this._cdr.markForCheck(); |
| 1405 | + } |
| 1406 | +} |
0 commit comments