Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
1 change: 1 addition & 0 deletions goldens/aria/combobox/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class Combobox extends DeferredContentAware implements OnInit {

// @public
export class ComboboxPopup implements OnInit, OnDestroy {
constructor();
readonly activeDescendant: _angular_core.Signal<string | undefined>;
readonly combobox: _angular_core.InputSignal<Combobox>;
readonly controlTarget: _angular_core.Signal<HTMLElement | undefined>;
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 @@ -114,6 +115,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 @@ -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));

Expand All @@ -77,6 +88,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 @@ -85,6 +86,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, '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({
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