diff --git a/goldens/aria/accordion/testing/index.api.md b/goldens/aria/accordion/testing/index.api.md index ef4d0432fa9a..52aa0d861e4c 100644 --- a/goldens/aria/accordion/testing/index.api.md +++ b/goldens/aria/accordion/testing/index.api.md @@ -43,6 +43,7 @@ export class AccordionHarness extends ContentContainerComponentHarness { let loader: any; @Component({ - imports: [AccordionGroup, AccordionPanel, AccordionTrigger], + imports: [AccordionGroup, AccordionPanel, AccordionTrigger, AccordionContent], template: `
@@ -37,10 +38,19 @@ describe('Accordion Harnesses', () => {
Content 2
+ +
+ + + +
+
`, }) - class AccordionHarnessTestComponent {} + class AccordionHarnessTestComponent { + preserveContent = signal(false); + } beforeEach(() => { TestBed.configureTestingModule({ @@ -55,14 +65,14 @@ describe('Accordion Harnesses', () => { const group = await loader.getHarness(AccordionGroupHarness); const accordions = await group.getAccordions(); - expect(accordions.length).toBe(2); + expect(accordions.length).toBe(3); expect(await accordions[0].getTitle()).toBe('Section 1'); expect(await accordions[1].getTitle()).toBe('Section 2'); }); it('should find all individual accordions via standard root loader', async () => { const accordions = await loader.getAllHarnesses(AccordionHarness); - expect(accordions.length).toBe(2); + expect(accordions.length).toBe(3); }); it('should filter accordions by title', async () => { @@ -147,4 +157,40 @@ describe('Accordion Harnesses', () => { const button = await accordion.getHarness(TestButtonHarness); expect(await button.getText()).toBe('Inside Content 1'); }); + + it('should filter accordions by id', async () => { + const accordions = await loader.getAllHarnesses(AccordionHarness.with({id: 'custom-id-3'})); + expect(accordions.length).toBe(1); + expect(await accordions[0].getTitle()).toBe('Section 3'); + }); + + it('should handle deferred content when collapsed', async () => { + const accordion = await loader.getHarness(AccordionHarness.with({title: 'Section 3'})); + + // Initially collapsed, content should not be available + const button = await accordion.getHarnessOrNull(TestButtonHarness); + expect(button).toBeNull(); + }); + + it('should handle deferred content when expanded', async () => { + const accordion = await loader.getHarness(AccordionHarness.with({title: 'Section 3'})); + await accordion.expand(); + + // Now expanded, content should be available + const button = await accordion.getHarness(TestButtonHarness); + expect(await button.getText()).toBe('Inside Content 3'); + }); + + it('should preserve content when collapsed if preserveContent is true', async () => { + fixture.componentInstance.preserveContent.set(true); + fixture.detectChanges(); + + const accordion = await loader.getHarness(AccordionHarness.with({title: 'Section 3'})); + await accordion.expand(); // Render it first + await accordion.collapse(); + + // Content should still be available even though collapsed + const button = await accordion.getHarness(TestButtonHarness); + expect(await button.getText()).toBe('Inside Content 3'); + }); }); diff --git a/src/aria/accordion/testing/accordion-harness.ts b/src/aria/accordion/testing/accordion-harness.ts index d37c40bdac01..b2d3d1207efb 100644 --- a/src/aria/accordion/testing/accordion-harness.ts +++ b/src/aria/accordion/testing/accordion-harness.ts @@ -41,6 +41,9 @@ export class AccordionHarness extends ContentContainerComponentHarness (await harness.isDisabled()) === disabled, + ) + .addOption('id', options.id, async (harness, id) => + HarnessPredicate.stringMatches((await harness.host()).getAttribute('id'), id), ); } 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(); + }); + }); }); }); 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..26232b3fbdc1 100644 --- a/src/aria/tabs/tabs.spec.ts +++ b/src/aria/tabs/tabs.spec.ts @@ -740,6 +740,97 @@ 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('Content lazy rendering', () => { + beforeEach(() => { + setupTestTabs(); + updateTabs({ + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2'}, + ], + selectedTab: 'tab1', + }); + }); + + it('should not render content of unselected tabs', () => { + expect(tabPanelElements[0].textContent?.trim()).toContain('Content 1'); + expect(tabPanelElements[1].textContent?.trim()).not.toContain('Content 2'); + }); + + it('should render content when tab becomes selected', () => { + updateTabs({selectedTab: 'tab2'}); + + expect(tabPanelElements[0].textContent?.trim()).not.toContain('Content 1'); + expect(tabPanelElements[1].textContent?.trim()).toContain('Content 2'); + }); + }); + + 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 +889,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-filters.ts b/src/aria/tabs/testing/tabs-harness-filters.ts index c257e4dda2c1..052db88b0fa7 100644 --- a/src/aria/tabs/testing/tabs-harness-filters.ts +++ b/src/aria/tabs/testing/tabs-harness-filters.ts @@ -19,4 +19,6 @@ export interface TabHarnessFilters extends BaseHarnessFilters { selected?: boolean; /** Only find instances that are disabled. */ disabled?: boolean; + /** Only find instances whose id matches the given value. */ + id?: string | RegExp; } diff --git a/src/aria/tabs/testing/tabs-harness.spec.ts b/src/aria/tabs/testing/tabs-harness.spec.ts index a4d0c8e77186..4709d1644f24 100644 --- a/src/aria/tabs/testing/tabs-harness.spec.ts +++ b/src/aria/tabs/testing/tabs-harness.spec.ts @@ -126,6 +126,35 @@ describe('TabsHarness', () => { expect(filteredTabs.length).toBe(1); expect(await filteredTabs[0].getTitle()).toBe('Tab 3'); }); + + it('should filter tabs by id', async () => { + const tabs = await loader.getHarness(TabsHarness); + const filteredTabs = await tabs.getTabs({id: 'custom-id-2'}); + + expect(filteredTabs.length).toBe(1); + expect(await filteredTabs[0].getTitle()).toBe('Tab 2'); + }); + + it('should handle deferred content when collapsed', async () => { + const tabs = await loader.getHarness(TabsHarness); + const tabItems = await tabs.getTabs(); + + // Tab 2 is collapsed initially, content should not be available + const contentHarness = await tabItems[1].getHarnessOrNull(TestContentHarness); + expect(contentHarness).toBeNull(); + }); + + it('should handle deferred content when expanded', async () => { + const tabs = await loader.getHarness(TabsHarness); + const tabItems = await tabs.getTabs(); + + await tabItems[1].select(); // Expand Tab 2 + + // Now expanded, content should be available + const contentHarness = await tabItems[1].getHarness(TestContentHarness); + expect(contentHarness).toBeTruthy(); + expect(await contentHarness.getText()).toBe('Content 2'); + }); }); @Component({ @@ -133,7 +162,7 @@ describe('TabsHarness', () => {
  • Tab 1
  • -
  • Tab 2
  • +
  • Tab 2
  • Tab 3
@@ -144,7 +173,9 @@ describe('TabsHarness', () => {
- Content 2 + +
Content 2
+
Content 3 diff --git a/src/aria/tabs/testing/tabs-harness.ts b/src/aria/tabs/testing/tabs-harness.ts index b2e82c8cf8d9..81bb5f917916 100644 --- a/src/aria/tabs/testing/tabs-harness.ts +++ b/src/aria/tabs/testing/tabs-harness.ts @@ -38,6 +38,9 @@ export class TabHarness extends ContentContainerComponentHarness { 'disabled', options.disabled, async (harness, disabled) => (await harness.isDisabled()) === disabled, + ) + .addOption('id', options.id, async (harness, id) => + HarnessPredicate.stringMatches((await harness.host()).getAttribute('id'), id), ); }