diff --git a/docs/src/app/shared/doc-viewer/doc-viewer.spec.ts b/docs/src/app/shared/doc-viewer/doc-viewer.spec.ts index d8ccc111b343..9c500951c83b 100644 --- a/docs/src/app/shared/doc-viewer/doc-viewer.spec.ts +++ b/docs/src/app/shared/doc-viewer/doc-viewer.spec.ts @@ -95,11 +95,12 @@ describe('DocViewer', () => { http.expectOne(testUrl).flush(FAKE_DOCS[testUrl]); - const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer)); - expect(exampleViewer.componentInstance.file).toBe('some-example.html'); - expect(exampleViewer.componentInstance.showCompactToggle).toBeTrue(); - expect(exampleViewer.componentInstance.region).toBe('some-region'); - expect(exampleViewer.componentInstance.view).toBe('snippet'); + const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer)) + .componentInstance as ExampleViewer; + expect(exampleViewer.file()).toBe('some-example.html'); + expect(exampleViewer.showCompactToggle()).toBeTrue(); + expect(exampleViewer.region()).toBe('some-region'); + expect(exampleViewer.view()).toBe('snippet'); }); it('should instantiate example viewer in demo view', () => { @@ -111,10 +112,11 @@ describe('DocViewer', () => { http.expectOne(testUrl).flush(FAKE_DOCS[testUrl]); - const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer)); - expect(exampleViewer.componentInstance.file).toBeUndefined(); - expect(exampleViewer.componentInstance.showCompactToggle).toBeFalse(); - expect(exampleViewer.componentInstance.view).toBe('demo'); + const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer)) + .componentInstance as ExampleViewer; + expect(exampleViewer.file()).toBeUndefined(); + expect(exampleViewer.showCompactToggle()).toBeFalse(); + expect(exampleViewer.view()).toBe('demo'); }); it('should instantiate example viewer in snippet view with whole snippet', () => { @@ -126,10 +128,11 @@ describe('DocViewer', () => { http.expectOne(testUrl).flush(FAKE_DOCS[testUrl]); - const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer)); - expect(exampleViewer.componentInstance.file).toBe('whole-snippet-example.ts'); - expect(exampleViewer.componentInstance.showCompactToggle).toBeTrue(); - expect(exampleViewer.componentInstance.view).toBe('snippet'); + const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer)) + .componentInstance as ExampleViewer; + expect(exampleViewer.file()).toBe('whole-snippet-example.ts'); + expect(exampleViewer.showCompactToggle()).toBeTrue(); + expect(exampleViewer.view()).toBe('snippet'); }); it('should show error message when doc not found', () => { diff --git a/docs/src/app/shared/doc-viewer/doc-viewer.ts b/docs/src/app/shared/doc-viewer/doc-viewer.ts index e0f2cb6b80d5..89b3b73cc81b 100644 --- a/docs/src/app/shared/doc-viewer/doc-viewer.ts +++ b/docs/src/app/shared/doc-viewer/doc-viewer.ts @@ -30,6 +30,7 @@ import { ViewContainerRef, input, inject, + Type, } from '@angular/core'; import {Observable, Subscription} from 'rxjs'; import {shareReplay, take, tap} from 'rxjs/operators'; @@ -113,17 +114,17 @@ export class DocViewer implements OnDestroy { if (file) { // if the html div has field `file` then it should be in compact view to show the code // snippet - exampleViewerComponent.view = 'snippet'; - exampleViewerComponent.showCompactToggle = true; - exampleViewerComponent.file = file; + exampleViewerComponent.view.set('snippet'); + exampleViewerComponent.showCompactToggle.set(true); + exampleViewerComponent.file.set(file); if (region) { // `region` should only exist when `file` exists but not vice versa // It is valid for embedded example snippets to show the whole file (esp short files) - exampleViewerComponent.region = region; + exampleViewerComponent.region.set(region); } } else { // otherwise it is an embedded demo - exampleViewerComponent.view = 'demo'; + exampleViewerComponent.view.set('demo'); } } @@ -175,7 +176,7 @@ export class DocViewer implements OnDestroy { } /** Instantiate a ExampleViewer for each example. */ - private _loadComponents(componentName: string, componentClass: any) { + private _loadComponents(componentName: string, componentClass: Type) { const exampleElements = this._elementRef.nativeElement.querySelectorAll(`[${componentName}]`); [...exampleElements].forEach((element: Element) => { @@ -185,9 +186,14 @@ export class DocViewer implements OnDestroy { const portalHost = new DomPortalOutlet(element, this._appRef, this._injector); const examplePortal = new ComponentPortal(componentClass, this._viewContainerRef); const exampleViewer = portalHost.attach(examplePortal); - const exampleViewerComponent = exampleViewer.instance as ExampleViewer; - if (example !== null) { - DocViewer._initExampleViewer(exampleViewerComponent, example, file, region); + const exampleViewerComponent = exampleViewer.instance; + if (example !== null && componentClass === ExampleViewer) { + DocViewer._initExampleViewer( + exampleViewerComponent as ExampleViewer, + example, + file, + region, + ); } this._portalHosts.push(portalHost); }); diff --git a/docs/src/app/shared/example-viewer/example-viewer.html b/docs/src/app/shared/example-viewer/example-viewer.html index 3216a817e9dd..96b7f0877c96 100644 --- a/docs/src/app/shared/example-viewer/example-viewer.html +++ b/docs/src/app/shared/example-viewer/example-viewer.html @@ -1,5 +1,8 @@ +@let exampleData = this.exampleData(); +@let fileUrl = this.fileUrl(); +
- @if (view === 'snippet') { + @if (view() === 'snippet') {
- @if (showCompactToggle) { + @if (showCompactToggle()) {
- @if (view === 'full') { + @if (view() === 'full') {
- @for (tabName of _getExampleTabNames(); track tabName) { + @for (tabName of _exampleTabNames(); track tabName) {
-
- +
}
@@ -79,10 +82,12 @@ } } - @if (view !== 'snippet') { + @if (view() !== 'snippet') {
- @if (_exampleComponentType && !example?.includes('harness')) { - + @let componentType = _exampleComponentType(); + + @if (componentType && !example?.includes('harness')) { + } @else {
This example contains tests. Open in Stackblitz to run the tests.
} diff --git a/docs/src/app/shared/example-viewer/example-viewer.ts b/docs/src/app/shared/example-viewer/example-viewer.ts index 889a4ba7633a..e8ca23cff8bc 100644 --- a/docs/src/app/shared/example-viewer/example-viewer.ts +++ b/docs/src/app/shared/example-viewer/example-viewer.ts @@ -6,7 +6,17 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Component, ElementRef, inject, Input, OnInit, Type, viewChildren} from '@angular/core'; +import { + Component, + computed, + ElementRef, + inject, + Input, + model, + signal, + Type, + viewChildren, +} from '@angular/core'; import {MatSnackBar} from '@angular/material/snack-bar'; import {Clipboard} from '@angular/cdk/clipboard'; @@ -47,7 +57,7 @@ const preferredExampleFileOrder = ['HTML', 'TS', 'CSS']; '[attr.id]': 'example', }, }) -export class ExampleViewer implements OnInit { +export class ExampleViewer { private readonly _snackbar = inject(MatSnackBar); private readonly _clipboard = inject(Clipboard); private readonly _elementRef = inject>(ElementRef); @@ -55,25 +65,48 @@ export class ExampleViewer implements OnInit { readonly snippet = viewChildren(CodeSnippet); /** The tab to jump to when expanding from snippet view. */ - selectedTab: number = 0; + readonly selectedTab = signal(0); /** Map of example files that should be displayed in the view-source tab in order. */ - exampleTabs: {[tabName: string]: string} = {}; + readonly exampleTabs = signal>({}); /** Data for the currently selected example. */ - exampleData: LiveExample | null = null; + readonly exampleData = signal(null); /** URL to fetch code snippet for snippet view. */ - fileUrl: string | undefined; + readonly fileUrl = computed(() => { + const file = this.file(); + const exampleData = this.exampleData(); + const region = this.region(); + + if (!file) { + return undefined; + } + + const lastDotIndex = file.lastIndexOf('.'); + const contentBeforeDot = file.substring(0, lastDotIndex); + const contentAfterDot = file.substring(lastDotIndex + 1); + let fileName: string; + + if (region) { + fileName = `${contentBeforeDot}_${region}-${contentAfterDot}.html`; + } else { + fileName = `${contentBeforeDot}-${contentAfterDot}.html`; + } + + return exampleData + ? `/docs-content/examples-highlighted/${exampleData.packagePath}/${fileName}` + : ''; + }); /** Component type for the current example. */ - _exampleComponentType: Type | null = null; + readonly _exampleComponentType = signal | null>(null); /** View of the example component. */ - @Input() view: Views | undefined; + readonly view = model(); /** Whether to show toggle for compact view. */ - @Input() showCompactToggle = false; + readonly showCompactToggle = model(false); /** String key of the currently displayed example. */ @Input() @@ -89,30 +122,26 @@ export class ExampleViewer implements OnInit { private _example: string | undefined; /** Range of lines of the source code to display in compact view. */ - @Input() region?: string; + readonly region = signal(undefined); /** Name of file to display in compact view. */ - @Input() file?: string; - - ngOnInit() { - if (this.file) { - this.fileUrl = this.generateUrl(this.file); - } - } + readonly file = model(); /** Selects a given tab based on the example file of the compact view. */ selectCorrectTab() { - if (!this.file || !this.exampleTabs) { + const file = this.file(); + const exampleTabNames = this._exampleTabNames(); + + if (!file || !exampleTabNames.length) { return; } - const extension = this.file.substring(this.file.lastIndexOf('.') + 1); - const exampleTabNames = this._getExampleTabNames(); + const extension = file.substring(file.lastIndexOf('.') + 1); for (let i = 0; i < exampleTabNames.length; i++) { const tabName = exampleTabNames[i]; if (tabName.toLowerCase() === extension || tabName.endsWith(`.${extension}`)) { - this.selectedTab = i; + this.selectedTab.set(i); return; } } @@ -121,16 +150,16 @@ export class ExampleViewer implements OnInit { } toggleCompactView() { - if (this.view === 'snippet') { - this.view = 'full'; + if (this.view() === 'snippet') { + this.view.set('full'); this.selectCorrectTab(); } else { - this.view = 'snippet'; + this.view.set('snippet'); } } toggleSourceView(): void { - this.view = this.view === 'full' ? 'demo' : 'full'; + this.view.set(this.view() === 'full' ? 'demo' : 'full'); } copySource(snippets: readonly CodeSnippet[], selectedIndex: number = 0) { @@ -142,42 +171,29 @@ export class ExampleViewer implements OnInit { } } - generateUrl(file: string): string { - const lastDotIndex = file.lastIndexOf('.'); - const contentBeforeDot = file.substring(0, lastDotIndex); - const contentAfterDot = file.substring(lastDotIndex + 1); - let fileName: string; + protected _exampleTabNames = computed(() => { + const exampleTabs = this.exampleTabs(); - if (this.region) { - fileName = `${contentBeforeDot}_${this.region}-${contentAfterDot}.html`; - } else { - fileName = `${contentBeforeDot}-${contentAfterDot}.html`; + if (!exampleTabs) { + return []; } - return this.exampleData - ? `/docs-content/examples-highlighted/${this.exampleData.packagePath}/${fileName}` - : ''; - } + return Object.keys(exampleTabs).sort((a, b) => { + let indexA = preferredExampleFileOrder.indexOf(a); + let indexB = preferredExampleFileOrder.indexOf(b); + // Files which are not part of the preferred example file order should be + // moved after all items with a preferred index. + if (indexA === -1) { + indexA = preferredExampleFileOrder.length; + } - _getExampleTabNames() { - return this.exampleTabs - ? Object.keys(this.exampleTabs).sort((a, b) => { - let indexA = preferredExampleFileOrder.indexOf(a); - let indexB = preferredExampleFileOrder.indexOf(b); - // Files which are not part of the preferred example file order should be - // moved after all items with a preferred index. - if (indexA === -1) { - indexA = preferredExampleFileOrder.length; - } - - if (indexB === -1) { - indexB = preferredExampleFileOrder.length; - } - - return indexA - indexB || 1; - }) - : []; - } + if (indexB === -1) { + indexB = preferredExampleFileOrder.length; + } + + return indexA - indexB || 1; + }); + }); _copyLink() { // Reconstruct the URL using `origin + pathname` so we drop any pre-existing hash. @@ -193,19 +209,19 @@ export class ExampleViewer implements OnInit { private async _exampleChanged(name: string) { const examples = (await this._docsItems.getData()).examples; - this.exampleData = examples[name]; + this.exampleData.set(examples[name]); - if (!this.exampleData) { + if (!this.exampleData()) { console.error(`Could not find example: ${name}`); return; } try { - this._generateExampleTabs(); + this._generateExampleTabs(this.exampleData()); // Lazily loads the example package that contains the requested example. const moduleExports = await loadExample(name); - this._exampleComponentType = moduleExports[examples[name].componentName]; + this._exampleComponentType.set(moduleExports[examples[name].componentName]); // Since the data is loaded asynchronously, we can't count on the native behavior // that scrolls the element into view automatically. We do it ourselves while giving @@ -218,20 +234,20 @@ export class ExampleViewer implements OnInit { } } - private _generateExampleTabs() { - this.exampleTabs = {}; + private _generateExampleTabs(data: LiveExample | null) { + const tabs: Record = {}; - if (this.exampleData) { + if (data) { // Name of the default example files. If files with such name exist within the example, // we provide a shorthand for them within the example tabs (for less verbose tabs). const exampleBaseFileName = `${this.example}-example`; - const docsContentPath = `/docs-content/examples-highlighted/${this.exampleData.packagePath}`; + const docsContentPath = `/docs-content/examples-highlighted/${data.packagePath}`; const tsPath = normalizePath(`${exampleBaseFileName}.ts`); const cssPath = normalizePath(`${exampleBaseFileName}.css`); const htmlPath = normalizePath(`${exampleBaseFileName}.html`); - for (let fileName of this.exampleData.files) { + for (let fileName of data.files) { // Since the additional files refer to the original file name, we need to transform // the file name to match the highlighted HTML file that displays the source. const fileSourceName = fileName.replace(fileExtensionRegex, '$1-$2.html'); @@ -242,15 +258,17 @@ export class ExampleViewer implements OnInit { fileName = normalizePath(fileName); if (fileName === tsPath) { - this.exampleTabs['TS'] = importPath; + tabs['TS'] = importPath; } else if (fileName === cssPath) { - this.exampleTabs['CSS'] = importPath; + tabs['CSS'] = importPath; } else if (fileName === htmlPath) { - this.exampleTabs['HTML'] = importPath; + tabs['HTML'] = importPath; } else { - this.exampleTabs[fileName] = importPath; + tabs[fileName] = importPath; } } } + + this.exampleTabs.set(tabs); } }