From 415f060dc008c9ee3e0a3b32d9f1e27193b55e0f Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Mon, 4 May 2026 11:00:14 -0700 Subject: [PATCH 1/9] 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 8b1e1f696279..353f4d7bb6b4 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,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 99e6e60b55dd..d7c62f102efb 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. + 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 43cc4cb55fdd..1bc83b09be44 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. + 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 433ebdba39aec887f106b646227a636d7d2984ee Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 14:09:13 -0700 Subject: [PATCH 2/9] test(aria/combobox): check for incorrect usage of Combobox directives and log violations --- goldens/aria/combobox/index.api.md | 1 + src/aria/combobox/combobox-popup.ts | 26 +++++++++++- src/aria/combobox/combobox.spec.ts | 65 +++++++++++++++++++++++++++++ src/aria/combobox/combobox.ts | 13 ++++++ 4 files changed, 104 insertions(+), 1 deletion(-) diff --git a/goldens/aria/combobox/index.api.md b/goldens/aria/combobox/index.api.md index 6ecc5febf20e..042b3b262c25 100644 --- a/goldens/aria/combobox/index.api.md +++ b/goldens/aria/combobox/index.api.md @@ -37,6 +37,7 @@ export class Combobox extends DeferredContentAware implements OnInit { // @public export class ComboboxPopup implements OnInit, OnDestroy { + constructor(); readonly activeDescendant: _angular_core.Signal; readonly combobox: _angular_core.InputSignal; readonly controlTarget: _angular_core.Signal; diff --git a/src/aria/combobox/combobox-popup.ts b/src/aria/combobox/combobox-popup.ts index b220a5d548fb..edd1b64f9dae 100644 --- a/src/aria/combobox/combobox-popup.ts +++ b/src/aria/combobox/combobox-popup.ts @@ -6,7 +6,16 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed, Directive, inject, input, OnDestroy, OnInit, signal} from '@angular/core'; +import { + computed, + Directive, + inject, + input, + OnDestroy, + OnInit, + signal, + afterRenderEffect, +} from '@angular/core'; import {DeferredContent, ComboboxPopupPattern} from '@angular/aria/private'; import type {Combobox} from './combobox'; import type {ComboboxWidget} from './combobox-widget'; @@ -58,6 +67,21 @@ export class ComboboxPopup implements OnInit, OnDestroy { ...this, }); + constructor() { + // Check for any violations after the DOM has been updated. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + afterRenderEffect({ + read: () => { + if (!this._widget()) { + console.error( + 'ngComboboxPopup must contain an ngComboboxWidget to establish focus controls.', + ); + } + }, + }); + } + } + ngOnInit() { this.combobox()._registerPopup(this); this._deferredContent.deferredContentAware.set(this.combobox()); diff --git a/src/aria/combobox/combobox.spec.ts b/src/aria/combobox/combobox.spec.ts index 08b587b2181e..b378389b0759 100644 --- a/src/aria/combobox/combobox.spec.ts +++ b/src/aria/combobox/combobox.spec.ts @@ -6,6 +6,7 @@ import { untracked, viewChild, afterRenderEffect, + ChangeDetectionStrategy, } from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; @@ -95,6 +96,45 @@ describe('Combobox', () => { afterEach(async () => await runAccessibilityChecks(fixture.nativeElement)); + describe('structural validations', () => { + let consoleSpy: jasmine.Spy; + + beforeEach(() => { + consoleSpy = spyOn(console, 'error'); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + setupCombobox(); + }); + + it('should warn when ngCombobox is missing ngComboboxPopup', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ComboboxWithoutPopup], + }); + const noPopupFixture = TestBed.createComponent(ComboboxWithoutPopup); + noPopupFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'ngCombobox must have a corresponding ngComboboxPopup template to render.', + ); + }); + + it('should warn when ngComboboxPopup is missing ngComboboxWidget', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ComboboxPopupWithoutWidget], + }); + const noWidgetFixture = TestBed.createComponent(ComboboxPopupWithoutWidget); + noWidgetFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'ngComboboxPopup must contain an ngComboboxWidget to establish focus controls.', + ); + }); + }); + describe('ARIA attributes and roles', () => { beforeEach(() => setupCombobox()); @@ -1705,3 +1745,28 @@ class ComboboxListboxHighlightExample { this.popupExpanded.set(false); } } + +@Component({ + template: ` +
+ +
+ `, + imports: [Combobox], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ComboboxWithoutPopup {} + +@Component({ + template: ` +
+ + +
No Widget Inside
+
+
+ `, + imports: [Combobox, ComboboxPopup], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ComboboxPopupWithoutWidget {} diff --git a/src/aria/combobox/combobox.ts b/src/aria/combobox/combobox.ts index f3c074bb3b32..62dc8d9bd5c4 100644 --- a/src/aria/combobox/combobox.ts +++ b/src/aria/combobox/combobox.ts @@ -123,6 +123,19 @@ export class Combobox extends DeferredContentAware implements OnInit { this._pattern.highlightEffect(); }); } + + // Check for any violations after the DOM has been updated. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + afterRenderEffect({ + read: () => { + if (!this._popup()) { + console.error( + 'ngCombobox must have a corresponding ngComboboxPopup template to render.', + ); + } + }, + }); + } } ngOnInit() { From 736ca3fd762952ca633f8d7759395d2851df58eb Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 14:13:55 -0700 Subject: [PATCH 3/9] test(aria/grid): check for incorrect usage of Grid directives and log violations --- src/aria/grid/grid.spec.ts | 56 +++++++++++++++++++++++++++++++++++ src/aria/grid/grid.ts | 12 ++++++++ src/aria/private/grid/grid.ts | 17 +++++++++++ 3 files changed, 85 insertions(+) diff --git a/src/aria/grid/grid.spec.ts b/src/aria/grid/grid.spec.ts index cf5828525102..7478fb456ade 100644 --- a/src/aria/grid/grid.spec.ts +++ b/src/aria/grid/grid.spec.ts @@ -1060,6 +1060,41 @@ describe('Grid directives', () => { }); }); }); + + describe('structural validations', () => { + let consoleSpy: jasmine.Spy; + + beforeEach(() => { + consoleSpy = spyOn(console, 'error'); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + setupGrid(); + }); + + it('should warn when ngGrid contains no rows', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [GridWithoutRows], + }); + const noRowsFixture = TestBed.createComponent(GridWithoutRows); + noRowsFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith('ngGrid must contain at least one ngGridRow.'); + }); + + 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 +1171,24 @@ class GridTestComponent { onActivated = jasmine.createSpy('activated'); onDeactivated = jasmine.createSpy('deactivated'); } + +@Component({ + template: ` + +
+ `, + imports: [Grid], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class GridWithoutRows {} + +@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 0d53401ef6bc..86bd623f251c 100644 --- a/src/aria/grid/grid.ts +++ b/src/aria/grid/grid.ts @@ -156,6 +156,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..84aef333decd 100644 --- a/src/aria/private/grid/grid.ts +++ b/src/aria/private/grid/grid.ts @@ -196,6 +196,23 @@ export class GridPattern { }); } + /** Returns a set of violations */ + validate(): string[] { + const violations: string[] = []; + + const rows = this.inputs.rows(); + if (rows.length === 0) { + violations.push('ngGrid must contain at least one ngGridRow.'); + } + 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 d83c9baabc905c82d6cc840d66821532c84f0ea8 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 13:54:13 -0700 Subject: [PATCH 4/9] 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 3dc73c239af4..728e5b70ae21 100644 --- a/src/aria/listbox/listbox.ts +++ b/src/aria/listbox/listbox.ts @@ -161,17 +161,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 821c9e917f93934d58ebc1ce1a042054390cbeb5 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 13:57:52 -0700 Subject: [PATCH 5/9] 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 6f85007e713a..cce9a5a893cb 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. + 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 b03bf8af6aea..fd36a1f29be0 100644 --- a/src/aria/menu/menu.ts +++ b/src/aria/menu/menu.ts @@ -190,6 +190,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 a5641f5469c087fce2cc89953349b4608f80fb34 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Mon, 4 May 2026 16:20:24 -0700 Subject: [PATCH 6/9] 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..35d8f9cbd9b3 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. + 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 954043f426b9..2a3ba063e906 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. + 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 9df510843c20..d44ea05704fd 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() { + 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 760d7386a7e718b37fe93cac43996dac8c0f3db9 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 13:42:18 -0700 Subject: [PATCH 7/9] 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 5c73b44f38a6..0844ada289d3 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. + 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 8bb28c423360..babf81b77c18 100644 --- a/src/aria/toolbar/toolbar.ts +++ b/src/aria/toolbar/toolbar.ts @@ -109,6 +109,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 0f25e28d6f88b50a8dd31b6fd28f8b3762c03a47 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 14:19:37 -0700 Subject: [PATCH 8/9] 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 | 10 ++++ src/aria/tree/tree.spec.ts | 84 ++++++++++++++++++++++++++++++++++ src/aria/tree/tree.ts | 13 +++--- 4 files changed, 103 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..88281ae72efe 100644 --- a/src/aria/private/tree/tree.ts +++ b/src/aria/private/tree/tree.ts @@ -377,12 +377,22 @@ export class TreePattern implements TreeInputs { validate(): string[] { const violations: string[] = []; + if (this.inputs.items().length === 0) { + violations.push('ngTree must contain at least one ngTreeItem.'); + } + if (!this.inputs.multi() && this.inputs.value().length > 1) { violations.push( `A single-select tree should not have multiple selected options. Selected options: ${this.inputs.value().join(', ')}`, ); } + 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..09ec585e54bf 100644 --- a/src/aria/tree/tree.spec.ts +++ b/src/aria/tree/tree.spec.ts @@ -191,6 +191,56 @@ 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 ngTree contains no items', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [TreeWithoutItems], + }); + const noItemsFixture = TestBed.createComponent(TreeWithoutItems); + noItemsFixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith('ngTree must contain at least one ngTreeItem.'); + }); + + 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 +1730,37 @@ 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: ` +
    +
+ `, + imports: [Tree], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class TreeWithoutItems {} + +@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 5f676ab886ad..30a2144341fa 100644 --- a/src/aria/tree/tree.ts +++ b/src/aria/tree/tree.ts @@ -173,16 +173,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 15a16d901d58db4bd283b2d7531f88e14021b1dc Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 May 2026 15:43:17 -0700 Subject: [PATCH 9/9] 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/combobox/combobox-popup.ts | 6 ++++-- src/aria/combobox/combobox.ts | 11 +++++++++-- src/aria/grid/grid.ts | 6 ++---- src/aria/listbox/listbox.ts | 8 ++------ src/aria/menu/menu-item.ts | 6 ++++-- 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/toolbar/toolbar-widget-group.ts | 6 ++++-- src/aria/toolbar/toolbar.ts | 7 ++----- src/aria/tree/tree.ts | 14 ++++++++------ 19 files changed, 81 insertions(+), 55 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 353f4d7bb6b4..fa98bfea3e76 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'; @@ -120,10 +120,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 d7c62f102efb..4cdb704928d7 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'; /** @@ -102,9 +102,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 1bc83b09be44..0c6ad5151d59 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'; @@ -104,9 +104,7 @@ export class AccordionTrigger implements OnInit, OnDestroy { ); } - for (const violation of violations) { - console.error(violation); - } + reportViolations(violations, this.element); }, }); } diff --git a/src/aria/combobox/combobox-popup.ts b/src/aria/combobox/combobox-popup.ts index edd1b64f9dae..563e41f21396 100644 --- a/src/aria/combobox/combobox-popup.ts +++ b/src/aria/combobox/combobox-popup.ts @@ -16,7 +16,7 @@ import { signal, afterRenderEffect, } from '@angular/core'; -import {DeferredContent, ComboboxPopupPattern} from '@angular/aria/private'; +import {DeferredContent, ComboboxPopupPattern, reportViolations} from '@angular/aria/private'; import type {Combobox} from './combobox'; import type {ComboboxWidget} from './combobox-widget'; import {COMBOBOX_POPUP} from './combobox-tokens'; @@ -72,11 +72,13 @@ export class ComboboxPopup implements OnInit, OnDestroy { if (typeof ngDevMode === 'undefined' || ngDevMode) { afterRenderEffect({ read: () => { + const violations: string[] = []; if (!this._widget()) { - console.error( + violations.push( 'ngComboboxPopup must contain an ngComboboxWidget to establish focus controls.', ); } + reportViolations(violations, this.combobox().element); }, }); } diff --git a/src/aria/combobox/combobox.ts b/src/aria/combobox/combobox.ts index 62dc8d9bd5c4..7e16376d7c72 100644 --- a/src/aria/combobox/combobox.ts +++ b/src/aria/combobox/combobox.ts @@ -19,7 +19,12 @@ import { signal, Renderer2, } from '@angular/core'; -import {DeferredContentAware, ComboboxPattern, tabIndexTransform} from '@angular/aria/private'; +import { + DeferredContentAware, + ComboboxPattern, + tabIndexTransform, + reportViolations, +} from '@angular/aria/private'; import type {ComboboxPopup} from './combobox-popup'; /** @@ -128,11 +133,13 @@ export class Combobox extends DeferredContentAware implements OnInit { if (typeof ngDevMode === 'undefined' || ngDevMode) { afterRenderEffect({ read: () => { + const violations: string[] = []; if (!this._popup()) { - console.error( + violations.push( 'ngCombobox must have a corresponding ngComboboxPopup template to render.', ); } + reportViolations(violations, this.element); }, }); } diff --git a/src/aria/grid/grid.ts b/src/aria/grid/grid.ts index 86bd623f251c..c11793f813e5 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'; @@ -160,10 +161,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.ts b/src/aria/listbox/listbox.ts index 728e5b70ae21..b3c9d700d076 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'; @@ -165,11 +165,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 cce9a5a893cb..c1a5d5269243 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'; @@ -108,9 +108,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.ts b/src/aria/menu/menu.ts index fd36a1f29be0..073650e626d2 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'; @@ -194,10 +194,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..d59026f1087b --- /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.error('Violations found on element: %o:', element); + violations.forEach(violation => { + console.error(violation); + }); + } +} diff --git a/src/aria/tabs/tab-list.ts b/src/aria/tabs/tab-list.ts index 35d8f9cbd9b3..db7e5eba6365 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'; @@ -154,11 +154,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 2a3ba063e906..e00e2ffb23f5 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'; @@ -116,9 +116,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 d44ea05704fd..b5f15233156a 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'; /** @@ -97,13 +97,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/toolbar/toolbar-widget-group.ts b/src/aria/toolbar/toolbar-widget-group.ts index 0844ada289d3..248f7bfd61c7 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'; @@ -71,9 +71,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.ts b/src/aria/toolbar/toolbar.ts index babf81b77c18..c08aed28382c 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'; @@ -113,10 +113,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.ts b/src/aria/tree/tree.ts index 30a2144341fa..8d80a641646b 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'; /** @@ -176,11 +182,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); }, }); }