diff --git a/goldens/aria/simple-combobox/testing/index.api.md b/goldens/aria/simple-combobox/testing/index.api.md new file mode 100644 index 000000000000..a9b87fd9d748 --- /dev/null +++ b/goldens/aria/simple-combobox/testing/index.api.md @@ -0,0 +1,47 @@ +## API Report File for "@angular/aria_simple-combobox_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 { ComponentHarnessConstructor } from '@angular/cdk/testing'; +import { ContentContainerComponentHarness } from '@angular/cdk/testing'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { HarnessPredicate } from '@angular/cdk/testing'; + +// @public +export class ComboboxHarness extends ContentContainerComponentHarness { + blur(): Promise; + close(): Promise; + focus(): Promise; + getPlaceholder(): Promise; + getPopupLoader(): Promise; + getPopupWidget(type: ComponentHarnessConstructor & { + with: (options?: { + selector?: string; + }) => HarnessPredicate; + }): Promise; + protected getRootHarnessLoader(): Promise; + getValue(): Promise; + // (undocumented) + static hostSelector: string; + isDisabled(): Promise; + isFocused(): Promise; + isOpen(): Promise; + open(): Promise; + setValue(value: string): Promise; + static with(options?: ComboboxHarnessFilters): HarnessPredicate; +} + +// @public +export interface ComboboxHarnessFilters extends BaseHarnessFilters { + disabled?: boolean; + placeholder?: string | RegExp; + value?: string | RegExp; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/src/aria/config.bzl b/src/aria/config.bzl index 0e0578591b51..480c93fad6d5 100644 --- a/src/aria/config.bzl +++ b/src/aria/config.bzl @@ -10,6 +10,7 @@ ARIA_ENTRYPOINTS = [ "menu", "menu/testing", "simple-combobox", + "simple-combobox/testing", "tabs", "tabs/testing", "toolbar", diff --git a/src/aria/simple-combobox/testing/BUILD.bazel b/src/aria/simple-combobox/testing/BUILD.bazel new file mode 100644 index 000000000000..38540ebe2f98 --- /dev/null +++ b/src/aria/simple-combobox/testing/BUILD.bazel @@ -0,0 +1,43 @@ +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 = [ + "//:node_modules/@angular/core", + "//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", + "//src/aria/listbox", + "//src/aria/listbox/testing", + "//src/aria/simple-combobox", + "//src/cdk/overlay", + "//src/cdk/testing", + "//src/cdk/testing/testbed", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [ + ":unit_tests_lib", + ], +) diff --git a/src/aria/simple-combobox/testing/combobox-harness-filters.ts b/src/aria/simple-combobox/testing/combobox-harness-filters.ts new file mode 100644 index 000000000000..22013f276d3d --- /dev/null +++ b/src/aria/simple-combobox/testing/combobox-harness-filters.ts @@ -0,0 +1,19 @@ +/** + * @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'; + +/** A set of criteria that can be used to filter a list of `ComboboxHarness` instances. */ +export interface ComboboxHarnessFilters extends BaseHarnessFilters { + /** Only find instances whose placeholder matches the given value. */ + placeholder?: string | RegExp; + /** Only find instances whose value matches the given value. */ + value?: string | RegExp; + /** Only find instances with the given disabled state. */ + disabled?: boolean; +} diff --git a/src/aria/simple-combobox/testing/combobox-harness.spec.ts b/src/aria/simple-combobox/testing/combobox-harness.spec.ts new file mode 100644 index 000000000000..8b4ae5351d1a --- /dev/null +++ b/src/aria/simple-combobox/testing/combobox-harness.spec.ts @@ -0,0 +1,200 @@ +/** + * @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, signal} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {HarnessLoader} from '@angular/cdk/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '../index'; +import {Listbox, Option} from '../../listbox'; +import {ListboxHarness, ListboxOptionHarness} from '../../listbox/testing/listbox-harness'; +import {ComboboxHarness} from './combobox-harness'; +import {OverlayModule} from '@angular/cdk/overlay'; + +describe('ComboboxHarness', () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + + function setupTest(component: any) { + fixture = TestBed.createComponent(component); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.loader(fixture); + } + + describe('Basic usage', () => { + beforeEach(() => setupTest(ComboboxTestApp)); + + it('should load combobox harness', async () => { + await expectAsync(loader.getHarness(ComboboxHarness)).toBeResolved(); + }); + + it('should get and set values', async () => { + const combobox = await loader.getHarness(ComboboxHarness); + await combobox.setValue('California'); + fixture.detectChanges(); + + expect(await combobox.getValue()).toBe('California'); + }); + + it('should correctly report disabled state', async () => { + const combobox = await loader.getHarness(ComboboxHarness); + expect(await combobox.isDisabled()).toBeFalse(); + + fixture.componentInstance.disabled.set(true); + fixture.detectChanges(); + + expect(await combobox.isDisabled()).toBeTrue(); + }); + + it('should open and close the popup', async () => { + const combobox = await loader.getHarness(ComboboxHarness); + expect(await combobox.isOpen()).toBeFalse(); + + await combobox.open(); + fixture.detectChanges(); + expect(await combobox.isOpen()).toBeTrue(); + + await combobox.close(); + fixture.detectChanges(); + expect(await combobox.isOpen()).toBeFalse(); + }); + + it('should allow loading nested harnesses within the popup content via unified container API', async () => { + const combobox = await loader.getHarness(ComboboxHarness); + await combobox.open(); + fixture.detectChanges(); + + // We access the main widget harness using getPopupWidget. + const listbox = await combobox.getPopupWidget(ListboxHarness); + const options = await listbox.getOptions(); + expect(options.length).toBe(3); + }); + + it('should fail to resolve nested items when closed', async () => { + const combobox = await loader.getHarness(ComboboxHarness); + // Popup isn't open yet, so getPopupWidget should fail. + await expectAsync(combobox.getPopupWidget(ListboxHarness)).toBeRejectedWithError( + /Cannot retrieve popup content because the combobox is closed/, + ); + }); + + it('should support getting explicit popup loader for descendant matching', async () => { + const combobox = await loader.getHarness(ComboboxHarness); + await combobox.open(); + fixture.detectChanges(); + + const popupLoader = await combobox.getPopupLoader(); + // We are testing that the loader works for finding actual children (Options). + const option = await popupLoader.getHarness(ListboxOptionHarness); + expect(option).toBeDefined(); + }); + + it('should support focusing and blurring', async () => { + const combobox = await loader.getHarness(ComboboxHarness); + await combobox.focus(); + expect(await combobox.isFocused()).toBeTrue(); + + await combobox.blur(); + expect(await combobox.isFocused()).toBeFalse(); + }); + }); + + describe('Overlay and Popover integrations', () => { + it('should find and resolve harnesses nested inside standard CdkOverlay', async () => { + setupTest(ComboboxOverlayTestApp); + const combobox = await loader.getHarness(ComboboxHarness); + + await combobox.open(); + fixture.detectChanges(); + + // Should find listbox inside the dynamically attached cdk overlay root container + const listbox = await combobox.getPopupWidget(ListboxHarness); + expect(listbox).toBeDefined(); + expect((await listbox.getOptions()).length).toBe(2); + }); + + it('should resolve nested harnesses when using Native Popover API', async () => { + setupTest(ComboboxNativePopoverTestApp); + const combobox = await loader.getHarness(ComboboxHarness); + + await combobox.open(); + fixture.detectChanges(); + + const listbox = await combobox.getPopupWidget(ListboxHarness); + expect(listbox).toBeDefined(); + expect((await listbox.getOptions()).length).toBe(2); + }); + }); +}); + +@Component({ + template: ` +
+ + + +
+
California
+
Washington
+
Oregon
+
+
+
+ `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option], +}) +class ComboboxTestApp { + disabled = signal(false); +} + +@Component({ + template: ` +
+ +
+ + + +
+
A
+
B
+
+
+
+ `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +class ComboboxOverlayTestApp { + popupExpanded = signal(false); +} + +@Component({ + template: ` +
+ +
+ + + +
+
A
+
B
+
+
+
+ `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +class ComboboxNativePopoverTestApp { + popupExpanded = signal(false); +} diff --git a/src/aria/simple-combobox/testing/combobox-harness.ts b/src/aria/simple-combobox/testing/combobox-harness.ts new file mode 100644 index 000000000000..c13a53b69634 --- /dev/null +++ b/src/aria/simple-combobox/testing/combobox-harness.ts @@ -0,0 +1,151 @@ +/** + * @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, + ComponentHarnessConstructor, + ContentContainerComponentHarness, + HarnessLoader, + HarnessPredicate, + TestKey, +} from '@angular/cdk/testing'; +import {ComboboxHarnessFilters} from './combobox-harness-filters'; + +/** Harness for interacting with a standard `ngCombobox` input element in tests. */ +export class ComboboxHarness extends ContentContainerComponentHarness { + static hostSelector = '[ngCombobox]'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a combobox with specific attributes. + * @param options Options for filtering which combobox instances are considered a match. + * @return a `HarnessPredicate` configured with the given options. + */ + static with(options: ComboboxHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(ComboboxHarness, options) + .addOption('placeholder', options.placeholder, async (harness, placeholder) => + HarnessPredicate.stringMatches(await harness.getPlaceholder(), placeholder), + ) + .addOption('value', options.value, async (harness, value) => + HarnessPredicate.stringMatches(await harness.getValue(), value), + ) + .addOption( + 'disabled', + options.disabled, + async (harness, disabled) => (await harness.isDisabled()) === disabled, + ); + } + + /** + * Gets the component harness for the active widget contained inside the popup. + * Use this when you need to access the harness of the widget itself (e.g., `ListboxHarness`), + * rather than querying items within it. + * @param type The harness type to locate. Must implement standard static `.with()` method. + */ + async getPopupWidget( + type: ComponentHarnessConstructor & { + with: (options?: {selector?: string}) => HarnessPredicate; + }, + ): Promise { + const host = await this.host(); + const controlsId = await host.getAttribute('aria-controls'); + if (!controlsId) { + throw new Error( + 'Cannot retrieve popup content because the combobox is closed or not associated with a popup controls ID.', + ); + } + return this.documentRootLocatorFactory().locatorFor(type.with({selector: `#${controlsId}`}))(); + } + + /** + * Gets a harness loader scoped to the content inside the popup container. + * Note that lookups performed by this loader will only find descendants of the popup container. + */ + async getPopupLoader(): Promise { + return this.getRootHarnessLoader(); + } + + /** Overrides root loader to automatically resolve queries nested inside the associated popup. */ + protected override async getRootHarnessLoader(): Promise { + const host = await this.host(); + const controlsId = await host.getAttribute('aria-controls'); + if (!controlsId) { + throw new Error( + 'Cannot retrieve popup content because the combobox is closed or not associated with a popup controls ID.', + ); + } + const documentRoot = await this.documentRootLocatorFactory().rootHarnessLoader(); + // Locate the widget by ID, which was assigned by ngComboboxWidget and linked via aria-controls. + return documentRoot.getChildLoader(`#${controlsId}`); + } + + /** Whether the combobox is expanded (popup is open). */ + async isOpen(): Promise { + const host = await this.host(); + return (await host.getAttribute('aria-expanded')) === 'true'; + } + + /** Whether the combobox is disabled. */ + async isDisabled(): Promise { + const host = await this.host(); + return (await host.getAttribute('aria-disabled')) === 'true'; + } + + /** Gets the current value string of the combobox input. */ + async getValue(): Promise { + const host = await this.host(); + return host.getProperty('value'); + } + + /** Sets the value of the combobox input. */ + async setValue(value: string): Promise { + const host = await this.host(); + await host.clear(); + if (value) { + await host.sendKeys(value); + } + } + + /** Gets the placeholder text of the combobox. */ + async getPlaceholder(): Promise { + const host = await this.host(); + return host.getAttribute('placeholder'); + } + + /** Opens the combobox popup if it is currently closed. */ + async open(): Promise { + if (!(await this.isOpen())) { + const host = await this.host(); + await host.focus(); + await host.sendKeys(TestKey.DOWN_ARROW); + } + } + + /** Closes the combobox popup if it is currently open. */ + async close(): Promise { + if (await this.isOpen()) { + const host = await this.host(); + await host.focus(); + await host.sendKeys(TestKey.ESCAPE); + } + } + + /** Focuses the combobox. */ + async focus(): Promise { + return (await this.host()).focus(); + } + + /** Blurs the combobox. */ + async blur(): Promise { + return (await this.host()).blur(); + } + + /** Whether the combobox has focus. */ + async isFocused(): Promise { + return (await this.host()).isFocused(); + } +} diff --git a/src/aria/simple-combobox/testing/index.ts b/src/aria/simple-combobox/testing/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/aria/simple-combobox/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/simple-combobox/testing/public-api.ts b/src/aria/simple-combobox/testing/public-api.ts new file mode 100644 index 000000000000..46b2e5953146 --- /dev/null +++ b/src/aria/simple-combobox/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 './combobox-harness'; +export * from './combobox-harness-filters';