Skip to content

Commit 77c3cca

Browse files
committed
test(aria/tabs): check for incorrect usage of Tabs directives and log violations
1 parent be06cbf commit 77c3cca

4 files changed

Lines changed: 184 additions & 1 deletion

File tree

src/aria/tabs/tab-list.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,19 @@ export class TabList implements OnInit, OnDestroy {
149149
this.selectedTab.set(tab?.value());
150150
},
151151
});
152+
153+
// Check for any violations after the DOM has been updated.
154+
afterRenderEffect({
155+
read: () => {
156+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
157+
const values = this._collection.orderedItems().map(t => t.value());
158+
const duplicates = values.filter((item, index) => values.indexOf(item) !== index);
159+
if (duplicates.length > 0) {
160+
console.error(`Duplicate value '${duplicates[0]}' detected inside ngTabList.`);
161+
}
162+
}
163+
},
164+
});
152165
}
153166

154167
ngOnInit() {

src/aria/tabs/tab-panel.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import {
1414
inject,
1515
input,
1616
afterRenderEffect,
17+
contentChild,
1718
OnInit,
1819
OnDestroy,
1920
} from '@angular/core';
2021
import {TabPanelPattern, DeferredContentAware} from '../private';
2122
import {TABS} from './tab-tokens';
23+
import {TabContent} from './tab-content';
2224

2325
/**
2426
* A TabPanel container for the resources of layered content associated with a tab.
@@ -89,8 +91,37 @@ export class TabPanel implements OnInit, OnDestroy {
8991
tab: this._tabPattern,
9092
});
9193

94+
private readonly _tabContent = contentChild(TabContent);
95+
9296
constructor() {
93-
afterRenderEffect(() => this._deferredContentAware.contentVisible.set(this.visible()));
97+
// Connect the panel's hidden state to the DeferredContentAware's visibility.
98+
afterRenderEffect({
99+
write: () => {
100+
this._deferredContentAware.contentVisible.set(this.visible());
101+
},
102+
});
103+
104+
// Check for any violations after the DOM has been updated.
105+
afterRenderEffect({
106+
read: () => {
107+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
108+
const violations: string[] = [];
109+
110+
if (!this._tabContent()) {
111+
violations.push('ngTabPanel must have an ngTabContent structural directive to render.');
112+
}
113+
if (!this._tabs._tabMap().has(this.value())) {
114+
violations.push(
115+
`ngTabPanel with value '${this.value()}' does not have a corresponding ngTab.`,
116+
);
117+
}
118+
119+
for (const violation of violations) {
120+
console.error(violation);
121+
}
122+
}
123+
},
124+
});
94125
}
95126

96127
ngOnInit() {

src/aria/tabs/tab.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
computed,
1717
inject,
1818
input,
19+
afterRenderEffect,
1920
} from '@angular/core';
2021
import {TabPattern, HasElement} from '../private';
2122
import {TAB_LIST} from './tab-tokens';
@@ -92,6 +93,22 @@ export class Tab implements HasElement, OnInit, OnDestroy {
9293
this._pattern.open();
9394
}
9495

96+
constructor() {
97+
afterRenderEffect({
98+
read: () => {
99+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
100+
if (this._tabList && this._tabList._tabsParent) {
101+
if (!this._tabList._tabsParent._panelMap().has(this.value())) {
102+
console.error(
103+
`ngTab with value '${this.value()}' does not have a corresponding ngTabPanel.`,
104+
);
105+
}
106+
}
107+
}
108+
},
109+
});
110+
}
111+
95112
ngOnInit() {
96113
this._tabList._collection.register(this);
97114
}

src/aria/tabs/tabs.spec.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,69 @@ describe('Tabs', () => {
806806
expect(panelEl.getAttribute('aria-labelledby')).toBe('custom-tab-id');
807807
});
808808
});
809+
810+
describe('structural validations', () => {
811+
let consoleSpy: jasmine.Spy;
812+
813+
beforeEach(() => {
814+
consoleSpy = spyOn(console, 'error');
815+
});
816+
817+
afterEach(() => {
818+
TestBed.resetTestingModule();
819+
setupTestTabs();
820+
});
821+
822+
it('should warn when ngTab is missing its corresponding ngTabPanel', () => {
823+
TestBed.resetTestingModule();
824+
TestBed.configureTestingModule({
825+
imports: [TabWithoutPanelComponent],
826+
});
827+
const noPanelFixture = TestBed.createComponent(TabWithoutPanelComponent);
828+
noPanelFixture.detectChanges();
829+
830+
expect(consoleSpy).toHaveBeenCalledWith(
831+
"ngTab with value 'tab1' does not have a corresponding ngTabPanel.",
832+
);
833+
});
834+
835+
it('should warn when ngTabPanel is missing its corresponding ngTab', () => {
836+
TestBed.resetTestingModule();
837+
TestBed.configureTestingModule({
838+
imports: [PanelWithoutTabComponent],
839+
});
840+
const noTabFixture = TestBed.createComponent(PanelWithoutTabComponent);
841+
noTabFixture.detectChanges();
842+
843+
expect(consoleSpy).toHaveBeenCalledWith(
844+
"ngTabPanel with value 'tab1' does not have a corresponding ngTab.",
845+
);
846+
});
847+
848+
it('should warn when ngTabPanel is missing ngTabContent structural directive', () => {
849+
TestBed.resetTestingModule();
850+
TestBed.configureTestingModule({
851+
imports: [PanelWithoutContentComponent],
852+
});
853+
const noContentFixture = TestBed.createComponent(PanelWithoutContentComponent);
854+
noContentFixture.detectChanges();
855+
856+
expect(consoleSpy).toHaveBeenCalledWith(
857+
'ngTabPanel must have an ngTabContent structural directive to render.',
858+
);
859+
});
860+
861+
it('should warn when duplicate values are detected inside ngTabList', () => {
862+
TestBed.resetTestingModule();
863+
TestBed.configureTestingModule({
864+
imports: [DuplicateTabValuesComponent],
865+
});
866+
const duplicateFixture = TestBed.createComponent(DuplicateTabValuesComponent);
867+
duplicateFixture.detectChanges();
868+
869+
expect(consoleSpy).toHaveBeenCalledWith("Duplicate value 'tab1' detected inside ngTabList.");
870+
});
871+
});
809872
});
810873

811874
@Component({
@@ -882,3 +945,62 @@ class TestTabsComponent {
882945
class TestTabsCustomIdComponent {
883946
selectedTab = signal('tab1');
884947
}
948+
949+
@Component({
950+
template: `
951+
<div ngTabs>
952+
<ul ngTabList>
953+
<li ngTab value="tab1">Tab 1</li>
954+
</ul>
955+
</div>
956+
`,
957+
imports: [Tabs, TabList, Tab],
958+
changeDetection: ChangeDetectionStrategy.Eager,
959+
})
960+
class TabWithoutPanelComponent {}
961+
962+
@Component({
963+
template: `
964+
<div ngTabs>
965+
<div ngTabPanel value="tab1">
966+
<ng-template ngTabContent>Content 1</ng-template>
967+
</div>
968+
</div>
969+
`,
970+
imports: [Tabs, TabPanel, TabContent],
971+
changeDetection: ChangeDetectionStrategy.Eager,
972+
})
973+
class PanelWithoutTabComponent {}
974+
975+
@Component({
976+
template: `
977+
<div ngTabs>
978+
<ul ngTabList>
979+
<li ngTab value="tab1">Tab 1</li>
980+
</ul>
981+
<div ngTabPanel value="tab1">
982+
Content 1
983+
</div>
984+
</div>
985+
`,
986+
imports: [Tabs, TabList, Tab, TabPanel],
987+
changeDetection: ChangeDetectionStrategy.Eager,
988+
})
989+
class PanelWithoutContentComponent {}
990+
991+
@Component({
992+
template: `
993+
<div ngTabs>
994+
<ul ngTabList>
995+
<li ngTab value="tab1">Tab 1</li>
996+
<li ngTab value="tab1">Tab 1 Copy</li>
997+
</ul>
998+
<div ngTabPanel value="tab1">
999+
<ng-template ngTabContent>Content 1</ng-template>
1000+
</div>
1001+
</div>
1002+
`,
1003+
imports: [Tabs, TabList, Tab, TabPanel, TabContent],
1004+
changeDetection: ChangeDetectionStrategy.Eager,
1005+
})
1006+
class DuplicateTabValuesComponent {}

0 commit comments

Comments
 (0)