Skip to content
Merged
3 changes: 2 additions & 1 deletion goldens/aria/accordion/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,14 @@ export class AccordionPanel {
toggle(): void;
readonly visible: _angular_core.Signal<boolean>;
// (undocumented)
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: {}; }]>;
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: {}; }]>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<AccordionPanel, never>;
}

// @public
export class AccordionTrigger implements OnInit, OnDestroy {
constructor();
readonly active: _angular_core.Signal<boolean>;
collapse(): void;
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
Expand Down
7 changes: 7 additions & 0 deletions goldens/aria/private/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class AccordionGroupPattern {
onKeydown(event: KeyboardEvent): void;
readonly prevKey: SignalLike<"ArrowUp" | "ArrowRight" | "ArrowLeft">;
toggle(): void;
validate(): string[];
}

// @public
Expand Down Expand Up @@ -271,6 +272,7 @@ export class GridPattern {
restoreFocusEffect(): void;
setDefaultStateEffect(): void;
readonly tabIndex: SignalLike<0 | -1>;
validate(): string[];
}

// @public
Expand Down Expand Up @@ -461,6 +463,7 @@ export class MenuPattern<V> {
readonly tabIndex: () => 0 | -1;
trigger(): void;
readonly typeaheadRegexp: RegExp;
validate(): string[];
readonly visible: SignalLike<boolean>;
}

Expand Down Expand Up @@ -520,6 +523,9 @@ export class OptionPattern<V> {
readonly value: SignalLike<V>;
}

// @public
export function reportViolations(violations: string[], element: Element): void;

// @public
export function resolveElement<T = HTMLElement>(resolver: ElementResolver<T>, context: HTMLElement): T | undefined;

Expand Down Expand Up @@ -652,6 +658,7 @@ export class ToolbarPattern<V> {
setDefaultStateEffect(): void;
readonly softDisabled: SignalLike<boolean>;
readonly tabIndex: SignalLike<0 | -1>;
validate(): string[];
}

// @public
Expand Down
3 changes: 2 additions & 1 deletion goldens/aria/tabs/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { WritableSignal } from '@angular/core';

// @public
export class Tab implements HasElement, OnInit, OnDestroy {
constructor();
readonly active: _angular_core.Signal<boolean>;
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly element: HTMLElement;
Expand Down Expand Up @@ -81,7 +82,7 @@ export class TabPanel implements OnInit, OnDestroy {
readonly value: _angular_core.InputSignal<string>;
readonly visible: _angular_core.Signal<boolean>;
// (undocumented)
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: {}; }]>;
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: {}; }]>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<TabPanel, never>;
}
Expand Down
1 change: 1 addition & 0 deletions goldens/aria/toolbar/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export class ToolbarWidget<V> implements OnInit, OnDestroy {

// @public
export class ToolbarWidgetGroup<V> {
constructor();
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly element: HTMLElement;
readonly multi: _angular_core.InputSignalWithTransform<boolean, unknown>;
Expand Down
12 changes: 11 additions & 1 deletion src/aria/accordion/accordion-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ import {
input,
signal,
afterNextRender,
afterRenderEffect,
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';

Expand Down Expand Up @@ -113,6 +114,15 @@ 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: () => {
reportViolations(this._pattern.validate(), this.element);
},
});
}
}

ngOnDestroy() {
Expand Down
33 changes: 31 additions & 2 deletions src/aria/accordion/accordion-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {DeferredContentAware, AccordionTriggerPattern, reportViolations} from '../private';
import {AccordionContent} from './accordion-content';

/**
* The content panel of an accordion item that is conditionally visible.
Expand Down Expand Up @@ -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));

Expand All @@ -76,6 +87,24 @@ 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[] = [];
Comment thread
ok7sai marked this conversation as resolved.

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.');
}

reportViolations(violations, this.element);
},
});
}
}

/** Expands this item. */
Expand Down
27 changes: 26 additions & 1 deletion src/aria/accordion/accordion-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ import {
inject,
input,
model,
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';

Expand Down Expand Up @@ -84,6 +85,30 @@ 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.',
);
}

reportViolations(violations, this.element);
},
});
}
}

ngOnInit() {
this._pattern = new AccordionTriggerPattern({
...this,
Expand Down
161 changes: 161 additions & 0 deletions src/aria/accordion/accordion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,89 @@ describe('AccordionGroup', () => {
});
});
});

describe('structural validations', () => {
let consoleSpy: jasmine.Spy;

beforeEach(() => {
consoleSpy = spyOn(console, 'warn');
});

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({
Expand Down Expand Up @@ -606,3 +689,81 @@ class AccordionGroupWithIfs extends AccordionGroupWithLoop {
includeSecond = signal(true);
includeThird = signal(true);
}

@Component({
template: `
<div ngAccordionGroup>
<button ngAccordionTrigger [panel]="panel1">Trigger 1</button>
<button ngAccordionTrigger [panel]="panel1">Trigger 2</button>
<div ngAccordionPanel #panel1="ngAccordionPanel">
<ng-template ngAccordionContent>Content</ng-template>
</div>
</div>
`,
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
changeDetection: ChangeDetectionStrategy.Eager,
})
class AccordionWithDuplicateTriggers {}

@Component({
template: `
<div ngAccordionGroup>
<div ngAccordionPanel #panel1="ngAccordionPanel">
<button ngAccordionTrigger [panel]="panel1">Nested Trigger</button>
<ng-template ngAccordionContent>Content</ng-template>
</div>
</div>
`,
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
changeDetection: ChangeDetectionStrategy.Eager,
})
class AccordionWithNestedTrigger {}

@Component({
template: `
<div ngAccordionGroup>
<button ngAccordionTrigger [panel]="panel1">Trigger</button>
<div ngAccordionPanel #panel1="ngAccordionPanel">
Content
</div>
</div>
`,
imports: [AccordionGroup, AccordionTrigger, AccordionPanel],
changeDetection: ChangeDetectionStrategy.Eager,
})
class AccordionPanelWithoutContent {}

@Component({
template: `
<div ngAccordionGroup>
<div ngAccordionPanel>
<ng-template ngAccordionContent>Content</ng-template>
</div>
</div>
`,
imports: [AccordionGroup, AccordionPanel, AccordionContent],
changeDetection: ChangeDetectionStrategy.Eager,
})
class AccordionPanelWithoutTrigger {}

@Component({
template: `
<div ngAccordionGroup [multiExpandable]="false">
<div>
<button ngAccordionTrigger [panel]="panel1" [expanded]="true">Trigger 1</button>
<div ngAccordionPanel #panel1="ngAccordionPanel">
<ng-template ngAccordionContent>Content 1</ng-template>
</div>
</div>
<div>
<button ngAccordionTrigger [panel]="panel2" [expanded]="true">Trigger 2</button>
<div ngAccordionPanel #panel2="ngAccordionPanel">
<ng-template ngAccordionContent>Content 2</ng-template>
</div>
</div>
</div>
`,
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
changeDetection: ChangeDetectionStrategy.Eager,
})
class AccordionWithMultipleExpandedItems {}
Loading
Loading