mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
Pawel Kozlowski
parent
c0d8be7fa1
commit
4e89bc6244
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user