From 74a9ee51abb975f9769ea993e17feefb7e28cb8a Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Mon, 4 May 2026 11:00:14 -0700 Subject: [PATCH 1/8] test(aria/accordion): check for incorrect usage of Accordion directives and log violations --- goldens/aria/accordion/index.api.md | 3 +- src/aria/accordion/accordion-group.ts | 13 ++ src/aria/accordion/accordion-panel.ts | 33 ++++- src/aria/accordion/accordion-trigger.ts | 27 ++++ src/aria/accordion/accordion.spec.ts | 161 ++++++++++++++++++++++++ src/aria/private/accordion/accordion.ts | 16 +++ 6 files changed, 251 insertions(+), 2 deletions(-) diff --git a/goldens/aria/accordion/index.api.md b/goldens/aria/accordion/index.api.md index ef3ac71b595e..8fa6f80b9a57 100644 --- a/goldens/aria/accordion/index.api.md +++ b/goldens/aria/accordion/index.api.md @@ -50,13 +50,14 @@ export class AccordionPanel { toggle(): void; readonly visible: _angular_core.Signal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } // @public export class AccordionTrigger implements OnInit, OnDestroy { + constructor(); readonly active: _angular_core.Signal; collapse(): void; readonly disabled: _angular_core.InputSignalWithTransform; diff --git a/src/aria/accordion/accordion-group.ts b/src/aria/accordion/accordion-group.ts index aef30e266611..9385c5c4f34c 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'; @@ -113,6 +114,18 @@ export class AccordionGroup implements OnDestroy { afterNextRender(() => { this._collection.startObserving(this.element); }); + + // Check for any violations after the DOM has been updated. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + afterRenderEffect({ + read: () => { + const violations = this._pattern.validate(); + for (const violation of violations) { + console.error(violation); + } + }, + }); + } } ngOnDestroy() { diff --git a/src/aria/accordion/accordion-panel.ts b/src/aria/accordion/accordion-panel.ts index 7909a8a277a3..3c5de431b1d3 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. @@ -56,6 +65,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)); @@ -76,6 +87,26 @@ export class AccordionPanel { this._deferredContentAware.contentVisible.set(this.visible()); }, }); + + // Check for any violations after the DOM has been updated. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + afterRenderEffect({ + read: () => { + 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 d9524a578065..89486811221f 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'; @@ -84,6 +85,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. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + afterRenderEffect({ + read: () => { + 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, 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/private/accordion/accordion.ts b/src/aria/private/accordion/accordion.ts index fe0ca0dedb29..c3db0cecd4d1 100644 --- a/src/aria/private/accordion/accordion.ts +++ b/src/aria/private/accordion/accordion.ts @@ -126,6 +126,22 @@ export class AccordionGroupPattern { this.expansionBehavior.closeAll(); } + /** Returns a set of violations */ + validate(): string[] { + const violations: string[] = []; + + if (!this.inputs.multiExpandable()) { + const expandedCount = this.inputs.items().filter(t => t.expanded()).length; + if (expandedCount > 1) { + violations.push( + 'ngAccordionGroup has multiExpandable set to false, but multiple ngAccordionTrigger panels are initially expanded.', + ); + } + } + + return violations; + } + /** Finds the trigger pattern for a given element. */ private _findTriggerPattern( element: Element | null | undefined, From 5dec26246eeb49f7c4519fbf2f94b320420914a3 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 14:13:55 -0700 Subject: [PATCH 2/8] test(aria/grid): check for incorrect usage of Grid directives and log violations --- src/aria/grid/grid.spec.ts | 35 +++++++++++++++++++++++++++++++++++ src/aria/grid/grid.ts | 12 ++++++++++++ src/aria/private/grid/grid.ts | 14 ++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/src/aria/grid/grid.spec.ts b/src/aria/grid/grid.spec.ts index cf5828525102..6dd2e1667a60 100644 --- a/src/aria/grid/grid.spec.ts +++ b/src/aria/grid/grid.spec.ts @@ -1060,6 +1060,30 @@ describe('Grid directives', () => { }); }); }); + + describe('structural validations', () => { + let consoleSpy: jasmine.Spy; + + beforeEach(() => { + consoleSpy = spyOn(console, 'error'); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + setupGrid(); + }); + + it('should warn when ngGridRow contains no cells', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [GridRowWithoutCells], + }); + const noCellsFixture = TestBed.createComponent(GridRowWithoutCells); + noCellsFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith('ngGridRow must contain at least one ngGridCell.'); + }); + }); }); @Component({ @@ -1136,3 +1160,14 @@ class GridTestComponent { onActivated = jasmine.createSpy('activated'); onDeactivated = jasmine.createSpy('deactivated'); } + +@Component({ + template: ` + + +
+ `, + imports: [Grid, GridRow], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class GridRowWithoutCells {} diff --git a/src/aria/grid/grid.ts b/src/aria/grid/grid.ts index 723d0bb0b829..40c45561a294 100644 --- a/src/aria/grid/grid.ts +++ b/src/aria/grid/grid.ts @@ -154,6 +154,18 @@ export class Grid implements OnDestroy { afterRenderEffect({write: () => this._pattern.restoreFocusEffect()}); afterRenderEffect({write: () => this._pattern.focusEffect()}); + // Check for any violations after the DOM has been updated. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + afterRenderEffect({ + read: () => { + const violations = this._pattern.validate(); + for (const violation of violations) { + console.error(violation); + } + }, + }); + } + afterNextRender(() => { this._collection.startObserving(this.element); }); diff --git a/src/aria/private/grid/grid.ts b/src/aria/private/grid/grid.ts index 718768bd0f0b..6c2b746c3c0e 100644 --- a/src/aria/private/grid/grid.ts +++ b/src/aria/private/grid/grid.ts @@ -196,6 +196,20 @@ export class GridPattern { }); } + /** Returns a set of violations */ + validate(): string[] { + const violations: string[] = []; + + const rows = this.inputs.rows(); + for (const row of rows) { + if (row.inputs.cells().length === 0) { + violations.push('ngGridRow must contain at least one ngGridCell.'); + } + } + + return violations; + } + /** Handles keydown events on the grid. */ onKeydown(event: KeyboardEvent) { if (this.disabled()) return; From b2851a1344b946e7bee746d3e733ce8e5b4c809d Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 13:54:13 -0700 Subject: [PATCH 3/8] test(aria/listbox): check for incorrect usage of Listbox directives and log violations --- src/aria/listbox/listbox.spec.ts | 88 +++++++++++++++++++++++++++++ src/aria/listbox/listbox.ts | 15 ++--- src/aria/private/listbox/listbox.ts | 12 ++++ 3 files changed, 108 insertions(+), 7 deletions(-) diff --git a/src/aria/listbox/listbox.spec.ts b/src/aria/listbox/listbox.spec.ts index 4be1421008be..71f3165798ab 100644 --- a/src/aria/listbox/listbox.spec.ts +++ b/src/aria/listbox/listbox.spec.ts @@ -454,6 +454,58 @@ 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.", + ); + }); + + it('should warn when single-select listbox has multiple selected options', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [SingleSelectListboxWithMultipleValues], + }); + const singleSelectFixture = TestBed.createComponent(SingleSelectListboxWithMultipleValues); + singleSelectFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'A single-select listbox should not have multiple selected options. Selected options: item0, item1', + ); + }); + }); + describe('keyboard interactions', () => { describe('single select', () => { describe('selection follows focus', () => { @@ -905,3 +957,39 @@ 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 {} + +@Component({ + template: ` +
    +
  • Item 0
  • +
  • Item 1
  • +
+ `, + imports: [Listbox, Option], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class SingleSelectListboxWithMultipleValues {} diff --git a/src/aria/listbox/listbox.ts b/src/aria/listbox/listbox.ts index b5ddea56fe03..726d51ce9d62 100644 --- a/src/aria/listbox/listbox.ts +++ b/src/aria/listbox/listbox.ts @@ -159,17 +159,18 @@ export class Listbox implements OnDestroy { this._collection.startObserving(this.element); }); - // Check for any violationns after the DOM has been updated. - afterRenderEffect({ - read: () => { - if (typeof ngDevMode === 'undefined' || ngDevMode) { + // Check for any violations after the DOM has been updated. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + afterRenderEffect({ + read: () => { const violations = this._pattern.validate(); + for (const violation of violations) { console.error(violation); } - } - }, - }); + }, + }); + } afterRenderEffect({write: () => this._pattern.setDefaultStateEffect()}); diff --git a/src/aria/private/listbox/listbox.ts b/src/aria/private/listbox/listbox.ts index b71445b6e0c1..c6079f78fbf0 100644 --- a/src/aria/private/listbox/listbox.ts +++ b/src/aria/private/listbox/listbox.ts @@ -215,6 +215,18 @@ export class ListboxPattern { ); } + const values = this.inputs.items().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.inputs.items().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.`); + } + return violations; } From ca191e41c203f27d2eb8468e3d7cbeb0e3a8cc65 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 13:57:52 -0700 Subject: [PATCH 4/8] 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 | 12 +++++++ src/aria/private/menu/menu.ts | 13 ++++++++ 4 files changed, 97 insertions(+) diff --git a/src/aria/menu/menu-item.ts b/src/aria/menu/menu-item.ts index 983a7e7d6c8d..a9f1cd097cd3 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'; @@ -100,6 +101,17 @@ export class MenuItem implements OnInit, OnDestroy { constructor() { effect(() => this.submenu()?.parent.set(this)); + + // Check for any violations after the DOM has been updated. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + afterRenderEffect({ + read: () => { + 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 9fea166ffef4..0a9de664f6df 100644 --- a/src/aria/menu/menu.ts +++ b/src/aria/menu/menu.ts @@ -188,6 +188,18 @@ export class Menu implements OnDestroy { afterRenderEffect({write: () => this._pattern.setDefaultStateEffect()}); + // Check for any violations after the DOM has been updated. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + afterRenderEffect({ + read: () => { + const violations = this._pattern.validate(); + for (const violation of violations) { + console.error(violation); + } + }, + }); + } + afterNextRender(() => { this._collection.startObserving(this.element); }); diff --git a/src/aria/private/menu/menu.ts b/src/aria/private/menu/menu.ts index c81962926071..1a3ebb877610 100644 --- a/src/aria/private/menu/menu.ts +++ b/src/aria/private/menu/menu.ts @@ -181,6 +181,19 @@ export class MenuPattern { }); } + /** Returns a set of violations */ + validate(): string[] { + const violations: string[] = []; + + const values = this.inputs.items().map(i => i.value()); + const duplicates = values.filter((val, idx) => values.indexOf(val) !== idx); + if (duplicates.length > 0) { + violations.push(`Duplicate value '${duplicates[0]}' detected inside ngMenu.`); + } + + return violations; + } + /** Sets the default state for the menu. */ setDefaultState() { if (!this.inputs.parent()) { From d6e5008f725510a9758a3d2ef1876ad2e687e3f2 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Mon, 4 May 2026 16:20:24 -0700 Subject: [PATCH 5/8] 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 19e468626a89..96f94806974c 100644 --- a/src/aria/tabs/tab-list.ts +++ b/src/aria/tabs/tab-list.ts @@ -147,6 +147,19 @@ export class TabList implements OnInit, OnDestroy { this.selectedTab.set(tab?.value()); }, }); + + // Check for any violations after the DOM has been updated. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + afterRenderEffect({ + read: () => { + 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 8bfc11016c48..b7433a5923cd 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. @@ -87,8 +89,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. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + afterRenderEffect({ + read: () => { + 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 33f318a084e2..bf429f0281f2 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'; @@ -90,6 +91,22 @@ export class Tab implements HasElement, OnInit, OnDestroy { this._pattern.open(); } + constructor() { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + afterRenderEffect({ + read: () => { + 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 c132151931251a592088ae15cd4b91cdece7fa5d Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 13:42:18 -0700 Subject: [PATCH 6/8] test(aria/toolbar): check for incorrect usage of Toolbar directives and log violations --- goldens/aria/toolbar/index.api.md | 1 + src/aria/private/toolbar/toolbar.ts | 13 +++++ src/aria/toolbar/toolbar-widget-group.ts | 14 ++++++ src/aria/toolbar/toolbar.spec.ts | 60 ++++++++++++++++++++++++ src/aria/toolbar/toolbar.ts | 12 +++++ 5 files changed, 100 insertions(+) diff --git a/goldens/aria/toolbar/index.api.md b/goldens/aria/toolbar/index.api.md index 81ea71d6883b..2d833a5cf11a 100644 --- a/goldens/aria/toolbar/index.api.md +++ b/goldens/aria/toolbar/index.api.md @@ -55,6 +55,7 @@ export class ToolbarWidget implements OnInit, OnDestroy { // @public export class ToolbarWidgetGroup { + constructor(); readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; readonly multi: _angular_core.InputSignalWithTransform; diff --git a/src/aria/private/toolbar/toolbar.ts b/src/aria/private/toolbar/toolbar.ts index 25d9b976c1e1..0e0124f92e09 100644 --- a/src/aria/private/toolbar/toolbar.ts +++ b/src/aria/private/toolbar/toolbar.ts @@ -170,6 +170,19 @@ export class ToolbarPattern { }); } + /** Returns a set of violations */ + validate(): string[] { + const violations: string[] = []; + + const values = this.inputs.items().map(w => w.value()); + const duplicates = values.filter((val, idx) => values.indexOf(val) !== idx); + if (duplicates.length > 0) { + violations.push(`Duplicate value '${duplicates[0]}' detected inside ngToolbar.`); + } + + return violations; + } + /** Handles keydown events for the toolbar. */ onKeydown(event: KeyboardEvent) { if (this.disabled()) return; diff --git a/src/aria/toolbar/toolbar-widget-group.ts b/src/aria/toolbar/toolbar-widget-group.ts index 7ab98cc5408f..7db72bd1282a 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'; @@ -62,4 +63,17 @@ export class ToolbarWidgetGroup { items: this._itemPatterns, toolbar: this._toolbarPattern, }); + + constructor() { + // Check for any violations after the DOM has been updated. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + afterRenderEffect({ + read: () => { + 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 c8240b8e64f8..ec6adf99eeae 100644 --- a/src/aria/toolbar/toolbar.ts +++ b/src/aria/toolbar/toolbar.ts @@ -107,6 +107,18 @@ export class Toolbar implements OnDestroy { constructor() { afterRenderEffect({write: () => this._pattern.setDefaultStateEffect()}); + // Check for any violations after the DOM has been updated. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + afterRenderEffect({ + read: () => { + const violations = this._pattern.validate(); + for (const violation of violations) { + console.error(violation); + } + }, + }); + } + afterNextRender(() => { this._collection.startObserving(this.element); }); From 4235d517a83ea4702a1bffc1b92c1d85f2a9da53 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 14:19:37 -0700 Subject: [PATCH 7/8] test(aria/tree): check for incorrect usage of Tree directives and log violations --- goldens/aria/tabs/index.api.md | 3 +- src/aria/private/tree/tree.ts | 6 ++++ src/aria/tree/tree.spec.ts | 63 ++++++++++++++++++++++++++++++++++ src/aria/tree/tree.ts | 13 +++---- 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/goldens/aria/tabs/index.api.md b/goldens/aria/tabs/index.api.md index 66824bceea06..fdb9b12b2f05 100644 --- a/goldens/aria/tabs/index.api.md +++ b/goldens/aria/tabs/index.api.md @@ -13,6 +13,7 @@ import { WritableSignal } from '@angular/core'; // @public export class Tab implements HasElement, OnInit, OnDestroy { + constructor(); readonly active: _angular_core.Signal; readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; @@ -81,7 +82,7 @@ export class TabPanel implements OnInit, OnDestroy { readonly value: _angular_core.InputSignal; readonly visible: _angular_core.Signal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/src/aria/private/tree/tree.ts b/src/aria/private/tree/tree.ts index 0364c4ef7084..9f41f17859d7 100644 --- a/src/aria/private/tree/tree.ts +++ b/src/aria/private/tree/tree.ts @@ -383,6 +383,12 @@ export class TreePattern implements TreeInputs { ); } + const values = this.inputs.items().map(t => t.value()); + const duplicates = values.filter((val, idx) => values.indexOf(val) !== idx); + if (duplicates.length > 0) { + violations.push(`Duplicate tree item value '${duplicates[0]}' detected inside ngTree.`); + } + return violations; } diff --git a/src/aria/tree/tree.spec.ts b/src/aria/tree/tree.spec.ts index b6485b059e6b..941800209813 100644 --- a/src/aria/tree/tree.spec.ts +++ b/src/aria/tree/tree.spec.ts @@ -191,6 +191,45 @@ describe('Tree', () => { }); }); + describe('structural validations', () => { + let consoleSpy: jasmine.Spy; + + beforeEach(() => { + consoleSpy = spyOn(console, 'error'); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + setupTestTree(); + }); + + it('should warn when duplicate values are detected inside ngTree', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [TreeWithDuplicateValues], + }); + const duplicateFixture = TestBed.createComponent(TreeWithDuplicateValues); + duplicateFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + "Duplicate tree item value 'item0' detected inside ngTree.", + ); + }); + + it('should warn when single-select tree has multiple selected values', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [SingleSelectTreeWithMultipleValues], + }); + const singleSelectFixture = TestBed.createComponent(SingleSelectTreeWithMultipleValues); + singleSelectFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'A single-select tree should not have multiple selected options. Selected options: item0, item1', + ); + }); + }); + describe('ARIA attributes and roles', () => { describe('default configuration', () => { beforeEach(() => { @@ -1680,3 +1719,27 @@ class TestTreeComponent { currentType = signal('page' as 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'); tabIndex = signal(undefined); } + +@Component({ + template: ` +
    +
  • Item 0
  • +
  • Item 0 Copy
  • +
+ `, + imports: [Tree, TreeItem], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class TreeWithDuplicateValues {} + +@Component({ + template: ` +
    +
  • Item 0
  • +
  • Item 1
  • +
+ `, + imports: [Tree, TreeItem], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class SingleSelectTreeWithMultipleValues {} diff --git a/src/aria/tree/tree.ts b/src/aria/tree/tree.ts index cd5e3ec1fec7..eaa07da072ee 100644 --- a/src/aria/tree/tree.ts +++ b/src/aria/tree/tree.ts @@ -171,16 +171,17 @@ export class Tree implements OnDestroy { }); // Check for any violations after the DOM has been updated. - afterRenderEffect({ - read: () => { - if (typeof ngDevMode === 'undefined' || ngDevMode) { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + afterRenderEffect({ + read: () => { const violations = this._pattern.validate(); + for (const violation of violations) { console.error(violation); } - } - }, - }); + }, + }); + } // Resets default focus based on selection state until interacted. afterRenderEffect({write: () => this._pattern.setDefaultStateEffect()}); From e40eec9fbe2cc7e12241f7f0a78900034b7dee6f Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 15:43:17 -0700 Subject: [PATCH 8/8] test(multiple): Add reportViolations method for aria directives to log element and violations --- goldens/aria/private/index.api.md | 7 +++++++ src/aria/accordion/accordion-group.ts | 7 ++----- src/aria/accordion/accordion-panel.ts | 6 ++---- src/aria/accordion/accordion-trigger.ts | 6 ++---- src/aria/accordion/accordion.spec.ts | 2 +- src/aria/grid/grid.spec.ts | 2 +- src/aria/grid/grid.ts | 6 ++---- src/aria/listbox/listbox.spec.ts | 2 +- src/aria/listbox/listbox.ts | 8 ++------ src/aria/menu/menu-item.ts | 6 ++++-- src/aria/menu/menu.spec.ts | 2 +- src/aria/menu/menu.ts | 7 ++----- src/aria/private/public-api.ts | 1 + src/aria/private/utils/BUILD.bazel | 1 + src/aria/private/utils/violations.ts | 17 +++++++++++++++++ src/aria/tabs/tab-list.ts | 8 ++++++-- src/aria/tabs/tab-panel.ts | 6 ++---- src/aria/tabs/tab.ts | 6 ++++-- src/aria/tabs/tabs.spec.ts | 2 +- src/aria/toolbar/toolbar-widget-group.ts | 6 ++++-- src/aria/toolbar/toolbar.spec.ts | 2 +- src/aria/toolbar/toolbar.ts | 7 ++----- src/aria/tree/tree.spec.ts | 2 +- src/aria/tree/tree.ts | 14 ++++++++------ 24 files changed, 75 insertions(+), 58 deletions(-) create mode 100644 src/aria/private/utils/violations.ts diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index 5e5403a31d63..dd477e85709d 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -32,6 +32,7 @@ export class AccordionGroupPattern { onKeydown(event: KeyboardEvent): void; readonly prevKey: SignalLike<"ArrowUp" | "ArrowRight" | "ArrowLeft">; toggle(): void; + validate(): string[]; } // @public @@ -271,6 +272,7 @@ export class GridPattern { restoreFocusEffect(): void; setDefaultStateEffect(): void; readonly tabIndex: SignalLike<0 | -1>; + validate(): string[]; } // @public @@ -461,6 +463,7 @@ export class MenuPattern { readonly tabIndex: () => 0 | -1; trigger(): void; readonly typeaheadRegexp: RegExp; + validate(): string[]; readonly visible: SignalLike; } @@ -520,6 +523,9 @@ export class OptionPattern { readonly value: SignalLike; } +// @public +export function reportViolations(violations: string[], element: Element): void; + // @public export function resolveElement(resolver: ElementResolver, context: HTMLElement): T | undefined; @@ -652,6 +658,7 @@ export class ToolbarPattern { setDefaultStateEffect(): void; readonly softDisabled: SignalLike; readonly tabIndex: SignalLike<0 | -1>; + validate(): string[]; } // @public diff --git a/src/aria/accordion/accordion-group.ts b/src/aria/accordion/accordion-group.ts index 9385c5c4f34c..2d2657e46d71 100644 --- a/src/aria/accordion/accordion-group.ts +++ b/src/aria/accordion/accordion-group.ts @@ -19,7 +19,7 @@ import { OnDestroy, } from '@angular/core'; import {Directionality} from '@angular/cdk/bidi'; -import {AccordionGroupPattern, SortedCollection} from '../private'; +import {AccordionGroupPattern, SortedCollection, reportViolations} from '../private'; import {ACCORDION_GROUP} from './accordion-tokens'; import {AccordionTrigger} from './accordion-trigger'; @@ -119,10 +119,7 @@ export class AccordionGroup implements OnDestroy { if (typeof ngDevMode === 'undefined' || ngDevMode) { afterRenderEffect({ read: () => { - const violations = this._pattern.validate(); - for (const violation of violations) { - console.error(violation); - } + reportViolations(this._pattern.validate(), this.element); }, }); } diff --git a/src/aria/accordion/accordion-panel.ts b/src/aria/accordion/accordion-panel.ts index 3c5de431b1d3..89d30917de64 100644 --- a/src/aria/accordion/accordion-panel.ts +++ b/src/aria/accordion/accordion-panel.ts @@ -16,7 +16,7 @@ import { input, } from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; -import {DeferredContentAware, AccordionTriggerPattern} from '../private'; +import {DeferredContentAware, AccordionTriggerPattern, reportViolations} from '../private'; import {AccordionContent} from './accordion-content'; /** @@ -101,9 +101,7 @@ export class AccordionPanel { violations.push('ngAccordionPanel must have an ngAccordionTrigger to control it.'); } - for (const violation of violations) { - console.error(violation); - } + reportViolations(violations, this.element); }, }); } diff --git a/src/aria/accordion/accordion-trigger.ts b/src/aria/accordion/accordion-trigger.ts index 89486811221f..987145a70dd5 100644 --- a/src/aria/accordion/accordion-trigger.ts +++ b/src/aria/accordion/accordion-trigger.ts @@ -19,7 +19,7 @@ import { afterRenderEffect, } from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; -import {AccordionTriggerPattern} from '../private'; +import {AccordionTriggerPattern, reportViolations} from '../private'; import {ACCORDION_GROUP} from './accordion-tokens'; import {AccordionPanel} from './accordion-panel'; @@ -103,9 +103,7 @@ export class AccordionTrigger implements OnInit, OnDestroy { ); } - for (const violation of violations) { - console.error(violation); - } + reportViolations(violations, this.element); }, }); } diff --git a/src/aria/accordion/accordion.spec.ts b/src/aria/accordion/accordion.spec.ts index 2475fc40814b..77694188d697 100644 --- a/src/aria/accordion/accordion.spec.ts +++ b/src/aria/accordion/accordion.spec.ts @@ -485,7 +485,7 @@ describe('AccordionGroup', () => { let consoleSpy: jasmine.Spy; beforeEach(() => { - consoleSpy = spyOn(console, 'error'); + consoleSpy = spyOn(console, 'warn'); }); afterEach(() => { diff --git a/src/aria/grid/grid.spec.ts b/src/aria/grid/grid.spec.ts index 6dd2e1667a60..b62fea85aca5 100644 --- a/src/aria/grid/grid.spec.ts +++ b/src/aria/grid/grid.spec.ts @@ -1065,7 +1065,7 @@ describe('Grid directives', () => { let consoleSpy: jasmine.Spy; beforeEach(() => { - consoleSpy = spyOn(console, 'error'); + consoleSpy = spyOn(console, 'warn'); }); afterEach(() => { diff --git a/src/aria/grid/grid.ts b/src/aria/grid/grid.ts index 40c45561a294..ebc1a928c7d0 100644 --- a/src/aria/grid/grid.ts +++ b/src/aria/grid/grid.ts @@ -25,6 +25,7 @@ import { GridRowPattern, SortedCollection, tabIndexTransform, + reportViolations, } from '../private'; import {GridRow} from './grid-row'; import {GRID} from './grid-tokens'; @@ -158,10 +159,7 @@ export class Grid implements OnDestroy { if (typeof ngDevMode === 'undefined' || ngDevMode) { afterRenderEffect({ read: () => { - const violations = this._pattern.validate(); - for (const violation of violations) { - console.error(violation); - } + reportViolations(this._pattern.validate(), this.element); }, }); } diff --git a/src/aria/listbox/listbox.spec.ts b/src/aria/listbox/listbox.spec.ts index 71f3165798ab..84cb05254921 100644 --- a/src/aria/listbox/listbox.spec.ts +++ b/src/aria/listbox/listbox.spec.ts @@ -458,7 +458,7 @@ describe('Listbox', () => { let consoleSpy: jasmine.Spy; beforeEach(() => { - consoleSpy = spyOn(console, 'error'); + consoleSpy = spyOn(console, 'warn'); }); afterEach(() => { diff --git a/src/aria/listbox/listbox.ts b/src/aria/listbox/listbox.ts index 726d51ce9d62..616154b6021e 100644 --- a/src/aria/listbox/listbox.ts +++ b/src/aria/listbox/listbox.ts @@ -23,7 +23,7 @@ import { } from '@angular/core'; import {Directionality} from '@angular/cdk/bidi'; import {_IdGenerator} from '@angular/cdk/a11y'; -import {ListboxPattern, SortedCollection, tabIndexTransform} from '../private'; +import {ListboxPattern, SortedCollection, tabIndexTransform, reportViolations} from '../private'; import {Option} from './option'; import {LISTBOX} from './tokens'; @@ -163,11 +163,7 @@ export class Listbox implements OnDestroy { if (typeof ngDevMode === 'undefined' || ngDevMode) { afterRenderEffect({ read: () => { - const violations = this._pattern.validate(); - - for (const violation of violations) { - console.error(violation); - } + reportViolations(this._pattern.validate(), this.element); }, }); } diff --git a/src/aria/menu/menu-item.ts b/src/aria/menu/menu-item.ts index a9f1cd097cd3..958de1a1ec69 100644 --- a/src/aria/menu/menu-item.ts +++ b/src/aria/menu/menu-item.ts @@ -18,7 +18,7 @@ import { OnInit, afterRenderEffect, } from '@angular/core'; -import {MenuItemPattern} from '../private'; +import {MenuItemPattern, reportViolations} from '../private'; import {_IdGenerator} from '@angular/cdk/a11y'; import {MENU_COMPONENT} from './menu-tokens'; import type {Menu} from './menu'; @@ -106,9 +106,11 @@ export class MenuItem implements OnInit, OnDestroy { if (typeof ngDevMode === 'undefined' || ngDevMode) { afterRenderEffect({ read: () => { + const violations: string[] = []; if (!this.parent) { - console.error('ngMenuItem must be placed inside an ngMenu or ngMenuBar container.'); + violations.push('ngMenuItem must be placed inside an ngMenu or ngMenuBar container.'); } + reportViolations(violations, this.element); }, }); } diff --git a/src/aria/menu/menu.spec.ts b/src/aria/menu/menu.spec.ts index e82367b72537..5030ec322f6d 100644 --- a/src/aria/menu/menu.spec.ts +++ b/src/aria/menu/menu.spec.ts @@ -502,7 +502,7 @@ describe('Standalone Menu Pattern', () => { let consoleSpy: jasmine.Spy; beforeEach(() => { - consoleSpy = spyOn(console, 'error'); + consoleSpy = spyOn(console, 'warn'); }); afterEach(() => { diff --git a/src/aria/menu/menu.ts b/src/aria/menu/menu.ts index 0a9de664f6df..fb8d29b815d2 100644 --- a/src/aria/menu/menu.ts +++ b/src/aria/menu/menu.ts @@ -21,7 +21,7 @@ import { signal, untracked, } from '@angular/core'; -import {MenuPattern, DeferredContentAware, SortedCollection} from '../private'; +import {MenuPattern, DeferredContentAware, SortedCollection, reportViolations} from '../private'; import {_IdGenerator} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {MenuTrigger} from './menu-trigger'; @@ -192,10 +192,7 @@ export class Menu implements OnDestroy { if (typeof ngDevMode === 'undefined' || ngDevMode) { afterRenderEffect({ read: () => { - const violations = this._pattern.validate(); - for (const violation of violations) { - console.error(violation); - } + reportViolations(this._pattern.validate(), this.element); }, }); } diff --git a/src/aria/private/public-api.ts b/src/aria/private/public-api.ts index 00248757bd83..213e6706b137 100644 --- a/src/aria/private/public-api.ts +++ b/src/aria/private/public-api.ts @@ -26,4 +26,5 @@ export * from './utils/collection'; export * from './utils/element'; export * from './utils/element-resolver'; export * from './utils/transforms'; +export * from './utils/violations'; export * from './combobox/combobox'; diff --git a/src/aria/private/utils/BUILD.bazel b/src/aria/private/utils/BUILD.bazel index db82bca1e81e..67b663d83222 100644 --- a/src/aria/private/utils/BUILD.bazel +++ b/src/aria/private/utils/BUILD.bazel @@ -9,6 +9,7 @@ ts_project( "element.ts", "element-resolver.ts", "transforms.ts", + "violations.ts", ], deps = [ "//:node_modules/@angular/core", diff --git a/src/aria/private/utils/violations.ts b/src/aria/private/utils/violations.ts new file mode 100644 index 000000000000..ed1565a99f5e --- /dev/null +++ b/src/aria/private/utils/violations.ts @@ -0,0 +1,17 @@ +/** + * @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 + */ + +/** Logs each of the violations to the console as errors, optionally with the host element context. */ +export function reportViolations(violations: string[], element: Element): void { + if (violations.length) { + console.warn('Violations found on element: %o:', element); + violations.forEach(violation => { + console.warn(violation); + }); + } +} diff --git a/src/aria/tabs/tab-list.ts b/src/aria/tabs/tab-list.ts index 96f94806974c..6cccfb77ab72 100644 --- a/src/aria/tabs/tab-list.ts +++ b/src/aria/tabs/tab-list.ts @@ -23,7 +23,7 @@ import { linkedSignal, WritableSignal, } from '@angular/core'; -import {SortedCollection, TabListPattern, TabPattern} from '../private'; +import {SortedCollection, TabListPattern, TabPattern, reportViolations} from '../private'; import {TABS, TAB_LIST} from './tab-tokens'; import type {Tab} from './tab'; @@ -152,11 +152,15 @@ export class TabList implements OnInit, OnDestroy { if (typeof ngDevMode === 'undefined' || ngDevMode) { afterRenderEffect({ read: () => { + const violations: string[] = []; + 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.`); + violations.push(`Duplicate value '${duplicates[0]}' detected inside ngTabList.`); } + + reportViolations(violations, this.element); }, }); } diff --git a/src/aria/tabs/tab-panel.ts b/src/aria/tabs/tab-panel.ts index b7433a5923cd..42eb5759240b 100644 --- a/src/aria/tabs/tab-panel.ts +++ b/src/aria/tabs/tab-panel.ts @@ -18,7 +18,7 @@ import { OnInit, OnDestroy, } from '@angular/core'; -import {TabPanelPattern, DeferredContentAware} from '../private'; +import {TabPanelPattern, DeferredContentAware, reportViolations} from '../private'; import {TABS} from './tab-tokens'; import {TabContent} from './tab-content'; @@ -114,9 +114,7 @@ export class TabPanel implements OnInit, OnDestroy { ); } - for (const violation of violations) { - console.error(violation); - } + reportViolations(violations, this.element); }, }); } diff --git a/src/aria/tabs/tab.ts b/src/aria/tabs/tab.ts index bf429f0281f2..1c2477607d7a 100644 --- a/src/aria/tabs/tab.ts +++ b/src/aria/tabs/tab.ts @@ -18,7 +18,7 @@ import { input, afterRenderEffect, } from '@angular/core'; -import {TabPattern, HasElement} from '../private'; +import {TabPattern, HasElement, reportViolations} from '../private'; import {TAB_LIST} from './tab-tokens'; /** @@ -95,13 +95,15 @@ export class Tab implements HasElement, OnInit, OnDestroy { if (typeof ngDevMode === 'undefined' || ngDevMode) { afterRenderEffect({ read: () => { + const violations: string[] = []; if (this._tabList && this._tabList._tabsParent) { if (!this._tabList._tabsParent._panelMap().has(this.value())) { - console.error( + violations.push( `ngTab with value '${this.value()}' does not have a corresponding ngTabPanel.`, ); } } + reportViolations(violations, this.element); }, }); } diff --git a/src/aria/tabs/tabs.spec.ts b/src/aria/tabs/tabs.spec.ts index f56199ae8791..2459a852187b 100644 --- a/src/aria/tabs/tabs.spec.ts +++ b/src/aria/tabs/tabs.spec.ts @@ -811,7 +811,7 @@ describe('Tabs', () => { let consoleSpy: jasmine.Spy; beforeEach(() => { - consoleSpy = spyOn(console, 'error'); + consoleSpy = spyOn(console, 'warn'); }); afterEach(() => { diff --git a/src/aria/toolbar/toolbar-widget-group.ts b/src/aria/toolbar/toolbar-widget-group.ts index 7db72bd1282a..304d3cd21ab5 100644 --- a/src/aria/toolbar/toolbar-widget-group.ts +++ b/src/aria/toolbar/toolbar-widget-group.ts @@ -16,7 +16,7 @@ import { contentChildren, afterRenderEffect, } from '@angular/core'; -import {ToolbarWidgetPattern, ToolbarWidgetGroupPattern} from '../private'; +import {ToolbarWidgetPattern, ToolbarWidgetGroupPattern, reportViolations} from '../private'; import {Toolbar} from './toolbar'; import {ToolbarWidget} from './toolbar-widget'; import {TOOLBAR_WIDGET_GROUP} from './toolbar-tokens'; @@ -69,9 +69,11 @@ export class ToolbarWidgetGroup { if (typeof ngDevMode === 'undefined' || ngDevMode) { afterRenderEffect({ read: () => { + const violations: string[] = []; if (!this._toolbar) { - console.error('ngToolbarWidgetGroup must be placed inside an ngToolbar container.'); + violations.push('ngToolbarWidgetGroup must be placed inside an ngToolbar container.'); } + reportViolations(violations, this.element); }, }); } diff --git a/src/aria/toolbar/toolbar.spec.ts b/src/aria/toolbar/toolbar.spec.ts index 790fa117eae7..8b7060ec3e1e 100644 --- a/src/aria/toolbar/toolbar.spec.ts +++ b/src/aria/toolbar/toolbar.spec.ts @@ -708,7 +708,7 @@ describe('Toolbar', () => { let consoleSpy: jasmine.Spy; beforeEach(() => { - consoleSpy = spyOn(console, 'error'); + consoleSpy = spyOn(console, 'warn'); }); afterEach(() => { diff --git a/src/aria/toolbar/toolbar.ts b/src/aria/toolbar/toolbar.ts index ec6adf99eeae..0d8ad21be0cd 100644 --- a/src/aria/toolbar/toolbar.ts +++ b/src/aria/toolbar/toolbar.ts @@ -19,7 +19,7 @@ import { OnDestroy, signal, } from '@angular/core'; -import {ToolbarPattern, ToolbarWidgetPattern, SortedCollection} from '../private'; +import {ToolbarPattern, ToolbarWidgetPattern, SortedCollection, reportViolations} from '../private'; import {Directionality} from '@angular/cdk/bidi'; import type {ToolbarWidget} from './toolbar-widget'; @@ -111,10 +111,7 @@ export class Toolbar implements OnDestroy { if (typeof ngDevMode === 'undefined' || ngDevMode) { afterRenderEffect({ read: () => { - const violations = this._pattern.validate(); - for (const violation of violations) { - console.error(violation); - } + reportViolations(this._pattern.validate(), this.element); }, }); } diff --git a/src/aria/tree/tree.spec.ts b/src/aria/tree/tree.spec.ts index 941800209813..e54c2b8c09da 100644 --- a/src/aria/tree/tree.spec.ts +++ b/src/aria/tree/tree.spec.ts @@ -195,7 +195,7 @@ describe('Tree', () => { let consoleSpy: jasmine.Spy; beforeEach(() => { - consoleSpy = spyOn(console, 'error'); + consoleSpy = spyOn(console, 'warn'); }); afterEach(() => { diff --git a/src/aria/tree/tree.ts b/src/aria/tree/tree.ts index eaa07da072ee..0b759a0d4b12 100644 --- a/src/aria/tree/tree.ts +++ b/src/aria/tree/tree.ts @@ -23,7 +23,13 @@ import { } from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; -import {SortedCollection, tabIndexTransform, TreeItemPattern, TreePattern} from '../private'; +import { + SortedCollection, + tabIndexTransform, + TreeItemPattern, + TreePattern, + reportViolations, +} from '../private'; import type {TreeItem} from './tree-item'; /** @@ -174,11 +180,7 @@ export class Tree implements OnDestroy { if (typeof ngDevMode === 'undefined' || ngDevMode) { afterRenderEffect({ read: () => { - const violations = this._pattern.validate(); - - for (const violation of violations) { - console.error(violation); - } + reportViolations(this._pattern.validate(), this.element); }, }); }