Skip to content

Commit

Permalink
feat(material-angular-io): allow module imports to be copied from API…
Browse files Browse the repository at this point in the history
… tab (#30389)

adds icon button next to module import text allowing users to copy it directly

fixes #16127
  • Loading branch information
naaajii authored Feb 3, 2025
1 parent f9ca76c commit fe441bd
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {NgModule} from '@angular/core';
import {HeaderLink} from './header-link';
import {CodeSnippet} from '../example-viewer/code-snippet';
import {DeprecatedFieldComponent} from './deprecated-tooltip';
import {ModuleImportCopyButton} from './module-import-copy-button';

// ExampleViewer is included in the DocViewerModule because they have a circular dependency.
@NgModule({
Expand All @@ -25,7 +26,8 @@ import {DeprecatedFieldComponent} from './deprecated-tooltip';
HeaderLink,
CodeSnippet,
DeprecatedFieldComponent,
ModuleImportCopyButton,
],
exports: [DocViewer, ExampleViewer, HeaderLink, DeprecatedFieldComponent],
exports: [DocViewer, ExampleViewer, HeaderLink, DeprecatedFieldComponent, ModuleImportCopyButton],
})
export class DocViewerModule {}
36 changes: 36 additions & 0 deletions material.angular.io/src/app/shared/doc-viewer/doc-viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ import {DocViewer} from './doc-viewer';
import {DocViewerModule} from './doc-viewer-module';
import {ExampleViewer} from '../example-viewer/example-viewer';
import {MatTooltip} from '@angular/material/tooltip';
import {MatIconButton} from '@angular/material/button';
import {Clipboard} from '@angular/cdk/clipboard';

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

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [DocViewerModule, DocsAppTestingModule, DocViewerTestComponent],
providers: [{provide: Clipboard, useValue: clipboardSpy}],
}).compileComponents();
}));

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

it('should show copy icon button for module imports', () => {
const fixture = TestBed.createComponent(DocViewerTestComponent);
fixture.componentInstance.documentUrl = `http://material.angular.io/copy-module-import.html`;
fixture.detectChanges();

const url = fixture.componentInstance.documentUrl;
http.expectOne(url).flush(FAKE_DOCS[url]);

const docViewer = fixture.debugElement.query(By.directive(DocViewer));
expect(docViewer).not.toBeNull();

const iconButton = fixture.debugElement.query(By.directive(MatIconButton));
// icon button for copying module import should exist
expect(iconButton).toBeTruthy();

// click on icon button to trigger copying the module import
iconButton.nativeNode.dispatchEvent(new MouseEvent('click'));
fixture.detectChanges();
expect(clipboardSpy.copy).toHaveBeenCalled();
});

// TODO(mmalerba): Add test that example-viewer is instantiated.
});

Expand Down Expand Up @@ -221,6 +246,17 @@ const FAKE_DOCS: {[key: string]: string} = {
<div class="docs-api-deprecated-marker"
deprecated-message="deprecated">Deprecated</div>`,
'http://material.angular.io/copy-module-import.html': `<div class="docs-api-module">
<p class="docs-api-module-import">
<code>
import {MatIconModule} from '@angular/material/icon';
</code>
</p>
<div class="docs-api-module-import-button"
data-docs-api-module-import-button="import {MatIconModule} from '@angular/material/icon';">
</div>
</div>`,
/* eslint-enable @typescript-eslint/naming-convention */
};

Expand Down
35 changes: 35 additions & 0 deletions material.angular.io/src/app/shared/doc-viewer/doc-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {shareReplay, take, tap} from 'rxjs/operators';
import {ExampleViewer} from '../example-viewer/example-viewer';
import {HeaderLink} from './header-link';
import {DeprecatedFieldComponent} from './deprecated-tooltip';
import {ModuleImportCopyButton} from './module-import-copy-button';

@Injectable({providedIn: 'root'})
class DocFetcher {
Expand Down Expand Up @@ -150,6 +151,9 @@ export class DocViewer implements OnDestroy {
// Create tooltips for the deprecated fields
this._createTooltipsForDeprecated();

// Create icon buttons to copy module import
this._createCopyIconForModule();

// Resolving and creating components dynamically in Angular happens synchronously, but since
// we want to emit the output if the components are actually rendered completely, we wait
// until the Angular zone becomes stable.
Expand Down Expand Up @@ -235,4 +239,35 @@ export class DocViewer implements OnDestroy {
this._portalHosts.push(elementPortalOutlet);
});
}

_createCopyIconForModule() {
// every module import element will be marked with docs-api-module-import-button attribute
const moduleImportElements = this._elementRef.nativeElement.querySelectorAll(
'[data-docs-api-module-import-button]',
);

[...moduleImportElements].forEach((element: HTMLElement) => {
// get the module import path stored in the attribute
const moduleImport = element.getAttribute('data-docs-api-module-import-button');

const elementPortalOutlet = new DomPortalOutlet(
element,
this._componentFactoryResolver,
this._appRef,
this._injector,
);

const moduleImportPortal = new ComponentPortal(
ModuleImportCopyButton,
this._viewContainerRef,
);
const moduleImportOutlet = elementPortalOutlet.attach(moduleImportPortal);

if (moduleImport) {
moduleImportOutlet.instance.import = moduleImport;
}

this._portalHosts.push(elementPortalOutlet);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {Component, inject} from '@angular/core';
import {MatIconButton} from '@angular/material/button';
import {MatIcon} from '@angular/material/icon';
import {MatSnackBar} from '@angular/material/snack-bar';
import {MatTooltip} from '@angular/material/tooltip';
import {Clipboard} from '@angular/cdk/clipboard';

/**
* Shows up an icon button which will allow users to copy the module import
*/
@Component({
selector: 'module-import-copy-button',
template: `<button
mat-icon-button
matTooltip="Copy import to the clipboard"
(click)="copy()">
<mat-icon>content_copy</mat-icon>
</button>`,
standalone: true,
imports: [MatIconButton, MatIcon, MatTooltip],
})
export class ModuleImportCopyButton {
private clipboard = inject(Clipboard);
private snackbar = inject(MatSnackBar);
/** Import path for the module that will be copied */
import = '';

copy(): void {
const message = this.clipboard.copy(this.import)
? 'Copied module import'
: 'Failed to copy module import';
this.snackbar.open(message, undefined, {duration: 2500});
}
}
9 changes: 9 additions & 0 deletions material.angular.io/src/styles/_api.scss
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@
word-break: break-word;
}

.docs-api-module {
display: flex;
align-items: center;
}

.docs-api-module-import {
margin: 0px;
}

.docs-api-class-name,
.docs-api-module-import,
.docs-api-class-selector-name,
Expand Down
14 changes: 9 additions & 5 deletions tools/dgeni/templates/entry-point.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@ <h2>
</h2>

{%- if doc.primaryExportName -%}
<p class="docs-api-module-import">
<code>
import {{$ doc.primaryExportName $}} from '{$ doc.moduleImportPath $}';
</code>
</p>
<div class="docs-api-module">
<p class="docs-api-module-import">
<code>
import {{$ doc.primaryExportName $}} from '{$ doc.moduleImportPath $}';
</code>
</p>

<div class="docs-api-module-import-button" data-docs-api-module-import-button="import {{$ doc.primaryExportName $}} from '{$ doc.moduleImportPath $}';"></div>
</div>
{% else %}
<p>Import symbols from <code>{$ doc.moduleImportPath $}</code></p>
{%- endif -%}
Expand Down

0 comments on commit fe441bd

Please sign in to comment.