From 818beee947acdc6db3234f1f36f152a8dce8511a Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Wed, 29 Apr 2026 15:53:22 -0700 Subject: [PATCH 1/7] test(aria/accordion): generate additional tests for Accordion directives and patterns --- src/aria/accordion/accordion.spec.ts | 22 +++++----- src/aria/private/accordion/accordion.spec.ts | 44 ++++++++++++++++++++ 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/aria/accordion/accordion.spec.ts b/src/aria/accordion/accordion.spec.ts index 4467bbf28be0..e34afcdef0b9 100644 --- a/src/aria/accordion/accordion.spec.ts +++ b/src/aria/accordion/accordion.spec.ts @@ -293,23 +293,23 @@ describe('AccordionGroup', () => { }); it('should update collection order when items are shuffled', async () => { - const groupDebugElement = fixture.debugElement.query(By.directive(AccordionGroup)); - const groupDirective = groupDebugElement.injector.get(AccordionGroup); - - let orderedItems = groupDirective._collection.orderedItems(); - expect(orderedItems.length).toBe(3); - expect(orderedItems[0].element.textContent?.trim()).toBe('Item 1 Header'); - expect(orderedItems[2].element.textContent?.trim()).toBe('Item 3 Header'); + // Verify initial DOM order + expect(triggerElements.length).toBe(3); + expect(triggerElements[0].textContent?.trim()).toBe('Item 1 Header'); + expect(triggerElements[2].textContent?.trim()).toBe('Item 3 Header'); + // Shuffle (reverse) data const items = testComponent.items().reverse(); testComponent.items.set([...items]); fixture.detectChanges(); await waitForMicrotasks(); - orderedItems = groupDirective._collection.orderedItems(); - expect(orderedItems.length).toBe(3); - expect(orderedItems[0].element.textContent?.trim()).toBe('Item 3 Header'); - expect(orderedItems[2].element.textContent?.trim()).toBe('Item 1 Header'); + // Re-query elements to check new DOM order + setupTriggerAndPanels(); + + expect(triggerElements.length).toBe(3); + expect(triggerElements[0].textContent?.trim()).toBe('Item 3 Header'); + expect(triggerElements[2].textContent?.trim()).toBe('Item 1 Header'); }); describe('wrap behavior', () => { diff --git a/src/aria/private/accordion/accordion.spec.ts b/src/aria/private/accordion/accordion.spec.ts index 52dfae6617d1..610654b64486 100644 --- a/src/aria/private/accordion/accordion.spec.ts +++ b/src/aria/private/accordion/accordion.spec.ts @@ -322,5 +322,49 @@ describe('Accordion Pattern', () => { expect(triggerPatterns[1].expanded()).toBeTrue(); }); }); + + describe('AccordionTriggerPattern methods', () => { + it('should expand via open()', () => { + expect(triggerPatterns[0].expanded()).toBeFalse(); + triggerPatterns[0].open(); + expect(triggerPatterns[0].expanded()).toBeTrue(); + }); + + it('should collapse via close()', () => { + triggerPatterns[0].expanded.set(true); + expect(triggerPatterns[0].expanded()).toBeTrue(); + triggerPatterns[0].close(); + expect(triggerPatterns[0].expanded()).toBeFalse(); + }); + + it('should toggle via toggle()', () => { + expect(triggerPatterns[0].expanded()).toBeFalse(); + triggerPatterns[0].toggle(); + expect(triggerPatterns[0].expanded()).toBeTrue(); + + triggerPatterns[0].toggle(); + expect(triggerPatterns[0].expanded()).toBeFalse(); + }); + }); + + describe('softDisabled behavior', () => { + it('should compute hardDisabled as true when disabled=true and softDisabled=false', () => { + triggerInputs[0].disabled.set(true); + groupInputs.softDisabled.set(false); + expect(triggerPatterns[0].hardDisabled()).toBeTrue(); + }); + + it('should compute hardDisabled as false when disabled=true and softDisabled=true', () => { + triggerInputs[0].disabled.set(true); + groupInputs.softDisabled.set(true); + expect(triggerPatterns[0].hardDisabled()).toBeFalse(); + }); + + it('should compute hardDisabled as false when disabled=false', () => { + triggerInputs[0].disabled.set(false); + groupInputs.softDisabled.set(false); + expect(triggerPatterns[0].hardDisabled()).toBeFalse(); + }); + }); }); }); From 5bb63c69045a339de60881682e1272f975c12a48 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 30 Apr 2026 17:39:50 -0700 Subject: [PATCH 2/7] test(aria/grid): generate additional methods and tests for Grid harness --- goldens/aria/grid/testing/index.api.md | 2 ++ src/aria/grid/testing/grid-harness.spec.ts | 13 +++++++++++++ src/aria/grid/testing/grid-harness.ts | 12 ++++++++++++ 3 files changed, 27 insertions(+) diff --git a/goldens/aria/grid/testing/index.api.md b/goldens/aria/grid/testing/index.api.md index 81184e165b74..33ea212086a3 100644 --- a/goldens/aria/grid/testing/index.api.md +++ b/goldens/aria/grid/testing/index.api.md @@ -17,7 +17,9 @@ export class GridCellHarness extends ContentContainerComponentHarness { getText(): Promise; // (undocumented) static hostSelector: string; + isActive(): Promise; isDisabled(): Promise; + isFocused(): Promise; isSelected(): Promise; static with(options?: GridCellHarnessFilters): HarnessPredicate; } diff --git a/src/aria/grid/testing/grid-harness.spec.ts b/src/aria/grid/testing/grid-harness.spec.ts index f43382ecf7ac..02f91d10f5f5 100644 --- a/src/aria/grid/testing/grid-harness.spec.ts +++ b/src/aria/grid/testing/grid-harness.spec.ts @@ -97,6 +97,19 @@ describe('Grid Harness', () => { const rows = await loader.getAllHarnesses(GridRowHarness); expect(await rows[0].getCellTextByIndex()).toEqual(['Cell 1.1', 'Cell 1.2']); }); + + it('reports the active state of a cell', async () => { + const cell = await loader.getHarness(GridCellHarness.with({text: 'Cell 1.1'})); + expect(await cell.isActive()).toBeTrue(); + }); + + it('reports the focused state of a cell', async () => { + const cell = await loader.getHarness(GridCellHarness.with({text: 'Cell 1.1'})); + expect(await cell.isFocused()).toBeFalse(); + + await cell.focus(); + expect(await cell.isFocused()).toBeTrue(); + }); }); @Component({ diff --git a/src/aria/grid/testing/grid-harness.ts b/src/aria/grid/testing/grid-harness.ts index 2c69d1c1f757..f3a1efb55a3a 100644 --- a/src/aria/grid/testing/grid-harness.ts +++ b/src/aria/grid/testing/grid-harness.ts @@ -79,6 +79,18 @@ export class GridCellHarness extends ContentContainerComponentHarness { const host = await this.host(); return host.blur(); } + + /** Whether the cell is active. */ + async isActive(): Promise { + const host = await this.host(); + return (await host.getAttribute('data-active')) === 'true'; + } + + /** Whether the cell is focused. */ + async isFocused(): Promise { + const host = await this.host(); + return host.isFocused(); + } } /** Harness for interacting with a standard ngGridRow in tests. */ From 65216551cfda7e63379360318bec274886bf9fb7 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Wed, 29 Apr 2026 22:46:17 -0700 Subject: [PATCH 3/7] test(aria/listbox): generate additional tests for Listbox directives, harness, and patterns --- goldens/aria/listbox/testing/index.api.md | 16 ++-------- src/aria/listbox/listbox.spec.ts | 18 +++++++----- .../listbox/testing/listbox-harness.spec.ts | 27 +++++++++++++++++ src/aria/listbox/testing/listbox-harness.ts | 29 +++++++++++++++++++ src/aria/private/listbox/listbox.spec.ts | 20 +++++++++++++ 5 files changed, 89 insertions(+), 21 deletions(-) diff --git a/goldens/aria/listbox/testing/index.api.md b/goldens/aria/listbox/testing/index.api.md index 088029b94111..e9b69ef2b102 100644 --- a/goldens/aria/listbox/testing/index.api.md +++ b/goldens/aria/listbox/testing/index.api.md @@ -8,22 +8,17 @@ import { BaseHarnessFilters } from '@angular/cdk/testing'; import { ComponentHarness } from '@angular/cdk/testing'; import { HarnessPredicate } from '@angular/cdk/testing'; -// @public (undocumented) +// @public export class ListboxHarness extends ComponentHarness { blur(): Promise; - // (undocumented) focus(): Promise; - // (undocumented) + getActiveDescendantId(): Promise; getOptions(filters?: ListboxOptionHarnessFilters): Promise; - // (undocumented) getOrientation(): Promise<'vertical' | 'horizontal'>; // (undocumented) static hostSelector: string; - // (undocumented) isDisabled(): Promise; - // (undocumented) isMulti(): Promise; - // (undocumented) static with(options?: ListboxHarnessFilters): HarnessPredicate; } @@ -32,19 +27,14 @@ export interface ListboxHarnessFilters extends BaseHarnessFilters { disabled?: boolean; } -// @public (undocumented) +// @public export class ListboxOptionHarness extends ComponentHarness { - // (undocumented) click(): Promise; - // (undocumented) getText(): Promise; // (undocumented) static hostSelector: string; - // (undocumented) isDisabled(): Promise; - // (undocumented) isSelected(): Promise; - // (undocumented) static with(options?: ListboxOptionHarnessFilters): HarnessPredicate; } diff --git a/src/aria/listbox/listbox.spec.ts b/src/aria/listbox/listbox.spec.ts index fe082f3a9d0c..8399824b14b4 100644 --- a/src/aria/listbox/listbox.spec.ts +++ b/src/aria/listbox/listbox.spec.ts @@ -434,10 +434,10 @@ describe('Listbox', () => { ], }); - let orderedItems = listboxInstance._collection.orderedItems(); - expect(orderedItems.length).toBe(3); - expect(orderedItems[0].element.textContent?.trim()).toBe('Item 1'); - expect(orderedItems[2].element.textContent?.trim()).toBe('Item 3'); + // Verify initial DOM order + expect(optionElements.length).toBe(3); + expect(optionElements[0].textContent?.trim()).toBe('Item 1'); + expect(optionElements[2].textContent?.trim()).toBe('Item 3'); const testComponent = fixture.componentInstance as ListboxExample; const items = testComponent.options().reverse(); @@ -445,10 +445,12 @@ describe('Listbox', () => { fixture.detectChanges(); await waitForMicrotasks(); - orderedItems = listboxInstance._collection.orderedItems(); - expect(orderedItems.length).toBe(3); - expect(orderedItems[0].element.textContent?.trim()).toBe('Item 3'); - expect(orderedItems[2].element.textContent?.trim()).toBe('Item 1'); + // Re-query elements to check new DOM order + defineTestVariables(fixture); + + expect(optionElements.length).toBe(3); + expect(optionElements[0].textContent?.trim()).toBe('Item 3'); + expect(optionElements[2].textContent?.trim()).toBe('Item 1'); }); }); diff --git a/src/aria/listbox/testing/listbox-harness.spec.ts b/src/aria/listbox/testing/listbox-harness.spec.ts index 4d90e5bf09ac..44a77c4d3835 100644 --- a/src/aria/listbox/testing/listbox-harness.spec.ts +++ b/src/aria/listbox/testing/listbox-harness.spec.ts @@ -83,6 +83,22 @@ describe('Listbox Harness', () => { expect(orientation).toBe('horizontal'); }); + it('gets the active descendant ID', async () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ListboxActiveDescendantTestComponent], + }); + const customFixture = TestBed.createComponent(ListboxActiveDescendantTestComponent); + customFixture.detectChanges(); + const customLoader = TestbedHarnessEnvironment.loader(customFixture); + + const listbox = await customLoader.getHarness(ListboxHarness); + const options = await listbox.getOptions(); + + await options[0].click(); + expect(await listbox.getActiveDescendantId()).toBe('apple-id'); + }); + it('clicks an option inside the listbox', async () => { const option = await loader.getHarness(ListboxOptionHarness.with({text: 'Apple'})); @@ -91,3 +107,14 @@ describe('Listbox Harness', () => { expect(await option.isSelected()).toBeTrue(); }); }); + +@Component({ + imports: [Listbox, Option], + template: ` +
    +
  • Apple
  • +
  • Banana
  • +
+ `, +}) +class ListboxActiveDescendantTestComponent {} diff --git a/src/aria/listbox/testing/listbox-harness.ts b/src/aria/listbox/testing/listbox-harness.ts index 9a07b7ed87ee..4d9178a881e7 100644 --- a/src/aria/listbox/testing/listbox-harness.ts +++ b/src/aria/listbox/testing/listbox-harness.ts @@ -9,9 +9,16 @@ import {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing'; import {ListboxHarnessFilters, ListboxOptionHarnessFilters} from './listbox-harness-filters'; +/** Harness for interacting with a standard ngOption in tests. */ export class ListboxOptionHarness extends ComponentHarness { static hostSelector = '[ngOption]'; + /** + * Gets a `HarnessPredicate` that can be used to search for an option + * with specific attributes. + * @param options Options for filtering which option instances are considered a match. + * @return a `HarnessPredicate` configured with the given options. + */ static with(options: ListboxOptionHarnessFilters = {}): HarnessPredicate { return new HarnessPredicate(ListboxOptionHarness, options) .addOption('text', options.text, (harness, text) => @@ -29,11 +36,13 @@ export class ListboxOptionHarness extends ComponentHarness { ); } + /** Whether the option is selected. */ async isSelected(): Promise { const host = await this.host(); return (await host.getAttribute('aria-selected')) === 'true'; } + /** Whether the option is disabled. */ async isDisabled(): Promise { const host = await this.host(); return ( @@ -42,20 +51,29 @@ export class ListboxOptionHarness extends ComponentHarness { ); } + /** Gets the option's text. */ async getText(): Promise { const host = await this.host(); return host.text(); } + /** Clicks the option to toggle its selected state. */ async click(): Promise { const host = await this.host(); return host.click(); } } +/** Harness for interacting with a standard ngListbox in tests. */ export class ListboxHarness extends ComponentHarness { static hostSelector = '[ngListbox]'; + /** + * Gets a `HarnessPredicate` that can be used to search for a listbox + * with specific attributes. + * @param options Options for filtering which listbox instances are considered a match. + * @return a `HarnessPredicate` configured with the given options. + */ static with(options: ListboxHarnessFilters = {}): HarnessPredicate { return new HarnessPredicate(ListboxHarness, options).addOption( 'disabled', @@ -64,26 +82,31 @@ export class ListboxHarness extends ComponentHarness { ); } + /** Gets the orientation of the listbox. */ async getOrientation(): Promise<'vertical' | 'horizontal'> { const host = await this.host(); const orientation = await host.getAttribute('aria-orientation'); return orientation === 'horizontal' ? 'horizontal' : 'vertical'; } + /** Whether the listbox is multiselectable. */ async isMulti(): Promise { const host = await this.host(); return (await host.getAttribute('aria-multiselectable')) === 'true'; } + /** Whether the listbox is disabled. */ async isDisabled(): Promise { const host = await this.host(); return (await host.getAttribute('aria-disabled')) === 'true'; } + /** Gets the options inside the listbox. */ async getOptions(filters: ListboxOptionHarnessFilters = {}): Promise { return this.locatorForAll(ListboxOptionHarness.with(filters))(); } + /** Focuses the listbox container. */ async focus(): Promise { await (await this.host()).focus(); } @@ -92,4 +115,10 @@ export class ListboxHarness extends ComponentHarness { async blur(): Promise { await (await this.host()).blur(); } + + /** Gets the ID of the active option. */ + async getActiveDescendantId(): Promise { + const host = await this.host(); + return host.getAttribute('aria-activedescendant'); + } } diff --git a/src/aria/private/listbox/listbox.spec.ts b/src/aria/private/listbox/listbox.spec.ts index 6eb2aac56355..3471a2993584 100644 --- a/src/aria/private/listbox/listbox.spec.ts +++ b/src/aria/private/listbox/listbox.spec.ts @@ -90,6 +90,26 @@ describe('Listbox Pattern', () => { ); } + describe('Tabindex', () => { + it('should expose tabIndex signals on listbox and options', () => { + const {listbox, options} = getDefaultPatterns(); + + expect(listbox.tabIndex()).toBe(-1); + expect(options[0].tabIndex()).toBe(0); + expect(options[1].tabIndex()).toBe(-1); + + listbox.onKeydown(down()); + + expect(options[0].tabIndex()).toBe(-1); + expect(options[1].tabIndex()).toBe(0); + }); + + it('should set tabindex 0 for listbox in activedescendant mode', () => { + const {listbox} = getDefaultPatterns({focusMode: signal('activedescendant')}); + expect(listbox.tabIndex()).toBe(0); + }); + }); + describe('Keyboard Navigation', () => { it('should navigate next on ArrowDown', () => { const {listbox} = getDefaultPatterns(); From 8a5768571cd283d92826bf9638be8a04e8272cb6 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 30 Apr 2026 17:39:33 -0700 Subject: [PATCH 4/7] test(aria/menu): generate additional methods and tests for Menu harness --- goldens/aria/menu/testing/index.api.md | 3 +++ src/aria/menu/testing/menu-harness.spec.ts | 30 ++++++++++++++++++++++ src/aria/menu/testing/menu-harness.ts | 20 +++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/goldens/aria/menu/testing/index.api.md b/goldens/aria/menu/testing/index.api.md index a9a44f0ecec2..3a687bdcf2b2 100644 --- a/goldens/aria/menu/testing/index.api.md +++ b/goldens/aria/menu/testing/index.api.md @@ -16,6 +16,7 @@ export class MenuHarness extends ComponentHarness { _getTrigger(): Promise; // (undocumented) static hostSelector: string; + isMenuBar(): Promise; isOpen(): Promise; open(): Promise; // (undocumented) @@ -32,10 +33,12 @@ export class MenuItemHarness extends ComponentHarness { click(): Promise; getSubmenu(): Promise; getText(): Promise; + hasSubmenu(): Promise; // (undocumented) static hostSelector: string; isDisabled(): Promise; isExpanded(): Promise; + isFocused(): Promise; // (undocumented) static with(options?: MenuItemHarnessFilters): HarnessPredicate; } diff --git a/src/aria/menu/testing/menu-harness.spec.ts b/src/aria/menu/testing/menu-harness.spec.ts index 8d4dbbe5b7ad..3bc42181e7f4 100644 --- a/src/aria/menu/testing/menu-harness.spec.ts +++ b/src/aria/menu/testing/menu-harness.spec.ts @@ -117,6 +117,36 @@ describe('Aria Menu Harness', () => { expect(await items[0].getText()).toBe('File'); expect(await items[1].getText()).toBe('Edit'); }); + + it('should be able to get whether a menu item is focused', async () => { + const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + await menu.open(); + const items = await menu.getItems(); + + const itemHost = await items[0].host(); + await itemHost.focus(); + fixture.detectChanges(); + + expect(await items[0].isFocused()).toBe(true); + expect(await items[1].isFocused()).toBe(false); + }); + + it('should be able to get whether a menu item has a submenu', async () => { + const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + await menu.open(); + const items = await menu.getItems(); + + expect(await items[0].hasSubmenu()).toBe(false); + expect(await items[2].hasSubmenu()).toBe(true); + }); + + it('should be able to get whether a menu is a menu bar', async () => { + const menubar = await loader.getHarness(MenuHarness.with({selector: '[ngMenuBar]'})); + expect(await menubar.isMenuBar()).toBe(true); + + const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + expect(await menu.isMenuBar()).toBe(false); + }); }); @Component({ diff --git a/src/aria/menu/testing/menu-harness.ts b/src/aria/menu/testing/menu-harness.ts index 8980d54928be..059522461e0e 100644 --- a/src/aria/menu/testing/menu-harness.ts +++ b/src/aria/menu/testing/menu-harness.ts @@ -62,6 +62,20 @@ export class MenuItemHarness extends ComponentHarness { } return null; } + + /** Whether the menu item has focus. */ + async isFocused(): Promise { + return (await this.host()).isFocused(); + } + + /** Whether the menu item acts as a submenu trigger. */ + async hasSubmenu(): Promise { + const host = await this.host(); + return ( + (await host.getAttribute('aria-haspopup')) === 'true' || + !!(await host.getAttribute('aria-controls')) + ); + } } /** Harness for interacting with a standard ngMenu or ngMenuBar in tests. */ @@ -97,6 +111,12 @@ export class MenuHarness extends ComponentHarness { return (await host.getAttribute('data-visible')) === 'true'; } + /** Whether the menu is a menu bar. */ + async isMenuBar(): Promise { + const host = await this.host(); + return host.matchesSelector('[ngMenuBar]'); + } + /** Opens the menu if it is currently closed. */ async open(): Promise { if (!(await this.isOpen())) { From deadf7f704b8e364e9075fd726ea170a12c2a262 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Wed, 29 Apr 2026 15:50:55 -0700 Subject: [PATCH 5/7] test(aria/tabs): generate additional tests for Tab directives and patterns --- src/aria/private/tabs/tabs.spec.ts | 17 ++++ src/aria/tabs/tabs.spec.ts | 106 ++++++++++++++++++--- src/aria/tabs/testing/tabs-harness.spec.ts | 1 - 3 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/aria/private/tabs/tabs.spec.ts b/src/aria/private/tabs/tabs.spec.ts index be888aa391ba..196b80dcaf3b 100644 --- a/src/aria/private/tabs/tabs.spec.ts +++ b/src/aria/private/tabs/tabs.spec.ts @@ -420,4 +420,21 @@ describe('Tabs Pattern', () => { expect(tabPanelPatterns[2].labelledBy()).toBe('tab-3-id'); }); }); + + describe('ActiveDescendant mode', () => { + beforeEach(() => { + tabListInputs.focusMode.set('activedescendant'); + tabListPattern.setDefaultState(); + }); + + it('should update activeDescendant when navigating', () => { + expect(tabListPattern.activeDescendant()).toBe('tab-1-id'); + + tabListPattern.onKeydown(right()); + expect(tabListPattern.activeDescendant()).toBe('tab-2-id'); + + tabListPattern.onKeydown(right()); + expect(tabListPattern.activeDescendant()).toBe('tab-3-id'); + }); + }); }); diff --git a/src/aria/tabs/tabs.spec.ts b/src/aria/tabs/tabs.spec.ts index aabdc1911520..7e550996355d 100644 --- a/src/aria/tabs/tabs.spec.ts +++ b/src/aria/tabs/tabs.spec.ts @@ -495,23 +495,23 @@ describe('Tabs', () => { ], }); - const tabsDebugElement = fixture.debugElement.query(By.directive(Tabs)); - const tabsDirective = tabsDebugElement.injector.get(Tabs); - - let orderedItems = tabsDirective._collection.orderedItems(); - expect(orderedItems.length).toBe(3); - expect(orderedItems[0].value()).toBe('tab1'); - expect(orderedItems[2].value()).toBe('tab3'); + // Verify initial DOM order + expect(tabElements.length).toBe(3); + expect(tabElements[0].textContent?.trim()).toBe('Tab 1'); + expect(tabElements[2].textContent?.trim()).toBe('Tab 3'); + // Shuffle (reverse) data const items = testComponent.tabsData().reverse(); testComponent.tabsData.set([...items]); fixture.detectChanges(); await waitForMicrotasks(); - orderedItems = tabsDirective._collection.orderedItems(); - expect(orderedItems.length).toBe(3); - expect(orderedItems[0].value()).toBe('tab3'); - expect(orderedItems[2].value()).toBe('tab1'); + // Re-query elements to check new DOM order + defineTestVariables(); + + expect(tabElements.length).toBe(3); + expect(tabElements[0].textContent?.trim()).toBe('Tab 3'); + expect(tabElements[2].textContent?.trim()).toBe('Tab 1'); }); }); @@ -740,6 +740,72 @@ describe('Tabs', () => { expect(tabPanelElements[2].hasAttribute('inert')).toBe(true); }); }); + + describe('Dynamic tabs', () => { + beforeEach(() => { + setupTestTabs(); + updateTabs({ + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2'}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ], + selectedTab: 'tab2', + }); + }); + + it('should update selection when active tab is removed', () => { + expect(testComponent.selectedTab()).toBe('tab2'); + + testComponent.tabsData.set([ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ]); + fixture.detectChanges(); + defineTestVariables(); + + expect(testComponent.selectedTab()).toBeUndefined(); + }); + + it('should maintain selection when a new tab is added', () => { + expect(testComponent.selectedTab()).toBe('tab2'); + + testComponent.tabsData.set([ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2'}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + {value: 'tab4', label: 'Tab 4', content: 'Content 4'}, + ]); + fixture.detectChanges(); + defineTestVariables(); + + expect(testComponent.selectedTab()).toBe('tab2'); + }); + }); + + describe('Custom IDs', () => { + let customIdFixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideFakeDirectionality('ltr')], + }); + customIdFixture = TestBed.createComponent(TestTabsCustomIdComponent); + fixture = customIdFixture as any; + customIdFixture.detectChanges(); + }); + + it('should use custom ID for tab and link to panel', async () => { + const tabEl = customIdFixture.nativeElement.querySelector('#custom-tab-id'); + const panelEl = customIdFixture.nativeElement.querySelector('#custom-panel-id'); + + expect(tabEl).toBeTruthy(); + expect(panelEl).toBeTruthy(); + + expect(tabEl.getAttribute('aria-controls')).toBe('custom-panel-id'); + expect(panelEl.getAttribute('aria-labelledby')).toBe('custom-tab-id'); + }); + }); }); @Component({ @@ -798,3 +864,21 @@ class TestTabsComponent { focusMode = signal<'roving' | 'activedescendant'>('roving'); selectionMode = signal<'follow' | 'explicit'>('follow'); } + +@Component({ + template: ` +
+
    +
  • Tab 1
  • +
+
+ Content 1 +
+
+ `, + imports: [Tabs, TabList, Tab, TabPanel, TabContent], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class TestTabsCustomIdComponent { + selectedTab = signal('tab1'); +} diff --git a/src/aria/tabs/testing/tabs-harness.spec.ts b/src/aria/tabs/testing/tabs-harness.spec.ts index a4d0c8e77186..8982caba43c8 100644 --- a/src/aria/tabs/testing/tabs-harness.spec.ts +++ b/src/aria/tabs/testing/tabs-harness.spec.ts @@ -137,7 +137,6 @@ describe('TabsHarness', () => {
  • Tab 3
  • -
    Content 1
    From 36a566390b57d662115c5964646d9aaac71a07f7 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Wed, 29 Apr 2026 22:28:43 -0700 Subject: [PATCH 6/7] test(aria/toolbar): generate additional tests for Toolbar directives, harnesses and patterns --- goldens/aria/toolbar/testing/index.api.md | 2 + src/aria/private/toolbar/toolbar.spec.ts | 14 ++++++ .../testing/toolbar-harness-filters.ts | 3 ++ .../toolbar/testing/toolbar-harness.spec.ts | 10 +++- .../toolbar/testing/toolbar-widget-harness.ts | 11 +++++ src/aria/toolbar/toolbar.spec.ts | 49 +++++++++++++++++++ 6 files changed, 88 insertions(+), 1 deletion(-) diff --git a/goldens/aria/toolbar/testing/index.api.md b/goldens/aria/toolbar/testing/index.api.md index 89f1e49fa675..d8f7d8b0468f 100644 --- a/goldens/aria/toolbar/testing/index.api.md +++ b/goldens/aria/toolbar/testing/index.api.md @@ -44,12 +44,14 @@ export class ToolbarWidgetHarness extends ContentContainerComponentHarness; isDisabled(): Promise; + isSelected(): Promise; static with(options?: ToolbarWidgetHarnessFilters): HarnessPredicate; } // @public export interface ToolbarWidgetHarnessFilters extends BaseHarnessFilters { active?: boolean; + selected?: boolean; text?: string | RegExp; } diff --git a/src/aria/private/toolbar/toolbar.spec.ts b/src/aria/private/toolbar/toolbar.spec.ts index 47418df44b6e..b62ec5d01fbc 100644 --- a/src/aria/private/toolbar/toolbar.spec.ts +++ b/src/aria/private/toolbar/toolbar.spec.ts @@ -163,6 +163,20 @@ describe('Toolbar Pattern', () => { return toolbar.inputs.items().find(item => item.value() === value)!; } + describe('Tabindex', () => { + it('should expose tabIndex signals', () => { + const {toolbar, items} = getPatterns(); + + expect(items[0].tabIndex()).toBe(0); + expect(items[1].tabIndex()).toBe(-1); + + toolbar.onKeydown(right()); // Move focus to item 1 + + expect(items[0].tabIndex()).toBe(-1); + expect(items[1].tabIndex()).toBe(0); + }); + }); + describe('Navigation', () => { describe('with horizontal orientation', () => { it('should navigate on click (horizontal, ltr)', () => { diff --git a/src/aria/toolbar/testing/toolbar-harness-filters.ts b/src/aria/toolbar/testing/toolbar-harness-filters.ts index e71fee33a16a..0993e1b3f3a9 100644 --- a/src/aria/toolbar/testing/toolbar-harness-filters.ts +++ b/src/aria/toolbar/testing/toolbar-harness-filters.ts @@ -21,4 +21,7 @@ export interface ToolbarWidgetHarnessFilters extends BaseHarnessFilters { /** Active state that the widget should match. */ active?: boolean; + + /** Selected state that the widget should match. */ + selected?: boolean; } diff --git a/src/aria/toolbar/testing/toolbar-harness.spec.ts b/src/aria/toolbar/testing/toolbar-harness.spec.ts index 3f1885dcb937..daa6c5e9873a 100644 --- a/src/aria/toolbar/testing/toolbar-harness.spec.ts +++ b/src/aria/toolbar/testing/toolbar-harness.spec.ts @@ -93,12 +93,20 @@ describe('ToolbarHarness', () => { fixture.componentInstance.undoDisabled.set(true); expect(await widget.isDisabled()).toBe(true); }); + + it('should be able to get whether a widget is selected', async () => { + const widget = await loader.getHarness(ToolbarWidgetHarness.with({text: 'Undo'})); + expect(await widget.isSelected()).toBe(false); + + await widget.click(); + expect(await widget.isSelected()).toBe(true); + }); }); @Component({ template: `
    - +
    diff --git a/src/aria/toolbar/testing/toolbar-widget-harness.ts b/src/aria/toolbar/testing/toolbar-widget-harness.ts index 6825aad100bf..1df1745c05bc 100644 --- a/src/aria/toolbar/testing/toolbar-widget-harness.ts +++ b/src/aria/toolbar/testing/toolbar-widget-harness.ts @@ -28,6 +28,11 @@ export class ToolbarWidgetHarness extends ContentContainerComponentHarness (await harness.isActive()) === active, + ) + .addOption( + 'selected', + options.selected, + async (harness, selected) => (await harness.isSelected()) === selected, ); } @@ -52,4 +57,10 @@ export class ToolbarWidgetHarness extends ContentContainerComponentHarness { + const host = await this.host(); + return (await host.getAttribute('aria-pressed')) === 'true'; + } } diff --git a/src/aria/toolbar/toolbar.spec.ts b/src/aria/toolbar/toolbar.spec.ts index 3f6d1994fd4b..4c95ec164de6 100644 --- a/src/aria/toolbar/toolbar.spec.ts +++ b/src/aria/toolbar/toolbar.spec.ts @@ -626,6 +626,55 @@ describe('Toolbar', () => { expect(item2.getAttribute('aria-pressed')).toBe('false'); }); }); + + describe('ARIA attributes and roles', () => { + beforeEach(() => setupToolbar()); + + it('should have role="toolbar"', () => { + expect(toolbarElement.getAttribute('role')).toBe('toolbar'); + }); + + it('should set aria-orientation based on input', () => { + expect(toolbarElement.getAttribute('aria-orientation')).toBe('horizontal'); + fixture.componentInstance.orientation.set('vertical'); + fixture.detectChanges(); + expect(toolbarElement.getAttribute('aria-orientation')).toBe('vertical'); + }); + + it('should set aria-disabled based on input', () => { + expect(toolbarElement.getAttribute('aria-disabled')).toBe('false'); + fixture.componentInstance.disabled.set(true); + fixture.detectChanges(); + expect(toolbarElement.getAttribute('aria-disabled')).toBe('true'); + }); + }); + + describe('Focus management', () => { + beforeEach(() => setupToolbar()); + + it('should have tabindex on widgets set by active state', () => { + const widgets = getWidgetEls(); + expect(widgets[0].getAttribute('tabindex')).toBe('0'); + expect(widgets[1].getAttribute('tabindex')).toBe('-1'); + + click(widgets[1]); + expect(widgets[0].getAttribute('tabindex')).toBe('-1'); + expect(widgets[1].getAttribute('tabindex')).toBe('0'); + }); + }); + + describe('Hard disabled state attributes', () => { + beforeEach(() => setupToolbar({softDisabled: false})); + + it('should set inert and disabled attributes on hard-disabled widgets', () => { + fixture.componentInstance.widgets[0].disabled.set(true); + fixture.detectChanges(); + + const widgets = getWidgetEls(); + expect(widgets[0].hasAttribute('inert')).toBe(true); + expect(widgets[0].getAttribute('disabled')).toBe('true'); + }); + }); }); @Component({ From 4e51d547bf0088bef19720aca1fc2d8551a4308e Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 30 Apr 2026 18:29:46 -0700 Subject: [PATCH 7/7] test(aria/tree): generate additional methods and tests for Tree harness --- src/aria/tree/testing/item-harness.ts | 20 ++++++++++++++++++++ src/aria/tree/testing/tree-harness.spec.ts | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/aria/tree/testing/item-harness.ts b/src/aria/tree/testing/item-harness.ts index 204236b5adad..e4e98dfbfc04 100644 --- a/src/aria/tree/testing/item-harness.ts +++ b/src/aria/tree/testing/item-harness.ts @@ -76,6 +76,26 @@ export class TreeItemHarness extends ContentContainerComponentHarness { return (await this.host()).click(); } + /** Focuses the tree item. */ + async focus(): Promise { + return (await this.host()).focus(); + } + + /** Blurs the tree item. */ + async blur(): Promise { + return (await this.host()).blur(); + } + + /** Whether the tree item is active. */ + async isActive(): Promise { + return (await this._getHostAttribute('data-active')) === 'true'; + } + + /** Whether the tree item has focus. */ + async isFocused(): Promise { + return (await this.host()).isFocused(); + } + private async _getHostAttribute(attributeName: string): Promise { return (await this.host()).getAttribute(attributeName); } diff --git a/src/aria/tree/testing/tree-harness.spec.ts b/src/aria/tree/testing/tree-harness.spec.ts index 2203a59c216a..7c7800a02006 100644 --- a/src/aria/tree/testing/tree-harness.spec.ts +++ b/src/aria/tree/testing/tree-harness.spec.ts @@ -109,6 +109,26 @@ describe('TreeHarness', () => { ], }); }); + + it('should be able to get whether a tree item has focus', async () => { + const tree = await loader.getHarness(TreeHarness); + const item = (await tree.getItems({text: 'angular.json'}))[0]; + + expect(await item.isFocused()).toBeFalse(); + + await item.focus(); + expect(await item.isFocused()).toBeTrue(); + + await item.blur(); + expect(await item.isFocused()).toBeFalse(); + }); + + it('should be able to get whether a tree item is active', async () => { + const tree = await loader.getHarness(TreeHarness); + const item = (await tree.getItems({text: 'public'}))[0]; + + expect(await item.isActive()).toBeTrue(); + }); }); interface TreeNode {