From 588362fc7de862c915c2ace6f69cafad42750171 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Mon, 4 May 2026 11:00:14 -0700 Subject: [PATCH 1/5] test(aria/accordion): check for incorrect usage of Accordion directives and log violations --- src/aria/accordion/accordion-group.ts | 17 +++ src/aria/accordion/accordion-panel.ts | 33 ++++- src/aria/accordion/accordion-trigger.ts | 33 ++++- src/aria/accordion/accordion.spec.ts | 161 ++++++++++++++++++++++++ 4 files changed, 242 insertions(+), 2 deletions(-) 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 {} From 0c9eb7b38858dce69e6e355f4447fc0fa3808141 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Mon, 4 May 2026 16:20:24 -0700 Subject: [PATCH 2/5] test(aria/tabs): check for incorrect usage of Tabs directives and log violations --- src/aria/tabs/tab-list.ts | 13 ++++ src/aria/tabs/tab-panel.ts | 33 +++++++++- src/aria/tabs/tab.ts | 17 ++++++ src/aria/tabs/tabs.spec.ts | 122 +++++++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 1 deletion(-) 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: ` +
+
    +
  • Tab 1
  • +
+
+ `, + imports: [Tabs, TabList, Tab], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class TabWithoutPanelComponent {} + +@Component({ + template: ` +
+
+ Content 1 +
+
+ `, + imports: [Tabs, TabPanel, TabContent], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class PanelWithoutTabComponent {} + +@Component({ + template: ` +
+
    +
  • Tab 1
  • +
+
+ Content 1 +
+
+ `, + imports: [Tabs, TabList, Tab, TabPanel], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class PanelWithoutContentComponent {} + +@Component({ + template: ` +
+
    +
  • Tab 1
  • +
  • Tab 1 Copy
  • +
+
+ Content 1 +
+
+ `, + imports: [Tabs, TabList, Tab, TabPanel, TabContent], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class DuplicateTabValuesComponent {} From dd28ac5243e0119c320fc6adc05ea7732437913a Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 13:42:18 -0700 Subject: [PATCH 3/5] test(aria/toolbar): check for incorrect usage of Toolbar directives and log violations --- src/aria/toolbar/toolbar-widget-group.ts | 14 ++++++ src/aria/toolbar/toolbar.spec.ts | 60 ++++++++++++++++++++++++ src/aria/toolbar/toolbar.ts | 13 +++++ 3 files changed, 87 insertions(+) diff --git a/src/aria/toolbar/toolbar-widget-group.ts b/src/aria/toolbar/toolbar-widget-group.ts index 5c73b44f38a6..fa19cd0fbd5b 100644 --- a/src/aria/toolbar/toolbar-widget-group.ts +++ b/src/aria/toolbar/toolbar-widget-group.ts @@ -14,6 +14,7 @@ import { input, booleanAttribute, contentChildren, + afterRenderEffect, } from '@angular/core'; import {ToolbarWidgetPattern, ToolbarWidgetGroupPattern} from '../private'; import {Toolbar} from './toolbar'; @@ -64,4 +65,17 @@ export class ToolbarWidgetGroup { items: this._itemPatterns, toolbar: this._toolbarPattern, }); + + constructor() { + // Check for any violations after the DOM has been updated. + afterRenderEffect({ + read: () => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + if (!this._toolbar) { + console.error('ngToolbarWidgetGroup must be placed inside an ngToolbar container.'); + } + } + }, + }); + } } diff --git a/src/aria/toolbar/toolbar.spec.ts b/src/aria/toolbar/toolbar.spec.ts index a911cd95022d..790fa117eae7 100644 --- a/src/aria/toolbar/toolbar.spec.ts +++ b/src/aria/toolbar/toolbar.spec.ts @@ -703,6 +703,43 @@ describe('Toolbar', () => { expect(widgets[0].getAttribute('disabled')).toBe('true'); }); }); + + describe('structural validations', () => { + let consoleSpy: jasmine.Spy; + + beforeEach(() => { + consoleSpy = spyOn(console, 'error'); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + setupToolbar(); + }); + + it('should warn when duplicate values are detected inside ngToolbar', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ToolbarWithDuplicateValues], + }); + const duplicateFixture = TestBed.createComponent(ToolbarWithDuplicateValues); + duplicateFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith("Duplicate value 'item0' detected inside ngToolbar."); + }); + + it('should warn when ngToolbarWidgetGroup is outside ngToolbar', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ToolbarGroupOutsideToolbar], + }); + const noToolbarFixture = TestBed.createComponent(ToolbarGroupOutsideToolbar); + noToolbarFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'ngToolbarWidgetGroup must be placed inside an ngToolbar container.', + ); + }); + }); }); @Component({ @@ -816,3 +853,26 @@ class WrappedToolbarExample {} class ShuffledToolbarExample { items = signal([{value: 'item 0'}, {value: 'item 1'}, {value: 'item 2'}]); } + +@Component({ + template: ` +
+ + +
+ `, + imports: [Toolbar, ToolbarWidget], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ToolbarWithDuplicateValues {} + +@Component({ + template: ` +
+ Widget Group Content +
+ `, + imports: [ToolbarWidgetGroup], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ToolbarGroupOutsideToolbar {} diff --git a/src/aria/toolbar/toolbar.ts b/src/aria/toolbar/toolbar.ts index 8bb28c423360..0101ca5f17d6 100644 --- a/src/aria/toolbar/toolbar.ts +++ b/src/aria/toolbar/toolbar.ts @@ -109,6 +109,19 @@ export class Toolbar implements OnDestroy { constructor() { afterRenderEffect({write: () => this._pattern.setDefaultStateEffect()}); + // Check for any violations after the DOM has been updated. + afterRenderEffect({ + read: () => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + const values = this._collection.orderedItems().map(w => w.value()); + const duplicates = values.filter((val, idx) => values.indexOf(val) !== idx); + if (duplicates.length > 0) { + console.error(`Duplicate value '${duplicates[0]}' detected inside ngToolbar.`); + } + } + }, + }); + afterNextRender(() => { this._collection.startObserving(this.element); }); From dc86708dc289c630c9ea003dffffa7de1f51394b Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 13:54:13 -0700 Subject: [PATCH 4/5] test(aria/listbox): check for incorrect usage of Listbox directives and log violations --- src/aria/listbox/listbox.spec.ts | 63 ++++++++++++++++++++++++++++++++ src/aria/listbox/listbox.ts | 15 +++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/aria/listbox/listbox.spec.ts b/src/aria/listbox/listbox.spec.ts index 4be1421008be..b6ab3cd18ddf 100644 --- a/src/aria/listbox/listbox.spec.ts +++ b/src/aria/listbox/listbox.spec.ts @@ -454,6 +454,45 @@ describe('Listbox', () => { }); }); + describe('structural validations', () => { + let consoleSpy: jasmine.Spy; + + beforeEach(() => { + consoleSpy = spyOn(console, 'error'); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + setupListbox(); + }); + + it('should warn when duplicate option values are detected inside ngListbox', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ListboxWithDuplicateValues], + }); + const duplicateFixture = TestBed.createComponent(ListboxWithDuplicateValues); + duplicateFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + "Duplicate option value 'item0' detected inside ngListbox.", + ); + }); + + it('should warn when duplicate option IDs are detected inside ngListbox', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ListboxWithDuplicateIds], + }); + const duplicateFixture = TestBed.createComponent(ListboxWithDuplicateIds); + duplicateFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + "Duplicate option ID 'option0' detected inside ngListbox.", + ); + }); + }); + describe('keyboard interactions', () => { describe('single select', () => { describe('selection follows focus', () => { @@ -905,3 +944,27 @@ class ListboxExample { changeDetection: ChangeDetectionStrategy.Eager, }) class DefaultListboxExample {} + +@Component({ + template: ` +
    +
  • Item 0
  • +
  • Item 0 Copy
  • +
+ `, + imports: [Listbox, Option], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ListboxWithDuplicateValues {} + +@Component({ + template: ` +
    +
  • Item 0
  • +
  • Item 1
  • +
+ `, + imports: [Listbox, Option], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ListboxWithDuplicateIds {} diff --git a/src/aria/listbox/listbox.ts b/src/aria/listbox/listbox.ts index 3dc73c239af4..b475ac206b9b 100644 --- a/src/aria/listbox/listbox.ts +++ b/src/aria/listbox/listbox.ts @@ -161,11 +161,24 @@ export class Listbox implements OnDestroy { this._collection.startObserving(this.element); }); - // Check for any violationns after the DOM has been updated. + // Check for any violations after the DOM has been updated. afterRenderEffect({ read: () => { if (typeof ngDevMode === 'undefined' || ngDevMode) { const violations = this._pattern.validate(); + + const values = this._collection.orderedItems().map(o => o.value()); + const duplicates = values.filter((val, idx) => values.indexOf(val) !== idx); + if (duplicates.length > 0) { + violations.push(`Duplicate option value '${duplicates[0]}' detected inside ngListbox.`); + } + + const ids = this._collection.orderedItems().map(o => o.id()); + const duplicateIds = ids.filter((id, idx) => ids.indexOf(id) !== idx); + if (duplicateIds.length > 0) { + violations.push(`Duplicate option ID '${duplicateIds[0]}' detected inside ngListbox.`); + } + for (const violation of violations) { console.error(violation); } From 3dd901102461c44264dccd6258e9ff041183356a Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 13:57:52 -0700 Subject: [PATCH 5/5] test(aria/menu): check for incorrect usage of Menu directives and log violations --- src/aria/menu/menu-item.ts | 12 ++++++++ src/aria/menu/menu.spec.ts | 60 ++++++++++++++++++++++++++++++++++++++ src/aria/menu/menu.ts | 13 +++++++++ 3 files changed, 85 insertions(+) diff --git a/src/aria/menu/menu-item.ts b/src/aria/menu/menu-item.ts index 6f85007e713a..34e98073c577 100644 --- a/src/aria/menu/menu-item.ts +++ b/src/aria/menu/menu-item.ts @@ -16,6 +16,7 @@ import { model, OnDestroy, OnInit, + afterRenderEffect, } from '@angular/core'; import {MenuItemPattern} from '../private'; import {_IdGenerator} from '@angular/cdk/a11y'; @@ -102,6 +103,17 @@ export class MenuItem implements OnInit, OnDestroy { constructor() { effect(() => this.submenu()?.parent.set(this)); + + // Check for any violations after the DOM has been updated. + afterRenderEffect({ + read: () => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + if (!this.parent) { + console.error('ngMenuItem must be placed inside an ngMenu or ngMenuBar container.'); + } + } + }, + }); } ngOnInit() { diff --git a/src/aria/menu/menu.spec.ts b/src/aria/menu/menu.spec.ts index eb9dc8d99081..e82367b72537 100644 --- a/src/aria/menu/menu.spec.ts +++ b/src/aria/menu/menu.spec.ts @@ -497,6 +497,43 @@ describe('Standalone Menu Pattern', () => { fixture.detectChanges(); expect(item?.getAttribute('aria-label')).toBe('Apple item label'); }); + + describe('structural validations', () => { + let consoleSpy: jasmine.Spy; + + beforeEach(() => { + consoleSpy = spyOn(console, 'error'); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + setupMenu(); + }); + + it('should warn when duplicate values are detected inside ngMenu', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [MenuWithDuplicateValues], + }); + const duplicateFixture = TestBed.createComponent(MenuWithDuplicateValues); + duplicateFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith("Duplicate value 'item0' detected inside ngMenu."); + }); + + it('should warn when ngMenuItem is outside ngMenu or ngMenuBar', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [MenuItemOutsideMenu], + }); + const noMenuFixture = TestBed.createComponent(MenuItemOutsideMenu); + noMenuFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'ngMenuItem must be placed inside an ngMenu or ngMenuBar container.', + ); + }); + }); }); describe('Menu Trigger Pattern', () => { @@ -1167,3 +1204,26 @@ class ShuffledMenuExample { class ShuffledMenuBarExample { items = signal([{value: 'File'}, {value: 'Edit'}, {value: 'View'}]); } + +@Component({ + template: ` +
+ +
Item 0
+
Item 0 Copy
+
+
+ `, + imports: [Menu, MenuItem, MenuContent], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class MenuWithDuplicateValues {} + +@Component({ + template: ` +
Item 0
+ `, + imports: [MenuItem], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class MenuItemOutsideMenu {} diff --git a/src/aria/menu/menu.ts b/src/aria/menu/menu.ts index b03bf8af6aea..184ec6515761 100644 --- a/src/aria/menu/menu.ts +++ b/src/aria/menu/menu.ts @@ -190,6 +190,19 @@ export class Menu implements OnDestroy { afterRenderEffect({write: () => this._pattern.setDefaultStateEffect()}); + // Check for any violations after the DOM has been updated. + afterRenderEffect({ + read: () => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + const values = this._items().map(i => i.value()); + const duplicates = values.filter((val, idx) => values.indexOf(val) !== idx); + if (duplicates.length > 0) { + console.error(`Duplicate value '${duplicates[0]}' detected inside ngMenu.`); + } + } + }, + }); + afterNextRender(() => { this._collection.startObserving(this.element); });