diff --git a/goldens/aria/menu/testing/index.api.md b/goldens/aria/menu/testing/index.api.md new file mode 100644 index 000000000000..a9a44f0ecec2 --- /dev/null +++ b/goldens/aria/menu/testing/index.api.md @@ -0,0 +1,52 @@ +## API Report File for "@angular/aria_menu_testing" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BaseHarnessFilters } from '@angular/cdk/testing'; +import { ComponentHarness } from '@angular/cdk/testing'; +import { HarnessPredicate } from '@angular/cdk/testing'; +import { TestElement } from '@angular/cdk/testing'; + +// @public +export class MenuHarness extends ComponentHarness { + close(): Promise; + getItems(filters?: MenuItemHarnessFilters): Promise; + _getTrigger(): Promise; + // (undocumented) + static hostSelector: string; + isOpen(): Promise; + open(): Promise; + // (undocumented) + static with(options?: MenuHarnessFilters): HarnessPredicate; +} + +// @public +export interface MenuHarnessFilters extends BaseHarnessFilters { + triggerText?: string | RegExp; +} + +// @public +export class MenuItemHarness extends ComponentHarness { + click(): Promise; + getSubmenu(): Promise; + getText(): Promise; + // (undocumented) + static hostSelector: string; + isDisabled(): Promise; + isExpanded(): Promise; + // (undocumented) + static with(options?: MenuItemHarnessFilters): HarnessPredicate; +} + +// @public +export interface MenuItemHarnessFilters extends BaseHarnessFilters { + disabled?: boolean; + expanded?: boolean; + text?: string | RegExp; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/src/aria/config.bzl b/src/aria/config.bzl index 10069d32848a..fb6913d0a831 100644 --- a/src/aria/config.bzl +++ b/src/aria/config.bzl @@ -5,6 +5,7 @@ ARIA_ENTRYPOINTS = [ "grid", "listbox", "menu", + "menu/testing", "tabs", "toolbar", "tree", diff --git a/src/aria/menu/testing/BUILD.bazel b/src/aria/menu/testing/BUILD.bazel new file mode 100644 index 000000000000..c20fd6664983 --- /dev/null +++ b/src/aria/menu/testing/BUILD.bazel @@ -0,0 +1,40 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "testing", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/cdk/testing", + ], +) + +filegroup( + name = "source-files", + srcs = glob(["**/*.ts"]), +) + +ng_project( + name = "unit_tests_lib", + testonly = True, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":testing", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//src/aria/menu", + "//src/cdk/testing", + "//src/cdk/testing/testbed", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [ + ":unit_tests_lib", + ], +) diff --git a/src/aria/menu/testing/index.ts b/src/aria/menu/testing/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/aria/menu/testing/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './public-api'; diff --git a/src/aria/menu/testing/menu-harness-filters.ts b/src/aria/menu/testing/menu-harness-filters.ts new file mode 100644 index 000000000000..7700e9f03480 --- /dev/null +++ b/src/aria/menu/testing/menu-harness-filters.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {BaseHarnessFilters} from '@angular/cdk/testing'; + +/** Filters for locating a `MenuHarness`. */ +export interface MenuHarnessFilters extends BaseHarnessFilters { + /** Only find instances whose trigger text matches the given value. */ + triggerText?: string | RegExp; +} + +/** Filters for locating a `MenuItemHarness`. */ +export interface MenuItemHarnessFilters extends BaseHarnessFilters { + /** Only find instances whose text matches the given value. */ + text?: string | RegExp; + /** Only find instances whose disabled state matches the given value. */ + disabled?: boolean; + /** Only find instances whose expanded state matches the given value. */ + expanded?: boolean; +} diff --git a/src/aria/menu/testing/menu-harness.spec.ts b/src/aria/menu/testing/menu-harness.spec.ts new file mode 100644 index 000000000000..8d4dbbe5b7ad --- /dev/null +++ b/src/aria/menu/testing/menu-harness.spec.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {HarnessLoader} from '@angular/cdk/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +import {Menu} from '../menu'; +import {MenuContent} from '../menu-content'; +import {MenuItem} from '../menu-item'; +import {MenuTrigger} from '../menu-trigger'; +import {MenuBar} from '../menu-bar'; +import {MenuItemHarness, MenuHarness} from './menu-harness'; + +describe('Aria Menu Harness', () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(() => { + fixture = TestBed.createComponent(MenuTestApp); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.loader(fixture); + }); + + it('should locate the menu harness', async () => { + await expectAsync( + loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})), + ).toBeResolved(); + }); + + it('should verify that the menu is initially closed', async () => { + const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + expect(await menu.isOpen()).toBe(false); + }); + + it('should open the menu', async () => { + const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + await menu.open(); + fixture.detectChanges(); + + expect(await menu.isOpen()).toBe(true); + }); + + it('should close the menu', async () => { + const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + await menu.open(); + fixture.detectChanges(); + + await menu.close(); + fixture.detectChanges(); + expect(await menu.isOpen()).toBe(false); + }); + + it('should get all items inside an open menu', async () => { + const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + await menu.open(); + fixture.detectChanges(); + + const items = await menu.getItems(); + expect(items.length).toBe(3); + expect(await items[0].getText()).toBe('Item 1'); + }); + + it('should filter menu items by their disabled state', async () => { + const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + await menu.open(); + fixture.detectChanges(); + + const disabledItems = await loader.getAllHarnesses(MenuItemHarness.with({disabled: true})); + expect(disabledItems.length).toBe(1); + expect(await disabledItems[0].getText()).toBe('Item 2'); + }); + + it('should locate and interact with nested submenus', async () => { + const main = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + await main.open(); + fixture.detectChanges(); + + const subItem = await loader.getHarness(MenuItemHarness.with({text: 'Submenu'})); + await subItem.click(); + fixture.detectChanges(); + + const submenu = await subItem.getSubmenu(); + expect(await submenu!.isOpen()).toBe(true); + }); + + it('should read items within a nested submenu', async () => { + const main = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + await main.open(); + fixture.detectChanges(); + + const subItem = await loader.getHarness(MenuItemHarness.with({text: 'Submenu'})); + await subItem.click(); + fixture.detectChanges(); + + const submenu = await subItem.getSubmenu(); + const subItems = await submenu!.getItems(); + expect(subItems.length).toBe(1); + expect(await subItems[0].getText()).toBe('Nested Item'); + }); + + it('should confirm persistent horizontal menu bars are always open', async () => { + const menubar = await loader.getHarness(MenuHarness.with({selector: '[ngMenuBar]'})); + expect(await menubar.isOpen()).toBe(true); + }); + + it('should read items from a persistent horizontal menu bar', async () => { + const menubar = await loader.getHarness(MenuHarness.with({selector: '[ngMenuBar]'})); + const items = await menubar.getItems(); + + expect(items.length).toBe(2); + expect(await items[0].getText()).toBe('File'); + expect(await items[1].getText()).toBe('Edit'); + }); +}); + +@Component({ + template: ` + + +
+ +
Item 1
+
Item 2
+
Submenu
+
+
+ +
+ +
Nested Item
+
+
+ +
+
File
+
Edit
+
+ `, + imports: [Menu, MenuItem, MenuTrigger, MenuBar, MenuContent], +}) +class MenuTestApp {} diff --git a/src/aria/menu/testing/menu-harness.ts b/src/aria/menu/testing/menu-harness.ts new file mode 100644 index 000000000000..8980d54928be --- /dev/null +++ b/src/aria/menu/testing/menu-harness.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {ComponentHarness, HarnessPredicate, TestElement} from '@angular/cdk/testing'; +import {MenuHarnessFilters, MenuItemHarnessFilters} from './menu-harness-filters'; + +/** Harness for interacting with a standard ngMenuItem in tests. */ +export class MenuItemHarness extends ComponentHarness { + static hostSelector = '[ngMenuItem]'; + + static with(options: MenuItemHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(MenuItemHarness, options) + .addOption('text', options.text, (harness, text) => + HarnessPredicate.stringMatches(harness.getText(), text), + ) + .addOption( + 'disabled', + options.disabled, + async (harness, disabled) => (await harness.isDisabled()) === disabled, + ) + .addOption( + 'expanded', + options.expanded, + async (harness, expanded) => (await harness.isExpanded()) === expanded, + ); + } + + /** Gets the text content of the menu item. */ + async getText(): Promise { + return (await this.host()).text(); + } + + /** Whether the menu item is disabled. */ + async isDisabled(): Promise { + const host = await this.host(); + return (await host.getAttribute('aria-disabled')) === 'true'; + } + + /** Whether the menu item is expanded (contains an open submenu). */ + async isExpanded(): Promise { + const host = await this.host(); + return (await host.getAttribute('aria-expanded')) === 'true'; + } + + /** Clicks the menu item to trigger its action or toggle its submenu. */ + async click(): Promise { + return (await this.host()).click(); + } + + /** Resolves the nested submenu panel associated with this menu item, if any exists. */ + async getSubmenu(): Promise { + const controlsId = await (await this.host()).getAttribute('aria-controls'); + if (controlsId) { + return this.documentRootLocatorFactory().locatorFor( + MenuHarness.with({selector: `#${controlsId}`}), + )(); + } + return null; + } +} + +/** Harness for interacting with a standard ngMenu or ngMenuBar in tests. */ +export class MenuHarness extends ComponentHarness { + static hostSelector = '[ngMenu], [ngMenuBar]'; + + static with(options: MenuHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(MenuHarness, options).addOption( + 'triggerText', + options.triggerText, + async (harness, text) => { + const trigger = await harness._getTrigger(); + if (!trigger) return false; + return HarnessPredicate.stringMatches(await trigger.text(), text); + }, + ); + } + + /** Resolves the trigger associated with this menu container via aria-controls inversion. */ + async _getTrigger(): Promise { + const id = await (await this.host()).getAttribute('id'); + if (!id) return null; + return this.documentRootLocatorFactory().locatorForOptional(`[aria-controls="${id}"]`)(); + } + + /** Checks whether the menu container is visible. */ + async isOpen(): Promise { + const host = await this.host(); + // Menu bars are always visible persistently. + if (await host.matchesSelector('[ngMenuBar]')) { + return true; + } + return (await host.getAttribute('data-visible')) === 'true'; + } + + /** Opens the menu if it is currently closed. */ + async open(): Promise { + if (!(await this.isOpen())) { + const trigger = await this._getTrigger(); + if (trigger) { + await trigger.click(); + } + } + } + + /** Closes the menu if it is currently open. */ + async close(): Promise { + if (await this.isOpen()) { + const trigger = await this._getTrigger(); + if (trigger) { + await trigger.click(); + } + } + } + + /** Queries all menu items inside this menu container. */ + async getItems(filters: MenuItemHarnessFilters = {}): Promise { + return this.locatorForAll(MenuItemHarness.with(filters))(); + } +} diff --git a/src/aria/menu/testing/public-api.ts b/src/aria/menu/testing/public-api.ts new file mode 100644 index 000000000000..2a408c036830 --- /dev/null +++ b/src/aria/menu/testing/public-api.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './menu-harness'; +export * from './menu-harness-filters';