refactor(docs-infra): remove shared code from adev in favor of loading from dev-infra common package (#53214)

Remove shared code as part of its migration to the dev-infra package.

PR Close #53214
This commit is contained in:
Joey Perrott 2023-11-27 21:27:36 +00:00
parent 38bf0a320b
commit fc3e41cc9d
171 changed files with 47 additions and 7932 deletions

View file

@ -105,36 +105,6 @@
}
}
}
},
"shared": {
"projectType": "library",
"root": "projects/shared",
"sourceRoot": "projects/shared/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"project": "projects/shared/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/shared/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/shared/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"tsConfig": "projects/shared/tsconfig.spec.json",
"polyfills": ["zone.js", "zone.js/testing"]
}
}
}
}
},
"cli": {

View file

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
/* tslint:disable:no-console */
import {NavigationItem} from '@angular/docs-shared';
import {NavigationItem} from '@angular/docs';
import {readFileSync} from 'fs';
import {glob} from 'glob';
import {join} from 'path';

View file

@ -5,7 +5,7 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {NavigationItem} from '@angular/docs-shared';
import {NavigationItem} from '@angular/docs';
import {PagePrefix} from '../../src/app/core/enums/pages';

View file

@ -8,7 +8,7 @@
import type {FileSystemTree} from '@webcontainer/api';
import type {TutorialNavigationItemWithStep} from './generate-tutorials-routes';
import {NavigationItem} from '@angular/docs-shared';
import {NavigationItem} from '@angular/docs';
import {TutorialType} from './utils/web-constants';
/**

View file

@ -1,7 +0,0 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/shared",
"lib": {
"entryFile": "src/public-api.ts"
}
}

View file

@ -1,12 +0,0 @@
{
"name": "shared",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": "^16.0.1",
"@angular/core": "^16.0.1"
},
"dependencies": {
"tslib": "^2.3.0"
},
"sideEffects": false
}

View file

@ -1,52 +0,0 @@
<!-- Algolia logo -->
<svg
id="Layer_1"
class="adev-algolia-logo"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 2196.2 500"
>
<defs>
<style>
.cls-1,
.cls-2 {
fill: #003dff;
}
.cls-2 {
fill-rule: evenodd;
}
</style>
</defs>
<path
class="cls-2"
d="M1070.38,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"
/>
<rect class="cls-1" x="1845.88" y="104.73" width="62.58" height="277.9" rx="5.9" ry="5.9" />
<path
class="cls-2"
d="M1851.78,71.38h50.77c3.26,0,5.9-2.64,5.9-5.9V5.9c0-3.62-3.24-6.39-6.82-5.83l-50.77,7.95c-2.87,.45-4.99,2.92-4.99,5.83v51.62c0,3.26,2.64,5.9,5.9,5.9Z"
/>
<path
class="cls-2"
d="M1764.03,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"
/>
<path
class="cls-2"
d="M1631.95,142.72c-11.14-12.25-24.83-21.65-40.78-28.31-15.92-6.53-33.26-9.85-52.07-9.85-18.78,0-36.15,3.17-51.92,9.85-15.59,6.66-29.29,16.05-40.76,28.31-11.47,12.23-20.38,26.87-26.76,44.03-6.38,17.17-9.24,37.37-9.24,58.36,0,20.99,3.19,36.87,9.55,54.21,6.38,17.32,15.14,32.11,26.45,44.36,11.29,12.23,24.83,21.62,40.6,28.46,15.77,6.83,40.12,10.33,52.4,10.48,12.25,0,36.78-3.82,52.7-10.48,15.92-6.68,29.46-16.23,40.78-28.46,11.29-12.25,20.05-27.04,26.25-44.36,6.22-17.34,9.24-33.22,9.24-54.21,0-20.99-3.34-41.19-10.03-58.36-6.38-17.17-15.14-31.8-26.43-44.03Zm-44.43,163.75c-11.47,15.75-27.56,23.7-48.09,23.7-20.55,0-36.63-7.8-48.1-23.7-11.47-15.75-17.21-34.01-17.21-61.2,0-26.89,5.59-49.14,17.06-64.87,11.45-15.75,27.54-23.52,48.07-23.52,20.55,0,36.63,7.78,48.09,23.52,11.47,15.57,17.36,37.98,17.36,64.87,0,27.19-5.72,45.3-17.19,61.2Z"
/>
<path
class="cls-2"
d="M894.42,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"
/>
<path
class="cls-2"
d="M2133.97,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"
/>
<path
class="cls-2"
d="M1314.05,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-11.79,18.34-19.6,39.64-22.11,62.59-.58,5.3-.88,10.68-.88,16.14s.31,11.15,.93,16.59c4.28,38.09,23.14,71.61,50.66,94.52,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47h0c17.99,0,34.61-5.93,48.16-15.97,16.29-11.58,28.88-28.54,34.48-47.75v50.26h-.11v11.08c0,21.84-5.71,38.27-17.34,49.36-11.61,11.08-31.04,16.63-58.25,16.63-11.12,0-28.79-.59-46.6-2.41-2.83-.29-5.46,1.5-6.27,4.22l-12.78,43.11c-1.02,3.46,1.27,7.02,4.83,7.53,21.52,3.08,42.52,4.68,54.65,4.68,48.91,0,85.16-10.75,108.89-32.21,21.48-19.41,33.15-48.89,35.2-88.52V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,64.1s.65,139.13,0,143.36c-12.08,9.77-27.11,13.59-43.49,14.7-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-1.32,0-2.63-.03-3.94-.1-40.41-2.11-74.52-37.26-74.52-79.38,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33Z"
/>
<path
class="cls-1"
d="M249.83,0C113.3,0,2,110.09,.03,246.16c-2,138.19,110.12,252.7,248.33,253.5,42.68,.25,83.79-10.19,120.3-30.03,3.56-1.93,4.11-6.83,1.08-9.51l-23.38-20.72c-4.75-4.21-11.51-5.4-17.36-2.92-25.48,10.84-53.17,16.38-81.71,16.03-111.68-1.37-201.91-94.29-200.13-205.96,1.76-110.26,92-199.41,202.67-199.41h202.69V407.41l-115-102.18c-3.72-3.31-9.42-2.66-12.42,1.31-18.46,24.44-48.53,39.64-81.93,37.34-46.33-3.2-83.87-40.5-87.34-86.81-4.15-55.24,39.63-101.52,94-101.52,49.18,0,89.68,37.85,93.91,85.95,.38,4.28,2.31,8.27,5.52,11.12l29.95,26.55c3.4,3.01,8.79,1.17,9.63-3.3,2.16-11.55,2.92-23.58,2.07-35.92-4.82-70.34-61.8-126.93-132.17-131.26-80.68-4.97-148.13,58.14-150.27,137.25-2.09,77.1,61.08,143.56,138.19,145.26,32.19,.71,62.03-9.41,86.14-26.95l150.26,133.2c6.44,5.71,16.61,1.14,16.61-7.47V9.48C499.66,4.25,495.42,0,490.18,0H249.83Z"
/>
</svg>

Before

Width:  |  Height:  |  Size: 5 KiB

View file

@ -1,17 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Component} from '@angular/core';
@Component({
selector: 'docs-algolia-icon',
standalone: true,
imports: [],
templateUrl: './algolia-icon.component.html',
})
export class AlgoliaIcon {}

View file

@ -1,13 +0,0 @@
@for (breadcrumb of breadcrumbItems(); track breadcrumb) {
<div class="docs-breadcrumb">
@if (breadcrumb.path) {
@if (breadcrumb.isExternal) {
<a [href]="breadcrumb.path">{{ breadcrumb.label }}</a>
} @else {
<a [routerLink]="'/' + breadcrumb.path">{{ breadcrumb.label }}</a>
}
} @else {
<span>{{ breadcrumb.label }}</span>
}
</div>
}

View file

@ -1,25 +0,0 @@
:host {
display: flex;
align-items: center;
padding-block-end: 1.5rem;
}
.docs-breadcrumb {
span {
color: var(--quaternary-contrast);
font-size: 0.875rem;
display: flex;
align-items: center;
}
&:not(:last-child) {
span {
&::after {
content: 'chevron_right';
font-family: var(--icons);
margin-inline: 0.5rem;
color: var(--quinary-contrast);
}
}
}
}

View file

@ -1,29 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {Breadcrumb} from './breadcrumb.component';
describe('Breadcrumb', () => {
let component: Breadcrumb;
let fixture: ComponentFixture<Breadcrumb>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [Breadcrumb],
});
fixture = TestBed.createComponent(Breadcrumb);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,50 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ChangeDetectionStrategy, Component, OnInit, inject, signal} from '@angular/core';
import {NavigationState} from '../../services';
import {NavigationItem} from '../../interfaces';
import {NgFor, NgIf} from '@angular/common';
import {RouterLink} from '@angular/router';
@Component({
selector: 'docs-breadcrumb',
standalone: true,
imports: [NgIf, NgFor, RouterLink],
templateUrl: './breadcrumb.component.html',
styleUrls: ['./breadcrumb.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Breadcrumb implements OnInit {
private readonly navigationState = inject(NavigationState);
breadcrumbItems = signal<NavigationItem[]>([]);
ngOnInit(): void {
this.setBreadcrumbItemsBasedOnNavigationStructure();
}
private setBreadcrumbItemsBasedOnNavigationStructure(): void {
let breadcrumbs: NavigationItem[] = [];
const traverse = (node: NavigationItem | null) => {
if (!node) {
return;
}
if (node.parent) {
breadcrumbs = [node.parent, ...breadcrumbs];
traverse(node.parent);
}
};
traverse(this.navigationState.activeNavigationItem());
this.breadcrumbItems.set(breadcrumbs);
}
}

View file

@ -1,22 +0,0 @@
@if (!hasAccepted()) {
<div class="docs-cookies-popup adev-invert-mode">
<p>This site uses cookies from Google to deliver its services and to analyze traffic.</p>
<div>
<a href="https://policies.google.com/technologies/cookies" target="_blank" rel="noopener">
<button class="adev-primary-btn" [attr.text]="'Learn more'" aria-label="Learn More">
Learn more
</button>
</a>
<button
type="button"
(click)="accept()"
class="adev-primary-btn"
[attr.text]="'Ok, Got it'"
aria-label="Ok, Got it"
>
Ok, Got it
</button>
</div>
</div>
}

View file

@ -1,40 +0,0 @@
:host {
position: fixed;
bottom: 0.5rem;
right: 0.5rem;
z-index: var(--z-index-cookie-consent);
opacity: 0;
visibility: hidden;
animation: 1s linear forwards 0.5s fadeIn;
}
.docs-cookies-popup {
padding: 1rem;
background-color: var(--page-background);
border: 1px solid var(--senary-contrast);
border-radius: 0.25rem;
font-size: 0.875rem;
max-width: 265px;
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
> div {
display: flex;
gap: 0.5rem;
align-items: center;
width: 100%;
margin-block-start: 1rem;
}
p {
margin-block: 0;
color: var(--primary-contrast);
}
}
@keyframes fadeIn {
100% {
opacity: 100%;
visibility: visible;
}
}

View file

@ -1,80 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {CookiePopup, STORAGE_KEY} from './cookie-popup.component';
import {LOCAL_STORAGE} from '../../providers';
import {MockLocalStorage} from '../../utils/test.utils';
describe('CookiePopup', () => {
let fixture: ComponentFixture<CookiePopup>;
let mockLocalStorage = new MockLocalStorage();
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CookiePopup],
providers: [
{
provide: LOCAL_STORAGE,
useValue: mockLocalStorage,
},
],
});
});
it('should make the popup visible by default', () => {
initComponent(false);
expect(getCookiesPopup()).not.toBeNull();
});
it('should hide the cookies popup if the user has already accepted cookies', () => {
initComponent(true);
expect(getCookiesPopup()).toBeNull();
});
it('should hide the cookies popup', () => {
initComponent(false);
accept();
fixture.detectChanges();
expect(getCookiesPopup()).toBeNull();
});
it('should store the user confirmation', () => {
initComponent(false);
expect(mockLocalStorage.getItem(STORAGE_KEY)).toBeNull();
accept();
expect(mockLocalStorage.getItem(STORAGE_KEY)).toBe('true');
});
// Helpers
function getCookiesPopup() {
return (fixture.nativeElement as HTMLElement).querySelector('.docs-cookies-popup');
}
function accept() {
(fixture.nativeElement as HTMLElement)
.querySelector<HTMLButtonElement>('button[text="Ok, Got it"]')
?.click();
}
function initComponent(cookiesAccepted: boolean) {
mockLocalStorage.setItem(STORAGE_KEY, cookiesAccepted ? 'true' : null);
fixture = TestBed.createComponent(CookiePopup);
fixture.detectChanges();
}
});

View file

@ -1,47 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ChangeDetectionStrategy, Component, inject, signal} from '@angular/core';
import {NgIf} from '@angular/common';
import {LOCAL_STORAGE} from '../../providers';
export const STORAGE_KEY = 'docs-accepts-cookies';
@Component({
selector: 'docs-cookie-popup',
standalone: true,
imports: [NgIf],
templateUrl: './cookie-popup.component.html',
styleUrls: ['./cookie-popup.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CookiePopup {
private readonly localStorage = inject(LOCAL_STORAGE);
/** Whether the user has accepted the cookie disclaimer. */
hasAccepted = signal<boolean>(false);
constructor() {
// Needs to be in a try/catch, because some browsers will
// throw when using `localStorage` in private mode.
try {
this.hasAccepted.set(this.localStorage?.getItem(STORAGE_KEY) === 'true');
} catch {
this.hasAccepted.set(false);
}
}
/** Accepts the cookie disclaimer. */
protected accept(): void {
try {
this.localStorage?.setItem(STORAGE_KEY, 'true');
} catch {}
this.hasAccepted.set(true);
}
}

View file

@ -1,18 +0,0 @@
<i>
<svg
aria-hidden="true"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="adev-copy"
>
<path
d="M5 22C4.45 22 3.97917 21.8042 3.5875 21.4125C3.19583 21.0208 3 20.55 3 20V6H5V20H16V22H5ZM9 18C8.45 18 7.97917 17.8042 7.5875 17.4125C7.19583 17.0208 7 16.55 7 16V4C7 3.45 7.19583 2.97917 7.5875 2.5875C7.97917 2.19583 8.45 2 9 2H18C18.55 2 19.0208 2.19583 19.4125 2.5875C19.8042 2.97917 20 3.45 20 4V16C20 16.55 19.8042 17.0208 19.4125 17.4125C19.0208 17.8042 18.55 18 18 18H9ZM9 16H18V4H9V16Z"
fill="#A39FA9"
/>
</svg>
</i>
<docs-icon class="adev-check">check</docs-icon>

View file

@ -1,120 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ComponentFixture, TestBed, fakeAsync, tick} from '@angular/core/testing';
import {
CONFIRMATION_DISPLAY_TIME_MS,
CopySourceCodeButton,
} from './copy-source-code-button.component';
import {Component, Input} from '@angular/core';
import {By} from '@angular/platform-browser';
import {Clipboard} from '@angular/cdk/clipboard';
const SUCCESSFULLY_COPY_CLASS_NAME = 'docs-copy-source-code-button-success';
const FAILED_COPY_CLASS_NAME = 'docs-copy-source-code-button-failed';
describe('CopySourceCodeButton', () => {
let component: CodeSnippetWrapper;
let fixture: ComponentFixture<CodeSnippetWrapper>;
let copySpy: jasmine.Spy<(text: string) => boolean>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CodeSnippetWrapper],
});
fixture = TestBed.createComponent(CodeSnippetWrapper);
component = fixture.componentInstance;
fixture.detectChanges();
});
beforeEach(() => {
const clipboardService = TestBed.inject(Clipboard);
copySpy = spyOn(clipboardService, 'copy');
});
it('should call clipboard service when clicked on copy source code', () => {
const expectedCodeToBeCopied = 'npm install -g @angular/cli';
component.code = expectedCodeToBeCopied;
fixture.detectChanges();
const button = fixture.debugElement.query(By.directive(CopySourceCodeButton)).nativeElement;
button.click();
expect(copySpy.calls.argsFor(0)[0].trim()).toBe(expectedCodeToBeCopied);
});
it('should not copy lines marked as deleted when code snippet contains diff', () => {
const codeInHtmlFormat = `
<code>
<div class="hljs-ln-line remove"><span class="hljs-tag">&lt;<span class="hljs-name">div</span> *<span class="hljs-attr">ngFor</span>=<span class="hljs-string">"let product of products"</span>&gt;</span></div>
<div class="hljs-ln-line add"><span class="hljs-tag">&lt;<span class="hljs-name">div</span> *<span class="hljs-attr">ngFor</span>=<span class="hljs-string">"let product of products()"</span>&gt;</span></div>
</code>
`;
const expectedCodeToBeCopied = `<div *ngFor="let product of products()">`;
component.code = codeInHtmlFormat;
fixture.detectChanges();
const button = fixture.debugElement.query(By.directive(CopySourceCodeButton)).nativeElement;
button.click();
expect(copySpy.calls.argsFor(0)[0].trim()).toBe(expectedCodeToBeCopied);
});
it(`should set ${SUCCESSFULLY_COPY_CLASS_NAME} for ${CONFIRMATION_DISPLAY_TIME_MS} ms when copy was executed properly`, fakeAsync(() => {
component.code = 'example';
fixture.detectChanges();
const button = fixture.debugElement.query(By.directive(CopySourceCodeButton)).nativeElement;
button.click();
fixture.detectChanges();
expect(button).toHaveClass(SUCCESSFULLY_COPY_CLASS_NAME);
tick(CONFIRMATION_DISPLAY_TIME_MS);
fixture.detectChanges();
expect(button).not.toHaveClass(SUCCESSFULLY_COPY_CLASS_NAME);
}));
it(`should set ${FAILED_COPY_CLASS_NAME} for ${CONFIRMATION_DISPLAY_TIME_MS} ms when copy failed`, fakeAsync(() => {
component.code = 'example';
copySpy.and.throwError('Fake copy error');
fixture.detectChanges();
const button = fixture.debugElement.query(By.directive(CopySourceCodeButton)).nativeElement;
button.click();
fixture.detectChanges();
expect(button).toHaveClass(FAILED_COPY_CLASS_NAME);
tick(CONFIRMATION_DISPLAY_TIME_MS);
fixture.detectChanges();
expect(button).not.toHaveClass(FAILED_COPY_CLASS_NAME);
}));
});
@Component({
template: `
<pre>
<code [innerHtml]="code"></code>
</pre>
<button docs-copy-source-code></button>
`,
imports: [CopySourceCodeButton],
standalone: true,
})
class CodeSnippetWrapper {
@Input({required: true}) code!: string;
}

View file

@ -1,89 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
WritableSignal,
inject,
signal,
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {Clipboard} from '@angular/cdk/clipboard';
import {IconComponent} from '../icon/icon.component';
export const REMOVED_LINE_CLASS_NAME = '.hljs-ln-line.remove';
export const CONFIRMATION_DISPLAY_TIME_MS = 2000;
@Component({
selector: 'button[docs-copy-source-code]',
standalone: true,
imports: [CommonModule, IconComponent],
templateUrl: './copy-source-code-button.component.html',
host: {
'type': 'button',
'aria-label': 'Copy example source to clipboard',
'title': 'Copy example source',
'(click)': 'copySourceCode()',
'[class.docs-copy-source-code-button-success]': 'showCopySuccess()',
'[class.docs-copy-source-code-button-failed]': 'showCopyFailure()',
},
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CopySourceCodeButton {
private readonly changeDetector = inject(ChangeDetectorRef);
private readonly clipboard = inject(Clipboard);
private readonly elementRef = inject(ElementRef);
protected readonly showCopySuccess = signal(false);
protected readonly showCopyFailure = signal(false);
copySourceCode(): void {
try {
const codeElement = this.elementRef.nativeElement.parentElement.querySelector(
'code',
) as HTMLElement;
const sourceCode = this.getSourceCode(codeElement);
this.clipboard.copy(sourceCode);
this.showResult(this.showCopySuccess);
} catch {
this.showResult(this.showCopyFailure);
}
}
private getSourceCode(codeElement: HTMLElement): string {
this.showCopySuccess.set(false);
this.showCopyFailure.set(false);
const removedLines: NodeList = codeElement.querySelectorAll(REMOVED_LINE_CLASS_NAME);
if (removedLines.length) {
// Get only those lines which are not marked as removed
const formattedText = Array.from(codeElement.querySelectorAll('.hljs-ln-line:not(.remove)'))
.map((line) => (line as HTMLDivElement).innerText)
.join('\n');
return formattedText.trim();
} else {
const text: string = codeElement.innerText || '';
return text.replaceAll(`\n\n\n`, ``).trim();
}
}
private showResult(messageState: WritableSignal<boolean>) {
messageState.set(true);
setTimeout(() => {
messageState.set(false);
// It's required for code snippets embedded in the ExampleViewer.
this.changeDetector.markForCheck();
}, CONFIRMATION_DISPLAY_TIME_MS);
}
}

View file

@ -1,145 +0,0 @@
@use '../../styles/links' as links;
:host {
--translate-y: clamp(5px, 0.25em, 7px);
}
.docs-viewer {
display: flex;
flex-direction: column;
padding: var(--layout-padding);
max-width: var(--page-width);
width: 100%;
box-sizing: border-box;
@media only screen and (max-width: 1430px) {
container: docs-content / inline-size;
}
// If rendered on the docs page, accommodate width for TOC
adev-docs & {
@media only screen and (min-width: 1430px) and (max-width: 1550px) {
width: calc(100% - 195px - var(--layout-padding));
max-width: var(--page-width);
}
}
pre {
margin-block: 0;
padding-block: 0.75rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
.docs-anchor {
margin-block-start: 2.5rem;
display: inline-block;
color: inherit;
&::after {
content: '\e157'; // codepoint for "link"
font-family: 'Material Symbols Outlined';
opacity: 0;
margin-left: 8px;
vertical-align: middle;
color: var(--quaternary-contrast);
font-size: clamp(18px, 1.25em, 30px);
transition: opacity 0.3s ease;
}
&:hover {
&::after {
opacity: 1;
}
}
}
}
h1 {
font-size: 2.5rem;
margin-block-end: 0;
}
h2 {
font-size: 2rem;
margin-block-end: 0.5rem;
}
h3 {
font-size: 1.5rem;
margin-block-end: 0.5rem;
}
h4 {
font-size: 1.25rem;
margin-block-end: 0.5rem;
}
h5 {
font-size: 1rem;
margin-block-end: 0;
}
h6 {
font-size: 0.875rem;
margin-block-end: 0;
}
> :last-child {
margin-block-end: 0;
}
a:not(.docs-github-links):not(.docs-card):not(.docs-pill):not(.docs-example-github-link) {
&[href^='http:'],
&[href^='https:'] {
@include links.external-link-with-icon();
}
}
&-scroll-margin-large {
h2,
h3 {
scroll-margin: 5em;
}
}
}
.docs-header {
margin-block-end: 1rem;
& > p:first-child {
color: var(--quaternary-contrast);
font-weight: 500;
margin: 0;
}
}
.docs-page-title {
display: flex;
justify-content: space-between;
h1 {
margin-block: 0;
font-size: 2.25rem;
}
a {
color: var(--primary-contrast);
height: fit-content;
docs-icon {
color: var(--gray-400);
transition: color 0.3s ease;
}
&:hover {
docs-icon {
color: var(--primary-contrast);
}
}
}
}

View file

@ -1,104 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {RouterTestingModule} from '@angular/router/testing';
import {DocContent, ExampleViewerContentLoader} from '../../interfaces';
import {EXAMPLE_VIEWER_CONTENT_LOADER} from '../../providers';
import {CodeExampleViewMode, ExampleViewer} from '../example-viewer/example-viewer.component';
import {DocViewer} from './docs-viewer.component';
describe('DocViewer', () => {
let fixture: ComponentFixture<DocViewer>;
let exampleContentSpy: jasmine.SpyObj<ExampleViewerContentLoader>;
const sampleDocContentWithExampleViewerPlaceholders: DocContent = {
id: 'id',
contents: `<div class="docs-code linenums" visibleLines="[12, 31]" expanded="true" path="hello-world/hello-world-new.ts">
<div class="docs-code-header">A styled code example</div>
<pre>
<code><div class="hljs-ln-line"><span class="hljs-comment">/*!</div><div class="hljs-ln-line"> * @license</div><div class="hljs-ln-line"> * Copyright Google LLC All Rights Reserved.</div><div class="hljs-ln-line"> *</div><div class="hljs-ln-line"> * Use of this source code is governed by an MIT-style license that can be</div><div class="hljs-ln-line"> * found in the LICENSE file at https://angular.dev/license</div><div class="hljs-ln-line"> */</span></div><div class="hljs-ln-line"></div><div class="hljs-ln-line remove"><span class="hljs-keyword">import</span> {ChangeDetectorRef, Component, <span class="hljs-keyword">inject</span>, signal} <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@angular/core&#x27;</span>;</div><div class="hljs-ln-line add"><span class="hljs-keyword">import</span> {Component, signal} <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@angular/core&#x27;</span>;</div><div class="hljs-ln-line"><span class="hljs-keyword">import</span> {CommonModule} <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@angular/common&#x27;</span>;</div><div class="hljs-ln-line"></div><div class="hljs-ln-line highlighted">@Component({</div><div class="hljs-ln-line highlighted"> selector: <span class="hljs-string">&#x27;hello-world&#x27;</span>,</div><div class="hljs-ln-line highlighted"> standalone: <span class="hljs-keyword">true</span>,</div><div class="hljs-ln-line highlighted"> imports: [CommonModule],</div><div class="hljs-ln-line highlighted"> templateUrl: <span class="hljs-string">&#x27;./hello-world.html&#x27;</span>,</div><div class="hljs-ln-line highlighted"> styleUrls: [<span class="hljs-string">&#x27;./hello-world.css&#x27;</span>],</div><div class="hljs-ln-line highlighted">})</div><div class="hljs-ln-line">export <span class="hljs-keyword">default</span> <span class="hljs-keyword">class</span> HelloWorldComponent {</div><div class="hljs-ln-line remove"> world = <span class="hljs-string">&#x27;World&#x27;</span>;</div><div class="hljs-ln-line add"> world = <span class="hljs-string">&#x27;World!!!&#x27;</span>;</div><div class="hljs-ln-line"> <span class="hljs-keyword">count</span> = signal(<span class="hljs-number">0</span>);</div><div class="hljs-ln-line remove"> changeDetector = <span class="hljs-keyword">inject</span>(ChangeDetectorRef);</div><div class="hljs-ln-line"></div><div class="hljs-ln-line"> increase(): <span class="hljs-keyword">void</span> {</div><div class="hljs-ln-line"> <span class="hljs-keyword">this</span>.<span class="hljs-keyword">count</span>.update((<span class="hljs-keyword">previous</span>) =&gt; {</div><div class="hljs-ln-line highlighted"> <span class="hljs-keyword">return</span> <span class="hljs-keyword">previous</span> + <span class="hljs-number">1</span>;</div><div class="hljs-ln-line"> });</div><div class="hljs-ln-line remove"> <span class="hljs-keyword">this</span>.changeDetector.detectChanges();</div><div class="hljs-ln-line"> }</div><div class="hljs-ln-line">}</div><div class="hljs-ln-line"></div></code>
</pre>
</div>`,
};
const sampleDocContentWithExpandedExampleViewerPlaceholders: DocContent = {
id: 'id',
contents: ` <div class="docs-code-multifile" expanded="true" path="hello-world/hello-world-new.ts">
<div class="docs-code" visibleLines="[12, 31]" path="hello-world/hello-world-new.ts">
<pre>
<code><div class="hljs-ln-line"><span class="hljs-comment">/*!</div><div class="hljs-ln-line"> * @license</div><div class="hljs-ln-line"> * Copyright Google LLC All Rights Reserved.</div><div class="hljs-ln-line"> *</div><div class="hljs-ln-line"> * Use of this source code is governed by an MIT-style license that can be</div><div class="hljs-ln-line"> * found in the LICENSE file at https://angular.dev/license</div><div class="hljs-ln-line"> */</span></div><div class="hljs-ln-line"></div><div class="hljs-ln-line remove"><span class="hljs-keyword">import</span> {ChangeDetectorRef, Component, <span class="hljs-keyword">inject</span>, signal} <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@angular/core&#x27;</span>;</div><div class="hljs-ln-line add"><span class="hljs-keyword">import</span> {Component, signal} <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@angular/core&#x27;</span>;</div><div class="hljs-ln-line"><span class="hljs-keyword">import</span> {CommonModule} <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@angular/common&#x27;</span>;</div><div class="hljs-ln-line"></div><div class="hljs-ln-line">@Component({</div><div class="hljs-ln-line"> selector: <span class="hljs-string">&#x27;hello-world&#x27;</span>,</div><div class="hljs-ln-line"> standalone: <span class="hljs-keyword">true</span>,</div><div class="hljs-ln-line"> imports: [CommonModule],</div><div class="hljs-ln-line"> templateUrl: <span class="hljs-string">&#x27;./hello-world.html&#x27;</span>,</div><div class="hljs-ln-line"> styleUrls: [<span class="hljs-string">&#x27;./hello-world.css&#x27;</span>],</div><div class="hljs-ln-line">})</div><div class="hljs-ln-line">export <span class="hljs-keyword">default</span> <span class="hljs-keyword">class</span> HelloWorldComponent {</div><div class="hljs-ln-line remove"> world = <span class="hljs-string">&#x27;World&#x27;</span>;</div><div class="hljs-ln-line add"> world = <span class="hljs-string">&#x27;World!!!&#x27;</span>;</div><div class="hljs-ln-line"> <span class="hljs-keyword">count</span> = signal(<span class="hljs-number">0</span>);</div><div class="hljs-ln-line remove"> changeDetector = <span class="hljs-keyword">inject</span>(ChangeDetectorRef);</div><div class="hljs-ln-line"></div><div class="hljs-ln-line"> increase(): <span class="hljs-keyword">void</span> {</div><div class="hljs-ln-line"> <span class="hljs-keyword">this</span>.<span class="hljs-keyword">count</span>.update((<span class="hljs-keyword">previous</span>) =&gt; {</div><div class="hljs-ln-line"> <span class="hljs-keyword">return</span> <span class="hljs-keyword">previous</span> + <span class="hljs-number">1</span>;</div><div class="hljs-ln-line"> });</div><div class="hljs-ln-line remove"> <span class="hljs-keyword">this</span>.changeDetector.detectChanges();</div><div class="hljs-ln-line"> }</div><div class="hljs-ln-line">}</div><div class="hljs-ln-line"></div></code>
</pre>
</div>
<div class="docs-code linenums" path="hello-world/hello-world.html">
<pre>
<code><div class="hljs-ln-line"><span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">h2</span>&gt;</span>Hello </span><span class="hljs-template-variable">{{ <span class="hljs-name">world</span> }}</span><span class="language-xml"><span class="hljs-tag">&lt;/<span class="hljs-name">h2</span>&gt;</span></div><div class="hljs-ln-line"><span class="hljs-tag">&lt;<span class="hljs-name">button</span> (<span class="hljs-attr">click</span>)=<span class="hljs-string">&quot;increase()&quot;</span>&gt;</span>Increase<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span></div><div class="hljs-ln-line"><span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>Counter: </span><span class="hljs-template-variable">{{ <span class="hljs-name">count</span>() }}</span><span class="language-xml"><span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span></div><div class="hljs-ln-line"></span></div></code>
</pre>
</div>
</div>`,
};
beforeEach(() => {
exampleContentSpy = jasmine.createSpyObj('ExampleContentLoader', ['getCodeExampleData']);
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DocViewer, NoopAnimationsModule, RouterTestingModule],
providers: [{provide: EXAMPLE_VIEWER_CONTENT_LOADER, useValue: exampleContentSpy}],
}).compileComponents();
fixture = TestBed.createComponent(DocViewer);
fixture.detectChanges();
});
it('should load doc into innerHTML', () => {
const fixture = TestBed.createComponent(DocViewer);
fixture.componentRef.setInput('docContent', 'hello world');
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toBe('hello world');
});
it('should instantiate example viewer in snippet view mode', async () => {
const fixture = TestBed.createComponent(DocViewer);
fixture.componentRef.setInput(
'docContent',
sampleDocContentWithExampleViewerPlaceholders.contents,
);
fixture.detectChanges();
await fixture.whenStable();
const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer));
expect(exampleViewer).not.toBeNull();
expect(exampleViewer.componentInstance.view()).toBe(CodeExampleViewMode.SNIPPET);
});
it('should display example viewer in multi file mode when user clicks expand', async () => {
const fixture = TestBed.createComponent(DocViewer);
fixture.componentRef.setInput(
'docContent',
sampleDocContentWithExpandedExampleViewerPlaceholders.contents,
);
fixture.detectChanges();
await fixture.whenStable();
const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer));
const expandButton = fixture.debugElement.query(
By.css('button[aria-label="Expand code example"]'),
);
expandButton.nativeElement.click();
expect(exampleViewer).not.toBeNull();
expect(exampleViewer.componentInstance.view()).toBe(CodeExampleViewMode.MULTI_FILE);
expect(exampleViewer.componentInstance.tabs().length).toBe(2);
});
});

View file

@ -1,332 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {CommonModule, DOCUMENT, isPlatformBrowser, Location} from '@angular/common';
import {
ApplicationRef,
ChangeDetectionStrategy,
Component,
ComponentRef,
createComponent,
DestroyRef,
ElementRef,
EnvironmentInjector,
inject,
Injector,
Input,
OnChanges,
PLATFORM_ID,
SimpleChanges,
Type,
ViewContainerRef,
ViewEncapsulation,
ɵInitialRenderPendingTasks as PendingRenderTasks,
EventEmitter,
Output,
} from '@angular/core';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {
handleHrefClickEventWithRouter,
IconComponent,
NavigationState,
Snippet,
TableOfContents,
TOC_SKIP_CONTENT_MARKER,
} from '@angular/docs-shared';
import {Router} from '@angular/router';
import {fromEvent} from 'rxjs';
import {Breadcrumb} from '../breadcrumb/breadcrumb.component';
import {CopySourceCodeButton} from '../copy-source-code-button/copy-source-code-button.component';
import {ExampleViewer} from '../example-viewer/example-viewer.component';
/// <reference types="@types/dom-view-transitions" />
const TOC_HOST_ELEMENT_NAME = 'docs-table-of-contents';
export const ASSETS_EXAMPLES_PATH = 'assets/content/examples';
export const DOCS_VIEWER_SELECTOR = 'docs-viewer';
export const DOCS_CODE_SELECTOR = '.docs-code';
export const DOCS_CODE_MUTLIFILE_SELECTOR = '.docs-code-multifile';
// TODO: Update the branch/sha
export const GITHUB_CONTENT_URL =
'https://github.com/angular/angular/blob/main/adev/src/content/examples/';
@Component({
selector: DOCS_VIEWER_SELECTOR,
standalone: true,
imports: [CommonModule],
template: '',
styleUrls: ['docs-viewer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
'[class.docs-animate-content]': 'animateContent',
},
})
export class DocViewer implements OnChanges {
@Input() docContent?: string;
@Input() hasToc = false;
@Output() contentLoaded = new EventEmitter<void>();
private readonly destroyRef = inject(DestroyRef);
private readonly document = inject(DOCUMENT);
private readonly elementRef = inject(ElementRef);
private readonly location = inject(Location);
private readonly navigationState = inject(NavigationState);
private readonly platformId = inject(PLATFORM_ID);
private readonly router = inject(Router);
private readonly viewContainer = inject(ViewContainerRef);
private readonly environmentInjector = inject(EnvironmentInjector);
private readonly injector = inject(Injector);
private readonly appRef = inject(ApplicationRef);
// tslint:disable-next-line:no-unused-variable
private animateContent = false;
private readonly pendingRenderTasks = inject(PendingRenderTasks);
private countOfExamples = 0;
async ngOnChanges(changes: SimpleChanges): Promise<void> {
const taskId = this.pendingRenderTasks.add();
if ('docContent' in changes) {
await this.renderContentsAndRunClientSetup(this.docContent!);
}
this.pendingRenderTasks.remove(taskId);
}
async renderContentsAndRunClientSetup(content?: string): Promise<void> {
const isBrowser = isPlatformBrowser(this.platformId);
const contentContainer = this.elementRef.nativeElement;
if (content) {
if (isBrowser && !(this.document as any).startViewTransition) {
// Apply a special class to the host node to trigger animation.
// Note: when a page is hydrated, the `content` would be empty,
// so we don't trigger an animation to avoid a content flickering
// visual effect. In addition, if the browser supports view transitions (startViewTransition is present), the animation is handled by the native View Transition API so it does not need to be done here.
this.animateContent = true;
}
contentContainer.innerHTML = content;
}
if (isBrowser) {
// First we setup event listeners on the HTML we just loaded.
// We want to do this before things like the example viewers are loaded.
this.setupAnchorListeners(contentContainer);
// Rewrite relative anchors (hrefs starting with `#`) because relative hrefs are relative to the base URL, which is '/'
this.rewriteRelativeAnchors(contentContainer);
// In case when content contains placeholders for executable examples, create ExampleViewer components.
await this.loadExamples();
// In case when content contains static code snippets, then create buttons
// responsible for copy source code.
this.loadCopySourceCodeButtons();
}
// Display Breadcrumb component if the `<docs-breadcrumb>` element exists
this.loadBreadcrumb(contentContainer);
// Display Icon component if the `<docs-icon>` element exists
this.loadIcons(contentContainer);
// Render ToC
this.renderTableOfContents(contentContainer);
this.contentLoaded.next();
}
/**
* Load ExampleViewer component when:
* - exists docs-code-multifile element with multiple files OR
* - exists docs-code element with single file AND
* - 'preview' attribute was provided OR
* - 'visibleLines' attribute was provided
*/
private async loadExamples(): Promise<void> {
const multifileCodeExamples = <HTMLElement[]>(
Array.from(this.elementRef.nativeElement.querySelectorAll(DOCS_CODE_MUTLIFILE_SELECTOR))
);
for (let placeholder of multifileCodeExamples) {
const path = placeholder.getAttribute('path')!;
const snippets = this.getCodeSnippetsFromMultifileWrapper(placeholder);
await this.renderExampleViewerComponents(placeholder, snippets, path);
}
const docsCodeElements = this.elementRef.nativeElement.querySelectorAll(DOCS_CODE_SELECTOR);
for (const placeholder of docsCodeElements) {
const snippet = this.getStandaloneCodeSnippet(placeholder);
if (snippet) {
await this.renderExampleViewerComponents(placeholder, [snippet], snippet.name);
}
}
}
private renderTableOfContents(element: HTMLElement): void {
if (!this.hasToc) {
return;
}
const firstHeading = element.querySelector<HTMLHeadingElement>('h2,h3[id]');
if (!firstHeading) {
return;
}
// Since the content of the main area is dynamically created and there is
// no host element for a ToC component, we create it manually.
let tocHostElement: HTMLElement | null = element.querySelector(TOC_HOST_ELEMENT_NAME);
if (!tocHostElement) {
tocHostElement = this.document.createElement(TOC_HOST_ELEMENT_NAME);
tocHostElement.setAttribute(TOC_SKIP_CONTENT_MARKER, 'true');
firstHeading?.parentNode?.insertBefore(tocHostElement, firstHeading);
}
this.renderComponent(TableOfContents, tocHostElement, {contentSourceElement: element});
}
private async renderExampleViewerComponents(
placeholder: HTMLElement,
snippets: Snippet[],
path: string,
): Promise<void> {
const preview = Boolean(placeholder.getAttribute('preview'));
const title = placeholder.getAttribute('header') ?? undefined;
const firstCodeSnippetTitle =
snippets.length > 0 ? snippets[0].title ?? snippets[0].name : undefined;
const exampleRef = this.viewContainer.createComponent(ExampleViewer);
this.countOfExamples++;
exampleRef.instance.metadata = {
title: title ?? firstCodeSnippetTitle,
path,
files: snippets,
preview,
id: this.countOfExamples,
};
exampleRef.instance.githubUrl = `${GITHUB_CONTENT_URL}/${snippets[0].name}`;
exampleRef.instance.stackblitzUrl = `${ASSETS_EXAMPLES_PATH}/${snippets[0].name}.html`;
placeholder.parentElement!.replaceChild(exampleRef.location.nativeElement, placeholder);
await exampleRef.instance.renderExample();
}
private getCodeSnippetsFromMultifileWrapper(element: HTMLElement): Snippet[] {
const tabs = <Element[]>Array.from(element.querySelectorAll(DOCS_CODE_SELECTOR));
return tabs.map((tab) => ({
name: tab.getAttribute('path') ?? tab.getAttribute('header') ?? '',
content: tab.innerHTML,
visibleLinesRange: tab.getAttribute('visibleLines') ?? undefined,
}));
}
private getStandaloneCodeSnippet(element: HTMLElement): Snippet | null {
const visibleLines = element.getAttribute('visibleLines') ?? undefined;
const preview = element.getAttribute('preview');
if (!visibleLines && !preview) {
return null;
}
const content = element.querySelector('pre')!;
const path = element.getAttribute('path')!;
const title = element.getAttribute('header') ?? undefined;
return {
title,
name: path,
content: content?.outerHTML,
visibleLinesRange: visibleLines,
};
}
// If the content contains static code snippets, we should add buttons to copy
// the code
private loadCopySourceCodeButtons(): void {
const staticCodeSnippets = <Element[]>(
Array.from(this.elementRef.nativeElement.querySelectorAll('.docs-code:not([mermaid])'))
);
for (let codeSnippet of staticCodeSnippets) {
const copySourceCodeButton = this.viewContainer.createComponent(CopySourceCodeButton);
codeSnippet.appendChild(copySourceCodeButton.location.nativeElement);
}
}
private loadBreadcrumb(element: HTMLElement): void {
const breadcrumbPlaceholder = element.querySelector('docs-breadcrumb') as HTMLElement;
const activeNavigationItem = this.navigationState.activeNavigationItem();
if (breadcrumbPlaceholder && !!activeNavigationItem?.parent) {
this.renderComponent(Breadcrumb, breadcrumbPlaceholder);
}
}
private loadIcons(element: HTMLElement): void {
element.querySelectorAll('docs-icon').forEach((iconsPlaceholder) => {
this.renderComponent(IconComponent, iconsPlaceholder as HTMLElement);
});
}
/**
* Helper method to render a component dynamically in a context of this class.
*/
private renderComponent<T>(
type: Type<T>,
hostElement: HTMLElement,
inputs?: {[key: string]: unknown},
): ComponentRef<T> {
const componentRef = createComponent(type, {
hostElement,
elementInjector: this.injector,
environmentInjector: this.environmentInjector,
});
if (inputs) {
for (const [name, value] of Object.entries(inputs)) {
componentRef.setInput(name, value);
}
}
// Trigger change detection after setting inputs.
componentRef.changeDetectorRef.detectChanges();
// Attach a view to the ApplicationRef for change detection
// purposes and for hydration serialization to pick it up
// during SSG.
this.appRef.attachView(componentRef.hostView);
return componentRef;
}
private setupAnchorListeners(element: HTMLElement): void {
element.querySelectorAll(`a[href]`).forEach((anchor) => {
// Get the target element's ID from the href attribute
const url = new URL((anchor as HTMLAnchorElement).href);
const isExternalLink = url.origin !== this.document.location.origin;
if (isExternalLink) {
return;
}
fromEvent(anchor, 'click')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((e) => {
handleHrefClickEventWithRouter(e, this.router);
});
});
}
private rewriteRelativeAnchors(element: HTMLElement) {
for (const anchor of Array.from(element.querySelectorAll(`a[href^="#"]:not(a[download])`))) {
const url = new URL((anchor as HTMLAnchorElement).href);
(anchor as HTMLAnchorElement).href = this.location.path() + url.hash;
}
}
}

View file

@ -1,155 +0,0 @@
<div class="docs-example-viewer" role="group">
<header class="docs-example-viewer-actions">
@if (view() === CodeExampleViewMode.SNIPPET) {
<span>{{ exampleMetadata()?.title }}</span>
}
@if (view() === CodeExampleViewMode.MULTI_FILE) {
<mat-tab-group
#codeTabs
animationDuration="0ms"
mat-stretch-tabs="false"
>
@for (tab of tabs(); track tab) {
<mat-tab [label]="tab.name"></mat-tab>
}
</mat-tab-group>
}
<div class="docs-example-viewer-icons">
<button
type="button"
class="docs-example-copy-link"
[attr.aria-label]="'Copy link to ' + exampleMetadata()?.title + ' example to the clipboard'"
(click)="copyLink()"
>
<i aria-hidden="true">
<svg
aria-hidden="true"
width="24"
height="24"
viewBox="0 0 24 24"
fill="inherit"
xmlns="http://www.w3.org/2000/svg"
>
<!-- link icon -->
<path
d="M11 17H7C5.61667 17 4.4375 16.5125 3.4625 15.5375C2.4875 14.5625 2 13.3833 2 12C2 10.6167 2.4875 9.4375 3.4625 8.4625C4.4375 7.4875 5.61667 7 7 7H11V9H7C6.16667 9 5.45833 9.29167 4.875 9.875C4.29167 10.4583 4 11.1667 4 12C4 12.8333 4.29167 13.5417 4.875 14.125C5.45833 14.7083 6.16667 15 7 15H11V17ZM8 13V11H16V13H8ZM13 17V15H17C17.8333 15 18.5417 14.7083 19.125 14.125C19.7083 13.5417 20 12.8333 20 12C20 11.1667 19.7083 10.4583 19.125 9.875C18.5417 9.29167 17.8333 9 17 9H13V7H17C18.3833 7 19.5625 7.4875 20.5375 8.4625C21.5125 9.4375 22 10.6167 22 12C22 13.3833 21.5125 14.5625 20.5375 15.5375C19.5625 16.5125 18.3833 17 17 17H13Z"
fill="inherit"
/>
</svg>
</i>
</button>
<ng-container *ngTemplateOutlet="openCodeInExternalProvider" />
@if (expandable()) {
<button
type="button"
(click)="toggleExampleVisibility()"
[attr.title]="(expanded() ? 'Collapse' : 'Expand') + ' example'"
[attr.aria-label]="(expanded() ? 'Collapse' : 'Expand') + ' code example'"
>
<i aria-hidden="true">
@if (!expanded()) {
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
>
<!-- Expand arrow -->
<path d="M3 21v-8h2v4.6L17.6 5H13V3h8v8h-2V6.4L6.4 19H11v2H3Z" />
</svg>
} @else {
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
>
<path
fill="var(--gray-400)"
d="M3.4 22 2 20.6 8.6 14H4v-2h8v8h-2v-4.6L3.4 22ZM12 12V4h2v4.6L20.6 2 22 3.4 15.4 10H20v2h-8Z"
/>
</svg>
}
</i>
</button>
}
</div>
</header>
<div
class="docs-example-viewer-code-wrapper"
[class.docs-example-viewer-snippet]="view() === CodeExampleViewMode.SNIPPET"
[class.docs-example-viewer-multi-file]="view() === CodeExampleViewMode.MULTI_FILE"
>
<button docs-copy-source-code></button>
<docs-viewer [docContent]="snippetCode()?.content" />
</div>
@if (exampleComponent) {
<div class="docs-example-viewer-preview">
<ng-container *ngComponentOutlet="exampleComponent" />
</div>
}
<ng-template #openCodeInExternalProvider>
@if (exampleComponent) {
@if (githubUrl) {
<a
[href]="githubUrl"
target="_blank"
title="Open example on GitHub"
class="docs-example-github-link"
aria-label="Open example on GitHub"
>
<i aria-hidden="true">
<svg
aria-hidden="true"
width="24"
height="24"
viewBox="0 0 24 24"
fill="inherit"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.16141 22.8681C9.16141 22.5894 9.15159 21.8509 9.14614 20.8707C5.96014 21.5798 5.28759 19.296 5.28759 19.296C4.76668 17.9389 4.01559 17.5778 4.01559 17.5778C2.97541 16.8485 4.09414 16.8638 4.09414 16.8638C5.24396 16.9467 5.84886 18.0747 5.84886 18.0747C6.8705 19.8692 8.52923 19.3516 9.18268 19.0505C9.28686 18.2912 9.5825 17.7736 9.90977 17.4801C7.36632 17.184 4.69196 16.176 4.69196 11.6754C4.69196 10.3936 5.13868 9.34523 5.87123 8.52377C5.75396 8.22705 5.36014 7.03305 5.98359 5.41577C5.98359 5.41577 6.94577 5.09996 9.13359 6.61959C10.0467 6.35941 11.0269 6.2285 12.0016 6.22414C12.9741 6.2285 13.9538 6.35941 14.869 6.61959C17.0558 5.09996 18.0163 5.41577 18.0163 5.41577C18.6414 7.0325 18.2481 8.2265 18.1298 8.52377C18.864 9.34523 19.3069 10.3936 19.3069 11.6754C19.3069 16.1874 16.6287 17.1801 14.077 17.4709C14.4889 17.8336 14.8543 18.5503 14.8543 19.6461C14.8543 21.2165 14.8396 22.4836 14.8396 22.8681C14.8396 23.1829 15.0463 23.5478 15.6278 23.4327C20.1758 21.877 23.4545 17.4774 23.4545 12.2907C23.4545 5.80359 18.3256 0.54541 11.9994 0.54541C5.67432 0.54541 0.54541 5.80359 0.54541 12.2907C0.545956 17.479 3.82796 21.8814 8.37977 23.4343C8.95196 23.5418 9.16141 23.179 9.16141 22.8681Z"
fill="inherit"
/>
</svg>
</i>
</a>
}
@if (stackblitzUrl) {
<a
[href]="stackblitzUrl"
target="_blank"
class="docs-example-stackblitz-link"
title="Edit this example in StackBlitz"
aria-label="Edit this example in StackBlitz"
>
<i aria-hidden="true">
<svg
width="24"
height="24"
viewBox="0 0 356 511"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M138.719 150.22C62.6928 232.614 0.340573 300.4 0.158928 300.856C-0.0227172 301.311 33.9559 301.799 75.6665 301.939L151.505 302.195L117.656 396.511C74.7852 515.966 76.7972 510.288 77.3522 510.288C78.2145 510.288 355.296 209.735 355.296 208.799C355.296 208.245 325.263 207.879 279.943 207.879C233.709 207.879 204.591 207.518 204.591 206.943C204.591 206.428 220.136 162.751 239.137 109.883C279.06 -1.20153 278.545 0.264614 277.638 0.347453C277.26 0.382384 214.746 67.8247 138.719 150.22Z"
/>
</svg>
</i>
</a>
}
}
</ng-template>
</div>

View file

@ -1,133 +0,0 @@
:host {
.docs-example-viewer-preview {
.adev-dark-mode & {
background: var(--gray-100);
}
@media screen and (prefers-color-scheme: dark) {
background: var(--gray-100);
}
.adev-light-mode & {
background: var(--page-background);
}
}
}
.docs-example-viewer {
border: 1px solid var(--senary-contrast);
border-radius: 0.25rem;
overflow: hidden;
}
// Example viewer header
.docs-example-viewer-actions {
background: var(--subtle-purple);
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
border-bottom: 1px solid var(--senary-contrast);
transition: background 0.3s ease, border-color 0.3s ease;
padding-inline-end: 0.65rem;
font-family: var(--inter-tight-font);
mat-tab-group {
max-width: calc(100% - 140px);
}
span:first-of-type {
background-image: var(--purple-to-blue-horizontal-gradient);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
padding: 0.7rem 1.1rem;
font-size: 0.875rem;
font-style: normal;
font-weight: 400;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
margin: 0;
word-wrap: break-word;
width: fit-content;
}
.docs-example-viewer-icons {
display: flex;
gap: 0.75rem;
svg {
fill: var(--gray-400);
}
}
a,
button {
padding: 0;
margin: 0;
cursor: pointer;
height: 24px;
width: 24px;
path {
transition: fill 0.3s ease;
}
&:hover {
svg {
fill: var(--tertiary-contrast);
}
}
}
}
// Example viewer code
.docs-example-viewer-code-wrapper {
position: relative;
font-size: 0.875rem;
// TODO: only show this if there is a preview
// border-block-end: 1px solid var(--senary-contrast);
transition: border-color 0.3s ease;
container: viewerblock / inline-size;
background-color: var(--octonary-contrast);
button[docs-copy-source-code] {
top: 0.31rem;
@container viewerblock (min-width: 400px) {
background-color: transparent;
border: 1px solid transparent;
}
}
}
// stylelint-disable-next-line
::ng-deep {
.docs-example-viewer-preview {
// stylelint-disable-next-line
all: initial;
display: block;
padding: 1rem;
border-block-start: 1px solid var(--senary-contrast);
*,
code::before,
code,
pre,
a,
i,
p,
h1,
h2,
h3,
h4,
h5,
h6,
ol,
ul,
li,
hr,
input,
select,
table {
all: revert;
}
}
}

View file

@ -1,228 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ExampleViewer} from './example-viewer.component';
import {DocsContentLoader, ExampleMetadata, ExampleViewerContentLoader} from '../../interfaces';
import {DOCS_CONTENT_LOADER, EXAMPLE_VIEWER_CONTENT_LOADER} from '../../providers';
import {Component} from '@angular/core';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {HarnessLoader} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {Clipboard} from '@angular/cdk/clipboard';
import {By} from '@angular/platform-browser';
import {MatTabGroupHarness} from '@angular/material/tabs/testing';
import {CopySourceCodeButton} from '../copy-source-code-button/copy-source-code-button.component';
import {ActivatedRoute} from '@angular/router';
describe('ExampleViewer', () => {
let component: ExampleViewer;
let fixture: ComponentFixture<ExampleViewer>;
let loader: HarnessLoader;
let exampleContentSpy: jasmine.SpyObj<ExampleViewerContentLoader>;
let contentServiceSpy: jasmine.SpyObj<DocsContentLoader>;
beforeEach(() => {
exampleContentSpy = jasmine.createSpyObj('ExampleContentLoader', ['loadPreview']);
contentServiceSpy = jasmine.createSpyObj('ContentLoader', ['getContent']);
contentServiceSpy.getContent.and.returnValue(Promise.resolve(undefined));
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ExampleViewer, NoopAnimationsModule],
providers: [
{provide: EXAMPLE_VIEWER_CONTENT_LOADER, useValue: exampleContentSpy},
{provide: DOCS_CONTENT_LOADER, useValue: contentServiceSpy},
{provide: ActivatedRoute, useValue: {snapshot: {fragment: 'fragment'}}},
],
}).compileComponents();
fixture = TestBed.createComponent(ExampleViewer);
component = fixture.componentInstance;
loader = TestbedHarnessEnvironment.loader(fixture);
fixture.detectChanges();
});
it('should set file extensions as tab names when all files have different extension', waitForAsync(async () => {
component.metadata = getMetadata({
files: [
{name: 'file.ts', content: ''},
{name: 'file.html', content: ''},
{name: 'file.css', content: ''},
],
});
await component.renderExample();
expect(component.tabs()!.length).toBe(3);
expect(component.tabs()![0].name).toBe('TS');
expect(component.tabs()![1].name).toBe('HTML');
expect(component.tabs()![2].name).toBe('CSS');
}));
it('should generate correct code content for multi file mode when it is expanded', waitForAsync(async () => {
component.metadata = getMetadata({
files: [
{name: 'file.ts', content: 'typescript file'},
{name: 'file.html', content: 'html file'},
{name: 'file.css', content: 'css file'},
],
});
await component.renderExample();
expect(component.tabs()!.length).toBe(3);
expect(component.tabs()![0].code).toBe('typescript file');
expect(component.tabs()![1].code).toBe('html file');
expect(component.tabs()![2].code).toBe('css file');
}));
it('should set file names as tab names when there is at least one duplication', async () => {
component.metadata = getMetadata({
files: [
{name: 'example.ts', content: 'typescript file'},
{name: 'example.html', content: 'html file'},
{name: 'another-example.ts', content: 'css file'},
],
});
await component.renderExample();
expect(component.tabs()!.length).toBe(3);
expect(component.tabs()![0].name).toBe('example.ts');
expect(component.tabs()![1].name).toBe('example.html');
expect(component.tabs()![2].name).toBe('another-example.ts');
});
it('should expandable be false when none of the example files have defined visibleLinesRange ', waitForAsync(async () => {
component.metadata = getMetadata();
await component.renderExample();
expect(component.expandable()).toBeFalse();
}));
it('should expandable be true when at least one example file has defined visibleLinesRange ', waitForAsync(async () => {
component.metadata = getMetadata({
files: [
{name: 'example.ts', content: 'typescript file'},
{
name: 'example.html',
content: 'html file',
visibleLinesRange: '[1, 2]',
},
{name: 'another-example.ts', content: 'css file'},
],
});
await component.renderExample();
expect(component.expandable()).toBeTrue();
}));
it('should set exampleComponent when metadata contains path and preview is true', waitForAsync(async () => {
exampleContentSpy.loadPreview.and.resolveTo(ExampleComponent);
component.metadata = getMetadata({
path: 'example.ts',
preview: true,
});
await component.renderExample();
expect(component.exampleComponent).toBe(ExampleComponent);
}));
it('should display GitHub button when githubUrl is provided and there is preview', waitForAsync(async () => {
exampleContentSpy.loadPreview.and.resolveTo(ExampleComponent);
component.metadata = getMetadata({
path: 'example.ts',
preview: true,
});
component.githubUrl = 'https://github.com/';
await component.renderExample();
const githubButton = fixture.debugElement.query(
By.css('a[aria-label="Open example on GitHub"]'),
);
expect(githubButton).toBeTruthy();
expect(githubButton.nativeElement.href).toBe(component.githubUrl);
}));
it('should display StackBlitz button when stackblitzUrl is provided and there is preview', waitForAsync(async () => {
exampleContentSpy.loadPreview.and.resolveTo(ExampleComponent);
component.metadata = getMetadata({
path: 'example.ts',
preview: true,
});
component.stackblitzUrl = 'https://stackblitz.com/';
await component.renderExample();
const stackblitzButton = fixture.debugElement.query(
By.css('a[aria-label="Edit this example in StackBlitz"]'),
);
expect(stackblitzButton).toBeTruthy();
expect(stackblitzButton.nativeElement.href).toBe(component.stackblitzUrl);
}));
it('should set expanded flag in metadata after toggleExampleVisibility', waitForAsync(async () => {
component.metadata = getMetadata();
await component.renderExample();
component.toggleExampleVisibility();
expect(component.expanded()).toBeTrue();
const tabGroup = await loader.getHarness(MatTabGroupHarness);
const tab = await tabGroup.getSelectedTab();
expect(await tab.getLabel()).toBe('TS');
component.toggleExampleVisibility();
expect(component.expanded()).toBeFalse();
}));
it('should call clipboard service when clicked on copy source code', waitForAsync(async () => {
const expectedCodeSnippetContent = 'typescript code';
component.metadata = getMetadata({
files: [
{
name: 'example.ts',
content: `<pre><code>${expectedCodeSnippetContent}</code></pre>`,
},
{name: 'example.css', content: ''},
],
});
const clipboardService = TestBed.inject(Clipboard);
const spy = spyOn(clipboardService, 'copy');
await component.renderExample();
const button = fixture.debugElement.query(By.directive(CopySourceCodeButton)).nativeElement;
button.click();
expect(spy.calls.argsFor(0)[0].trim()).toBe(expectedCodeSnippetContent);
}));
it('should call clipboard service when clicked on copy example link', waitForAsync(async () => {
component.metadata = getMetadata();
component.expanded.set(true);
fixture.detectChanges();
const clipboardService = TestBed.inject(Clipboard);
const spy = spyOn(clipboardService, 'copy');
await component.renderExample();
const button = fixture.debugElement.query(
By.css('button.docs-example-copy-link'),
).nativeElement;
button.click();
expect(spy.calls.argsFor(0)[0].trim()).toBe(`http://localhost:9876/context.html#example-1`);
}));
});
const getMetadata = (value: Partial<ExampleMetadata> = {}): ExampleMetadata => {
return {
id: 1,
files: [
{name: 'example.ts', content: ''},
{name: 'example.css', content: ''},
],
preview: false,
...value,
};
};
@Component({
template: '',
standalone: true,
})
class ExampleComponent {}

View file

@ -1,237 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
Input,
Type,
computed,
inject,
ChangeDetectorRef,
ViewChild,
signal,
ElementRef,
forwardRef,
} from '@angular/core';
import {CommonModule, DOCUMENT} from '@angular/common';
import {MatTabGroup, MatTabsModule} from '@angular/material/tabs';
import {Clipboard} from '@angular/cdk/clipboard';
import {CopySourceCodeButton} from '../copy-source-code-button/copy-source-code-button.component';
import {ExampleMetadata, Snippet} from '../../interfaces';
import {EXAMPLE_VIEWER_CONTENT_LOADER} from '../../providers';
import {DocViewer} from '../docs-viewer/docs-viewer.component';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
export enum CodeExampleViewMode {
SNIPPET = 'snippet',
MULTI_FILE = 'multi',
}
export const CODE_LINE_NUMBER_CLASS_NAME = 'hljs-ln-number';
export const CODE_LINE_CLASS_NAME = 'hljs-ln-line';
export const GAP_CODE_LINE_CLASS_NAME = 'gap';
export const HIDDEN_CLASS_NAME = 'hidden';
@Component({
selector: 'docs-example-viewer',
standalone: true,
imports: [CommonModule, forwardRef(() => DocViewer), CopySourceCodeButton, MatTabsModule],
templateUrl: './example-viewer.component.html',
styleUrls: ['./example-viewer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExampleViewer {
// TODO: replace by signal-based input when it'll be available
@Input({required: true}) set metadata(value: ExampleMetadata) {
this.exampleMetadata.set(value);
}
@Input() githubUrl: string | null = null;
@Input() stackblitzUrl: string | null = null;
@ViewChild('codeTabs') matTabGroup?: MatTabGroup;
private readonly changeDetector = inject(ChangeDetectorRef);
private readonly clipboard = inject(Clipboard);
private readonly destroyRef = inject(DestroyRef);
private readonly document = inject(DOCUMENT);
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly exampleViewerContentLoader = inject(EXAMPLE_VIEWER_CONTENT_LOADER);
private readonly shouldDisplayFullName = computed(() => {
const fileExtensions =
this.exampleMetadata()?.files.map((file) => this.getFileExtension(file.name)) ?? [];
// Display full file names only when exist files with the same extension
return new Set(fileExtensions).size !== fileExtensions.length;
});
CodeExampleViewMode = CodeExampleViewMode;
exampleComponent?: Type<unknown>;
expanded = signal<boolean>(false);
exampleMetadata = signal<ExampleMetadata | null>(null);
snippetCode = signal<Snippet | undefined>(undefined);
tabs = computed(() =>
this.exampleMetadata()?.files.map((file) => ({
name:
file.title ?? (this.shouldDisplayFullName() ? file.name : this.getFileExtension(file.name)),
code: file.content,
})),
);
view = computed(() =>
this.exampleMetadata()?.files.length === 1
? CodeExampleViewMode.SNIPPET
: CodeExampleViewMode.MULTI_FILE,
);
expandable = computed(() =>
this.exampleMetadata()?.files.some((file) => !!file.visibleLinesRange),
);
async renderExample(): Promise<void> {
// Lazy load live example component
if (this.exampleMetadata()?.path && this.exampleMetadata()?.preview) {
this.exampleComponent = await this.exampleViewerContentLoader.loadPreview(
this.exampleMetadata()?.path!,
);
}
this.snippetCode.set(this.exampleMetadata()?.files[0]);
this.changeDetector.detectChanges();
this.setCodeLinesVisibility();
this.elementRef.nativeElement.setAttribute(
'id',
`example-${this.exampleMetadata()?.id.toString()!}`,
);
this.matTabGroup?.realignInkBar();
this.listenToMatTabIndexChange();
}
toggleExampleVisibility(): void {
this.expanded.update((expanded) => !expanded);
this.setCodeLinesVisibility();
}
copyLink(): void {
// Reconstruct the URL using `origin + pathname` so we drop any pre-existing hash.
const fullUrl = location.origin + location.pathname + '#example-' + this.exampleMetadata()?.id;
this.clipboard.copy(fullUrl);
}
private listenToMatTabIndexChange(): void {
this.matTabGroup?.realignInkBar();
this.matTabGroup?.selectedIndexChange
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((index) => {
this.snippetCode.set(this.exampleMetadata()?.files[index]);
this.setCodeLinesVisibility();
});
}
private getFileExtension(name: string): string {
const segments = name.split('.');
return segments.length ? segments[segments.length - 1].toLocaleUpperCase() : '';
}
private setCodeLinesVisibility(): void {
this.expanded()
? this.handleExpandedStateForCodeBlock()
: this.handleCollapsedStateForCodeBlock();
}
private handleExpandedStateForCodeBlock(): void {
const lines = <HTMLDivElement[]>(
Array.from(
this.elementRef.nativeElement.querySelectorAll(
`.${CODE_LINE_CLASS_NAME}.${HIDDEN_CLASS_NAME}`,
),
)
);
const lineNumbers = <HTMLSpanElement[]>(
Array.from(
this.elementRef.nativeElement.querySelectorAll(
`.${CODE_LINE_NUMBER_CLASS_NAME}.${HIDDEN_CLASS_NAME}`,
),
)
);
const gapLines = <HTMLDivElement[]>(
Array.from(
this.elementRef.nativeElement.querySelectorAll(
`.${CODE_LINE_CLASS_NAME}.${GAP_CODE_LINE_CLASS_NAME}`,
),
)
);
for (const line of lines) {
line.classList.remove(HIDDEN_CLASS_NAME);
}
for (const lineNumber of lineNumbers) {
lineNumber.classList.remove(HIDDEN_CLASS_NAME);
}
for (const expandLine of gapLines) {
expandLine.remove();
}
}
private handleCollapsedStateForCodeBlock(): void {
const visibleLinesRange = this.snippetCode()?.visibleLinesRange;
if (!visibleLinesRange) {
return;
}
const linesToDisplay = (visibleLinesRange?.split(',') ?? []).map((line) => Number(line));
const lines = <HTMLDivElement[]>(
Array.from(this.elementRef.nativeElement.querySelectorAll(`.${CODE_LINE_CLASS_NAME}`))
);
const lineNumbers = <HTMLSpanElement[]>(
Array.from(this.elementRef.nativeElement.querySelectorAll(`.${CODE_LINE_NUMBER_CLASS_NAME}`))
);
const appendGapBefore = [];
for (const [index, line] of lines.entries()) {
if (!linesToDisplay.includes(index)) {
line.classList.add(HIDDEN_CLASS_NAME);
} else if (!linesToDisplay.includes(index - 1)) {
appendGapBefore.push(line);
}
}
for (const [index, lineNumber] of lineNumbers.entries()) {
if (!linesToDisplay.includes(index)) {
lineNumber.classList.add(HIDDEN_CLASS_NAME);
}
}
// Create gap line between visible ranges. For example we would like to display 10-16 and 20-29 lines.
// We should display separator, gap between those two scopes.
// TODO: we could replace div it with the component, and allow to expand code block after click.
for (const [index, element] of appendGapBefore.entries()) {
if (index === 0) {
continue;
}
const separator = this.document.createElement('div');
separator.textContent = `...`;
separator.classList.add(CODE_LINE_CLASS_NAME);
separator.classList.add(GAP_CODE_LINE_CLASS_NAME);
element.parentNode?.insertBefore(separator, element);
}
}
}

View file

@ -1 +0,0 @@
<ng-content></ng-content>

View file

@ -1,3 +0,0 @@
.docs-icon_high-contrast {
color: var(--primary-contrast);
}

View file

@ -1,58 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {DOCUMENT} from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
afterNextRender,
inject,
} from '@angular/core';
@Component({
selector: 'docs-icon',
standalone: true,
templateUrl: './icon.component.html',
styleUrl: './icon.component.scss',
host: {
'[class]': 'MATERIAL_SYMBOLS_OUTLINED',
'[style.font-size.px]': 'fontSize',
'aria-hidden': 'true',
},
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class IconComponent {
private readonly cdRef = inject(ChangeDetectorRef);
get fontSize(): number | null {
return IconComponent.isFontLoaded ? null : 0;
}
protected readonly MATERIAL_SYMBOLS_OUTLINED = 'material-symbols-outlined';
private static isFontLoaded: boolean = false;
/** Share the same promise across different instances of the component */
private static whenFontLoad?: Promise<FontFace[]> | undefined;
constructor() {
if (IconComponent.isFontLoaded) {
return;
}
const document = inject(DOCUMENT);
afterNextRender(async () => {
IconComponent.whenFontLoad ??= document.fonts.load('normal 1px "Material Symbols Outlined"');
await IconComponent.whenFontLoad;
IconComponent.isFontLoaded = true;
// We need to ensure CD is triggered on the component when the font is loaded
this.cdRef.markForCheck();
});
}
}

View file

@ -1,17 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
export * from './cookie-popup/cookie-popup.component';
export * from './docs-viewer/docs-viewer.component';
export * from './navigation-list/navigation-list.component';
export * from './select/select.component';
export * from './slide-toggle/slide-toggle.component';
export * from './table-of-contents/table-of-contents.component';
export * from './text-field/text-field.component';
export * from './icon/icon.component';
export * from './search-dialog/search-dialog.component';

View file

@ -1,76 +0,0 @@
<ng-template #navigationList let-navigationItems>
<ul
class="docs-navigation-list adev-faceted-list"
[class.docs-navigation-list-dropdown]="isDropdownView"
>
@for (item of navigationItems; track $index) {
<li
class="adev-faceted-list-item"
[class.docs-navigation-link-hidden]="displayItemsToLevel && item.level > displayItemsToLevel"
>
@if (item.path) { @if (item.isExternal) {
<a [href]="item.path" target="_blank">
<span [class.adev-external-link]="item.isExternal">{{ item.label }}</span>
@if (item.children && item.level! > 1 && !item.isExpanded) {
<docs-icon>chevron_right</docs-icon>
}
</a>
} @else {
<a
[routerLink]="'/' + item.path"
[routerLinkActiveOptions]="{
queryParams: 'ignored',
fragment: 'ignored',
matrixParams: 'exact',
paths: 'exact',
exact: false
}"
routerLinkActive="adev-faceted-list-item-active"
(click)="emitClickOnLink()"
>
<span>{{ item.label }}</span>
@if (item.children && !item.isExpanded) {
<docs-icon>chevron_right</docs-icon>
}
</a>
} } @else {
<!-- Nav Section Header -->
@if (item.level !== collapsableLevel && item.level !== expandableLevel) {
<div class="adev-secondary-nav-header">
<span>{{ item.label }}</span>
</div>
}
<!-- Nav Button Expand/Collapse -->
@if ((item.children && item.level === expandableLevel) || item.level === collapsableLevel) {
<button
type="button"
(click)="toggle(item)"
attr.aria-label="{{ item.isExpanded ? 'Collapse' : 'Expand' }} {{ item.label }}"
[attr.aria-expanded]="item.isExpanded"
class="adev-secondary-nav-button"
[class.adev-faceted-list-item-active]="item | isActiveNavigationItem: activeItem()"
[class.adev-expanded-button]="item.children && item.level == collapsableLevel"
[class.adev-not-expanded-button]="item.children && item.level === expandableLevel"
[class.adev-nav-item-has-icon]="
item.children && item.level === expandableLevel && !item.isExpanded
"
>
@if (item.children && item.level === collapsableLevel) {
<docs-icon>arrow_back</docs-icon>
}
<span>{{ item.label }}</span>
</button>
} } @if (item.children?.length > 0) {
<ng-container
*ngTemplateOutlet="navigationList; context: {$implicit: item.children}"
></ng-container>
}
</li>
}
</ul>
</ng-template>
<ng-container
*ngTemplateOutlet="navigationList; context: {$implicit: navigationItems}"
></ng-container>

View file

@ -1,148 +0,0 @@
@use '../../../../../shared/src/lib/styles/media-queries' as mq;
:host {
display: flex;
min-width: var(--secondary-nav-width);
list-style: none;
overflow-y: auto;
overflow-x: hidden;
height: 100vh;
padding: 0;
margin: 0;
padding-block: 1.5rem;
font-size: 0.875rem;
box-sizing: border-box;
&::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0);
cursor: pointer;
}
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: var(--septenary-contrast);
@include mq.for-tablet-landscape-down {
background-color: var(--quinary-contrast);
}
border-radius: 10px;
transition: background-color 0.3s ease;
}
&::-webkit-scrollbar-thumb:hover {
background-color: var(--quinary-contrast);
}
.adev-nav-secondary & {
padding-block: 2rem;
}
> .adev-faceted-list {
border: 0;
}
.docs-navigation-link-hidden {
display: none;
}
.adev-nav-item-has-icon {
&::after {
// FIXME: for some reason this disappears when transformed
content: 'chevron_right';
font-size: 1.25rem;
font-family: var(--icons);
}
}
}
.adev-secondary-nav-header {
padding-block: 1.25rem;
font-weight: 500;
}
.adev-secondary-nav-button {
width: 15rem;
display: flex;
justify-content: space-between;
align-items: center;
border: none;
padding-block: 1.25rem;
padding-inline-start: 0;
color: var(--primary-contrast);
font-size: 0.875rem;
font-family: var(--inter-font);
line-height: 160%;
letter-spacing: -0.00875rem;
transition: color 0.3s ease, background 0.3s ease;
text-align: left; // forces left alignment of text in button
&.adev-secondary-nav-button-active {
// font gradient
background-image: var(--pink-to-purple-vertical-gradient);
&::before {
opacity: 1;
transform: scaleY(1);
background: var(--pink-to-purple-vertical-gradient);
}
&:hover {
&::before {
opacity: 1;
transform: scaleY(1.1);
}
}
}
}
.adev-expanded-button {
justify-content: start;
gap: 0.5rem;
}
a,
.adev-not-expanded-button {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
padding: 0.5rem;
padding-inline-start: 1rem;
text-align: left;
}
// Add padding-bottom to last item in the list
.docs-navigation-list {
width: 100%;
li:last-of-type {
ul:last-of-type {
li:last-of-type {
padding-block-end: 1rem;
}
}
}
&:first-child {
margin-inline-start: 1rem;
}
}
.adev-external-link {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 0.5rem;
&::after {
content: 'open_in_new';
font-family: var(--icons);
font-size: 1.1rem;
color: var(--quinary-contrast);
transition: color 0.3s ease;
margin-inline-end: 0.4rem;
}
}

View file

@ -1,58 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {NavigationList} from './navigation-list.component';
import {By} from '@angular/platform-browser';
import {NavigationItem} from '../../interfaces';
import {RouterTestingModule} from '@angular/router/testing';
import {signal} from '@angular/core';
import {NavigationState} from '@angular/docs-shared';
const navigationItems: NavigationItem[] = [
{
label: 'Introduction',
path: 'guide',
},
{
label: 'Getting Started',
children: [
{label: 'What is Angular?', path: 'guide/what-is-angular'},
{label: 'Setup', path: 'guide/setup'},
],
},
];
describe('NavigationList', () => {
let component: NavigationList;
let fixture: ComponentFixture<NavigationList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NavigationList, RouterTestingModule],
providers: [{provide: NavigationState, useClass: FakeNavigationListState}],
}).compileComponents();
fixture = TestBed.createComponent(NavigationList);
component = fixture.componentInstance;
});
it('should display provided navigation structure', () => {
component.navigationItems = [...navigationItems];
fixture.detectChanges(true);
const links = fixture.debugElement.queryAll(By.css('a'));
expect(links.length).toBe(3);
});
});
class FakeNavigationListState {
isOpened = signal(true);
activeNavigationItem = signal(navigationItems.at(1));
}

View file

@ -1,60 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
inject,
} from '@angular/core';
import {NavigationItem} from '../../interfaces';
import {NavigationState} from '../../services';
import {RouterLink, RouterLinkActive} from '@angular/router';
import {CommonModule} from '@angular/common';
import {IconComponent} from '../icon/icon.component';
import {IsActiveNavigationItem} from '../../pipes/is-active-navigation-item.pipe';
@Component({
selector: 'docs-navigation-list',
standalone: true,
imports: [CommonModule, RouterLink, RouterLinkActive, IconComponent, IsActiveNavigationItem],
templateUrl: './navigation-list.component.html',
styleUrls: ['./navigation-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NavigationList {
@Input({required: true}) navigationItems: NavigationItem[] = [];
@Input() displayItemsToLevel: number = 2;
@Input() collapsableLevel: number | undefined = undefined;
@Input() expandableLevel: number = 2;
@Input() isDropdownView = false;
@Output() linkClicked = new EventEmitter<void>();
private readonly navigationState = inject(NavigationState);
expandedItems = this.navigationState.expandedItems;
activeItem = this.navigationState.activeNavigationItem;
toggle(item: NavigationItem): void {
if (
item.level === 1 &&
item.level !== this.expandableLevel &&
item.level !== this.collapsableLevel
) {
return;
}
this.navigationState.toggleItem(item);
}
emitClickOnLink(): void {
this.linkClicked.emit();
}
}

View file

@ -1,79 +0,0 @@
<dialog #searchDialog>
<div class="adev-search-container" (docsClickOutside)="closeSearchDialog()">
<docs-text-field
[autofocus]="true"
[hideIcon]="true"
[ngModel]="searchQuery()"
(ngModelChange)="updateSearchQuery($event)"
class="adev-search-input"
placeholder="Search docs"
></docs-text-field>
@if (searchResults() && searchResults()!.length > 0) {
<ul class="adev-search-results adev-mini-scroll-track">
@for (result of searchResults(); track result.objectID) {
<li docsSearchItem [item]="result">
@if (result.url) {
<a [routerLink]="'/' + result.url | relativeLink: 'pathname'" [fragment]="result.url | relativeLink: 'hash'">
<div>
<div class="adev-result-icon-and-type">
<!-- Icon -->
<span class="adev-search-result-icon" aria-hidden="true">
@if (result.hierarchy?.lvl0 === 'Docs') {
<i role="presentation" class="material-symbols-outlined docs-icon-small">
description
</i>
} @else if (result.hierarchy?.lvl0 === 'Tutorials') {
<i role="presentation" class="material-symbols-outlined docs-icon-small">code</i>
} @else if (result.hierarchy?.lvl0 === 'Reference') {
<i role="presentation" class="material-symbols-outlined docs-icon-small">
description
</i>
}
</span>
<!-- Results type -->
<span class="adev-search-results__type">{{ result.hierarchy?.lvl1 }}</span>
</div>
<!-- Hide level 2 if level 3 exists -->
<!-- Level 2 -->
@if (result.hierarchy?.lvl2 && !result.hierarchy?.lvl3) {
<span class="adev-search-results__type adev-search-results__lvl2">
{{ result.hierarchy?.lvl2 }}
</span>
}
<!-- Level 3 -->
@if (result.hierarchy?.lvl3) {
<span class="adev-search-results__type adev-search-results__lvl3">
{{ result.hierarchy?.lvl3 }}
</span>
}
</div>
<!-- Page title -->
<span class="adev-result-page-title">{{ result.hierarchy?.lvl0 }}</span>
</a>
}
</li>
}
</ul>
} @else {
<div class="adev-search-results adev-mini-scroll-track">
@if (searchResults() === undefined) {
<div class="adev-search-results__no-results">
<span>Start typing to see results</span>
</div>
} @else if (searchResults()?.length === 0) {
<div class="adev-search-results__no-results">
<span>No results found</span>
</div>
}
</div>
}
<div class="adev-algolia">
<span>Search by</span>
<docs-algolia-icon />
</div>
</div>
</dialog>

View file

@ -1,144 +0,0 @@
dialog {
background-color: transparent;
border: none;
padding-block-end: 3rem;
&::backdrop {
backdrop-filter: blur(5px);
}
}
.adev-search-container {
width: 500px;
max-width: 90vw;
background-color: var(--page-background);
border: 1px solid var(--senary-contrast);
border-radius: 0.25rem;
box-sizing: border-box;
.adev-search-input {
border-radius: 0.25rem 0.25rem 0 0;
border: none;
border-block-end: 1px solid var(--senary-contrast);
height: 2.6875rem; // 43px;
padding-inline-start: 1rem;
position: relative;
&::after {
content: 'Esc';
position: absolute;
right: 1rem;
color: var(--gray-400);
font-size: 0.875rem;
}
}
ul {
max-height: 260px;
overflow-y: auto;
list-style-type: none;
padding-inline: 0;
padding-block-start: 1rem;
margin: 0;
li {
border-inline-start: 2px solid var(--senary-contrast);
margin-inline-start: 1rem;
padding-inline-end: 1rem;
padding-block: 0.25rem;
a {
color: var(--secondary-contrast);
display: flex;
justify-content: space-between;
gap: 0.5rem;
.adev-search-result-icon {
i {
display: flex;
align-items: center;
font-size: 1.2rem;
}
}
}
&.active {
background-color: var(--septenary-contrast); // stylelint-disable-line
}
&:hover,
&.active {
background-color: var(--octonary-contrast); // stylelint-disable-line
border-inline-start: 2px solid var(--primary-contrast);
a {
span:not(.adev-result-page-title),
.adev-search-results__type {
color: var(--primary-contrast);
i {
color: var(--primary-contrast);
}
}
}
}
}
.adev-search-result-icon,
.adev-search-results__type,
.adev-result-page-title {
color: var(--quaternary-contrast);
display: inline-block;
font-size: 0.875rem;
transition: color 0.3s ease;
padding: 0.75rem;
padding-inline-end: 0;
}
.adev-search-results__lvl2 {
display: inline-block;
margin-inline-start: 2rem;
padding-block-start: 0;
}
.adev-search-results__lvl3 {
margin-inline-start: 2rem;
padding-block-start: 0;
}
}
.adev-result-page-title {
font-size: 0.875rem;
font-weight: 400;
}
}
.adev-search-results__no-results {
padding: 0.75rem;
color: var(--gray-400);
}
.adev-result-icon-and-type {
display: flex;
.adev-search-results__type {
padding-inline-start: 0;
}
}
.adev-algolia {
display: flex;
align-items: center;
justify-content: end;
color: var(--gray-400);
padding: 1rem;
font-size: 0.75rem;
font-weight: 500;
gap: 0.25rem;
background-color: var(--page-background);
border-radius: 0 0 0.25rem 0.25rem;
docs-algolia-icon {
margin-block-start: 0.12rem;
margin-inline-start: 0.15rem;
width: 4rem;
}
}

View file

@ -1,51 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {SearchDialog} from './search-dialog.component';
import {WINDOW} from '../../providers';
import {Search} from '../../services';
import {signal} from '@angular/core';
import {FakeEventTarget} from '../../utils/test.utils';
describe('SearchDialog', () => {
let component: SearchDialog;
let fixture: ComponentFixture<SearchDialog>;
const fakeSearch = {
keyDown: signal(null),
searchQuery: signal(''),
searchResults: signal([]),
};
const fakeWindow = new FakeEventTarget();
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SearchDialog],
providers: [
{
provide: Search,
useValue: fakeSearch,
},
{
provide: WINDOW,
useValue: fakeWindow,
},
],
}).compileComponents();
fixture = TestBed.createComponent(SearchDialog);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,139 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
AfterViewInit,
Component,
DestroyRef,
ElementRef,
EventEmitter,
NgZone,
OnDestroy,
OnInit,
Output,
QueryList,
ViewChild,
ViewChildren,
inject,
} from '@angular/core';
import {ClickOutside, Search, TextField, WINDOW} from '@angular/docs-shared';
import {FormsModule} from '@angular/forms';
import {ActiveDescendantKeyManager} from '@angular/cdk/a11y';
import {SearchItem} from '../../directives/search-item/search-item.directive';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {Router, RouterLink} from '@angular/router';
import {filter, fromEvent} from 'rxjs';
import {AlgoliaIcon} from '../algolia-icon/algolia-icon.component';
import {RelativeLink} from '../../pipes/relative-link.pipe';
@Component({
selector: 'docs-search-dialog',
standalone: true,
imports: [
ClickOutside,
TextField,
FormsModule,
SearchItem,
AlgoliaIcon,
RelativeLink,
RouterLink,
],
templateUrl: './search-dialog.component.html',
styleUrls: ['./search-dialog.component.scss'],
})
export class SearchDialog implements OnInit, AfterViewInit, OnDestroy {
@Output() onClose = new EventEmitter<void>();
@ViewChild('searchDialog') dialog?: ElementRef<HTMLDialogElement>;
@ViewChildren(SearchItem) items?: QueryList<SearchItem>;
private readonly destroyRef = inject(DestroyRef);
private readonly ngZone = inject(NgZone);
private readonly search = inject(Search);
private readonly relativeLink = new RelativeLink();
private readonly router = inject(Router);
private readonly window = inject(WINDOW);
private keyManager?: ActiveDescendantKeyManager<SearchItem>;
searchQuery = this.search.searchQuery;
searchResults = this.search.searchResults;
ngOnInit(): void {
this.ngZone.runOutsideAngular(() => {
fromEvent<KeyboardEvent>(this.window, 'keydown')
.pipe(
filter((_) => !!this.keyManager),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((event) => {
// When user presses Enter we can navigate to currently selected item in the search result list.
if (event.key === 'Enter') {
this.navigateToTheActiveItem();
} else {
this.ngZone.run(() => {
this.keyManager?.onKeydown(event);
});
}
});
});
}
ngAfterViewInit() {
this.dialog?.nativeElement.showModal();
if (!this.items) {
return;
}
this.keyManager = new ActiveDescendantKeyManager(this.items).withWrap();
this.keyManager?.setFirstItemActive();
this.updateActiveItemWhenResultsChanged();
this.scrollToActiveItem();
}
ngOnDestroy(): void {
this.keyManager?.destroy();
}
closeSearchDialog() {
this.dialog?.nativeElement.close();
this.onClose.next();
}
updateSearchQuery(query: string) {
this.search.updateSearchQuery(query);
}
private updateActiveItemWhenResultsChanged(): void {
this.items?.changes.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
// Change detection should be run before execute `setFirstItemActive`.
Promise.resolve().then(() => {
this.keyManager?.setFirstItemActive();
});
});
}
private navigateToTheActiveItem(): void {
const activeItemLink: string | undefined = this.keyManager?.activeItem?.item?.url;
if (!activeItemLink) {
return;
}
this.router.navigateByUrl(this.relativeLink.transform(activeItemLink));
this.onClose.next();
}
private scrollToActiveItem(): void {
this.keyManager?.change.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.keyManager?.activeItem?.scrollIntoView();
});
}
}

View file

@ -1,5 +0,0 @@
<select [attr.id]="id" [attr.name]="name" [ngModel]="selectedOption()" (ngModelChange)="setOption($event)">
@for (item of options; track item) {
<option [value]="item.value">{{ item.label }}</option>
}
</select>

View file

@ -1,29 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {Select} from './select.component';
describe('Select', () => {
let component: Select;
let fixture: ComponentFixture<Select>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [Select],
});
fixture = TestBed.createComponent(Select);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,78 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule} from '@angular/forms';
import {Component, Input, forwardRef, signal} from '@angular/core';
import {CommonModule} from '@angular/common';
type SelectOptionValue = string | number | boolean;
export interface SelectOption {
label: string;
value: SelectOptionValue;
}
@Component({
selector: 'docs-select',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './select.component.html',
styleUrls: ['./select.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => Select),
multi: true,
},
],
host: {
class: 'adev-form-element',
},
})
export class Select implements ControlValueAccessor {
@Input({required: true, alias: 'selectId'}) id!: string;
@Input({required: true}) name!: string;
@Input({required: true}) options!: SelectOption[];
@Input() disabled = false;
// Implemented as part of ControlValueAccessor.
private onChange: (value: SelectOptionValue) => void = (_: SelectOptionValue) => {};
private onTouched: () => void = () => {};
protected readonly selectedOption = signal<SelectOptionValue | null>(null);
// Implemented as part of ControlValueAccessor.
writeValue(value: SelectOptionValue): void {
this.selectedOption.set(value);
}
// Implemented as part of ControlValueAccessor.
registerOnChange(fn: any): void {
this.onChange = fn;
}
// Implemented as part of ControlValueAccessor.
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
// Implemented as part of ControlValueAccessor.
setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled;
}
setOption($event: SelectOptionValue): void {
if (this.disabled) {
return;
}
this.selectedOption.set($event);
this.onChange($event);
this.onTouched();
}
}

View file

@ -1,14 +0,0 @@
<label [attr.for]="buttonId">
<span class="adev-label">{{ label }}</span>
<div class="adev-toggle">
<input
type="checkbox"
[id]="buttonId"
role="switch"
(click)="toggle()"
[class.adev-toggle-active]="checked()"
[checked]="checked()"
/>
<span class="adev-slider"></span>
</div>
</label>

View file

@ -1,78 +0,0 @@
:host,
label {
display: inline-flex;
gap: 0.5em;
align-items: center;
}
.adev-label {
font-size: 0.875rem;
font-style: normal;
font-weight: 500;
line-height: 160%; // 1.4rem
letter-spacing: -0.00875rem;
color: var(--quaternary-contrast);
}
.adev-toggle {
position: relative;
display: inline-block;
width: 3rem;
height: 1.5rem;
border: 1px solid var(--senary-contrast);
border-radius: 34px;
input {
opacity: 0;
width: 0;
height: 0;
}
}
.adev-slider {
position: absolute;
cursor: pointer;
border-radius: 34px;
inset: 0;
background-color: var(--septenary-contrast);
transition: background-color 0.3s ease, border-color 0.3s ease;
// background
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: 34px;
background: var(--pink-to-purple-horizontal-gradient);
opacity: 0;
transition: opacity 0.3s ease;
}
// toggle knob
&::after {
position: absolute;
content: '';
height: 1.25rem;
width: 1.25rem;
left: 0.125rem;
bottom: 0.125rem;
background-color: var(--page-background);
transition: transform 0.3s ease, background-color 0.3s ease;
border-radius: 50%;
}
}
input {
&:checked + .adev-slider {
// background
&::before {
opacity: 1;
}
// toggle knob
&::after {
transform: translateX(1.5rem);
}
}
}

View file

@ -1,58 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {SlideToggle} from './slide-toggle.component';
describe('SlideToggle', () => {
let component: SlideToggle;
let fixture: ComponentFixture<SlideToggle>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [SlideToggle],
});
fixture = TestBed.createComponent(SlideToggle);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should toggle the value when clicked', () => {
expect(component['checked']()).toBeFalse();
const buttonElement = fixture.nativeElement.querySelector('input');
buttonElement.click();
expect(component['checked']()).toBeTrue();
});
it('should call onChange and onTouched when toggled', () => {
const onChangeSpy = jasmine.createSpy('onChangeSpy');
const onTouchedSpy = jasmine.createSpy('onTouchedSpy');
component.registerOnChange(onChangeSpy);
component.registerOnTouched(onTouchedSpy);
component.toggle();
expect(onChangeSpy).toHaveBeenCalled();
expect(onChangeSpy).toHaveBeenCalledWith(true);
expect(onTouchedSpy).toHaveBeenCalled();
});
it('should set active class for button when is checked', () => {
component.writeValue(true);
fixture.detectChanges();
const buttonElement: HTMLButtonElement = fixture.nativeElement.querySelector('input');
expect(buttonElement.classList.contains('adev-toggle-active')).toBeTrue();
component.writeValue(false);
fixture.detectChanges();
expect(buttonElement.classList.contains('adev-toggle-active')).toBeFalse();
});
});

View file

@ -1,68 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Component, Input, forwardRef, signal} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
@Component({
selector: 'docs-slide-toggle',
standalone: true,
imports: [CommonModule],
templateUrl: './slide-toggle.component.html',
styleUrls: ['./slide-toggle.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SlideToggle),
multi: true,
},
],
})
export class SlideToggle implements ControlValueAccessor {
@Input({required: true}) buttonId!: string;
@Input({required: true}) label!: string;
@Input() disabled = false;
// Implemented as part of ControlValueAccessor.
private onChange: (value: boolean) => void = (_: boolean) => {};
private onTouched: () => void = () => {};
protected readonly checked = signal(false);
// Implemented as part of ControlValueAccessor.
writeValue(value: boolean): void {
this.checked.set(value);
}
// Implemented as part of ControlValueAccessor.
registerOnChange(fn: any): void {
this.onChange = fn;
}
// Implemented as part of ControlValueAccessor.
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
// Implemented as part of ControlValueAccessor.
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
// Toggles the checked state of the slide-toggle.
toggle(): void {
if (this.disabled) {
return;
}
this.checked.update((checked) => !checked);
this.onChange(this.checked());
this.onTouched();
}
}

View file

@ -1,31 +0,0 @@
<aside>
<nav>
<header>
<h2 class="docs-title">On this page</h2>
</header>
<ul class="adev-faceted-list">
<!-- TODO: Hide li elements with class docs-toc-item-h3 for laptop, table and phone screen resolutions -->
@for (item of tableOfContentItems(); track item.id) {
<li
class="adev-faceted-list-item"
[class.docs-toc-item-h2]="item.level === TableOfContentsLevel.H2"
[class.docs-toc-item-h3]="item.level === TableOfContentsLevel.H3"
>
<a
routerLink="."
[fragment]="item.id"
[class.adev-faceted-list-item-active]="item.id === activeItemId()"
>
{{ item.title }}
</a>
</li>
}
</ul>
</nav>
@if (shouldDisplayScrollToTop()) {
<button type="button" (click)="scrollToTop()">
<docs-icon role="presentation">arrow_upward_alt</docs-icon>
Back to the top
</button>
}
</aside>

View file

@ -1,91 +0,0 @@
:host {
display: flex;
flex-direction: column;
position: fixed;
right: 16px;
top: 0;
height: fit-content;
width: 14rem;
padding-inline: 1rem;
max-height: 100vh;
overflow-y: scroll;
aside {
margin-bottom: 2rem;
}
@media only screen and (max-width: 1430px) {
position: relative;
right: 0;
max-height: min-content;
width: 100%;
}
.docs-title {
font-size: 1.25rem;
margin-block-start: var(--layout-padding);
}
&::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0);
cursor: pointer;
}
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: var(--septenary-contrast);
border-radius: 10px;
transition: background-color 0.3s ease;
}
&::-webkit-scrollbar-thumb:hover {
background-color: var(--quinary-contrast);
}
.adev-faceted-list-item {
font-size: 0.875rem;
a {
display: block; // to prevent overflow from the li parent
padding: 0.5rem 0.5rem 0.5rem 1rem;
font-weight: 500;
}
&.docs-toc-item-h3 a {
padding-inline-start: 2rem;
}
}
}
button {
background: transparent;
border: none;
font-size: 0.875rem;
font-family: var(--inter-font);
display: flex;
align-items: center;
margin: 0.5rem 0;
color: var(--tertiary-contrast);
transition: color 0.3s ease;
cursor: pointer;
docs-icon {
margin-inline-end: 0.35rem;
opacity: 0.6;
transition: opacity 0.3s ease;
}
&:hover {
docs-icon {
opacity: 1;
}
}
@media only screen and (max-width: 1430px) {
display: none;
}
}

View file

@ -1,124 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {TableOfContents} from './table-of-contents.component';
import {RouterTestingModule} from '@angular/router/testing';
import {TableOfContentsItem, TableOfContentsLevel} from '../../interfaces';
import {TableOfContentsLoader} from '../../services/table-of-contents-loader.service';
import {TableOfContentsScrollSpy} from '../../services/table-of-contents-scroll-spy.service';
import {WINDOW} from '../../providers';
describe('TableOfContents', () => {
let component: TableOfContents;
let fixture: ComponentFixture<TableOfContents>;
let tableOfContentsLoaderSpy: jasmine.SpyObj<TableOfContentsLoader>;
let scrollSpy: jasmine.SpyObj<TableOfContentsScrollSpy>;
const items: TableOfContentsItem[] = [
{
title: 'Heading 2',
top: 0,
id: 'item-heading-2',
level: TableOfContentsLevel.H2,
},
{
title: 'First Heading 3',
top: 100,
id: 'first-item-heading-3',
level: TableOfContentsLevel.H3,
},
{
title: 'Second Heading 3',
top: 200,
id: 'second-item-heading-3',
level: TableOfContentsLevel.H3,
},
];
const fakeWindow = {
addEventListener: () => {},
removeEventListener: () => {},
};
beforeEach(async () => {
scrollSpy = jasmine.createSpyObj<TableOfContentsScrollSpy>('TableOfContentsScrollSpy', [
'startListeningToScroll',
'activeItemId',
'scrollbarThumbOnTop',
]);
scrollSpy.startListeningToScroll.and.returnValue();
scrollSpy.activeItemId.and.returnValue(items[0].id);
scrollSpy.scrollbarThumbOnTop.and.returnValue(false);
tableOfContentsLoaderSpy = jasmine.createSpyObj<TableOfContentsLoader>(
'TableOfContentsLoader',
['buildTableOfContent'],
);
tableOfContentsLoaderSpy.buildTableOfContent.and.returnValue();
tableOfContentsLoaderSpy.tableOfContentItems = items;
await TestBed.configureTestingModule({
imports: [TableOfContents, RouterTestingModule],
providers: [
{
provide: WINDOW,
useValue: fakeWindow,
},
],
}).compileComponents();
TestBed.overrideProvider(TableOfContentsLoader, {
useValue: tableOfContentsLoaderSpy,
});
TestBed.overrideProvider(TableOfContentsScrollSpy, {
useValue: scrollSpy,
});
fixture = TestBed.createComponent(TableOfContents);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call scrollToTop when user click on Back to the top button', () => {
const spy = spyOn(component, 'scrollToTop');
fixture.detectChanges();
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
button.click();
expect(spy).toHaveBeenCalledOnceWith();
});
it('should render items when tableOfContentItems has value', () => {
fixture.detectChanges();
const renderedItems = fixture.nativeElement.querySelectorAll('li');
expect(renderedItems.length).toBe(3);
expect(component.tableOfContentItems().length).toBe(3);
});
it('should append level class to element', () => {
fixture.detectChanges();
const h2Items = fixture.nativeElement.querySelectorAll('li.docs-toc-item-h2');
const h3Items = fixture.nativeElement.querySelectorAll('li.docs-toc-item-h3');
expect(h2Items.length).toBe(1);
expect(h3Items.length).toBe(2);
});
it('should append active class when item is active', () => {
fixture.detectChanges();
const activeItem = fixture.nativeElement.querySelector('.adev-faceted-list-item-active');
expect(activeItem).toBeTruthy();
});
});

View file

@ -1,48 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {NgFor, NgIf} from '@angular/common';
import {Component, Input, computed, inject} from '@angular/core';
import {RouterLink} from '@angular/router';
import {TableOfContentsLevel} from '../../interfaces';
import {TableOfContentsLoader} from '../../services/table-of-contents-loader.service';
import {TableOfContentsScrollSpy} from '../../services/table-of-contents-scroll-spy.service';
import {IconComponent} from '../icon/icon.component';
@Component({
selector: 'docs-table-of-contents',
standalone: true,
providers: [TableOfContentsLoader, TableOfContentsScrollSpy],
templateUrl: './table-of-contents.component.html',
styleUrls: ['./table-of-contents.component.scss'],
imports: [NgIf, NgFor, RouterLink, IconComponent],
})
export class TableOfContents {
// Element that contains the content from which the Table of Contents is built
@Input({required: true}) contentSourceElement!: HTMLElement;
private readonly scrollSpy = inject(TableOfContentsScrollSpy);
private readonly tableOfContentsLoader = inject(TableOfContentsLoader);
activeItemId = this.scrollSpy.activeItemId;
shouldDisplayScrollToTop = computed(() => !this.scrollSpy.scrollbarThumbOnTop());
TableOfContentsLevel = TableOfContentsLevel;
tableOfContentItems() {
return this.tableOfContentsLoader.tableOfContentItems;
}
ngAfterViewInit() {
this.tableOfContentsLoader.buildTableOfContent(this.contentSourceElement);
this.scrollSpy.startListeningToScroll(this.contentSourceElement);
}
scrollToTop(): void {
this.scrollSpy.scrollToTop();
}
}

View file

@ -1,12 +0,0 @@
@if (!hideIcon) {
<docs-icon class="docs-icon_high-contrast">search</docs-icon>
}
<input
#inputRef
type="text"
[attr.placeholder]="placeholder"
[attr.name]="name"
[ngModel]="value()"
(ngModelChange)="setValue($event)"
class="adev-text-field"
/>

View file

@ -1,9 +0,0 @@
// search field
.adev-text-field {
font-size: 1.125rem;
}
// filter field on api reference list
docs-icon + .adev-text-field {
font-size: 1rem;
}

View file

@ -1,29 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {TextField} from './text-field.component';
describe('TextField', () => {
let component: TextField;
let fixture: ComponentFixture<TextField>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TextField],
});
fixture = TestBed.createComponent(TextField);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,91 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
Component,
ElementRef,
Input,
ViewChild,
afterNextRender,
forwardRef,
signal,
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR} from '@angular/forms';
import {IconComponent} from '../icon/icon.component';
@Component({
selector: 'docs-text-field',
standalone: true,
imports: [CommonModule, FormsModule, IconComponent],
templateUrl: './text-field.component.html',
styleUrls: ['./text-field.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TextField),
multi: true,
},
],
host: {
class: 'adev-form-element',
},
})
export class TextField implements ControlValueAccessor {
@ViewChild('inputRef') private input?: ElementRef<HTMLInputElement>;
@Input() name: string | null = null;
@Input() placeholder: string | null = null;
@Input() disabled = false;
@Input() hideIcon = false;
@Input() autofocus = false;
// Implemented as part of ControlValueAccessor.
private onChange: (value: string) => void = (_: string) => {};
private onTouched: () => void = () => {};
protected readonly value = signal<string | null>(null);
constructor() {
afterNextRender(() => {
if (this.autofocus) {
this.input?.nativeElement.focus();
}
});
}
// Implemented as part of ControlValueAccessor.
writeValue(value: string): void {
this.value.set(value);
}
// Implemented as part of ControlValueAccessor.
registerOnChange(fn: any): void {
this.onChange = fn;
}
// Implemented as part of ControlValueAccessor.
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
// Implemented as part of ControlValueAccessor.
setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled;
}
setValue(value: string): void {
if (this.disabled) {
return;
}
this.value.set(value);
this.onChange(value);
this.onTouched();
}
}

View file

@ -1,13 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
// Used for both the table of contents and the home animation
export const RESIZE_EVENT_DELAY = 500;
// Used for the home animation
export const WEBGL_LOADED_DELAY = 250;

View file

@ -1,9 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
export * from './delay';

View file

@ -1,47 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {DOCUMENT} from '@angular/common';
import {Directive, ElementRef, EventEmitter, Input, Output, inject} from '@angular/core';
@Directive({
selector: '[docsClickOutside]',
standalone: true,
host: {
'(document:click)': 'onClick($event)',
},
})
export class ClickOutside {
@Input('docsClickOutsideIgnore') public ignoredElementsIds: string[] = [];
@Output('docsClickOutside') public clickOutside = new EventEmitter<void>();
private readonly document = inject(DOCUMENT);
private readonly elementRef = inject(ElementRef<HTMLElement>);
onClick($event: PointerEvent): void {
if (
!this.elementRef.nativeElement.contains($event.target) &&
!this.wasClickedOnIgnoredElement($event)
) {
this.clickOutside.emit();
}
}
private wasClickedOnIgnoredElement($event: PointerEvent): boolean {
if (this.ignoredElementsIds.length === 0) {
return false;
}
return this.ignoredElementsIds.some((elementId) => {
const element = this.document.getElementById(elementId);
const target = $event.target as Node;
const contains = element?.contains(target);
return contains;
});
}
}

View file

@ -1,44 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {isPlatformBrowser} from '@angular/common';
import {Directive, ElementRef, OnInit, PLATFORM_ID, inject} from '@angular/core';
import {WINDOW, isExternalLink} from '@angular/docs-shared';
/**
* The directive will set target of anchor elements to '_blank' for the external links.
* We can opt-out this behavior by adding `noBlankForExternalLink` attribute to anchor element.
*/
@Directive({
selector: 'a[href]:not([noBlankForExternalLink])',
host: {
'[attr.target]': 'target',
},
standalone: true,
})
export class ExternalLink implements OnInit {
private readonly anchor: ElementRef<HTMLAnchorElement> = inject(ElementRef);
private readonly platformId = inject(PLATFORM_ID);
private readonly window = inject(WINDOW);
target?: '_blank' | '_self' | '_parent' | '_top' | '';
ngOnInit(): void {
this.setAnchorTarget();
}
private setAnchorTarget(): void {
if (!isPlatformBrowser(this.platformId)) {
return;
}
if (isExternalLink(this.anchor.nativeElement.href, this.window.location.origin)) {
this.target = '_blank';
}
}
}

View file

@ -1,10 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
export * from './click-outside/click-outside.directive';
export * from './external-link/external-link.directive';

View file

@ -1,51 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Directive, ElementRef, Input, inject} from '@angular/core';
import {Highlightable} from '@angular/cdk/a11y';
import {SearchResult} from '../../interfaces/search-results';
@Directive({
selector: '[docsSearchItem]',
standalone: true,
host: {
'[class.active]': 'isActive',
},
})
export class SearchItem implements Highlightable {
@Input() item?: SearchResult;
@Input() disabled = false;
private readonly elementRef = inject(ElementRef<HTMLLIElement>);
private _isActive = false;
protected get isActive() {
return this._isActive;
}
setActiveStyles(): void {
this._isActive = true;
}
setInactiveStyles(): void {
this._isActive = false;
}
getLabel(): string {
if (!this.item?.hierarchy) {
return '';
}
const {hierarchy} = this.item;
return `${hierarchy.lvl0}${hierarchy.lvl1}${hierarchy.lvl2}`;
}
scrollIntoView(): void {
this.elementRef?.nativeElement.scrollIntoView({block: 'nearest'});
}
}

View file

@ -1,3 +0,0 @@
<svg width="10" height="7" viewBox="0 0 10 7" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M-5.09966e-08 1.83341L1.16667 0.666748L5 4.50008L8.83333 0.666748L10 1.83341L5 6.83341L-5.09966e-08 1.83341Z" fill="#746E7C"/>
</svg>

Before

Width:  |  Height:  |  Size: 239 B

View file

@ -1,3 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.59948 19.0428C7.59948 18.8069 7.59118 18.182 7.58656 17.3526C4.89071 17.9526 4.32164 16.0201 4.32164 16.0201C3.88087 14.8718 3.24533 14.5663 3.24533 14.5663C2.36518 13.9492 3.31179 13.9621 3.31179 13.9621C4.28471 14.0323 4.79656 14.9868 4.79656 14.9868C5.66102 16.5052 7.06456 16.0672 7.61748 15.8125C7.70564 15.17 7.95579 14.732 8.23271 14.4837C6.08056 14.2331 3.81764 13.3801 3.81764 9.57199C3.81764 8.48737 4.19564 7.6003 4.81548 6.90522C4.71625 6.65414 4.38302 5.64384 4.91056 4.27537C4.91056 4.27537 5.72471 4.00814 7.57594 5.29399C8.34856 5.07384 9.17795 4.96307 10.0027 4.95937C10.8256 4.96307 11.6546 5.07384 12.429 5.29399C14.2793 4.00814 15.0921 4.27537 15.0921 4.27537C15.621 5.64337 15.2883 6.65368 15.1881 6.90522C15.8093 7.6003 16.1841 8.48737 16.1841 9.57199C16.1841 13.3898 13.9179 14.2298 11.7589 14.4758C12.1073 14.7828 12.4166 15.3892 12.4166 16.3165C12.4166 17.6452 12.4041 18.7174 12.4041 19.0428C12.4041 19.3091 12.579 19.6178 13.071 19.5205C16.9193 18.2041 19.6936 14.4814 19.6936 10.0926C19.6936 4.60353 15.3538 0.154297 10.0009 0.154297C4.64887 0.154297 0.309021 4.60353 0.309021 10.0926C0.309483 14.4828 3.08656 18.2078 6.9381 19.5218C7.42225 19.6128 7.59948 19.3058 7.59948 19.0428Z" fill="#A39FA9"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1,3 +0,0 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.04145 0.04432l6.56351 8.77603L0 15.95564h1.48651l5.78263-6.24705 4.6722 6.24705h5.05865l-6.9328-9.26967L16.21504.04432h-1.48651l-5.32552 5.75341L5.1001.04432H.04145Zm2.18602 1.09497h2.32396l10.26221 13.72122h-2.32396L2.22747 1.13928Z" fill="#A39FA9"/>
</svg>

Before

Width:  |  Height:  |  Size: 367 B

View file

@ -1,3 +0,0 @@
<svg width="20" height="15" viewBox="0 0 20 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.7556 2.94783C18.5803 1.98018 17.745 1.27549 16.7756 1.05549C15.325 0.747832 12.6403 0.527832 9.73563 0.527832C6.83266 0.527832 4.105 0.747832 2.65266 1.05549C1.685 1.27549 0.847969 1.93549 0.672656 2.94783C0.495625 4.04783 0.320312 5.58783 0.320312 7.56783C0.320312 9.54783 0.495625 11.0878 0.715625 12.1878C0.892656 13.1555 1.72797 13.8602 2.69563 14.0802C4.23563 14.3878 6.87563 14.6078 9.78031 14.6078C12.685 14.6078 15.325 14.3878 16.865 14.0802C17.8327 13.8602 18.668 13.2002 18.845 12.1878C19.0203 11.0878 19.2403 9.50314 19.285 7.56783C19.1956 5.58783 18.9756 4.04783 18.7556 2.94783ZM7.36031 10.6478V4.48783L12.728 7.56783L7.36031 10.6478Z" fill="#A39FA9"/>
</svg>

Before

Width:  |  Height:  |  Size: 782 B

View file

@ -1,13 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
export interface AlgoliaConfig {
apiKey: string;
appId: string;
indexName: string;
}

View file

@ -1,40 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Type} from '@angular/core';
/**
* Map of the examples, values are functions which returns the promise of the component type, which will be displayed as preview in the ExampleViewer component
*/
export interface CodeExamplesMap {
[id: string]: () => Promise<Type<unknown>>;
}
export interface Snippet {
/** Title of the code snippet */
title?: string;
/** Name of the file. */
name: string;
/** Content of code snippet */
content: string;
/** Text in following format `start-end`. Start and end are numbers, based on them provided range of lines will be displayed in collapsed mode */
visibleLinesRange?: string;
}
export interface ExampleMetadata {
/** Numeric id of example, used to generate unique link to the example */
id: number;
/** Title of the example. */
title?: string;
/** Path to the preview component */
path?: string;
/** List of files which are part of the example. */
files: Snippet[];
/** True when ExampleViewer should have preview */
preview: boolean;
}

View file

@ -1,17 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
/** Represents a documentation page data. */
export interface DocContent {
/** The unique identifier for this document. */
id: string;
/** The HTML to display in the doc viewer. */
contents: string;
/** The unique title for this document page. */
title?: string;
}

View file

@ -1,14 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {DocContent} from './doc-content';
/** The service responsible for fetching static content for docs pages */
export interface DocsContentLoader {
getContent(path: string): Promise<DocContent | undefined>;
}

View file

@ -1,15 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {AlgoliaConfig} from './algolia-config';
export interface Environment {
production: boolean;
algolia: AlgoliaConfig;
googleAnalyticsId: string;
}

View file

@ -1,15 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Type} from '@angular/core';
/** The service responsible for fetching the type of Component to display in the preview */
export interface ExampleViewerContentLoader {
/** Returns type of Component to display in the preview */
loadPreview(id: string): Promise<Type<unknown>>;
}

View file

@ -1,15 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
export * from './code-example';
export * from './doc-content';
export * from './docs-content-loader';
export * from './example-viewer-content-loader';
export * from './environment';
export * from './navigation-item';
export * from './table-of-contents-item';

View file

@ -1,18 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
export interface NavigationItem {
label?: string;
path?: string;
children?: NavigationItem[];
isExternal?: boolean;
isExpanded?: boolean;
level?: number;
parent?: NavigationItem;
contentPath?: string;
}

View file

@ -1,31 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
/* The interface represents Algolia search result item. */
export interface SearchResult {
/* The url link to the search result page */
url?: string;
/* The hierarchy of the item */
hierarchy?: Hierarchy;
/* The unique id of the search result item */
objectID: string;
}
/* The hierarchy of the item */
export interface Hierarchy {
/* It's kind of the page i.e `Docs`, `Tutorials`, `Reference` etc. */
lvl0: string | null;
/* Typicaly it's the content of H1 of the page */
lvl1: string | null;
/* Typicaly it's the content of H2 of the page */
lvl2: string | null;
lvl3: string | null;
lvl4: string | null;
lvl5: string | null;
lvl6: string | null;
}

View file

@ -1,24 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
export enum TableOfContentsLevel {
H2 = 'h2',
H3 = 'h3',
}
/** Represents a table of content item. */
export interface TableOfContentsItem {
/** The url fragment of specific section */
id: string;
/** The level of the item. */
level: TableOfContentsLevel;
/** The unique title for this document page. */
title: string;
/** The top offset px of the heading */
top: number;
}

View file

@ -1,32 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Pipe, PipeTransform} from '@angular/core';
import {NavigationItem} from '../interfaces';
@Pipe({
name: 'isActiveNavigationItem',
standalone: true,
})
export class IsActiveNavigationItem implements PipeTransform {
// Check whether provided item: `itemToCheck` should be marked as active, based on `activeItem`.
// In addition to `activeItem`, we should mark all its parents, grandparents, etc. as active.
transform(itemToCheck: NavigationItem, activeItem: NavigationItem | null): boolean {
let node = activeItem?.parent;
while (node) {
if (node === itemToCheck) {
return true;
}
node = node.parent;
}
return false;
}
}

View file

@ -1,28 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Pipe, PipeTransform} from '@angular/core';
import {normalizePath, removeTrailingSlash} from '../utils';
@Pipe({
name: 'relativeLink',
standalone: true,
})
export class RelativeLink implements PipeTransform {
transform(absoluteUrl: string, result: 'relative' | 'pathname' | 'hash' = 'relative'): string {
const url = new URL(normalizePath(absoluteUrl));
if (result === 'hash') {
return url.hash?.substring(1) ?? '';
}
if (result === 'pathname') {
return `${removeTrailingSlash(normalizePath(url.pathname))}`;
}
return `${removeTrailingSlash(normalizePath(url.pathname))}${url.hash ?? ''}`;
}
}

View file

@ -1,18 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {InjectionToken, inject} from '@angular/core';
import {DocsContentLoader} from '../interfaces/docs-content-loader';
import {ResolveFn} from '@angular/router';
import {DocContent} from '../interfaces';
export const DOCS_CONTENT_LOADER = new InjectionToken<DocsContentLoader>('DOCS_CONTENT_LOADER');
export function contentResolver(contentPath: string): ResolveFn<DocContent | undefined> {
return () => inject(DOCS_CONTENT_LOADER).getContent(contentPath);
}

View file

@ -1,12 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {InjectionToken} from '@angular/core';
import {Environment} from '../interfaces';
export const ENVIRONMENT = new InjectionToken<Environment>('ENVIRONMENT');

View file

@ -1,14 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {InjectionToken} from '@angular/core';
import {ExampleViewerContentLoader} from '../interfaces/example-viewer-content-loader';
export const EXAMPLE_VIEWER_CONTENT_LOADER = new InjectionToken<ExampleViewerContentLoader>(
'EXAMPLE_VIEWER_CONTENT_LOADER',
);

View file

@ -1,15 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
export * from './docs-content-loader';
export * from './environment';
export * from './example-viewer-content-loader';
export * from './is-search-dialog-open';
export * from './local-storage';
export * from './previews-components';
export * from './window';

View file

@ -1,14 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {InjectionToken, signal} from '@angular/core';
export const IS_SEARCH_DIALOG_OPEN = new InjectionToken('', {
providedIn: 'root',
factory: () => signal(false),
});

View file

@ -1,68 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {isPlatformBrowser} from '@angular/common';
import {InjectionToken, PLATFORM_ID, inject} from '@angular/core';
export const LOCAL_STORAGE = new InjectionToken<Storage | null>('LOCAL_STORAGE', {
providedIn: 'root',
factory: () => getStorage(inject(PLATFORM_ID)),
});
const getStorage = (platformId: Object): Storage | null => {
// Prerendering: localStorage is undefined for prerender build
return isPlatformBrowser(platformId) ? new LocalStorage() : null;
};
/**
* LocalStorage is wrapper class for localStorage, operations can fail due to various reasons,
* such as browser restrictions or storage limits being exceeded. A wrapper is providing error handling.
*/
class LocalStorage implements Storage {
get length(): number {
try {
return localStorage.length;
} catch {
return 0;
}
}
clear(): void {
try {
localStorage.clear();
} catch {}
}
getItem(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
key(index: number): string | null {
try {
return localStorage.key(index);
} catch {
return null;
}
}
removeItem(key: string): void {
try {
localStorage.removeItem(key);
} catch {}
}
setItem(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {}
}
}

View file

@ -1,12 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {InjectionToken} from '@angular/core';
import {CodeExamplesMap} from '../interfaces/code-example';
export const PREVIEWS_COMPONENTS = new InjectionToken<CodeExamplesMap>('PREVIEWS_COMPONENTS');

View file

@ -1,18 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {InjectionToken} from '@angular/core';
// Providing window using injection token could increase testability and portability (i.e SSR don't have a real browser environment).
export const WINDOW = new InjectionToken<Window>('WINDOW');
// The project uses prerendering, to resolve issue: 'window is not defined', we should get window from DOCUMENT.
// As it is recommended here: https://github.com/angular/universal/blob/main/docs/gotchas.md#strategy-1-injection
export function windowProvider(document: Document) {
return document.defaultView;
}

View file

@ -1,11 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
export * from './navigation-state.service';
export {TOC_SKIP_CONTENT_MARKER} from './table-of-contents-loader.service';
export * from './search.service';

View file

@ -1,115 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Injectable, inject, signal} from '@angular/core';
import {NavigationItem} from '../interfaces';
import {Router} from '@angular/router';
@Injectable({
providedIn: 'root',
})
export class NavigationState {
private readonly router = inject(Router);
private readonly _activeNavigationItem = signal<NavigationItem | null>(null);
private readonly _expandedItems = signal<NavigationItem[]>([]);
private readonly _isMobileNavVisible = signal<boolean>(false);
primaryActiveRouteItem = signal<string | null>(null);
activeNavigationItem = this._activeNavigationItem.asReadonly();
expandedItems = this._expandedItems.asReadonly();
isMobileNavVisible = this._isMobileNavVisible.asReadonly();
async toggleItem(item: NavigationItem): Promise<void> {
if (!item.children) {
return;
}
if (item.isExpanded) {
this.collapse(item);
} else if (item.children && item.children.length > 0 && item.children[0].path) {
// It resolves false, when the user has displayed the page, then changed the slide to a secondary navigation component
// and wants to reopen the slide, where the first item is the currently displayed page
const navigationSucceeds = await this.navigateToFirstPageOfTheCategory(item.children[0].path);
if (!navigationSucceeds) {
this.expand(item);
}
}
}
cleanExpandedState(): void {
this._expandedItems.set([]);
}
expandItemHierarchy(
item: NavigationItem,
shouldExpand: (item: NavigationItem) => boolean,
skipExpandPredicateFn?: (item: NavigationItem) => boolean,
): void {
if (skipExpandPredicateFn && skipExpandPredicateFn(item)) {
// When `skipExpandPredicateFn` returns `true` then we should trigger `cleanExpandedState`
// to be sure that first navigation slide will be displayed.
this.cleanExpandedState();
return;
}
// Returns item when parent node was already expanded
const parentItem = this._expandedItems().find(
(expandedItem) =>
item.parent?.label === expandedItem.label && item.parent?.path === expandedItem.path,
);
if (parentItem) {
// If the parent item is expanded, then we should display all expanded items up to the active item level.
// This provides us with an appropriate list of expanded elements also when the user navigates using browser buttons.
this._expandedItems.update((expandedItems) =>
expandedItems.filter(
(item) =>
item.level !== undefined &&
parentItem.level !== undefined &&
item.level <= parentItem.level,
),
);
} else {
let itemsToExpand: NavigationItem[] = [];
let node = item.parent;
while (node && shouldExpand(node)) {
itemsToExpand.push({...node, isExpanded: true});
node = node.parent;
}
this._expandedItems.set(itemsToExpand.reverse());
}
}
setActiveNavigationItem(item: NavigationItem | null): void {
this._activeNavigationItem.set(item);
}
setMobileNavigationListVisibility(isVisible: boolean): void {
this._isMobileNavVisible.set(isVisible);
}
private expand(item: NavigationItem): void {
// Add item to the expanded items list
this._expandedItems.update((expandedItems) => {
return [...(expandedItems ?? []), {...item, isExpanded: true}];
});
}
private collapse(item: NavigationItem): void {
item.isExpanded = false;
this._expandedItems.update((expandedItems) => expandedItems.slice(0, -1));
}
private async navigateToFirstPageOfTheCategory(path: string): Promise<boolean> {
return this.router.navigateByUrl(path);
}
}

View file

@ -1,85 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Injectable, afterNextRender, inject, signal} from '@angular/core';
import {ENVIRONMENT} from '../providers';
import {SearchResult} from '../interfaces/search-results';
import {toObservable} from '@angular/core/rxjs-interop';
import {debounceTime, filter, from, of, switchMap} from 'rxjs';
import algoliasearch from 'algoliasearch/lite';
import {NavigationEnd, Router} from '@angular/router';
export const SEARCH_DELAY = 200;
// Maximum number of facet values to return for each facet during a regular search.
export const MAX_VALUE_PER_FACET = 5;
@Injectable({
providedIn: 'root',
})
export class Search {
private readonly _searchQuery = signal('');
private readonly _searchResults = signal<undefined | SearchResult[]>(undefined);
private readonly router = inject(Router);
private readonly config = inject(ENVIRONMENT);
private readonly client = algoliasearch(this.config.algolia.appId, this.config.algolia.apiKey);
private readonly index = this.client.initIndex(this.config.algolia.indexName);
searchQuery = this._searchQuery.asReadonly();
searchResults = this._searchResults.asReadonly();
searchResults$ = toObservable(this.searchQuery).pipe(
debounceTime(SEARCH_DELAY),
switchMap((query) => {
return !!query
? from(
this.index.search(query, {
maxValuesPerFacet: MAX_VALUE_PER_FACET,
}),
)
: of(undefined);
}),
);
constructor() {
afterNextRender(() => {
this.listenToSearchResults();
this.resetSearchQueryOnNavigationEnd();
});
}
updateSearchQuery(query: string): void {
this._searchQuery.set(query);
}
private listenToSearchResults(): void {
this.searchResults$.subscribe((response) => {
this._searchResults.set(
response ? this.getUniqueSearchResultItems(response.hits) : undefined,
);
});
}
private getUniqueSearchResultItems(items: SearchResult[]): SearchResult[] {
const uniqueUrls = new Set<string>();
return items.filter((item) => {
if (item.url && !uniqueUrls.has(item.url)) {
uniqueUrls.add(item.url);
return true;
}
return false;
});
}
private resetSearchQueryOnNavigationEnd(): void {
this.router.events.pipe(filter((event) => event instanceof NavigationEnd)).subscribe(() => {
this.updateSearchQuery('');
});
}
}

View file

@ -1,82 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {TestBed} from '@angular/core/testing';
import {TableOfContentsLoader} from './table-of-contents-loader.service';
describe('TableOfContentsLoader', () => {
let service: TableOfContentsLoader;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [TableOfContentsLoader],
});
service = TestBed.inject(TableOfContentsLoader);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should create empty table of content list when there is no headings in content', () => {
const element = createElement('element-without-headings');
service.buildTableOfContent(element);
expect(service.tableOfContentItems).toEqual([]);
});
it('should create empty table of content list when there is only h1 elements', () => {
const element = createElement('element-with-only-h1');
service.buildTableOfContent(element);
expect(service.tableOfContentItems).toEqual([]);
});
it('should create table of content list with h2 and h3 when h2 and h3 headings exists', () => {
const element = createElement('element-with-h1-h2-h3-h4');
service.buildTableOfContent(element);
expect(service.tableOfContentItems.length).toEqual(5);
expect(service.tableOfContentItems[0].id).toBe('item-2');
expect(service.tableOfContentItems[1].id).toBe('item-3');
expect(service.tableOfContentItems[2].id).toBe('item-5');
expect(service.tableOfContentItems[3].id).toBe('item-6');
expect(service.tableOfContentItems[4].id).toBe('item-7');
expect(service.tableOfContentItems[0].level).toBe('h2');
expect(service.tableOfContentItems[1].level).toBe('h3');
expect(service.tableOfContentItems[2].level).toBe('h3');
expect(service.tableOfContentItems[3].level).toBe('h2');
expect(service.tableOfContentItems[4].level).toBe('h3');
});
});
function createElement(id: string): HTMLElement {
const div = document.createElement('div');
div.innerHTML = fakeElementHtml[id];
return div;
}
const fakeElementHtml: Record<string, string> = {
'element-without-headings': `<div>content</div>`,
'element-with-only-h1': `<div><h1>heading</h1></div>`,
'element-with-h1-h2-h3-h4': `<div>
<h1 id="item-1">H1</h1>
<h2 id="item-2">H2 - first <docs-icon>link</docs-icon></h2>
<h3 id="item-3">H3 - first</h3>
<h4 id="item-4">H4</h4>
<h3 id="item-5">H3 - second</h3>
<h2 id="item-6">H2 - second</h2>
<h3 id="item-7">H3 - third</h3>
</div>
`,
};

View file

@ -1,87 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {DOCUMENT, isPlatformBrowser} from '@angular/common';
import {inject, Injectable, PLATFORM_ID} from '@angular/core';
import {TableOfContentsItem, TableOfContentsLevel} from '../interfaces/table-of-contents-item';
/**
* Name of an attribute that is set on an element that should be
* excluded from the `TableOfContentsLoader` lookup. This is needed
* to exempt SSR'ed content of the `TableOfContents` component from
* being inspected and accidentally pulling more content into ToC.
*/
export const TOC_SKIP_CONTENT_MARKER = 'toc-skip-content';
@Injectable()
export class TableOfContentsLoader {
// There are some cases when default browser anchor scrolls a little above the
// heading In that cases wrong item was selected. The value found by trial and
// error.
readonly toleranceThreshold = 40;
tableOfContentItems: TableOfContentsItem[] = [];
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
buildTableOfContent(docElement: Element): void {
const headings = this.getHeadings(docElement);
const tocList: TableOfContentsItem[] = headings.map((heading) => ({
id: heading.id,
level: heading.tagName.toLowerCase() as TableOfContentsLevel,
title: this.getHeadingTitle(heading),
top: this.calculateTop(heading),
}));
this.tableOfContentItems = tocList;
}
// Update top value of heading, it should be executed after window resize
updateHeadingsTopValue(element: HTMLElement): void {
const headings = this.getHeadings(element);
const updatedTopValues = new Map<string, number>();
for (const heading of headings) {
const parentTop = heading.parentElement?.offsetTop ?? 0;
const top = Math.floor(parentTop + heading.offsetTop - this.toleranceThreshold);
updatedTopValues.set(heading.id, top);
}
this.tableOfContentItems.forEach((item) => {
item.top = updatedTopValues.get(item.id) ?? 0;
});
}
private getHeadingTitle(heading: HTMLHeadingElement): string {
const div: HTMLDivElement = this.document.createElement('div');
div.innerHTML = heading.innerHTML;
return (div.textContent || '').trim();
}
// Get all headings (h2 and h3) with ids, which are not children of the
// docs-example-viewer component.
private getHeadings(element: Element): HTMLHeadingElement[] {
return Array.from(
element.querySelectorAll<HTMLHeadingElement>(
`h2[id]:not(docs-example-viewer h2):not([${TOC_SKIP_CONTENT_MARKER}]),` +
`h3[id]:not(docs-example-viewer h3):not([${TOC_SKIP_CONTENT_MARKER}])`,
),
);
}
private calculateTop(heading: HTMLHeadingElement): number {
if (!isPlatformBrowser(this.platformId)) return 0;
return (
Math.floor(heading.offsetTop > 0 ? heading.offsetTop : heading.getClientRects()[0]?.top) -
this.toleranceThreshold
);
}
}

View file

@ -1,78 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {TestBed, discardPeriodicTasks, fakeAsync, tick} from '@angular/core/testing';
import {WINDOW} from '../providers';
import {TableOfContentsLoader} from './table-of-contents-loader.service';
import {SCROLL_EVENT_DELAY, TableOfContentsScrollSpy} from './table-of-contents-scroll-spy.service';
import {DOCUMENT} from '@angular/common';
describe('TableOfContentsScrollSpy', () => {
let service: TableOfContentsScrollSpy;
let tableOfContentsLoaderSpy: jasmine.SpyObj<TableOfContentsLoader>;
const fakeWindow = {
addEventListener: () => {},
removeEventListener: () => {},
};
beforeEach(() => {
tableOfContentsLoaderSpy = jasmine.createSpyObj<TableOfContentsLoader>(
'TableOfContentsLoader',
['tableOfContentItems', 'updateHeadingsTopValue'],
);
TestBed.configureTestingModule({
providers: [
TableOfContentsScrollSpy,
{
provide: WINDOW,
useValue: fakeWindow,
},
{
provide: TableOfContentsLoader,
useValue: tableOfContentsLoaderSpy,
},
],
});
service = TestBed.inject(TableOfContentsScrollSpy);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should activeItemId be null by default', () => {
expect(service.activeItemId()).toBeNull();
});
it(`should only fire setActiveItemId every ${SCROLL_EVENT_DELAY}ms when scrolling`, fakeAsync(() => {
const doc = TestBed.inject(DOCUMENT);
const scrollableContainer = doc;
const setActiveItemIdSpy = spyOn(service as any, 'setActiveItemId');
service.startListeningToScroll(doc.querySelector('fake-selector'));
scrollableContainer.dispatchEvent(new Event('scroll'));
tick(SCROLL_EVENT_DELAY - 2);
expect(setActiveItemIdSpy).not.toHaveBeenCalled();
scrollableContainer.dispatchEvent(new Event('scroll'));
tick(1);
expect(setActiveItemIdSpy).not.toHaveBeenCalled();
scrollableContainer.dispatchEvent(new Event('scroll'));
tick(1);
expect(setActiveItemIdSpy).toHaveBeenCalled();
discardPeriodicTasks();
}));
});

View file

@ -1,167 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {DOCUMENT, ViewportScroller} from '@angular/common';
import {
DestroyRef,
EnvironmentInjector,
Injectable,
afterNextRender,
inject,
signal,
NgZone,
} from '@angular/core';
import {RESIZE_EVENT_DELAY} from '@angular/docs-shared';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {auditTime, debounceTime, fromEvent, startWith} from 'rxjs';
import {WINDOW} from '../providers';
import {shouldReduceMotion} from '../utils';
import {TableOfContentsLoader} from './table-of-contents-loader.service';
export const SCROLL_EVENT_DELAY = 20;
export const SCROLL_FINISH_DELAY = SCROLL_EVENT_DELAY * 2;
@Injectable()
// The service is responsible for listening for scrolling and resizing,
// thanks to which it sets the active item in the Table of contents
export class TableOfContentsScrollSpy {
private readonly destroyRef = inject(DestroyRef);
private readonly tableOfContentsLoader = inject(TableOfContentsLoader);
private readonly document = inject(DOCUMENT);
private readonly window = inject(WINDOW);
private readonly ngZone = inject(NgZone);
private readonly viewportScroller = inject(ViewportScroller);
private readonly injector = inject(EnvironmentInjector);
private contentSourceElement: HTMLElement | null = null;
private lastContentWidth = 0;
activeItemId = signal<string | null>(null);
scrollbarThumbOnTop = signal<boolean>(true);
startListeningToScroll(contentSourceElement: HTMLElement | null): void {
this.contentSourceElement = contentSourceElement;
this.lastContentWidth = this.getContentWidth();
this.setScrollEventHandlers();
this.setResizeEventHandlers();
}
scrollToTop(): void {
this.viewportScroller.scrollToPosition([0, 0]);
}
scrollToSection(id: string): void {
if (shouldReduceMotion()) {
this.offsetToSection(id);
} else {
const section = this.document.getElementById(id);
section?.scrollIntoView({behavior: 'smooth', block: 'start'});
// We don't want to set the active item here, it would mess up the animation
// The scroll event handler will handle it for us
}
}
offsetToSection(id: string): void {
const section = this.document.getElementById(id);
section?.scrollIntoView({block: 'start'});
// Here we need to set the active item manually because scroll events might not be fired
this.activeItemId.set(id);
}
// After window resize, we should update top value of each table content item
private setResizeEventHandlers() {
fromEvent(this.window, 'resize')
.pipe(debounceTime(RESIZE_EVENT_DELAY), takeUntilDestroyed(this.destroyRef), startWith())
.subscribe(() => {
this.ngZone.run(() => this.updateHeadingsTopAfterResize());
});
// We need to observe the height of the docs-viewer because it may change after the
// assets (fonts, images) are loaded. They can (and will) change the y-position of the headings.
const docsViewer = this.document.querySelector('docs-viewer');
if (docsViewer) {
afterNextRender(
() => {
const resizeObserver = new ResizeObserver(() => this.updateHeadingsTopAfterResize());
resizeObserver.observe(docsViewer);
this.destroyRef.onDestroy(() => resizeObserver.disconnect());
},
{injector: this.injector},
);
}
}
private updateHeadingsTopAfterResize(): void {
this.lastContentWidth = this.getContentWidth();
const contentElement = this.contentSourceElement;
if (contentElement) {
this.tableOfContentsLoader.updateHeadingsTopValue(contentElement);
this.setActiveItemId();
}
}
private setScrollEventHandlers(): void {
const scroll$ = fromEvent(this.document, 'scroll').pipe(
auditTime(SCROLL_EVENT_DELAY),
takeUntilDestroyed(this.destroyRef),
);
this.ngZone.runOutsideAngular(() => {
scroll$.subscribe(() => this.setActiveItemId());
});
}
private setActiveItemId(): void {
const tableOfContentItems = this.tableOfContentsLoader.tableOfContentItems;
if (tableOfContentItems.length === 0) return;
// Resize could emit scroll event, in that case we could stop setting active item until resize will be finished
if (this.lastContentWidth !== this.getContentWidth()) {
return;
}
const scrollOffset = this.getScrollOffset();
if (scrollOffset === null) return;
for (const [i, currentLink] of tableOfContentItems.entries()) {
const nextLink = tableOfContentItems[i + 1];
// A link is considered active if the page is scrolled past the
// anchor without also being scrolled passed the next link.
const isActive =
scrollOffset >= currentLink.top && (!nextLink || nextLink.top >= scrollOffset);
// When active item was changed then trigger change detection
if (isActive && this.activeItemId() !== currentLink.id) {
this.ngZone.run(() => this.activeItemId.set(currentLink.id));
return;
}
}
if (scrollOffset < tableOfContentItems[0].top && this.activeItemId() !== null) {
this.ngZone.run(() => this.activeItemId.set(null));
}
const scrollOffsetZero = scrollOffset === 0;
if (scrollOffsetZero !== this.scrollbarThumbOnTop()) {
// we want to trigger change detection only when the value changes
this.ngZone.run(() => this.scrollbarThumbOnTop.set(scrollOffsetZero));
}
}
// Gets the scroll offset of the scroll container
private getScrollOffset(): number {
return this.window.scrollY;
}
private getContentWidth(): number {
return this.document.body.clientWidth || Number.MAX_SAFE_INTEGER;
}
}

View file

@ -1,87 +0,0 @@
@mixin api-item-label() {
.adev-api-item-label {
--label-theme: var(--symbolic-purple);
display: flex;
justify-content: center;
align-items: center;
font-weight: 500;
color: var(--label-theme);
background: color-mix(in srgb, var(--label-theme) 10%, white);
border-radius: 0.25rem;
transition: color 0.3s ease, background-color 0.3s ease;
text-transform: capitalize;
&[data-mode='short'] {
height: 22px;
width: 22px;
}
&[data-mode='full'] {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
@media screen and (prefers-color-scheme: dark) {
background: color-mix(in srgb, var(--label-theme) 17%, #272727);
}
.adev-dark-mode & {
background: color-mix(in srgb, var(--label-theme) 17%, #272727);
}
.adev-light-mode & {
background: color-mix(in srgb, var(--label-theme) 10%, white);
}
&[data-type='undecorated_class'],
&[data-type='class'] {
--label-theme: var(--symbolic-purple);
}
&[data-type='constant'],
&[data-type='const'] {
--label-theme: var(--symbolic-gray);
}
&[data-type='decorator'] {
--label-theme: var(--symbolic-blue);
}
&[data-type='directive'] {
--label-theme: var(--symbolic-pink);
}
&[data-type='element'] {
--label-theme: var(--symbolic-orange);
}
&[data-type='enum'] {
--label-theme: var(--symbolic-yellow);
}
&[data-type='function'] {
--label-theme: var(--symbolic-green);
}
&[data-type='interface'] {
--label-theme: var(--symbolic-cyan);
}
&[data-type='pipe'] {
--label-theme: var(--symbolic-teal);
}
&[data-type='ng_module'] {
--label-theme: var(--symbolic-brown);
}
&[data-type='type_alias'] {
--label-theme: var(--symbolic-lime);
}
&[data-type='block'] {
--label-theme: var(--vivid-pink);
}
}
}

View file

@ -1,148 +0,0 @@
@mixin button() {
button {
font-family: var(--inter-font);
background: transparent;
-webkit-appearance: none;
border: 0;
font-weight: 600;
// Remove excess padding and border in Firefox 4+
&::-moz-focus-inner {
border: 0;
padding: 0;
}
&:disabled {
cursor: not-allowed;
}
}
@property --angle {
syntax: '<angle>';
initial-value: 90deg;
inherits: false;
}
@keyframes spin-gradient {
0% {
--angle: 90deg;
}
100% {
--angle: 450deg;
}
}
.adev-primary-btn {
cursor: pointer;
border: none;
outline: none;
position: relative;
border-radius: 0.25rem;
padding: 0.75rem 1.5rem;
width: max-content;
color: transparent;
// border gradient / background
--angle: 90deg;
background: linear-gradient(
var(--angle),
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
docs-icon {
z-index: var(--z-index-content);
position: relative;
}
// text & radial gradient
&::before {
content: attr(text);
position: absolute;
inset: 1px;
background: var(--page-bg-radial-gradient);
border-radius: 0.2rem;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.3s ease, background 0.3s ease;
color: var(--primary-contrast);
}
// solid color negative space - CSS transition supported
&::after {
content: attr(text);
position: absolute;
inset: 1px;
background: var(--page-background);
border-radius: 0.2rem;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.3s ease, background 0.3s ease;
color: var(--primary-contrast);
}
&:hover {
animation: spin-gradient 4s linear infinite forwards;
&::before {
background-color: var(--page-background);
background: var(--soft-pink-radial-gradient);
opacity: 0.9;
}
&::after {
opacity: 0;
}
}
&:active {
&::before {
opacity: 0.8;
}
}
&:disabled {
//gradient stroke
background: var(--quinary-contrast);
color: var(--quinary-contrast);
&::before {
background-color: var(--page-background);
background: var(--page-bg-radial-gradient);
opacity: 1;
}
docs-icon {
color: var(--quinary-contrast);
}
}
docs-icon {
z-index: var(--z-index-icon);
color: var(--primary-contrast);
}
}
.adev-secondary-btn {
border: 1px solid var(--senary-contrast);
background: var(--page-background);
padding: 0.75rem 1.5rem;
border-radius: 0.25rem;
color: var(--primary-contrast);
transition: background 0.3s ease;
docs-icon {
color: var(--quaternary-contrast);
transition: color 0.3s ease;
}
&:hover {
background: var(--septenary-contrast);
docs-icon {
color: var(--primary-contrast);
}
}
}
}

View file

@ -1,297 +0,0 @@
// Colors
// Using OKLCH color space for better color reproduction on P3 displays,
// as well as better human-readability
// --> https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch
@mixin root-definitions() {
// PRIMITIVES
// Colors
--bright-blue: oklch(51.01% 0.274 263.83); // #0546ff
--indigo-blue: oklch(51.64% 0.229 281.65); // #5c44e4
--electric-violet: oklch(53.18% 0.28 296.97); // #8514f5
--french-violet: oklch(47.66% 0.246 305.88); // #8001c6
--vivid-pink: oklch(69.02% 0.277 332.77); // #f637e3
--hot-pink: oklch(59.91% 0.239 8.14); // #e90464
--hot-red: oklch(61.42% 0.238 15.34); // #f11653
--orange-red: oklch(63.32% 0.24 31.68); // #fa2c04
--super-green: oklch(79.12% 0.257 155.13); // #00c572 // Used for success, merge additions, etc.
// subtle-purple is used for inline-code bg, docs-card hover bg & docs-code header bg
--subtle-purple: color-mix(in srgb, var(--bright-blue) 5%, white 10%);
--light-blue: color-mix(in srgb, var(--bright-blue), white 50%);
--light-violet: color-mix(in srgb, var(--electric-violet), white 65%);
--light-orange: color-mix(in srgb, var(--orange-red), white 50%);
--light-pink: color-mix(in srgb, var(--vivid-pink) 10%, white 80%);
// SYMBOLIC COLORS
// Used for Type Labels
--symbolic-purple: oklch(42.86% 0.29 266.4); //#1801ea
--symbolic-gray: oklch(66.98% 0 0); // #959595
--symbolic-blue: oklch(42.45% 0.223 263.38); // #0037c5;
--symbolic-pink: oklch(63.67% 0.254 13.47); // #ff025c
--symbolic-orange: oklch(64.73% 0.23769984683784018 33.18328352127882); // #fe3700
--symbolic-yellow: oklch(78.09% 0.163 65.69); // #fd9f28
--symbolic-green: oklch(67.83% 0.229 142.73); // #00b80a
--symbolic-cyan: oklch(67.05% 0.1205924489987394 181.34025902203868); // #00ad9a
--symbolic-magenta: oklch(51.74% 0.25453048882711515 315.26261625862725); // #9c00c8
--symbolic-teal: oklch(57.59% 0.083 230.58); // #3f82a1
--symbolic-brown: oklch(49.06% 0.128 46.41); // #994411
--symbolic-lime: oklch(70.33% 0.2078857836035299 135.66843631046476); // #5dba00
// Grays
--gray-1000: oklch(16.93% 0.004 285.95); // #0f0f11
--gray-900: oklch(19.37% 0.006 300.98); // #151417
--gray-800: oklch(25.16% 0.008 308.11); // #232125
--gray-700: oklch(36.98% 0.014 302.71); // #413e46
--gray-600: oklch(44% 0.019 306.08); // #55505b
--gray-500: oklch(54.84% 0.023 304.99); // #746e7c
--gray-400: oklch(70.9% 0.015 304.04); // #a39fa9
--gray-300: oklch(84.01% 0.009 308.34); // #ccc9cf
--gray-200: oklch(91.75% 0.004 301.42); // #e4e3e6
--gray-100: oklch(97.12% 0.002 325.59); // #f6f5f6
--gray-50: oklch(98.81% 0 0); // #fbfbfb
// GRADIENTS
--red-to-pink-horizontal-gradient: linear-gradient(
90deg,
var(--hot-pink) 11.42%,
var(--hot-red) 34.83%,
var(--vivid-pink) 60.69%
);
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
90deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--pink-to-highlight-to-purple-to-blue-horizontal-gradient: linear-gradient(
140deg,
var(--vivid-pink) 0%,
var(--vivid-pink) 15%,
color-mix(in srgb, var(--vivid-pink), var(--electric-violet) 50%) 25%,
color-mix(in srgb, var(--vivid-pink), var(--electric-violet) 10%) 35%,
color-mix(in srgb, var(--vivid-pink), var(--orange-red) 50%) 42%,
color-mix(in srgb, var(--vivid-pink), var(--orange-red) 50%) 44%,
color-mix(in srgb, var(--vivid-pink), var(--page-background) 70%) 47%,
var(--electric-violet) 48%,
var(--bright-blue) 60%
);
--purple-to-blue-horizontal-gradient: linear-gradient(
90deg,
var(--electric-violet) 0%,
var(--bright-blue) 100%
);
--purple-to-blue-vertical-gradient: linear-gradient(
0deg,
var(--electric-violet) 0%,
var(--bright-blue) 100%
);
--red-to-orange-horizontal-gradient: linear-gradient(
90deg,
var(--hot-pink) 0%,
var(--orange-red) 100%
);
--red-to-orange-vertical-gradient: linear-gradient(
0deg,
var(--hot-pink) 0%,
var(--orange-red) 100%
);
--pink-to-purple-horizontal-gradient: linear-gradient(
90deg,
var(--vivid-pink) 0%,
var(--electric-violet) 100%
);
--pink-to-purple-vertical-gradient: linear-gradient(
0deg,
var(--electric-violet) 0%,
var(--vivid-pink) 100%
);
--purple-to-light-purple-vertical-gradient: linear-gradient(
0deg,
var(--french-violet) 0%,
var(--light-violet) 100%
);
--green-to-cyan-vertical-gradient: linear-gradient(
0deg,
var(--symbolic-cyan) 0%,
var(--super-green) 100%
);
--blue-to-teal-vertical-gradient: linear-gradient(
0deg,
var(--bright-blue) 0%,
var(--light-blue) 100%
);
--blue-to-cyan-vertical-gradient: linear-gradient(
0deg,
var(--bright-blue) 0%,
var(--symbolic-cyan) 100%
);
--black-to-gray-vertical-gradient: linear-gradient(
0deg,
var(--primary-contrast) 0%,
var(--gray-400) 100%
);
--red-to-pink-vertical-gradient: linear-gradient(0deg, var(--hot-red) 0%, var(--vivid-pink) 100%);
--orange-to-pink-vertical-gradient: linear-gradient(
0deg,
var(--vivid-pink) 0%,
var(--light-orange) 100%
);
// Radial Gradients
--page-bg-radial-gradient: radial-gradient(circle, white 0%, white 100%);
--soft-pink-radial-gradient: radial-gradient(
circle at center bottom,
var(--light-pink) 0%,
white 80%
);
// ABSTRACTIONS light - dark
// --full-contrast: black - white
// --primary-constrast: gray-900 - gray-100
// --secondary-contrast: gray-800 - gray-300
// --tertiary-contrast: gray-700 - gray-300
// --quaternary-contrast: gray-500 - gray-400
// --quinary-contrast: gray-300 - gray-500
// --senary-contrast: gray-200 - gray-700
// --septenary-contrast: gray-100 - gray-800
// --octonary-contrast: gray-50 - gray-900
// --page-background white - gray-1000
// LIGHT MODE is default
// contrast - light mode
--full-contrast: black;
--primary-contrast: var(--gray-900);
--secondary-contrast: var(--gray-800);
--tertiary-contrast: var(--gray-700);
--quaternary-contrast: var(--gray-500);
--quinary-contrast: var(--gray-300);
--senary-contrast: var(--gray-200);
--septenary-contrast: var(--gray-100);
--octonary-contrast: var(--gray-50);
--page-background: white;
// Home page
// for the "unfilled" portion of the word that hasn't
// been highlighted by the gradient
--gray-unfilled: var(--gray-400);
// TODO: convert oklch to hex at build time
--webgl-page-background: #ffffff;
--webgl-gray-unfilled: #a39fa9;
}
@mixin dark-mode-definitions() {
// Contrasts
--full-contrast: white;
--primary-contrast: var(--gray-50);
--secondary-contrast: var(--gray-300);
--tertiary-contrast: var(--gray-300);
--quaternary-contrast: var(--gray-400);
--quinary-contrast: var(--gray-500);
--senary-contrast: var(--gray-700);
--septenary-contrast: var(--gray-800);
--octonary-contrast: var(--gray-900);
--page-background: var(--gray-1000);
--bright-blue: color-mix(in srgb, oklch(51.01% 0.274 263.83), var(--full-contrast) 60%);
--indigo-blue: color-mix(in srgb, oklch(51.64% 0.229 281.65), var(--full-contrast) 70%);
--electric-violet: color-mix(in srgb, oklch(53.18% 0.28 296.97), var(--full-contrast) 70%);
--french-violet: color-mix(in srgb, oklch(47.66% 0.246 305.88), var(--full-contrast) 70%);
--vivid-pink: color-mix(in srgb, oklch(69.02% 0.277 332.77), var(--full-contrast) 70%);
--hot-pink: color-mix(in srgb, oklch(59.91% 0.239 8.14), var(--full-contrast) 70%);
--hot-red: color-mix(in srgb, oklch(61.42% 0.238 15.34), var(--full-contrast) 70%);
--orange-red: color-mix(in srgb, oklch(63.32% 0.24 31.68), var(--full-contrast) 60%);
--super-green: color-mix(in srgb, oklch(79.12% 0.257 155.13), var(--full-contrast) 70%);
--light-pink: color-mix(in srgb, var(--vivid-pink) 5%, var(--page-background) 75%);
--symbolic-purple: color-mix(in srgb, oklch(42.86% 0.29 266.4), var(--full-contrast) 65%);
--symbolic-gray: color-mix(in srgb, oklch(66.98% 0 0), var(--full-contrast) 65%);
--symbolic-blue: color-mix(in srgb, oklch(42.45% 0.223 263.38), var(--full-contrast) 65%);
--symbolic-pink: color-mix(in srgb, oklch(63.67% 0.254 13.47), var(--full-contrast) 65%);
--symbolic-orange: color-mix(
in srgb,
oklch(64.73% 0.23769984683784018 33.18328352127882),
var(--full-contrast) 65%
);
--symbolic-yellow: color-mix(in srgb, oklch(78.09% 0.163 65.69), var(--full-contrast) 65%);
--symbolic-green: color-mix(in srgb, oklch(67.83% 0.229 142.73), var(--full-contrast) 65%);
--symbolic-cyan: color-mix(
in srgb,
oklch(67.05% 0.1205924489987394 181.34025902203868),
var(--full-contrast) 65%
);
--symbolic-magenta: color-mix(
in srgb,
oklch(51.74% 0.25453048882711515 315.26261625862725),
var(--full-contrast) 65%
);
--symbolic-teal: color-mix(in srgb, oklch(57.59% 0.083 230.58), var(--full-contrast) 65%);
--symbolic-brown: color-mix(in srgb, oklch(49.06% 0.128 46.41), var(--full-contrast) 65%);
--symbolic-lime: color-mix(
in srgb,
oklch(70.33% 0.2078857836035299 135.66843631046476),
var(--full-contrast) 65%
);
--page-bg-radial-gradient: radial-gradient(circle, black 0%, black 100%);
--soft-pink-radial-gradient: radial-gradient(
circle at center bottom,
var(--light-pink) 0%,
color-mix(in srgb, black, transparent 15%) 80%
);
// Home page - dark mode
--gray-unfilled: var(--gray-700);
// TODO: convert oklch to hex at build time
--webgl-page-background: #0f0f11;
--webgl-gray-unfilled: #413e46;
.adev-toggle {
input {
&:checked + .adev-slider {
background: var(--pink-to-purple-horizontal-gradient) !important;
}
}
}
}
@mixin mdc-definitions() {
--mdc-snackbar-container-shape: 0.25rem;
--mdc-snackbar-container-color: var(--page-background);
--mdc-snackbar-supporting-text-color: var(--primary-contrast);
}
// LIGHT MODE (Explicit)
.adev-light-mode {
background-color: #ffffff;
@include root-definitions();
@include mdc-definitions();
.adev-invert-mode {
@include dark-mode-definitions();
@include mdc-definitions();
}
}
// DARK MODE (Explicit)
.adev-dark-mode {
background-color: oklch(16.93% 0.004 285.95);
@include root-definitions();
@include dark-mode-definitions();
@include mdc-definitions();
.adev-invert-mode {
@include root-definitions();
@include mdc-definitions();
}
}

View file

@ -1,56 +0,0 @@
@mixin faceted-list() {
.adev-faceted-list {
--faceted-list-border-width: 2px;
list-style: none;
padding: 0;
margin: 0;
border-inline-start: calc(var(--faceted-list-border-width) - 1px) solid var(--senary-contrast);
}
.adev-faceted-list-item {
a,
button:not(.adev-expanded-button) {
position: relative;
background-color: var(--quaternary-contrast);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
transition: background-color 0.3s ease;
line-height: 1.1rem;
&::before {
content: '';
position: absolute;
top: 0;
left: calc(var(--faceted-list-border-width) * -1);
width: var(--faceted-list-border-width);
height: 100%;
background: var(--primary-contrast);
opacity: 0;
transform: scaleY(0.7);
transition: transform 0.3s ease, opacity 0.3s ease;
}
&:hover {
background-color: var(--primary-contrast);
&::before {
opacity: 0.3;
}
}
&.adev-faceted-list-item-active {
// font gradient
background-image: var(--pink-to-purple-vertical-gradient);
&::before {
opacity: 1;
transform: scaleY(1);
background: var(--pink-to-purple-vertical-gradient);
}
&:hover {
&::before {
opacity: 1;
transform: scaleY(1.1);
}
}
}
}
}
// a or button
}

View file

@ -1,28 +0,0 @@
@mixin kbd() {
kbd {
position: relative;
color: var(---tertiary-contrast);
border: 1px solid var(--quinary-contrast);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2), 0 0 0 2px var(--octonary-contrast) inset;
// NOTE: This line (in addition to others) prevents proper contrast checking in Lighthouse
text-shadow: 0 1px 0 var(--octonary-contrast);
border-radius: 3px;
display: inline-block;
font-family: sans-serif;
line-height: 1.5;
margin: 0 0.1em;
padding: 1px 0.4em;
min-width: 14px;
min-height: 20px;
vertical-align: middle;
text-align: center;
@media (prefers-reduced-motion: no-preference) {
&:hover {
box-shadow: 0 0.5px 0 rgba(0, 0, 0, 0.2), 0 0 0 2px var(--octonary-contrast) inset;
top: 1px;
}
}
}
}

View file

@ -1,9 +0,0 @@
@mixin external-link-with-icon() {
&::after {
display: inline-block;
content: '\e89e'; // codepoint for "open_in_new"
font-family: 'Material Symbols Outlined';
margin-left: 0.2rem;
vertical-align: middle;
}
}

View file

@ -1,64 +0,0 @@
$screen-xs: 700px;
$screen-sm: 775px;
$screen-md: 900px;
$screen-lg: 1200px;
$screen-xl: 1800px;
@mixin for-phone-only {
@media (max-width: $screen-xs) {
@content;
}
}
@mixin for-tablet-portrait-up {
@media (min-width: $screen-xs) {
@content;
}
}
@mixin for-tablet {
@media (min-width: $screen-xs) and (max-width: $screen-md) {
@content;
}
}
@mixin for-tablet-up {
@media (min-width: $screen-sm) {
@content;
}
}
@mixin for-tablet-landscape-up {
@media (min-width: $screen-md) {
@content;
}
}
@mixin for-desktop-up {
@media (min-width: $screen-lg) {
@content;
}
}
@mixin for-big-desktop-up {
@media (min-width: $screen-xl) {
@content;
}
}
@mixin for-desktop-down {
@media (max-width: $screen-lg) {
@content;
}
}
@mixin for-tablet-landscape-down {
@media (max-width: $screen-md) {
@content;
}
}
@mixin for-tablet-down {
@media (max-width: $screen-sm) {
@content;
}
}

View file

@ -1,478 +0,0 @@
@use './media-queries' as mq;
@mixin resets() {
:root {
--fallback-font-stack: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--page-width: 80ch;
--layout-padding: 3.12rem; // a common padding value throughout the layout
--primary-nav-width: 110px;
--secondary-nav-width: 16.25rem;
--fixed-content-height: calc(100vh - var(--layout-padding) * 2);
@include mq.for-tablet-landscape-down {
--layout-padding: 2rem;
}
@include mq.for-phone-only {
--layout-padding: 1rem;
}
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
// Define the default font for the document
font-family: var(--inter-font);
font-size: 16px;
background-color: var(--page-background);
color: var(--primary-contrast);
transition: color 0.3s ease, background-color 0.3s ease;
scroll-behavior: smooth;
}
@media (prefers-reduced-motion) {
html {
scroll-behavior: auto;
}
}
body {
margin: 0;
overflow-y: auto;
overflow-x: hidden;
}
html,
body {
// Ensures that these elements extend to the full height of the viewport
height: 100vh;
min-height: 100vh;
@supports (height: 100svh) {
height: 100svh;
}
}
button {
cursor: pointer;
}
img {
width: 100%;
border-radius: 0.25rem;
overflow: hidden;
margin: 1rem 0;
&[src$='#small'] {
max-width: 250px;
}
&[src$='#medium'] {
max-width: 450px;
}
}
abbr[title] {
text-decoration: none;
}
// Select & Input
.adev-form-element {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid var(--senary-contrast);
border-radius: 0.25rem;
padding: 0.5rem;
background-color: var(--page-background);
transition: color 0.3s ease, background-color 0.3s ease, border-color 0.3s ease;
docs-icon,
label {
color: var(--quinary-contrast);
transition: color 0.3s ease;
}
label {
font-size: 0.875rem;
}
select,
input {
width: 16rem;
-webkit-appearance: none;
display: flex;
flex: 1;
font-size: 0.875rem;
border: none;
outline: none;
height: 100%;
background-color: var(--page-background);
color: var(--tertiary-contrast);
transition: color 0.3s ease, background-color 0.3s ease;
}
select {
width: 10rem;
background-image: url('../icons/chevron.svg');
background-size: 0.7rem;
background-repeat: no-repeat;
background-position: right center;
margin-inline-end: 0.3rem;
}
&:focus-within {
border: 1px solid var(--french-violet);
docs-icon {
color: var(--tertiary-contrast);
}
}
}
// Progress bar styling
.ng-spinner {
display: none !important;
}
.ng-progress-bar {
.ng-bar {
background: var(--red-to-pink-to-purple-horizontal-gradient) !important;
}
}
// Material tab styling
.mat-mdc-tab-header {
--mat-tab-header-disabled-ripple-color: transparent;
--mat-tab-header-pagination-icon-color: var(--secondary-contrast);
--mat-tab-header-inactive-label-text-color: var(--secondary-contrast);
--mat-tab-header-inactive-ripple-color: transparent;
--mat-tab-header-inactive-hover-label-text-color: var(--tertiary-contrast);
--mat-tab-header-inactive-focus-label-text-color: var(--secondary-contrast);
--mat-tab-header-active-label-text-color: var(--primary-contrast);
--mat-tab-header-active-ripple-color: transparent;
--mdc-tab-indicator-active-indicator-color: color-mix(in srgb, var(--bright-blue) 40%, white);
--mat-tab-header-active-focus-label-text-color: var(--primary-contrast);
--mat-tab-header-active-hover-label-text-color: var(--primary-contrast);
--mat-tab-header-active-focus-indicator-color: var(--bright-blue);
--mat-tab-header-active-hover-indicator-color: color-mix(
in srgb,
var(--bright-blue) 40%,
white
);
.mdc-tab {
--mat-tab-header-label-text-font: Inter, sans-serif;
--mat-tab-header-label-text-letter-spacing: -0.00875rem;
--mat-tab-header-label-text-size: 0.875rem;
--mat-tab-header-label-text-weight: 500;
}
.mdc-tab__text-label {
user-select: none;
letter-spacing: -0.00875rem;
}
.mdc-tab--active {
--mat-tab-header-label-text-weight: 500;
}
}
// Material tab styling on Reference page
.adev-reference-tabs {
.mat-mdc-tab-labels {
gap: 20px;
border-bottom: 1px solid var(--senary-contrast);
transition: border-color 0.3s ease;
}
.mdc-tab__text-label {
letter-spacing: -0.00875rem;
}
.mdc-tab {
min-width: min-content !important;
padding-inline: 2px !important;
}
}
// Tabs on Tutorials page, Tutorials Playground
.adev-tutorial-editor,
.adev-code-editor-tabs,
.adev-editor-tabs {
.mat-mdc-tab-header {
--mdc-tab-indicator-active-indicator-color: transparent;
--mat-tab-header-active-focus-indicator-color: transparent;
--mat-tab-header-active-hover-indicator-color: transparent;
--mat-tab-header-label-text-font: InterTight, sans-serif;
--mat-tab-header-label-text-tracking: -0.00875rem;
--mat-tab-header-label-text-size: 0.8125rem;
.mat-mdc-tab-labels {
gap: 0;
transition: border-color 0.3s ease;
width: 100%;
}
.mdc-tab {
padding-inline: 18px !important;
}
.mdc-tab--active {
border: 0;
border-block-end-width: 2px;
border-style: solid;
border-image: var(--pink-to-purple-horizontal-gradient) 1;
&.cdk-keyboard-focused {
border-image: var(--blue-to-cyan-vertical-gradient) 1;
}
&:has(.adev-delete-file) {
padding-inline-start: 18px !important;
padding-inline-end: 0.5px !important;
}
}
}
}
.adev-editor-tabs {
.mat-mdc-tab-header {
border-block-end: 1px solid var(--senary-contrast);
}
}
.adev-code-editor-tabs {
.mat-mdc-tab-group {
// adjust width for + (add file) button
max-width: calc(100% - 40px);
}
}
.adev-editor-tabs,
.adev-code-editor-tabs {
.mat-mdc-tab-header {
background-color: var(--octonary-contrast);
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.mdc-tab__text-label {
i {
color: var(--bright-blue);
margin-inline-start: 0.5rem;
margin-inline-end: 0.25rem;
font-size: 1.25rem;
}
span {
color: var(--primary-contrast);
}
}
}
// Tabs inside Example Viewer Header
.docs-example-viewer-actions {
.mat-mdc-tab-labels {
width: 100%;
}
.mat-mdc-tab-header {
--mdc-tab-indicator-active-indicator-color: transparent;
--mat-tab-header-active-focus-indicator-color: transparent;
--mat-tab-header-active-hover-indicator-color: transparent;
--mat-tab-header-active-focus-label-text-color: transparent;
--mat-tab-header-active-hover-label-text-color: transparent;
--mat-tab-header-active-label-text-color: transparent;
--mat-tab-header-label-text-font: InterTight, sans-serif;
--mat-tab-header-label-text-letter-spacing: -0.00875rem;
--mat-tab-header-label-text-size: 0.8125rem;
.mat-mdc-tab-labels {
gap: 0;
border-bottom: 0;
}
.mdc-tab {
padding-inline: 15px !important;
}
.mdc-tab--active {
border: 0;
border-block-end-width: 2px;
border-style: solid;
border-image: var(--purple-to-blue-horizontal-gradient) 1;
span {
background-image: var(--purple-to-blue-horizontal-gradient);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
}
}
}
}
// TODO: Find better solution
// Temporary overrides for mat-tab in API Ref
.adev-app-main-content {
.mat-mdc-tab-body-wrapper,
.mat-mdc-tab-body-content,
.mat-mdc-tab-body {
display: contents;
}
}
.cm-editor,
.ͼ3 .cm-gutters,
.cm-scroller {
background-color: var(--page-background);
transition: background-color 0.3s ease;
font-size: 0.875rem;
}
.ͼ1.cm-focused {
outline: none;
}
.ͼ2u {
.cm-line.cm-activeLine,
.cm-activeLineGutter {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
transition: background-color 0.3s ease;
}
}
.ͼ1 .cm-button {
background-image: linear-gradient(var(--octonary-contrast), var(--page-background));
&:focus {
background-image: linear-gradient(var(--senary-contrast), var(--page-background));
}
}
.cm-scroller,
.xterm-viewport,
.xterm-screen {
&::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0);
cursor: pointer;
margin: 2px;
}
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: var(--senary-contrast);
border-radius: 10px;
transition: background-color 0.3s ease;
}
&::-webkit-scrollbar-thumb:hover {
background-color: var(--quaternary-contrast);
}
}
// Override terminal styles
.xterm {
height: 100%;
width: 100%;
}
.xterm-viewport {
overflow-y: auto !important;
height: 100% !important;
width: 100% !important;
background-color: var(--octonary-contrast) !important;
transition: background-color 0.3s ease;
}
.xterm-screen {
padding: 10px;
box-sizing: border-box;
overflow: visible !important;
height: 100% !important;
width: 100% !important;
}
.xterm-rows {
height: 100% !important;
overflow: visible !important;
color: var(--primary-contrast) !important;
transition: color 0.3s ease;
// It is important to not alter the font-size or the selection would lose in precision
.xterm-cursor {
&.xterm-cursor-outline {
outline-color: var(--primary-contrast) !important;
}
&.xterm-cursor-block {
background: var(--primary-contrast) !important;
}
}
}
.xterm-selection {
top: 10px !important;
left: 10px !important;
div {
background-color: transparent !important;
}
}
.xterm-decoration-top {
background-color: var(--quinary-contrast) !important;
}
.xterm-fg-11 {
color: var(--electric-violet) !important;
}
.xterm-fg-4 {
color: var(--bright-blue) !important;
}
// progress ###
.xterm-fg-15 {
color: var(--secondary-contrast) !important;
}
.xterm-fg-14 {
color: var(--vivid-pink) !important;
}
// > in terminal
.xterm-fg-5 {
color: var(--electric-violet) !important;
}
// error text, warning text
.xterm-fg-9,
.xterm-fg-3 {
color: var(--vivid-pink) !important;
}
.xterm-fg-10,
.xterm-fg-2 {
color: var(--symbolic-green) !important;
}
// error bg
.xterm-bg-1 {
background-color: var(--orange-red) !important;
}
// error text
.xterm-fg-257 {
color: var(--octonary-contrast) !important;
}
.xterm-fg-8 {
color: var(--tertiary-contrast) !important;
}
[docs-breadcrumb] {
height: 2.5625rem;
}

View file

@ -1,78 +0,0 @@
@mixin scroll-track {
// used on secondary nav
.adev-scroll-hide {
&::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0);
}
&::-webkit-scrollbar {
width: 0;
}
}
// used for main page scroll
.adev-scroll-track-transparent-large {
&::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0);
cursor: pointer;
}
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-thumb {
background-color: var(--quinary-contrast);
border-radius: 10px;
transition: background-color 0.3s ease;
}
&::-webkit-scrollbar-thumb:hover {
background-color: var(--quaternary-contrast);
}
}
// used on table & secondary navigation
.adev-scroll-track-transparent {
&::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0);
cursor: pointer;
}
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: var(--senary-contrast);
border-radius: 10px;
transition: background-color 0.3s ease;
}
&::-webkit-scrollbar-thumb:hover {
background-color: var(--quaternary-contrast);
}
}
// used on docs-code blocks
.adev-mini-scroll-track {
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: var(--senary-contrast);
border-radius: 10px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: var(--quinary-contrast);
}
}
}

Some files were not shown because too many files have changed in this diff Show more