diff --git a/src/aria/accordion/accordion-group.ts b/src/aria/accordion/accordion-group.ts index 8b1e1f696279..3adc66d16cd9 100644 --- a/src/aria/accordion/accordion-group.ts +++ b/src/aria/accordion/accordion-group.ts @@ -15,6 +15,7 @@ import { input, signal, afterNextRender, + afterRenderEffect, OnDestroy, } from '@angular/core'; import {Directionality} from '@angular/cdk/bidi'; @@ -114,6 +115,22 @@ export class AccordionGroup implements OnDestroy { afterNextRender(() => { this._collection.startObserving(this.element); }); + + // Check for any violations after the DOM has been updated. + afterRenderEffect({ + read: () => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + if (!this.multiExpandable()) { + const expandedCount = this._collection.orderedItems().filter(t => t.expanded()).length; + if (expandedCount > 1) { + console.error( + 'ngAccordionGroup has multiExpandable set to false, but multiple ngAccordionTrigger panels are initially expanded.', + ); + } + } + } + }, + }); } ngOnDestroy() { diff --git a/src/aria/accordion/accordion-panel.ts b/src/aria/accordion/accordion-panel.ts index 99e6e60b55dd..155fdc2a0eff 100644 --- a/src/aria/accordion/accordion-panel.ts +++ b/src/aria/accordion/accordion-panel.ts @@ -6,9 +6,18 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Directive, ElementRef, afterRenderEffect, computed, inject, input} from '@angular/core'; +import { + Directive, + ElementRef, + afterRenderEffect, + computed, + contentChild, + inject, + input, +} from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; import {DeferredContentAware, AccordionTriggerPattern} from '../private'; +import {AccordionContent} from './accordion-content'; /** * The content panel of an accordion item that is conditionally visible. @@ -57,6 +66,8 @@ export class AccordionPanel { /** The DeferredContentAware host directive. */ private readonly _deferredContentAware = inject(DeferredContentAware); + private readonly _accordionContent = contentChild(AccordionContent); + /** A global unique identifier for the panel. */ readonly id = input(inject(_IdGenerator).getId('ng-accordion-panel-', true)); @@ -77,6 +88,26 @@ export class AccordionPanel { this._deferredContentAware.contentVisible.set(this.visible()); }, }); + + // Check for any violations after the DOM has been updated. + afterRenderEffect({ + read: () => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + const violations: string[] = []; + + if (!this._accordionContent()) { + violations.push('ngAccordionPanel must have an ngAccordionContent to render.'); + } + if (!this._pattern) { + violations.push('ngAccordionPanel must have an ngAccordionTrigger to control it.'); + } + + for (const violation of violations) { + console.error(violation); + } + } + }, + }); } /** Expands this item. */ diff --git a/src/aria/accordion/accordion-trigger.ts b/src/aria/accordion/accordion-trigger.ts index 43cc4cb55fdd..846ed62b578a 100644 --- a/src/aria/accordion/accordion-trigger.ts +++ b/src/aria/accordion/accordion-trigger.ts @@ -16,6 +16,7 @@ import { inject, input, model, + afterRenderEffect, } from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; import {AccordionTriggerPattern} from '../private'; @@ -85,6 +86,32 @@ export class AccordionTrigger implements OnInit, OnDestroy { /** The UI pattern instance for this trigger. */ _pattern!: AccordionTriggerPattern; + constructor() { + // Check for any violations after the DOM has been updated. + afterRenderEffect({ + read: () => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + const violations: string[] = []; + + if (this.panel() && this.panel().element.contains(this.element)) { + violations.push( + 'ngAccordionTrigger must not be nested inside its controlled ngAccordionPanel, otherwise it will become unreachable when collapsed.', + ); + } + if (this.panel() && (this.panel() as any)._pattern !== this._pattern) { + violations.push( + 'ngAccordionPanel is already controlled by another ngAccordionTrigger.', + ); + } + + for (const violation of violations) { + console.error(violation); + } + } + }, + }); + } + ngOnInit() { this._pattern = new AccordionTriggerPattern({ ...this, @@ -93,7 +120,11 @@ export class AccordionTrigger implements OnInit, OnDestroy { accordionPanelId: this.panelId, }); - this.panel()._pattern = this._pattern; + // Only bind panel pattern if it wasn't already claimed, otherwise keep the original + // to let the violation checker detect it at render time. + if (this.panel() && !(this.panel() as any)._pattern) { + this.panel()._pattern = this._pattern; + } this._accordionGroup._collection.register(this); } diff --git a/src/aria/accordion/accordion.spec.ts b/src/aria/accordion/accordion.spec.ts index e34afcdef0b9..2475fc40814b 100644 --- a/src/aria/accordion/accordion.spec.ts +++ b/src/aria/accordion/accordion.spec.ts @@ -480,6 +480,89 @@ describe('AccordionGroup', () => { }); }); }); + + describe('structural validations', () => { + let consoleSpy: jasmine.Spy; + + beforeEach(() => { + consoleSpy = spyOn(console, 'error'); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [AccordionGroupWithLoop], + providers: [provideFakeDirectionality('ltr'), _IdGenerator], + }); + fixture = TestBed.createComponent(AccordionGroupWithLoop); + setupAccordionGroup(); + }); + + it('should warn when multiple triggers control the same panel', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [AccordionWithDuplicateTriggers], + }); + const duplicateFixture = TestBed.createComponent(AccordionWithDuplicateTriggers); + duplicateFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'ngAccordionPanel is already controlled by another ngAccordionTrigger.', + ); + }); + + it('should warn when trigger is nested inside its controlled panel', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [AccordionWithNestedTrigger], + }); + const nestedFixture = TestBed.createComponent(AccordionWithNestedTrigger); + nestedFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'ngAccordionTrigger must not be nested inside its controlled ngAccordionPanel, otherwise it will become unreachable when collapsed.', + ); + }); + + it('should warn when ngAccordionPanel is missing ngAccordionContent', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [AccordionPanelWithoutContent], + }); + const noContentFixture = TestBed.createComponent(AccordionPanelWithoutContent); + noContentFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'ngAccordionPanel must have an ngAccordionContent to render.', + ); + }); + + it('should warn when ngAccordionPanel is missing controlling trigger', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [AccordionPanelWithoutTrigger], + }); + const noTriggerFixture = TestBed.createComponent(AccordionPanelWithoutTrigger); + noTriggerFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'ngAccordionPanel must have an ngAccordionTrigger to control it.', + ); + }); + + it('should warn when multiple items are expanded in single-expand mode', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [AccordionWithMultipleExpandedItems], + }); + const multipleExpandedFixture = TestBed.createComponent(AccordionWithMultipleExpandedItems); + multipleExpandedFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'ngAccordionGroup has multiExpandable set to false, but multiple ngAccordionTrigger panels are initially expanded.', + ); + }); + }); }); @Component({ @@ -606,3 +689,81 @@ class AccordionGroupWithIfs extends AccordionGroupWithLoop { includeSecond = signal(true); includeThird = signal(true); } + +@Component({ + template: ` +
+ + +
+ Content +
+
+ `, + imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class AccordionWithDuplicateTriggers {} + +@Component({ + template: ` +
+
+ + Content +
+
+ `, + imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class AccordionWithNestedTrigger {} + +@Component({ + template: ` +
+ +
+ Content +
+
+ `, + imports: [AccordionGroup, AccordionTrigger, AccordionPanel], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class AccordionPanelWithoutContent {} + +@Component({ + template: ` +
+
+ Content +
+
+ `, + imports: [AccordionGroup, AccordionPanel, AccordionContent], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class AccordionPanelWithoutTrigger {} + +@Component({ + template: ` +
+
+ +
+ Content 1 +
+
+
+ +
+ Content 2 +
+
+
+ `, + imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class AccordionWithMultipleExpandedItems {} diff --git a/src/aria/tabs/tab-list.ts b/src/aria/tabs/tab-list.ts index 3dba92291e39..fb3271c3464d 100644 --- a/src/aria/tabs/tab-list.ts +++ b/src/aria/tabs/tab-list.ts @@ -149,6 +149,19 @@ export class TabList implements OnInit, OnDestroy { this.selectedTab.set(tab?.value()); }, }); + + // Check for any violations after the DOM has been updated. + afterRenderEffect({ + read: () => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + const values = this._collection.orderedItems().map(t => t.value()); + const duplicates = values.filter((item, index) => values.indexOf(item) !== index); + if (duplicates.length > 0) { + console.error(`Duplicate value '${duplicates[0]}' detected inside ngTabList.`); + } + } + }, + }); } ngOnInit() { diff --git a/src/aria/tabs/tab-panel.ts b/src/aria/tabs/tab-panel.ts index 954043f426b9..972124f2e292 100644 --- a/src/aria/tabs/tab-panel.ts +++ b/src/aria/tabs/tab-panel.ts @@ -14,11 +14,13 @@ import { inject, input, afterRenderEffect, + contentChild, OnInit, OnDestroy, } from '@angular/core'; import {TabPanelPattern, DeferredContentAware} from '../private'; import {TABS} from './tab-tokens'; +import {TabContent} from './tab-content'; /** * A TabPanel container for the resources of layered content associated with a tab. @@ -89,8 +91,37 @@ export class TabPanel implements OnInit, OnDestroy { tab: this._tabPattern, }); + private readonly _tabContent = contentChild(TabContent); + constructor() { - afterRenderEffect(() => this._deferredContentAware.contentVisible.set(this.visible())); + // Connect the panel's hidden state to the DeferredContentAware's visibility. + afterRenderEffect({ + write: () => { + this._deferredContentAware.contentVisible.set(this.visible()); + }, + }); + + // Check for any violations after the DOM has been updated. + afterRenderEffect({ + read: () => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + const violations: string[] = []; + + if (!this._tabContent()) { + violations.push('ngTabPanel must have an ngTabContent structural directive to render.'); + } + if (!this._tabs._tabMap().has(this.value())) { + violations.push( + `ngTabPanel with value '${this.value()}' does not have a corresponding ngTab.`, + ); + } + + for (const violation of violations) { + console.error(violation); + } + } + }, + }); } ngOnInit() { diff --git a/src/aria/tabs/tab.ts b/src/aria/tabs/tab.ts index 9df510843c20..2579a5f554ee 100644 --- a/src/aria/tabs/tab.ts +++ b/src/aria/tabs/tab.ts @@ -16,6 +16,7 @@ import { computed, inject, input, + afterRenderEffect, } from '@angular/core'; import {TabPattern, HasElement} from '../private'; import {TAB_LIST} from './tab-tokens'; @@ -92,6 +93,22 @@ export class Tab implements HasElement, OnInit, OnDestroy { this._pattern.open(); } + constructor() { + afterRenderEffect({ + read: () => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + if (this._tabList && this._tabList._tabsParent) { + if (!this._tabList._tabsParent._panelMap().has(this.value())) { + console.error( + `ngTab with value '${this.value()}' does not have a corresponding ngTabPanel.`, + ); + } + } + } + }, + }); + } + ngOnInit() { this._tabList._collection.register(this); } diff --git a/src/aria/tabs/tabs.spec.ts b/src/aria/tabs/tabs.spec.ts index 7e550996355d..f56199ae8791 100644 --- a/src/aria/tabs/tabs.spec.ts +++ b/src/aria/tabs/tabs.spec.ts @@ -806,6 +806,69 @@ describe('Tabs', () => { expect(panelEl.getAttribute('aria-labelledby')).toBe('custom-tab-id'); }); }); + + describe('structural validations', () => { + let consoleSpy: jasmine.Spy; + + beforeEach(() => { + consoleSpy = spyOn(console, 'error'); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + setupTestTabs(); + }); + + it('should warn when ngTab is missing its corresponding ngTabPanel', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [TabWithoutPanelComponent], + }); + const noPanelFixture = TestBed.createComponent(TabWithoutPanelComponent); + noPanelFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + "ngTab with value 'tab1' does not have a corresponding ngTabPanel.", + ); + }); + + it('should warn when ngTabPanel is missing its corresponding ngTab', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [PanelWithoutTabComponent], + }); + const noTabFixture = TestBed.createComponent(PanelWithoutTabComponent); + noTabFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + "ngTabPanel with value 'tab1' does not have a corresponding ngTab.", + ); + }); + + it('should warn when ngTabPanel is missing ngTabContent structural directive', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [PanelWithoutContentComponent], + }); + const noContentFixture = TestBed.createComponent(PanelWithoutContentComponent); + noContentFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'ngTabPanel must have an ngTabContent structural directive to render.', + ); + }); + + it('should warn when duplicate values are detected inside ngTabList', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [DuplicateTabValuesComponent], + }); + const duplicateFixture = TestBed.createComponent(DuplicateTabValuesComponent); + duplicateFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith("Duplicate value 'tab1' detected inside ngTabList."); + }); + }); }); @Component({ @@ -882,3 +945,62 @@ class TestTabsComponent { class TestTabsCustomIdComponent { selectedTab = signal('tab1'); } + +@Component({ + template: ` +
+ +
+ `, + imports: [Tabs, TabList, Tab], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class TabWithoutPanelComponent {} + +@Component({ + template: ` +
+
+ Content 1 +
+
+ `, + imports: [Tabs, TabPanel, TabContent], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class PanelWithoutTabComponent {} + +@Component({ + template: ` +
+ +
+ Content 1 +
+
+ `, + imports: [Tabs, TabList, Tab, TabPanel], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class PanelWithoutContentComponent {} + +@Component({ + template: ` +
+ +
+ Content 1 +
+
+ `, + imports: [Tabs, TabList, Tab, TabPanel, TabContent], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class DuplicateTabValuesComponent {}