diff --git a/adev/shared-docs/components/copy-link-anchor/BUILD.bazel b/adev/shared-docs/components/copy-link-anchor/BUILD.bazel
new file mode 100644
index 00000000000..26301f43551
--- /dev/null
+++ b/adev/shared-docs/components/copy-link-anchor/BUILD.bazel
@@ -0,0 +1,19 @@
+load("//adev/shared-docs:defaults.bzl", "ng_project")
+
+ng_project(
+ name = "copy-link-anchor",
+ srcs = [
+ "copy-link-anchor.component.ts",
+ ],
+ visibility = [
+ "//adev/shared-docs/components:__pkg__",
+ "//adev/shared-docs/components/viewers:__pkg__",
+ ],
+ deps = [
+ "//adev:node_modules/@angular/cdk",
+ "//adev:node_modules/@angular/common",
+ "//adev:node_modules/@angular/core",
+ "//adev:node_modules/@angular/material",
+ "//adev/shared-docs/components/icon",
+ ],
+)
diff --git a/adev/shared-docs/components/copy-link-anchor/copy-link-anchor.component.ts b/adev/shared-docs/components/copy-link-anchor/copy-link-anchor.component.ts
new file mode 100644
index 00000000000..d7ff132a3e3
--- /dev/null
+++ b/adev/shared-docs/components/copy-link-anchor/copy-link-anchor.component.ts
@@ -0,0 +1,66 @@
+/*!
+ * @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, input, signal} from '@angular/core';
+import {Clipboard} from '@angular/cdk/clipboard';
+import {MatTooltip} from '@angular/material/tooltip';
+import {IconComponent} from '../icon/icon.component';
+
+export const CONFIRMATION_DISPLAY_TIME_MS = 1000;
+
+@Component({
+ selector: 'docs-copy-link-button',
+ template: `{{ showCopySuccess() ? 'check' : 'link' }}`,
+ styles: `
+ :host {
+ cursor: pointer;
+ padding: 0;
+ margin-left: 8px;
+ display: inline-flex;
+ align-items: center;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ color: var(--quaternary-contrast);
+ vertical-align: middle;
+ }
+ :host(.docs-copy-link-success) {
+ color: var(--bright-blue);
+ }
+ `,
+ hostDirectives: [
+ {
+ directive: MatTooltip,
+ inputs: ['matTooltip'],
+ },
+ ],
+ host: {
+ '[ariaLabel]': '"Copy link to " + label()',
+ '(click)': 'copyLink()',
+ 'matTooltipPosition': 'above',
+ '[class.docs-copy-link-success]': 'showCopySuccess()',
+ },
+ imports: [IconComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CopyLinkButton {
+ readonly href = input.required();
+ readonly label = input.required();
+
+ protected readonly showCopySuccess = signal(false);
+
+ private readonly clipboard = inject(Clipboard);
+
+ copyLink(): void {
+ const link = `${window.location.origin}${this.href()}`;
+ this.clipboard.copy(link);
+ this.showCopySuccess.set(true);
+ setTimeout(() => {
+ this.showCopySuccess.set(false);
+ }, CONFIRMATION_DISPLAY_TIME_MS);
+ }
+}
diff --git a/adev/shared-docs/components/icon/BUILD.bazel b/adev/shared-docs/components/icon/BUILD.bazel
index 1595f9efb34..1fd0ff5a44c 100644
--- a/adev/shared-docs/components/icon/BUILD.bazel
+++ b/adev/shared-docs/components/icon/BUILD.bazel
@@ -12,6 +12,7 @@ ng_project(
],
visibility = [
"//adev/shared-docs/components:__pkg__",
+ "//adev/shared-docs/components/copy-link-anchor:__pkg__",
"//adev/shared-docs/components/copy-source-code-button:__pkg__",
"//adev/shared-docs/components/navigation-list:__pkg__",
"//adev/shared-docs/components/table-of-contents:__pkg__",
diff --git a/adev/shared-docs/components/viewers/BUILD.bazel b/adev/shared-docs/components/viewers/BUILD.bazel
index a5f8552943f..4bebf424338 100644
--- a/adev/shared-docs/components/viewers/BUILD.bazel
+++ b/adev/shared-docs/components/viewers/BUILD.bazel
@@ -25,6 +25,7 @@ ng_project(
"//adev:node_modules/@angular/router",
"//adev:node_modules/rxjs",
"//adev/shared-docs/components/breadcrumb",
+ "//adev/shared-docs/components/copy-link-anchor",
"//adev/shared-docs/components/copy-source-code-button",
"//adev/shared-docs/components/icon",
"//adev/shared-docs/components/tab-group",
@@ -63,6 +64,7 @@ ts_project(
"//adev:node_modules/@angular/platform-browser",
"//adev:node_modules/@angular/router",
"//adev/shared-docs/components/breadcrumb",
+ "//adev/shared-docs/components/copy-link-anchor",
"//adev/shared-docs/components/copy-source-code-button",
"//adev/shared-docs/components/icon",
"//adev/shared-docs/components/table-of-contents",
diff --git a/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.scss b/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.scss
index bb53477e6b4..9f68dda9912 100644
--- a/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.scss
+++ b/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.scss
@@ -1,5 +1,4 @@
@use '../../../styles/links' as links;
-@use '../../../styles/anchor' as anchor;
@use '../../../styles/media-queries' as mq;
:host {
@@ -56,8 +55,6 @@
color: inherit;
max-width: 100%;
- @include anchor.docs-anchor();
-
code {
// This can be useful on mobile if the heading contains a long code block
overflow: hidden;
@@ -65,6 +62,11 @@
vertical-align: middle;
}
}
+
+ // Show copy link button when hovering the heading
+ &:hover docs-copy-link-button {
+ opacity: 1;
+ }
}
h1 {
diff --git a/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.spec.ts b/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.spec.ts
index b6be1fe5cd2..7854a0f4987 100644
--- a/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.spec.ts
+++ b/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.spec.ts
@@ -17,8 +17,10 @@ import {IconComponent} from '../../icon/icon.component';
import {Breadcrumb} from '../../breadcrumb/breadcrumb.component';
import {NavigationState} from '../../../services';
import {CopySourceCodeButton} from '../../copy-source-code-button/copy-source-code-button.component';
+import {CopyLinkButton} from '../../copy-link-anchor/copy-link-anchor.component';
import {TableOfContents} from '../../table-of-contents/table-of-contents.component';
import {provideZonelessChangeDetection} from '@angular/core';
+import {Clipboard} from '@angular/cdk/clipboard';
describe('DocViewer', () => {
let fixture: ComponentFixture;
@@ -70,6 +72,12 @@ describe('DocViewer', () => {
Heading h3
`;
+ const exampleContentWithDocsAnchor = `
+
+ `;
+
beforeEach(() => {
exampleContentSpy = jasmine.createSpyObj('ExampleViewerContentLoader', ['getCodeExampleData']);
navigationStateSpy = jasmine.createSpyObj(NavigationState, ['activeNavigationItem']);
@@ -87,13 +95,12 @@ describe('DocViewer', () => {
});
fixture = TestBed.createComponent(DocViewer);
- fixture.detectChanges();
});
- it('should load doc into innerHTML', () => {
+ it('should load doc into innerHTML', async () => {
const fixture = TestBed.createComponent(DocViewer);
fixture.componentRef.setInput('docContent', 'hello world');
- fixture.detectChanges();
+ await fixture.whenStable();
expect(fixture.nativeElement.innerHTML).toBe('hello world');
});
@@ -101,7 +108,6 @@ describe('DocViewer', () => {
it('should instantiate example viewer with only a single file', async () => {
const fixture = TestBed.createComponent(DocViewer);
fixture.componentRef.setInput('docContent', exampleDocContentWithExampleViewerPlaceholders);
- fixture.detectChanges();
await fixture.whenStable();
const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer));
@@ -125,7 +131,6 @@ describe('DocViewer', () => {
'docContent',
exampleDocContentWithExpandedExampleViewerPlaceholders,
);
- fixture.detectChanges();
await fixture.whenStable();
const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer));
@@ -139,7 +144,6 @@ describe('DocViewer', () => {
const renderComponentSpy = spyOn(fixture.componentInstance, 'renderComponent' as any);
fixture.componentRef.setInput('docContent', exampleContentWithIcons);
- fixture.detectChanges();
await fixture.whenStable();
expect(renderComponentSpy).toHaveBeenCalledTimes(2);
@@ -165,7 +169,6 @@ describe('DocViewer', () => {
const renderComponentSpy = spyOn(fixture.componentInstance, 'renderComponent' as any);
fixture.componentRef.setInput('docContent', exampleContentWithBreadcrumbPlaceholder);
- fixture.detectChanges();
await fixture.whenStable();
expect(renderComponentSpy).toHaveBeenCalledTimes(1);
@@ -176,7 +179,6 @@ describe('DocViewer', () => {
const fixture = TestBed.createComponent(DocViewer);
fixture.componentRef.setInput('docContent', exampleContentWithCodeSnippet);
- fixture.detectChanges();
await fixture.whenStable();
const copySourceCodeButton = fixture.debugElement.query(By.directive(CopySourceCodeButton));
@@ -190,7 +192,6 @@ describe('DocViewer', () => {
fixture.componentRef.setInput('docContent', exampleContentWithHeadings);
fixture.componentRef.setInput('hasToc', true);
- fixture.detectChanges();
await fixture.whenStable();
expect(renderComponentSpy).toHaveBeenCalled();
@@ -203,9 +204,39 @@ describe('DocViewer', () => {
fixture.componentRef.setInput('docContent', exampleContentWithHeadings);
fixture.componentRef.setInput('hasToc', false);
- fixture.detectChanges();
await fixture.whenStable();
expect(renderComponentSpy).not.toHaveBeenCalled();
});
+
+ it('should setup copy link functionality for docs-anchor elements', async () => {
+ const fixture = TestBed.createComponent(DocViewer);
+ fixture.componentRef.setInput('docContent', exampleContentWithDocsAnchor);
+
+ await fixture.whenStable();
+
+ const copyLinkButton = fixture.debugElement.query(By.directive(CopyLinkButton));
+ expect(copyLinkButton).toBeTruthy();
+
+ const anchor = fixture.nativeElement.querySelector('a.docs-anchor');
+ expect(anchor).toBeTruthy();
+
+ const copyButton = fixture.nativeElement.querySelector('docs-copy-link-button');
+ expect(copyButton).toBeTruthy();
+ });
+
+ it('should copy link to clipboard when copy button is clicked', async () => {
+ const clipboard = TestBed.inject(Clipboard);
+ const clipboardSpy = spyOn(clipboard, 'copy').and.returnValue(true);
+
+ const fixture = TestBed.createComponent(DocViewer);
+ fixture.componentRef.setInput('docContent', exampleContentWithDocsAnchor);
+
+ await fixture.whenStable();
+
+ const copyButton = fixture.nativeElement.querySelector('docs-copy-link-button');
+ copyButton.click();
+
+ expect(clipboardSpy).toHaveBeenCalled();
+ });
});
diff --git a/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.ts b/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.ts
index df0b073034a..93e95092541 100644
--- a/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.ts
+++ b/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.ts
@@ -39,6 +39,7 @@ import {TableOfContents} from '../../table-of-contents/table-of-contents.compone
import {DomSanitizer} from '@angular/platform-browser';
import {Breadcrumb} from '../../breadcrumb/breadcrumb.component';
import {CopySourceCodeButton} from '../../copy-source-code-button/copy-source-code-button.component';
+import {CopyLinkButton} from '../../copy-link-anchor/copy-link-anchor.component';
import {ExampleViewer} from '../example-viewer/example-viewer.component';
import {TabGroup} from '../../tab-group/tab-group.component';
@@ -120,6 +121,8 @@ export class DocViewer {
// In case when content contains static code snippets, then create buttons
// responsible for copy source code.
this.loadCopySourceCodeButtons();
+ // Setup copy link functionality for section anchor links
+ this.loadCopyLinkAnchors(contentContainer);
// In case when content contains tabs, create tabs component and move
// content in a tab into tab panel.
this.constructTabs(contentContainer);
@@ -276,6 +279,22 @@ export class DocViewer {
}
}
+ private loadCopyLinkAnchors(element: HTMLElement): void {
+ const docsAnchors = Array.from(element.querySelectorAll('a.docs-anchor'));
+
+ for (const anchor of docsAnchors) {
+ const href = anchor.getAttribute('href')!;
+ const label = anchor.textContent!;
+
+ const copyLinkButtonRef = this.viewContainer.createComponent(CopyLinkButton);
+ copyLinkButtonRef.setInput('href', href);
+ copyLinkButtonRef.setInput('label', label);
+ copyLinkButtonRef.setInput('matTooltip', `Copy link to ${label}`);
+
+ anchor.appendChild(copyLinkButtonRef.location.nativeElement);
+ }
+ }
+
private loadBreadcrumb(element: HTMLElement): void {
const breadcrumbPlaceholder = element.querySelector('docs-breadcrumb') as HTMLElement;
const activeNavigationItem = this.navigationState.activeNavigationItem();
diff --git a/adev/shared-docs/pipeline/shared/marked/transformations/heading.mts b/adev/shared-docs/pipeline/shared/marked/transformations/heading.mts
index e8655b0cca1..f79428d7bdd 100644
--- a/adev/shared-docs/pipeline/shared/marked/transformations/heading.mts
+++ b/adev/shared-docs/pipeline/shared/marked/transformations/heading.mts
@@ -41,10 +41,11 @@ export function formatHeading(
const customId = customIdRegex.exec(anchorLessText)?.[1];
const link = customId ?? getHeaderId(anchorLessText);
const label = anchorLessText.replace(/`(.*?)`/g, '$1').replace(customIdRegex, '');
+ const normalizedLabel = label.replace(/<\/?code>/g, '');
return `
- ${label}
+ ${label}
`;
}
diff --git a/adev/shared-docs/services/table-of-contents-loader.service.ts b/adev/shared-docs/services/table-of-contents-loader.service.ts
index de84933cd79..a67c8a2fd49 100644
--- a/adev/shared-docs/services/table-of-contents-loader.service.ts
+++ b/adev/shared-docs/services/table-of-contents-loader.service.ts
@@ -44,7 +44,10 @@ export class TableOfContentsLoader {
private getHeadingTitle(heading: HTMLHeadingElement): string {
const div: HTMLDivElement = this.document.createElement('div');
+
div.innerHTML = heading.innerHTML;
+ // Remove any existing copy link buttons to avoid including their text in the title
+ div.querySelectorAll('docs-copy-link-button').forEach((el) => el.remove());
return (div.textContent || '').trim();
}
diff --git a/adev/shared-docs/styles/_anchor.scss b/adev/shared-docs/styles/_anchor.scss
deleted file mode 100644
index 20985353189..00000000000
--- a/adev/shared-docs/styles/_anchor.scss
+++ /dev/null
@@ -1,18 +0,0 @@
-@mixin docs-anchor() {
- &::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;
- }
- }
-}
diff --git a/adev/shared-docs/styles/_reference.scss b/adev/shared-docs/styles/_reference.scss
index 2f0ce55c6e4..1582508caaf 100644
--- a/adev/shared-docs/styles/_reference.scss
+++ b/adev/shared-docs/styles/_reference.scss
@@ -1,5 +1,3 @@
-@use './anchor' as anchor;
-
/* Common styles for the API & CLI references */
@mixin reference-common() {
.docs-code {
@@ -71,7 +69,6 @@
}
& > a {
- @include anchor.docs-anchor();
color: inherit;
}
}
diff --git a/adev/src/app/features/references/api-items-section/api-items-section.component.scss b/adev/src/app/features/references/api-items-section/api-items-section.component.scss
index ee36fae6ccf..ffed34e76aa 100644
--- a/adev/src/app/features/references/api-items-section/api-items-section.component.scss
+++ b/adev/src/app/features/references/api-items-section/api-items-section.component.scss
@@ -1,5 +1,3 @@
-@use '@angular/docs/styles/anchor' as anchor;
-
.adev-api-items-section-header {
display: flex;
@@ -92,6 +90,4 @@
.adev-api-anchor {
color: inherit;
-
- @include anchor.docs-anchor();
}