feat(docs-infra): Adds copy link to anchor functionality

Enables copying a direct link to any section by clicking its anchor. Also updates the aria-label to remove the code tag
This commit is contained in:
SkyZeroZx
2025-11-30 11:13:14 -05:00
committed by Pawel Kozlowski
parent c0d8be7fa1
commit 4e89bc6244
12 changed files with 158 additions and 39 deletions
@@ -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",
],
)
@@ -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: `<docs-icon>{{ showCopySuccess() ? 'check' : 'link' }}</docs-icon>`,
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<string>();
readonly label = input.required<string>();
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);
}
}
@@ -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__",
@@ -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",
@@ -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 {
@@ -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<DocViewer>;
@@ -70,6 +72,12 @@ describe('DocViewer', () => {
<h3>Heading h3</h3>
`;
const exampleContentWithDocsAnchor = `
<h2 id="test-section">
<a href="#test-section" class="docs-anchor" tabindex="-1" aria-label="Link to Test Section">Test Section</a>
</h2>
`;
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();
});
});
@@ -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<HTMLAnchorElement>('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();
@@ -41,10 +41,11 @@ export function formatHeading(
const customId = customIdRegex.exec(anchorLessText)?.[1];
const link = customId ?? getHeaderId(anchorLessText);
const label = anchorLessText.replace(/`(.*?)`/g, '<code>$1</code>').replace(customIdRegex, '');
const normalizedLabel = label.replace(/<\/?code>/g, '');
return `
<h${depth} id="${link}">
<a href="#${link}" class="docs-anchor" tabindex="-1" aria-label="Link to ${label}">${label}</a>
<a href="#${link}" class="docs-anchor" tabindex="-1" aria-label="Link to ${normalizedLabel}">${label}</a>
</h${depth}>
`;
}
@@ -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();
}
-18
View File
@@ -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;
}
}
}
-3
View File
@@ -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;
}
}