Skip to content

Commit d8ab182

Browse files
committed
fixup! feat(aria/menu): introduce menu harness
1 parent 8f6c6bd commit d8ab182

File tree

4 files changed

+145
-97
lines changed

4 files changed

+145
-97
lines changed

goldens/aria/menu/testing/index.api.md

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,36 +7,34 @@
77
import { BaseHarnessFilters } from '@angular/cdk/testing';
88
import { ComponentHarness } from '@angular/cdk/testing';
99
import { HarnessPredicate } from '@angular/cdk/testing';
10+
import { TestElement } from '@angular/cdk/testing';
1011

1112
// @public
1213
export class MenuHarness extends ComponentHarness {
13-
// (undocumented)
14+
close(): Promise<void>;
1415
getItems(filters?: MenuItemHarnessFilters): Promise<MenuItemHarness[]>;
16+
_getTrigger(): Promise<TestElement | null>;
1517
// (undocumented)
1618
static hostSelector: string;
19+
isOpen(): Promise<boolean>;
20+
open(): Promise<void>;
1721
// (undocumented)
1822
static with(options?: MenuHarnessFilters): HarnessPredicate<MenuHarness>;
1923
}
2024

2125
// @public
2226
export interface MenuHarnessFilters extends BaseHarnessFilters {
27+
triggerText?: string | RegExp;
2328
}
2429

2530
// @public
2631
export class MenuItemHarness extends ComponentHarness {
27-
// (undocumented)
2832
click(): Promise<void>;
29-
// (undocumented)
3033
getSubmenu(): Promise<MenuHarness | null>;
31-
// (undocumented)
3234
getText(): Promise<string>;
3335
// (undocumented)
34-
hasSubmenu(): Promise<boolean>;
35-
// (undocumented)
3636
static hostSelector: string;
37-
// (undocumented)
3837
isDisabled(): Promise<boolean>;
39-
// (undocumented)
4038
isExpanded(): Promise<boolean>;
4139
// (undocumented)
4240
static with(options?: MenuItemHarnessFilters): HarnessPredicate<MenuItemHarness>;
@@ -49,25 +47,6 @@ export interface MenuItemHarnessFilters extends BaseHarnessFilters {
4947
text?: string | RegExp;
5048
}
5149

52-
// @public
53-
export class MenuTriggerHarness extends ComponentHarness {
54-
// (undocumented)
55-
click(): Promise<void>;
56-
// (undocumented)
57-
getMenu(filters?: MenuHarnessFilters): Promise<MenuHarness>;
58-
// (undocumented)
59-
getText(): Promise<string>;
60-
// (undocumented)
61-
static hostSelector: string;
62-
// (undocumented)
63-
static with(options?: MenuTriggerHarnessFilters): HarnessPredicate<MenuTriggerHarness>;
64-
}
65-
66-
// @public
67-
export interface MenuTriggerHarnessFilters extends BaseHarnessFilters {
68-
text?: string | RegExp;
69-
}
70-
7150
// (No @packageDocumentation comment for this package)
7251

7352
```

src/aria/menu/testing/menu-harness-filters.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,11 @@
88

99
import {BaseHarnessFilters} from '@angular/cdk/testing';
1010

11-
/** Filters for locating a `MenuTriggerHarness`. */
12-
export interface MenuTriggerHarnessFilters extends BaseHarnessFilters {
13-
/** Only find instances whose text matches the given value. */
14-
text?: string | RegExp;
15-
}
16-
1711
/** Filters for locating a `MenuHarness`. */
18-
export interface MenuHarnessFilters extends BaseHarnessFilters {}
12+
export interface MenuHarnessFilters extends BaseHarnessFilters {
13+
/** Only find instances whose trigger text matches the given value. */
14+
triggerText?: string | RegExp;
15+
}
1916

2017
/** Filters for locating a `MenuItemHarness`. */
2118
export interface MenuItemHarnessFilters extends BaseHarnessFilters {

src/aria/menu/testing/menu-harness.spec.ts

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,88 +10,142 @@ import {Component} from '@angular/core';
1010
import {TestBed} from '@angular/core/testing';
1111
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
1212
import {Menu} from '../menu';
13+
import {MenuContent} from '../menu-content';
1314
import {MenuItem} from '../menu-item';
1415
import {MenuTrigger} from '../menu-trigger';
15-
import {MenuItemHarness, MenuTriggerHarness} from './menu-harness';
16+
import {MenuBar} from '../menu-bar';
17+
import {MenuItemHarness, MenuHarness} from './menu-harness';
1618

1719
describe('Aria Menu Harness', () => {
1820
let fixture: any;
1921
let loader: any;
2022

2123
beforeEach(() => {
2224
TestBed.configureTestingModule({
23-
imports: [Menu, MenuItem, MenuTrigger, MenuTestApp],
25+
imports: [Menu, MenuItem, MenuTrigger, MenuBar, MenuContent, MenuTestApp],
2426
});
2527

2628
fixture = TestBed.createComponent(MenuTestApp);
2729
fixture.detectChanges();
2830
loader = TestbedHarnessEnvironment.loader(fixture);
2931
});
3032

31-
it('should locate the menu trigger harness', async () => {
32-
const trigger = await loader.getHarness(MenuTriggerHarness.with({text: 'Open Menu'}));
33-
expect(trigger).toBeTruthy();
34-
expect(await trigger.getText()).toBe('Open Menu');
33+
it('should locate the menu harness', async () => {
34+
const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'}));
35+
expect(menu).toBeTruthy();
36+
});
37+
38+
it('should verify that the menu is initially closed', async () => {
39+
const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'}));
40+
expect(await menu.isOpen()).toBe(false);
3541
});
3642

37-
it('should open the menu and locate items', async () => {
38-
const trigger = await loader.getHarness(MenuTriggerHarness);
39-
await trigger.click();
43+
it('should open the menu', async () => {
44+
const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'}));
45+
await menu.open();
4046
fixture.detectChanges();
4147

42-
const menu = await trigger.getMenu();
43-
expect(menu).toBeTruthy();
48+
expect(await menu.isOpen()).toBe(true);
49+
});
50+
51+
it('should close the menu', async () => {
52+
const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'}));
53+
await menu.open();
54+
fixture.detectChanges();
55+
56+
await menu.close();
57+
fixture.detectChanges();
58+
expect(await menu.isOpen()).toBe(false);
59+
});
60+
61+
it('should get all items inside an open menu', async () => {
62+
const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'}));
63+
await menu.open();
64+
fixture.detectChanges();
4465

4566
const items = await menu.getItems();
46-
expect(items.length).toBe(4);
67+
expect(items.length).toBe(3);
4768
expect(await items[0].getText()).toBe('Item 1');
48-
expect(await items[1].getText()).toBe('Item 2');
49-
expect(await items[2].getText()).toBe('Submenu');
50-
expect(await items[3].getText()).toBe('Nested Item');
5169
});
5270

53-
it('should filter menu items by state', async () => {
54-
const trigger = await loader.getHarness(MenuTriggerHarness);
55-
await trigger.click();
71+
it('should filter menu items by their disabled state', async () => {
72+
const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'}));
73+
await menu.open();
5674
fixture.detectChanges();
5775

5876
const disabledItems = await loader.getAllHarnesses(MenuItemHarness.with({disabled: true}));
5977
expect(disabledItems.length).toBe(1);
6078
expect(await disabledItems[0].getText()).toBe('Item 2');
6179
});
6280

63-
it('should locate and open a nested submenu', async () => {
64-
const mainTrigger = await loader.getHarness(MenuTriggerHarness.with({text: 'Open Menu'}));
65-
await mainTrigger.click();
81+
it('should locate and interact with nested submenus', async () => {
82+
const main = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'}));
83+
await main.open();
6684
fixture.detectChanges();
6785

6886
const subItem = await loader.getHarness(MenuItemHarness.with({text: 'Submenu'}));
69-
expect(await subItem.hasSubmenu()).toBe(true);
7087
await subItem.click();
7188
fixture.detectChanges();
7289

7390
const submenu = await subItem.getSubmenu();
7491
expect(submenu).toBeTruthy();
92+
expect(await submenu!.isOpen()).toBe(true);
93+
});
94+
95+
it('should read items within a nested submenu', async () => {
96+
const main = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'}));
97+
await main.open();
98+
fixture.detectChanges();
99+
100+
const subItem = await loader.getHarness(MenuItemHarness.with({text: 'Submenu'}));
101+
await subItem.click();
102+
fixture.detectChanges();
103+
104+
const submenu = await subItem.getSubmenu();
75105
const subItems = await submenu!.getItems();
76106
expect(subItems.length).toBe(1);
77107
expect(await subItems[0].getText()).toBe('Nested Item');
78108
});
109+
110+
it('should confirm persistent horizontal menu bars are always open', async () => {
111+
const menubar = await loader.getHarness(MenuHarness.with({selector: '[ngMenuBar]'}));
112+
expect(menubar).toBeTruthy();
113+
expect(await menubar.isOpen()).toBe(true);
114+
});
115+
116+
it('should read items from a persistent horizontal menu bar', async () => {
117+
const menubar = await loader.getHarness(MenuHarness.with({selector: '[ngMenuBar]'}));
118+
const items = await menubar.getItems();
119+
120+
expect(items.length).toBe(2);
121+
expect(await items[0].getText()).toBe('File');
122+
expect(await items[1].getText()).toBe('Edit');
123+
});
79124
});
80125

81126
@Component({
82127
template: `
83128
<button ngMenuTrigger [menu]="testMenu">Open Menu</button>
84129
85130
<div ngMenu #testMenu="ngMenu">
86-
<div ngMenuItem value="Item 1">Item 1</div>
87-
<div ngMenuItem value="Item 2" [disabled]="true">Item 2</div>
88-
<div ngMenuItem value="Submenu" [submenu]="nestedMenu">Submenu</div>
131+
<ng-template ngMenuContent>
132+
<div ngMenuItem value="Item 1">Item 1</div>
133+
<div ngMenuItem value="Item 2" [disabled]="true">Item 2</div>
134+
<div ngMenuItem value="Submenu" [submenu]="nestedMenu">Submenu</div>
135+
</ng-template>
136+
</div>
89137
90-
<div ngMenu #nestedMenu="ngMenu">
138+
<div ngMenu #nestedMenu="ngMenu">
139+
<ng-template ngMenuContent>
91140
<div ngMenuItem value="Nested Item">Nested Item</div>
92-
</div>
141+
</ng-template>
142+
</div>
143+
144+
<div ngMenuBar>
145+
<div ngMenuItem value="File">File</div>
146+
<div ngMenuItem value="Edit">Edit</div>
93147
</div>
94148
`,
95-
imports: [Menu, MenuItem, MenuTrigger],
149+
imports: [Menu, MenuItem, MenuTrigger, MenuBar, MenuContent],
96150
})
97151
class MenuTestApp {}

src/aria/menu/testing/menu-harness.ts

Lines changed: 53 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,8 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing';
10-
import {
11-
MenuHarnessFilters,
12-
MenuItemHarnessFilters,
13-
MenuTriggerHarnessFilters,
14-
} from './menu-harness-filters';
9+
import {ComponentHarness, HarnessPredicate, TestElement} from '@angular/cdk/testing';
10+
import {MenuHarnessFilters, MenuItemHarnessFilters} from './menu-harness-filters';
1511

1612
/** Harness for interacting with a standard ngMenuItem in tests. */
1713
export class MenuItemHarness extends ComponentHarness {
@@ -34,73 +30,95 @@ export class MenuItemHarness extends ComponentHarness {
3430
);
3531
}
3632

33+
/** Gets the text content of the menu item. */
3734
async getText(): Promise<string> {
3835
return (await this.host()).text();
3936
}
4037

38+
/** Whether the menu item is disabled. */
4139
async isDisabled(): Promise<boolean> {
4240
const host = await this.host();
4341
return (await host.getAttribute('aria-disabled')) === 'true';
4442
}
4543

44+
/** Whether the menu item is expanded (contains an open submenu). */
4645
async isExpanded(): Promise<boolean> {
4746
const host = await this.host();
4847
return (await host.getAttribute('aria-expanded')) === 'true';
4948
}
5049

50+
/** Clicks the menu item to trigger its action or toggle its submenu. */
5151
async click(): Promise<void> {
5252
return (await this.host()).click();
5353
}
5454

55-
async hasSubmenu(): Promise<boolean> {
56-
return (await (await this.host()).getAttribute('aria-haspopup')) !== null;
57-
}
58-
55+
/** Resolves the nested submenu panel associated with this menu item, if any exists. */
5956
async getSubmenu(): Promise<MenuHarness | null> {
60-
if (await this.hasSubmenu()) {
61-
const controlsId = await (await this.host()).getAttribute('aria-controls');
57+
const controlsId = await (await this.host()).getAttribute('aria-controls');
58+
if (controlsId) {
6259
return this.documentRootLocatorFactory().locatorFor(
63-
MenuHarness.with({selector: controlsId ? `#${controlsId}` : undefined}),
60+
MenuHarness.with({selector: `#${controlsId}`}),
6461
)();
6562
}
6663
return null;
6764
}
6865
}
6966

70-
/** Harness for interacting with a standard ngMenu in tests. */
67+
/** Harness for interacting with a standard ngMenu or ngMenuBar in tests. */
7168
export class MenuHarness extends ComponentHarness {
72-
static hostSelector = '[ngMenu]';
69+
static hostSelector = '[ngMenu], [ngMenuBar]';
7370

7471
static with(options: MenuHarnessFilters = {}): HarnessPredicate<MenuHarness> {
75-
return new HarnessPredicate(MenuHarness, options);
72+
return new HarnessPredicate(MenuHarness, options).addOption(
73+
'triggerText',
74+
options.triggerText,
75+
async (harness, text) => {
76+
const trigger = await harness._getTrigger();
77+
if (!trigger) return false;
78+
return HarnessPredicate.stringMatches(await trigger.text(), text);
79+
},
80+
);
7681
}
7782

78-
async getItems(filters: MenuItemHarnessFilters = {}): Promise<MenuItemHarness[]> {
79-
return this.locatorForAll(MenuItemHarness.with(filters))();
83+
/** Resolves the trigger associated with this menu container via aria-controls inversion. */
84+
async _getTrigger(): Promise<TestElement | null> {
85+
const id = await (await this.host()).getAttribute('id');
86+
if (!id) return null;
87+
return this.documentRootLocatorFactory().locatorForOptional(`[aria-controls="${id}"]`)();
8088
}
81-
}
82-
83-
/** Harness for interacting with a standard ngMenuTrigger in tests. */
84-
export class MenuTriggerHarness extends ComponentHarness {
85-
static hostSelector = '[ngMenuTrigger]';
8689

87-
static with(options: MenuTriggerHarnessFilters = {}): HarnessPredicate<MenuTriggerHarness> {
88-
return new HarnessPredicate(MenuTriggerHarness, options).addOption(
89-
'text',
90-
options.text,
91-
(harness, text) => HarnessPredicate.stringMatches(harness.getText(), text),
92-
);
90+
/** Checks whether the menu container is visible. */
91+
async isOpen(): Promise<boolean> {
92+
const host = await this.host();
93+
// Menu bars are always visible persistently.
94+
if (await host.matchesSelector('[ngMenuBar]')) {
95+
return true;
96+
}
97+
return (await host.getAttribute('data-visible')) === 'true';
9398
}
9499

95-
async getText(): Promise<string> {
96-
return (await this.host()).text();
100+
/** Opens the menu if it is currently closed. */
101+
async open(): Promise<void> {
102+
if (!(await this.isOpen())) {
103+
const trigger = await this._getTrigger();
104+
if (trigger) {
105+
await trigger.click();
106+
}
107+
}
97108
}
98109

99-
async click(): Promise<void> {
100-
return (await this.host()).click();
110+
/** Closes the menu if it is currently open. */
111+
async close(): Promise<void> {
112+
if (await this.isOpen()) {
113+
const trigger = await this._getTrigger();
114+
if (trigger) {
115+
await trigger.click();
116+
}
117+
}
101118
}
102119

103-
async getMenu(filters: MenuHarnessFilters = {}): Promise<MenuHarness> {
104-
return this.documentRootLocatorFactory().locatorFor(MenuHarness.with(filters))();
120+
/** Queries all menu items inside this menu container. */
121+
async getItems(filters: MenuItemHarnessFilters = {}): Promise<MenuItemHarness[]> {
122+
return this.locatorForAll(MenuItemHarness.with(filters))();
105123
}
106124
}

0 commit comments

Comments
 (0)