Skip to content

Commit 33593e7

Browse files
authored
test(multiple): check for incorrect usage of Angular Aria directives and log violations (angular#33195)
* test(aria/accordion): check for incorrect usage of Accordion directives and log violations * test(aria/grid): check for incorrect usage of Grid directives and log violations * test(aria/listbox): check for incorrect usage of Listbox directives and log violations * test(aria/menu): check for incorrect usage of Menu directives and log violations * test(aria/tabs): check for incorrect usage of Tabs directives and log violations * test(aria/toolbar): check for incorrect usage of Toolbar directives and log violations * test(aria/tree): check for incorrect usage of Tree directives and log violations * test(multiple): Add reportViolations method for aria directives to log element and violations
1 parent 930fe9b commit 33593e7

33 files changed

Lines changed: 916 additions & 37 deletions

goldens/aria/accordion/index.api.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,14 @@ export class AccordionPanel {
5050
toggle(): void;
5151
readonly visible: _angular_core.Signal<boolean>;
5252
// (undocumented)
53-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<AccordionPanel, "[ngAccordionPanel]", ["ngAccordionPanel"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; }, {}, never, never, true, [{ directive: typeof DeferredContentAware; inputs: { "preserveContent": "preserveContent"; }; outputs: {}; }]>;
53+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<AccordionPanel, "[ngAccordionPanel]", ["ngAccordionPanel"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; }, {}, ["_accordionContent"], never, true, [{ directive: typeof DeferredContentAware; inputs: { "preserveContent": "preserveContent"; }; outputs: {}; }]>;
5454
// (undocumented)
5555
static ɵfac: _angular_core.ɵɵFactoryDeclaration<AccordionPanel, never>;
5656
}
5757

5858
// @public
5959
export class AccordionTrigger implements OnInit, OnDestroy {
60+
constructor();
6061
readonly active: _angular_core.Signal<boolean>;
6162
collapse(): void;
6263
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;

goldens/aria/private/index.api.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export class AccordionGroupPattern {
3232
onKeydown(event: KeyboardEvent): void;
3333
readonly prevKey: SignalLike<"ArrowUp" | "ArrowRight" | "ArrowLeft">;
3434
toggle(): void;
35+
validate(): string[];
3536
}
3637

3738
// @public
@@ -271,6 +272,7 @@ export class GridPattern {
271272
restoreFocusEffect(): void;
272273
setDefaultStateEffect(): void;
273274
readonly tabIndex: SignalLike<0 | -1>;
275+
validate(): string[];
274276
}
275277

276278
// @public
@@ -461,6 +463,7 @@ export class MenuPattern<V> {
461463
readonly tabIndex: () => 0 | -1;
462464
trigger(): void;
463465
readonly typeaheadRegexp: RegExp;
466+
validate(): string[];
464467
readonly visible: SignalLike<boolean>;
465468
}
466469

@@ -520,6 +523,9 @@ export class OptionPattern<V> {
520523
readonly value: SignalLike<V>;
521524
}
522525

526+
// @public
527+
export function reportViolations(violations: string[], element: Element): void;
528+
523529
// @public
524530
export function resolveElement<T = HTMLElement>(resolver: ElementResolver<T>, context: HTMLElement): T | undefined;
525531

@@ -652,6 +658,7 @@ export class ToolbarPattern<V> {
652658
setDefaultStateEffect(): void;
653659
readonly softDisabled: SignalLike<boolean>;
654660
readonly tabIndex: SignalLike<0 | -1>;
661+
validate(): string[];
655662
}
656663

657664
// @public

goldens/aria/tabs/index.api.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { WritableSignal } from '@angular/core';
1313

1414
// @public
1515
export class Tab implements HasElement, OnInit, OnDestroy {
16+
constructor();
1617
readonly active: _angular_core.Signal<boolean>;
1718
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
1819
readonly element: HTMLElement;
@@ -81,7 +82,7 @@ export class TabPanel implements OnInit, OnDestroy {
8182
readonly value: _angular_core.InputSignal<string>;
8283
readonly visible: _angular_core.Signal<boolean>;
8384
// (undocumented)
84-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<TabPanel, "[ngTabPanel]", ["ngTabPanel"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": true; "isSignal": true; }; }, {}, never, never, true, [{ directive: typeof DeferredContentAware; inputs: { "preserveContent": "preserveContent"; }; outputs: {}; }]>;
85+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<TabPanel, "[ngTabPanel]", ["ngTabPanel"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": true; "isSignal": true; }; }, {}, ["_tabContent"], never, true, [{ directive: typeof DeferredContentAware; inputs: { "preserveContent": "preserveContent"; }; outputs: {}; }]>;
8586
// (undocumented)
8687
static ɵfac: _angular_core.ɵɵFactoryDeclaration<TabPanel, never>;
8788
}

goldens/aria/toolbar/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export class ToolbarWidget<V> implements OnInit, OnDestroy {
5555

5656
// @public
5757
export class ToolbarWidgetGroup<V> {
58+
constructor();
5859
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
5960
readonly element: HTMLElement;
6061
readonly multi: _angular_core.InputSignalWithTransform<boolean, unknown>;

src/aria/accordion/accordion-group.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ import {
1515
input,
1616
signal,
1717
afterNextRender,
18+
afterRenderEffect,
1819
OnDestroy,
1920
} from '@angular/core';
2021
import {Directionality} from '@angular/cdk/bidi';
21-
import {AccordionGroupPattern, SortedCollection} from '../private';
22+
import {AccordionGroupPattern, SortedCollection, reportViolations} from '../private';
2223
import {ACCORDION_GROUP} from './accordion-tokens';
2324
import {AccordionTrigger} from './accordion-trigger';
2425

@@ -113,6 +114,15 @@ export class AccordionGroup implements OnDestroy {
113114
afterNextRender(() => {
114115
this._collection.startObserving(this.element);
115116
});
117+
118+
// Check for any violations after the DOM has been updated.
119+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
120+
afterRenderEffect({
121+
read: () => {
122+
reportViolations(this._pattern.validate(), this.element);
123+
},
124+
});
125+
}
116126
}
117127

118128
ngOnDestroy() {

src/aria/accordion/accordion-panel.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,18 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {Directive, ElementRef, afterRenderEffect, computed, inject, input} from '@angular/core';
9+
import {
10+
Directive,
11+
ElementRef,
12+
afterRenderEffect,
13+
computed,
14+
contentChild,
15+
inject,
16+
input,
17+
} from '@angular/core';
1018
import {_IdGenerator} from '@angular/cdk/a11y';
11-
import {DeferredContentAware, AccordionTriggerPattern} from '../private';
19+
import {DeferredContentAware, AccordionTriggerPattern, reportViolations} from '../private';
20+
import {AccordionContent} from './accordion-content';
1221

1322
/**
1423
* The content panel of an accordion item that is conditionally visible.
@@ -56,6 +65,8 @@ export class AccordionPanel {
5665
/** The DeferredContentAware host directive. */
5766
private readonly _deferredContentAware = inject(DeferredContentAware);
5867

68+
private readonly _accordionContent = contentChild(AccordionContent);
69+
5970
/** A global unique identifier for the panel. */
6071
readonly id = input(inject(_IdGenerator).getId('ng-accordion-panel-', true));
6172

@@ -76,6 +87,24 @@ export class AccordionPanel {
7687
this._deferredContentAware.contentVisible.set(this.visible());
7788
},
7889
});
90+
91+
// Check for any violations after the DOM has been updated.
92+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
93+
afterRenderEffect({
94+
read: () => {
95+
const violations: string[] = [];
96+
97+
if (!this._accordionContent()) {
98+
violations.push('ngAccordionPanel must have an ngAccordionContent to render.');
99+
}
100+
if (!this._pattern) {
101+
violations.push('ngAccordionPanel must have an ngAccordionTrigger to control it.');
102+
}
103+
104+
reportViolations(violations, this.element);
105+
},
106+
});
107+
}
79108
}
80109

81110
/** Expands this item. */

src/aria/accordion/accordion-trigger.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ import {
1616
inject,
1717
input,
1818
model,
19+
afterRenderEffect,
1920
} from '@angular/core';
2021
import {_IdGenerator} from '@angular/cdk/a11y';
21-
import {AccordionTriggerPattern} from '../private';
22+
import {AccordionTriggerPattern, reportViolations} from '../private';
2223
import {ACCORDION_GROUP} from './accordion-tokens';
2324
import {AccordionPanel} from './accordion-panel';
2425

@@ -84,6 +85,30 @@ export class AccordionTrigger implements OnInit, OnDestroy {
8485
/** The UI pattern instance for this trigger. */
8586
_pattern!: AccordionTriggerPattern;
8687

88+
constructor() {
89+
// Check for any violations after the DOM has been updated.
90+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
91+
afterRenderEffect({
92+
read: () => {
93+
const violations: string[] = [];
94+
95+
if (this.panel() && this.panel().element.contains(this.element)) {
96+
violations.push(
97+
'ngAccordionTrigger must not be nested inside its controlled ngAccordionPanel, otherwise it will become unreachable when collapsed.',
98+
);
99+
}
100+
if (this.panel() && (this.panel() as any)._pattern !== this._pattern) {
101+
violations.push(
102+
'ngAccordionPanel is already controlled by another ngAccordionTrigger.',
103+
);
104+
}
105+
106+
reportViolations(violations, this.element);
107+
},
108+
});
109+
}
110+
}
111+
87112
ngOnInit() {
88113
this._pattern = new AccordionTriggerPattern({
89114
...this,

src/aria/accordion/accordion.spec.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,89 @@ describe('AccordionGroup', () => {
480480
});
481481
});
482482
});
483+
484+
describe('structural validations', () => {
485+
let consoleSpy: jasmine.Spy;
486+
487+
beforeEach(() => {
488+
consoleSpy = spyOn(console, 'warn');
489+
});
490+
491+
afterEach(() => {
492+
TestBed.resetTestingModule();
493+
TestBed.configureTestingModule({
494+
imports: [AccordionGroupWithLoop],
495+
providers: [provideFakeDirectionality('ltr'), _IdGenerator],
496+
});
497+
fixture = TestBed.createComponent(AccordionGroupWithLoop);
498+
setupAccordionGroup();
499+
});
500+
501+
it('should warn when multiple triggers control the same panel', () => {
502+
TestBed.resetTestingModule();
503+
TestBed.configureTestingModule({
504+
imports: [AccordionWithDuplicateTriggers],
505+
});
506+
const duplicateFixture = TestBed.createComponent(AccordionWithDuplicateTriggers);
507+
duplicateFixture.detectChanges();
508+
509+
expect(consoleSpy).toHaveBeenCalledWith(
510+
'ngAccordionPanel is already controlled by another ngAccordionTrigger.',
511+
);
512+
});
513+
514+
it('should warn when trigger is nested inside its controlled panel', () => {
515+
TestBed.resetTestingModule();
516+
TestBed.configureTestingModule({
517+
imports: [AccordionWithNestedTrigger],
518+
});
519+
const nestedFixture = TestBed.createComponent(AccordionWithNestedTrigger);
520+
nestedFixture.detectChanges();
521+
522+
expect(consoleSpy).toHaveBeenCalledWith(
523+
'ngAccordionTrigger must not be nested inside its controlled ngAccordionPanel, otherwise it will become unreachable when collapsed.',
524+
);
525+
});
526+
527+
it('should warn when ngAccordionPanel is missing ngAccordionContent', () => {
528+
TestBed.resetTestingModule();
529+
TestBed.configureTestingModule({
530+
imports: [AccordionPanelWithoutContent],
531+
});
532+
const noContentFixture = TestBed.createComponent(AccordionPanelWithoutContent);
533+
noContentFixture.detectChanges();
534+
535+
expect(consoleSpy).toHaveBeenCalledWith(
536+
'ngAccordionPanel must have an ngAccordionContent to render.',
537+
);
538+
});
539+
540+
it('should warn when ngAccordionPanel is missing controlling trigger', () => {
541+
TestBed.resetTestingModule();
542+
TestBed.configureTestingModule({
543+
imports: [AccordionPanelWithoutTrigger],
544+
});
545+
const noTriggerFixture = TestBed.createComponent(AccordionPanelWithoutTrigger);
546+
noTriggerFixture.detectChanges();
547+
548+
expect(consoleSpy).toHaveBeenCalledWith(
549+
'ngAccordionPanel must have an ngAccordionTrigger to control it.',
550+
);
551+
});
552+
553+
it('should warn when multiple items are expanded in single-expand mode', () => {
554+
TestBed.resetTestingModule();
555+
TestBed.configureTestingModule({
556+
imports: [AccordionWithMultipleExpandedItems],
557+
});
558+
const multipleExpandedFixture = TestBed.createComponent(AccordionWithMultipleExpandedItems);
559+
multipleExpandedFixture.detectChanges();
560+
561+
expect(consoleSpy).toHaveBeenCalledWith(
562+
'ngAccordionGroup has multiExpandable set to false, but multiple ngAccordionTrigger panels are initially expanded.',
563+
);
564+
});
565+
});
483566
});
484567

485568
@Component({
@@ -606,3 +689,81 @@ class AccordionGroupWithIfs extends AccordionGroupWithLoop {
606689
includeSecond = signal(true);
607690
includeThird = signal(true);
608691
}
692+
693+
@Component({
694+
template: `
695+
<div ngAccordionGroup>
696+
<button ngAccordionTrigger [panel]="panel1">Trigger 1</button>
697+
<button ngAccordionTrigger [panel]="panel1">Trigger 2</button>
698+
<div ngAccordionPanel #panel1="ngAccordionPanel">
699+
<ng-template ngAccordionContent>Content</ng-template>
700+
</div>
701+
</div>
702+
`,
703+
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
704+
changeDetection: ChangeDetectionStrategy.Eager,
705+
})
706+
class AccordionWithDuplicateTriggers {}
707+
708+
@Component({
709+
template: `
710+
<div ngAccordionGroup>
711+
<div ngAccordionPanel #panel1="ngAccordionPanel">
712+
<button ngAccordionTrigger [panel]="panel1">Nested Trigger</button>
713+
<ng-template ngAccordionContent>Content</ng-template>
714+
</div>
715+
</div>
716+
`,
717+
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
718+
changeDetection: ChangeDetectionStrategy.Eager,
719+
})
720+
class AccordionWithNestedTrigger {}
721+
722+
@Component({
723+
template: `
724+
<div ngAccordionGroup>
725+
<button ngAccordionTrigger [panel]="panel1">Trigger</button>
726+
<div ngAccordionPanel #panel1="ngAccordionPanel">
727+
Content
728+
</div>
729+
</div>
730+
`,
731+
imports: [AccordionGroup, AccordionTrigger, AccordionPanel],
732+
changeDetection: ChangeDetectionStrategy.Eager,
733+
})
734+
class AccordionPanelWithoutContent {}
735+
736+
@Component({
737+
template: `
738+
<div ngAccordionGroup>
739+
<div ngAccordionPanel>
740+
<ng-template ngAccordionContent>Content</ng-template>
741+
</div>
742+
</div>
743+
`,
744+
imports: [AccordionGroup, AccordionPanel, AccordionContent],
745+
changeDetection: ChangeDetectionStrategy.Eager,
746+
})
747+
class AccordionPanelWithoutTrigger {}
748+
749+
@Component({
750+
template: `
751+
<div ngAccordionGroup [multiExpandable]="false">
752+
<div>
753+
<button ngAccordionTrigger [panel]="panel1" [expanded]="true">Trigger 1</button>
754+
<div ngAccordionPanel #panel1="ngAccordionPanel">
755+
<ng-template ngAccordionContent>Content 1</ng-template>
756+
</div>
757+
</div>
758+
<div>
759+
<button ngAccordionTrigger [panel]="panel2" [expanded]="true">Trigger 2</button>
760+
<div ngAccordionPanel #panel2="ngAccordionPanel">
761+
<ng-template ngAccordionContent>Content 2</ng-template>
762+
</div>
763+
</div>
764+
</div>
765+
`,
766+
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
767+
changeDetection: ChangeDetectionStrategy.Eager,
768+
})
769+
class AccordionWithMultipleExpandedItems {}

0 commit comments

Comments
 (0)