Skip to content

Commit 18c184f

Browse files
committed
fix(material.angular.io): allow module imports to be copied from api tab
adds icon button next to module import text allowing users to copy fixes #16127
1 parent 8950ec3 commit 18c184f

File tree

6 files changed

+171
-45
lines changed

6 files changed

+171
-45
lines changed

material.angular.io/src/app/shared/doc-viewer/doc-viewer-module.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {NgModule} from '@angular/core';
1010
import {HeaderLink} from './header-link';
1111
import {CodeSnippet} from '../example-viewer/code-snippet';
1212
import {DeprecatedFieldComponent} from './deprecated-tooltip';
13-
13+
import {ModuleImportCopyButton} from './module-import-copy-button';
1414

1515
// ExampleViewer is included in the DocViewerModule because they have a circular dependency.
1616
@NgModule({
@@ -25,8 +25,9 @@ import {DeprecatedFieldComponent} from './deprecated-tooltip';
2525
ExampleViewer,
2626
HeaderLink,
2727
CodeSnippet,
28-
DeprecatedFieldComponent
28+
DeprecatedFieldComponent,
29+
ModuleImportCopyButton,
2930
],
30-
exports: [DocViewer, ExampleViewer, HeaderLink, DeprecatedFieldComponent]
31+
exports: [DocViewer, ExampleViewer, HeaderLink, DeprecatedFieldComponent, ModuleImportCopyButton],
3132
})
32-
export class DocViewerModule { }
33+
export class DocViewerModule {}

material.angular.io/src/app/shared/doc-viewer/doc-viewer.spec.ts

+40-5
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@ import {DocViewer} from './doc-viewer';
77
import {DocViewerModule} from './doc-viewer-module';
88
import {ExampleViewer} from '../example-viewer/example-viewer';
99
import {MatTooltip} from '@angular/material/tooltip';
10+
import {MatIconButton} from '@angular/material/button';
11+
import {Clipboard} from '@angular/cdk/clipboard';
1012

1113
describe('DocViewer', () => {
1214
let http: HttpTestingController;
15+
const clipboardSpy = jasmine.createSpyObj<Clipboard>('Clipboard', ['copy']);
1316

1417
beforeEach(waitForAsync(() => {
1518
TestBed.configureTestingModule({
1619
imports: [DocViewerModule, DocsAppTestingModule, DocViewerTestComponent],
20+
providers: [{provide: Clipboard, useValue: clipboardSpy}],
1721
}).compileComponents();
1822
}));
1923

@@ -142,10 +146,10 @@ describe('DocViewer', () => {
142146

143147
http.expectOne(errorUrl).flush('Not found', {status: 404, statusText: 'Not found'});
144148

145-
146149
expect(docViewer).not.toBeNull();
147150
expect(docViewer.nativeElement.innerHTML).toContain(
148-
'Failed to load document: http://material.angular.io/error-doc.html');
151+
'Failed to load document: http://material.angular.io/error-doc.html',
152+
);
149153
expect(console.error).toHaveBeenCalledTimes(1);
150154
});
151155

@@ -165,7 +169,7 @@ describe('DocViewer', () => {
165169
// and properties.
166170
expect(docViewer.children.length).toBe(5);
167171

168-
// it should have "Deprecated" as its inner text
172+
// it should have "Deprecated" as its inner text
169173
const deprecatedSymbol = docViewer.children.shift()!;
170174
expect(deprecatedSymbol.nativeElement.innerText).toBe('Deprecated');
171175

@@ -179,6 +183,27 @@ describe('DocViewer', () => {
179183
expect(deprecatedSymbol.query(By.directive(MatTooltip))).toBeTruthy();
180184
});
181185

186+
it('should show copy icon button for module imports', () => {
187+
const fixture = TestBed.createComponent(DocViewerTestComponent);
188+
fixture.componentInstance.documentUrl = `http://material.angular.io/copy-module-import.html`;
189+
fixture.detectChanges();
190+
191+
const url = fixture.componentInstance.documentUrl;
192+
http.expectOne(url).flush(FAKE_DOCS[url]);
193+
194+
const docViewer = fixture.debugElement.query(By.directive(DocViewer));
195+
expect(docViewer).not.toBeNull();
196+
197+
const iconButton = fixture.debugElement.query(By.directive(MatIconButton));
198+
// icon button for copying module import should exist
199+
expect(iconButton).toBeTruthy();
200+
201+
// click on icon button to trigger copying the module import
202+
iconButton.nativeNode.dispatchEvent(new MouseEvent('click'));
203+
fixture.detectChanges();
204+
expect(clipboardSpy.copy).toHaveBeenCalled();
205+
});
206+
182207
// TODO(mmalerba): Add test that example-viewer is instantiated.
183208
});
184209

@@ -207,8 +232,7 @@ const FAKE_DOCS: {[key: string]: string} = {
207232
'<div material-docs-example="demo-example"></div>',
208233
'http://material.angular.io/whole-snippet-example.html':
209234
'<div material-docs-example="whole-snippet-example" file="whole-snippet-example.ts"></div>',
210-
'http://material.angular.io/deprecated.html':
211-
`<div class="docs-api-class-deprecated-marker"
235+
'http://material.angular.io/deprecated.html': `<div class="docs-api-class-deprecated-marker"
212236
deprecated-message="deprecated class">Deprecated</div>
213237
214238
<div class="docs-api-constant-deprecated-marker"
@@ -222,6 +246,17 @@ const FAKE_DOCS: {[key: string]: string} = {
222246
223247
<div class="docs-api-deprecated-marker"
224248
deprecated-message="deprecated">Deprecated</div>`,
249+
'http://material.angular.io/copy-module-import.html': `<div class="docs-api-module">
250+
<p class="docs-api-module-import">
251+
<code>
252+
import {MatIconModule} from '@angular/material/icon';
253+
</code>
254+
</p>
255+
256+
<div class="docs-api-module-import-button"
257+
data-docs-api-module-import-button="import {MatIconModule} from '@angular/material/icon';">
258+
</div>
259+
</div>`,
225260
/* eslint-enable @typescript-eslint/naming-convention */
226261
};
227262

material.angular.io/src/app/shared/doc-viewer/doc-viewer.ts

+74-31
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
ComponentPortal,
44
DomPortalOutlet,
55
Portal,
6-
PortalModule
6+
PortalModule,
77
} from '@angular/cdk/portal';
88
import {HttpClient, HttpErrorResponse} from '@angular/common/http';
99
import {DomSanitizer} from '@angular/platform-browser';
@@ -21,13 +21,14 @@ import {
2121
Output,
2222
SecurityContext,
2323
ViewContainerRef,
24-
input
24+
input,
2525
} from '@angular/core';
2626
import {Observable, Subscription} from 'rxjs';
2727
import {shareReplay, take, tap} from 'rxjs/operators';
2828
import {ExampleViewer} from '../example-viewer/example-viewer';
2929
import {HeaderLink} from './header-link';
3030
import {DeprecatedFieldComponent} from './deprecated-tooltip';
31+
import {ModuleImportCopyButton} from './module-import-copy-button';
3132

3233
@Injectable({providedIn: 'root'})
3334
class DocFetcher {
@@ -41,7 +42,7 @@ class DocFetcher {
4142
}
4243

4344
const stream = this._http.get(url, {responseType: 'text'}).pipe(shareReplay(1));
44-
return stream.pipe(tap(() => this._cache[url] = stream));
45+
return stream.pipe(tap(() => (this._cache[url] = stream)));
4546
}
4647
}
4748

@@ -55,7 +56,7 @@ class DocFetcher {
5556
}
5657
`,
5758
standalone: true,
58-
imports: [PortalModule]
59+
imports: [PortalModule],
5960
})
6061
export class DocViewer implements OnDestroy {
6162
private _portalHosts: DomPortalOutlet[] = [];
@@ -86,10 +87,12 @@ export class DocViewer implements OnDestroy {
8687
/** The document text. It should not be HTML encoded. */
8788
textContent = '';
8889

89-
private static initExampleViewer(exampleViewerComponent: ExampleViewer,
90-
example: string,
91-
file: string | null,
92-
region: string | null) {
90+
private static initExampleViewer(
91+
exampleViewerComponent: ExampleViewer,
92+
example: string,
93+
file: string | null,
94+
region: string | null,
95+
) {
9396
exampleViewerComponent.example = example;
9497
if (file) {
9598
// if the html div has field `file` then it should be in compact view to show the code
@@ -106,25 +109,25 @@ export class DocViewer implements OnDestroy {
106109
// otherwise it is an embedded demo
107110
exampleViewerComponent.view = 'demo';
108111
}
109-
110112
}
111113

112-
constructor(private _appRef: ApplicationRef,
113-
private _componentFactoryResolver: ComponentFactoryResolver,
114-
public _elementRef: ElementRef,
115-
private _injector: Injector,
116-
private _viewContainerRef: ViewContainerRef,
117-
private _ngZone: NgZone,
118-
private _domSanitizer: DomSanitizer,
119-
private _docFetcher: DocFetcher) {
120-
}
114+
constructor(
115+
private _appRef: ApplicationRef,
116+
private _componentFactoryResolver: ComponentFactoryResolver,
117+
public _elementRef: ElementRef,
118+
private _injector: Injector,
119+
private _viewContainerRef: ViewContainerRef,
120+
private _ngZone: NgZone,
121+
private _domSanitizer: DomSanitizer,
122+
private _docFetcher: DocFetcher,
123+
) {}
121124

122125
/** Fetch a document by URL. */
123126
private _fetchDocument(url: string) {
124127
this._documentFetchSubscription?.unsubscribe();
125128
this._documentFetchSubscription = this._docFetcher.fetchDocument(url).subscribe(
126129
document => this.updateDocument(document),
127-
error => this.showError(url, error)
130+
error => this.showError(url, error),
128131
);
129132
}
130133

@@ -148,6 +151,9 @@ export class DocViewer implements OnDestroy {
148151
// Create tooltips for the deprecated fields
149152
this._createTooltipsForDeprecated();
150153

154+
// Create icon buttons to copy module import
155+
this._createCopyIconForModule();
156+
151157
// Resolving and creating components dynamically in Angular happens synchronously, but since
152158
// we want to emit the output if the components are actually rendered completely, we wait
153159
// until the Angular zone becomes stable.
@@ -159,21 +165,23 @@ export class DocViewer implements OnDestroy {
159165
/** Show an error that occurred when fetching a document. */
160166
private showError(url: string, error: HttpErrorResponse) {
161167
console.error(error);
162-
this._elementRef.nativeElement.innerText =
163-
`Failed to load document: ${url}. Error: ${error.statusText}`;
168+
this._elementRef.nativeElement.innerText = `Failed to load document: ${url}. Error: ${error.statusText}`;
164169
}
165170

166171
/** Instantiate a ExampleViewer for each example. */
167172
private _loadComponents(componentName: string, componentClass: any) {
168-
const exampleElements =
169-
this._elementRef.nativeElement.querySelectorAll(`[${componentName}]`);
173+
const exampleElements = this._elementRef.nativeElement.querySelectorAll(`[${componentName}]`);
170174

171175
[...exampleElements].forEach((element: Element) => {
172176
const example = element.getAttribute(componentName);
173177
const region = element.getAttribute('region');
174178
const file = element.getAttribute('file');
175179
const portalHost = new DomPortalOutlet(
176-
element, this._componentFactoryResolver, this._appRef, this._injector);
180+
element,
181+
this._componentFactoryResolver,
182+
this._appRef,
183+
this._injector,
184+
);
177185
const examplePortal = new ComponentPortal(componentClass, this._viewContainerRef);
178186
const exampleViewer = portalHost.attach(examplePortal);
179187
const exampleViewerComponent = exampleViewer.instance as ExampleViewer;
@@ -195,36 +203,71 @@ export class DocViewer implements OnDestroy {
195203
}
196204

197205
_createTooltipsForDeprecated() {
198-
// all of the deprecated symbols end with `deprecated-marker`
206+
// all of the deprecated symbols end with `deprecated-marker`
199207
// class name on their element.
200-
// for example:
201-
// <div class="docs-api-deprecated-marker">Deprecated</div>,
208+
// for example:
209+
// <div class="docs-api-deprecated-marker">Deprecated</div>,
202210
// these can vary for each deprecated symbols such for class, interface,
203211
// type alias, constants or properties:
204212
// .docs-api-class-interface-marker, docs-api-type-alias-deprecated-marker
205213
// .docs-api-constant-deprecated-marker, .some-more
206214
// so instead of manually writing each deprecated class, we just query
207215
// elements that ends with `deprecated-marker` in their class name.
208-
const deprecatedElements =
209-
this._elementRef.nativeElement.querySelectorAll(`[class$=deprecated-marker]`);
216+
const deprecatedElements = this._elementRef.nativeElement.querySelectorAll(
217+
`[class$=deprecated-marker]`,
218+
);
210219

211220
[...deprecatedElements].forEach((element: Element) => {
212221
// the deprecation message, it will include alternative to deprecated item
213222
// and breaking change if there is one included.
214223
const deprecationTitle = element.getAttribute('deprecated-message');
215224

216225
const elementPortalOutlet = new DomPortalOutlet(
217-
element, this._componentFactoryResolver, this._appRef, this._injector);
226+
element,
227+
this._componentFactoryResolver,
228+
this._appRef,
229+
this._injector,
230+
);
218231

219232
const tooltipPortal = new ComponentPortal(DeprecatedFieldComponent, this._viewContainerRef);
220233
const tooltipOutlet = elementPortalOutlet.attach(tooltipPortal);
221234

222-
223235
if (deprecationTitle) {
224236
tooltipOutlet.instance.message = deprecationTitle;
225237
}
226238

227239
this._portalHosts.push(elementPortalOutlet);
228240
});
229241
}
242+
243+
_createCopyIconForModule() {
244+
// every module import element will be marked with docs-api-module-import-button attribute
245+
const moduleImportElements = this._elementRef.nativeElement.querySelectorAll(
246+
'[data-docs-api-module-import-button]',
247+
);
248+
249+
[...moduleImportElements].forEach((element: HTMLElement) => {
250+
// get the module import path stored in the attribute
251+
const moduleImport = element.getAttribute('data-docs-api-module-import-button');
252+
253+
const elementPortalOutlet = new DomPortalOutlet(
254+
element,
255+
this._componentFactoryResolver,
256+
this._appRef,
257+
this._injector,
258+
);
259+
260+
const moduleImportPortal = new ComponentPortal(
261+
ModuleImportCopyButton,
262+
this._viewContainerRef,
263+
);
264+
const moduleImportOutlet = elementPortalOutlet.attach(moduleImportPortal);
265+
266+
if (moduleImport) {
267+
moduleImportOutlet.instance.import = moduleImport;
268+
}
269+
270+
this._portalHosts.push(elementPortalOutlet);
271+
});
272+
}
230273
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {Component, inject} from '@angular/core';
2+
import {MatIconButton} from '@angular/material/button';
3+
import {MatIcon} from '@angular/material/icon';
4+
import {MatSnackBar} from '@angular/material/snack-bar';
5+
import {MatTooltip} from '@angular/material/tooltip';
6+
import {Clipboard} from '@angular/cdk/clipboard';
7+
8+
/**
9+
* Shows up an icon button which will allow users to copy the module import
10+
*/
11+
@Component({
12+
selector: 'module-import-copy-button',
13+
template: `<button
14+
mat-icon-button
15+
matTooltip="Copy import to the clipboard"
16+
(click)="copy()">
17+
<mat-icon>content_copy</mat-icon>
18+
</button>`,
19+
standalone: true,
20+
imports: [MatIconButton, MatIcon, MatTooltip],
21+
})
22+
export class ModuleImportCopyButton {
23+
private clipboard = inject(Clipboard);
24+
private snackbar = inject(MatSnackBar);
25+
/** Import path for the module that will be copied */
26+
import = '';
27+
28+
copy(): void {
29+
const message = this.clipboard.copy(this.import)
30+
? 'Copied module import'
31+
: 'Failed to copy module import';
32+
this.snackbar.open(message, undefined, {duration: 2500});
33+
}
34+
}

material.angular.io/src/styles/_api.scss

+9
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@
2727
word-break: break-word;
2828
}
2929

30+
.docs-api-module {
31+
display: flex;
32+
align-items: center;
33+
}
34+
35+
.docs-api-module-import {
36+
margin: 0px;
37+
}
38+
3039
.docs-api-class-name,
3140
.docs-api-module-import,
3241
.docs-api-class-selector-name,

0 commit comments

Comments
 (0)