diff --git a/docs/src/app/pages/component-viewer/component-api.html b/docs/src/app/pages/component-viewer/component-api.html
index 37c9621bcc67..50492b69e728 100644
--- a/docs/src/app/pages/component-viewer/component-api.html
+++ b/docs/src/app/pages/component-viewer/component-api.html
@@ -1,27 +1,27 @@
-@if (componentViewer.componentDocItem | async; as docItem) {
-
- API for {{docItem.id}}
-
+@let docItem = componentViewer.docItem();
-
-
-
+
+ API for {{docItem.id}}
+
- @for (additionalApiDoc of docItem.additionalApiDocs; track additionalApiDoc) {
-
- }
-
+
+
+
- @if (showToc | async) {
-
+ @for (additionalApiDoc of docItem.additionalApiDocs; track additionalApiDoc) {
+
}
+
+
+@if (showToc()) {
+
}
diff --git a/docs/src/app/pages/component-viewer/component-base-view.spec.ts b/docs/src/app/pages/component-viewer/component-base-view.spec.ts
new file mode 100644
index 000000000000..5ad83704c9ec
--- /dev/null
+++ b/docs/src/app/pages/component-viewer/component-base-view.spec.ts
@@ -0,0 +1,108 @@
+import {Component} from '@angular/core';
+import {TestBed} from '@angular/core/testing';
+import {provideHttpClient} from '@angular/common/http';
+import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
+import {BreakpointObserver, BreakpointState} from '@angular/cdk/layout';
+import {provideRouter, RouterOutlet, withComponentInputBinding} from '@angular/router';
+import {RouterTestingHarness} from '@angular/router/testing';
+import {BehaviorSubject} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {ComponentOverview, ComponentViewer} from './component-viewer';
+import {docItemResolver} from './doc-item-resolver';
+import {TableOfContents} from '../../shared/table-of-contents/table-of-contents';
+
+/** Stand-in for the sidenav, which sits between the `:section` and `:id` routes. */
+@Component({template: '', imports: [RouterOutlet]})
+class Shell {}
+
+describe('ComponentBaseView', () => {
+ let harness: RouterTestingHarness;
+ let httpTestingController: HttpTestingController;
+
+ /** Whether the screen is small enough for the table of contents to be hidden. */
+ let isSmallScreen: BehaviorSubject;
+
+ beforeEach(async () => {
+ isSmallScreen = new BehaviorSubject(false);
+
+ TestBed.configureTestingModule({
+ providers: [
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ {
+ provide: BreakpointObserver,
+ useValue: {
+ observe: () =>
+ isSmallScreen.pipe(map(matches => ({matches, breakpoints: {}}) as BreakpointState)),
+ },
+ },
+ provideRouter(
+ [
+ {
+ path: ':section',
+ children: [
+ {
+ path: '',
+ component: Shell,
+ children: [
+ {
+ path: ':id',
+ component: ComponentViewer,
+ resolve: {docItem: docItemResolver},
+ children: [{path: 'overview', component: ComponentOverview}],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ withComponentInputBinding(),
+ ),
+ ],
+ });
+
+ harness = await RouterTestingHarness.create();
+ httpTestingController = TestBed.inject(HttpTestingController);
+ });
+
+ /** Navigates to a URL and waits for it to render. */
+ async function navigate(url: string) {
+ await harness.navigateByUrl(url);
+ await harness.fixture.whenStable();
+ harness.detectChanges();
+ }
+
+ /** Responds to the request for a component's overview document. */
+ function flushOverview(id: string) {
+ httpTestingController
+ .expectOne(`/docs-content/overviews/material/${id}/${id}.md.html`)
+ .flush(`Usage
`);
+ }
+
+ describe('overview', () => {
+ it('should add the headers to the table of contents when it is shown again', async () => {
+ isSmallScreen.next(true);
+ await navigate('/components/button/overview');
+ flushOverview('button');
+ await harness.fixture.whenStable();
+
+ const addHeadersSpy = spyOn(TableOfContents.prototype, 'addHeaders').and.callThrough();
+ isSmallScreen.next(false);
+ harness.detectChanges();
+ await harness.fixture.whenStable();
+
+ expect(addHeadersSpy).toHaveBeenCalledWith('Overview Content', jasmine.any(HTMLElement), 0);
+ });
+
+ it('should reset the table of contents when the doc item changes', async () => {
+ await navigate('/components/button/overview');
+ flushOverview('button');
+ await harness.fixture.whenStable();
+
+ const resetHeadersSpy = spyOn(TableOfContents.prototype, 'resetHeaders').and.callThrough();
+ await navigate('/components/checkbox/overview');
+
+ expect(resetHeadersSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/docs/src/app/pages/component-viewer/component-examples.html b/docs/src/app/pages/component-viewer/component-examples.html
index e1378373d574..ae9ec8788656 100644
--- a/docs/src/app/pages/component-viewer/component-examples.html
+++ b/docs/src/app/pages/component-viewer/component-examples.html
@@ -1,12 +1,12 @@
-@if (componentViewer.componentDocItem | async; as docItem) {
-
- Examples for {{docItem.id}}
-
+@let docItem = componentViewer.docItem();
- @for (example of docItem.examples; track example) {
-
- }
+
+ Examples for {{docItem.id}}
+
+
+@for (example of docItem.examples; track example) {
+
}
diff --git a/docs/src/app/pages/component-viewer/component-overview.html b/docs/src/app/pages/component-viewer/component-overview.html
index a3397b3ed5e3..bc1b2960e364 100644
--- a/docs/src/app/pages/component-viewer/component-overview.html
+++ b/docs/src/app/pages/component-viewer/component-overview.html
@@ -1,16 +1,15 @@
-@if (componentViewer.componentDocItem | async; as docItem) {
-
- Overview for {{docItem.id}}
-
-
+@let docItem = componentViewer.docItem();
- @if (showToc | async) {
-
- }
-}
+
+ Overview for {{docItem.id}}
+
+
+@if (showToc()) {
+
+}
diff --git a/docs/src/app/pages/component-viewer/component-viewer.ts b/docs/src/app/pages/component-viewer/component-viewer.ts
index ed1e54648516..c8ba5f447461 100644
--- a/docs/src/app/pages/component-viewer/component-viewer.ts
+++ b/docs/src/app/pages/component-viewer/component-viewer.ts
@@ -7,25 +7,23 @@
*/
import {BreakpointObserver} from '@angular/cdk/layout';
-import {AsyncPipe} from '@angular/common';
import {
- ChangeDetectorRef,
Component,
Directive,
- OnInit,
ViewEncapsulation,
+ afterRenderEffect,
computed,
effect,
input,
+ untracked,
viewChild,
viewChildren,
inject,
- DestroyRef,
} from '@angular/core';
-import {takeUntilDestroyed, toObservable} from '@angular/core/rxjs-interop';
+import {toObservable, toSignal} from '@angular/core/rxjs-interop';
import {RouterLinkActive, RouterLink, RouterOutlet} from '@angular/router';
import {Observable} from 'rxjs';
-import {map, skip} from 'rxjs/operators';
+import {map} from 'rxjs/operators';
import {DocItem} from '../../shared/documentation-items/documentation-items';
import {TableOfContents} from '../../shared/table-of-contents/table-of-contents';
@@ -87,43 +85,42 @@ export class ComponentViewer {
* the table of contents headers.
*/
@Directive()
-export class ComponentBaseView implements OnInit {
+export class ComponentBaseView {
componentViewer = inject(ComponentViewer);
- private _changeDetectorRef = inject(ChangeDetectorRef);
- private readonly _destroyRef = inject(DestroyRef);
readonly tableOfContents = viewChild('toc');
readonly viewers = viewChildren(DocViewer);
- showToc: Observable;
+ /** Whether the table of contents should be shown. It's hidden on small screens. */
+ readonly showToc = toSignal(
+ inject(BreakpointObserver)
+ .observe('(max-width: 1200px)')
+ .pipe(map(result => !result.matches)),
+ {requireSync: true},
+ );
constructor() {
- const breakpointObserver = inject(BreakpointObserver);
-
- this.showToc = breakpointObserver.observe('(max-width: 1200px)').pipe(
- map(result => {
- this._changeDetectorRef.detectChanges();
- return !result.matches;
- }),
- );
- }
+ // Reset the table of contents when the doc item changes.
+ effect(() => {
+ this.componentViewer.docItem();
+ untracked(() => this.tableOfContents()?.resetHeaders());
+ });
- ngOnInit() {
- this.componentViewer.componentDocItem
- .pipe(takeUntilDestroyed(this._destroyRef))
- .subscribe(() => {
- const tableOfContents = this.tableOfContents();
- if (tableOfContents) {
- tableOfContents.resetHeaders();
- }
- });
-
- this.showToc.pipe(skip(1), takeUntilDestroyed(this._destroyRef)).subscribe(() => {
- if (this.tableOfContents()) {
- this.viewers().forEach(viewer => {
- viewer.contentRendered.emit(viewer._elementRef.nativeElement);
- });
+ // The table of contents is removed on small screens. When it's shown again, re-emit
+ // `contentRendered` for the documents that are already rendered so it picks up their headers.
+ let hadTableOfContents: boolean | undefined;
+ afterRenderEffect(() => {
+ const hasTableOfContents = !!this.tableOfContents();
+
+ if (hadTableOfContents === false && hasTableOfContents) {
+ untracked(() =>
+ this.viewers().forEach(viewer => {
+ viewer.contentRendered.emit(viewer._elementRef.nativeElement);
+ }),
+ );
}
+
+ hadTableOfContents = hasTableOfContents;
});
}
@@ -140,7 +137,7 @@ export class ComponentBaseView implements OnInit {
selector: 'component-overview',
templateUrl: './component-overview.html',
encapsulation: ViewEncapsulation.None,
- imports: [DocViewer, TableOfContents, AsyncPipe],
+ imports: [DocViewer, TableOfContents],
})
export class ComponentOverview extends ComponentBaseView {
getOverviewDocumentUrl(doc: DocItem) {
@@ -159,7 +156,7 @@ export class ComponentOverview extends ComponentBaseView {
templateUrl: './component-api.html',
styleUrls: ['./component-api.scss'],
encapsulation: ViewEncapsulation.None,
- imports: [DocViewer, TableOfContents, AsyncPipe],
+ imports: [DocViewer, TableOfContents],
})
export class ComponentApi extends ComponentBaseView {
getApiDocumentUrl(doc: DocItem) {
@@ -172,6 +169,6 @@ export class ComponentApi extends ComponentBaseView {
selector: 'component-examples',
templateUrl: './component-examples.html',
encapsulation: ViewEncapsulation.None,
- imports: [ExampleViewer, AsyncPipe],
+ imports: [ExampleViewer],
})
export class ComponentExamples extends ComponentBaseView {}