From d4a44cbcd429f606b08354c7f8b4f0d1a7dd01e7 Mon Sep 17 00:00:00 2001 From: MeAkib Date: Thu, 1 Oct 2026 08:40:52 +0600 Subject: [PATCH] docs: convert component viewer tabs to signals Replaces the `async` pipe usages in the overview, API and examples tabs with signals. The tabs read the doc item from the `ComponentViewer.docItem` input and `showToc` is converted using `toSignal`. The subscriptions that reset the table of contents when the doc item changes and that re-add its headers when it's shown again after a resize are replaced with an `effect` and an `afterRenderEffect`. Adds tests for both, since they weren't covered. --- .../pages/component-viewer/component-api.html | 44 +++---- .../component-base-view.spec.ts | 108 ++++++++++++++++++ .../component-viewer/component-examples.html | 20 ++-- .../component-viewer/component-overview.html | 27 +++-- .../component-viewer/component-viewer.ts | 71 ++++++------ 5 files changed, 187 insertions(+), 83 deletions(-) create mode 100644 docs/src/app/pages/component-viewer/component-base-view.spec.ts 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 {}