feat(aio): code copy button has tooltip and aria-label

Based on optional title passed in from parent element such as CodeExample or CodeTabs.
Darkens uncovered copy button slightly as recommended for a11y.
PR #16808
This commit is contained in:
Ward Bell 2017-05-15 18:03:56 -07:00 committed by Pete Bacon Darwin
parent 4ccb2269a5
commit a42322da0c
5 changed files with 54 additions and 18 deletions

View File

@ -18,7 +18,9 @@ import { getBoolFromAttribute } from 'app/shared/attribute-utils';
template: ` template: `
<header *ngIf="title">{{title}}</header> <header *ngIf="title">{{title}}</header>
<aio-code [ngClass]="classes" [code]="code" <aio-code [ngClass]="classes" [code]="code"
[language]="language" [linenums]="linenums" [path]="path" [region]="region" [hideCopy]="hideCopy"></aio-code> [language]="language" [linenums]="linenums"
[path]="path" [region]="region"
[hideCopy]="hideCopy" [title]="title"></aio-code>
` `
}) })
export class CodeExampleComponent implements OnInit { export class CodeExampleComponent implements OnInit {

View File

@ -27,7 +27,8 @@ export interface TabInfo {
<span class="{{tab.class}}">{{ tab.title }}</span> <span class="{{tab.class}}">{{ tab.title }}</span>
</ng-template> </ng-template>
<aio-code [code]="tab.code" [language]="tab.language" [linenums]="tab.linenums" <aio-code [code]="tab.code" [language]="tab.language" [linenums]="tab.linenums"
[path]="tab.path" [region]="tab.region" class="{{ tab.class }}"></aio-code> [path]="tab.path" [region]="tab.region" [title]="tab.title"
class="{{ tab.class }}"></aio-code>
</md-tab> </md-tab>
</md-tab-group> </md-tab-group>
` `

View File

@ -179,26 +179,45 @@ describe('CodeComponent', () => {
describe('copy button', () => { describe('copy button', () => {
function getButton() {
const btnDe = fixture.debugElement.query(By.css('button'));
return btnDe ? btnDe.nativeElement : null;
}
it('should be hidden if the `hideCopy` input is true', () => { it('should be hidden if the `hideCopy` input is true', () => {
hostComponent.hideCopy = true; hostComponent.hideCopy = true;
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.debugElement.query(By.css('button'))).toBe(null); expect(getButton()).toBe(null);
});
it('should have title', () => {
fixture.detectChanges();
expect(getButton().title).toBe('Copy code snippet');
});
it('should have no aria-label by default', () => {
fixture.detectChanges();
expect(getButton().getAttribute('aria-label')).toBe('');
});
it('should have aria-label explaining what is being copied when title passed in', () => {
hostComponent.title = 'a/b/c/foo.ts';
fixture.detectChanges();
expect(getButton().getAttribute('aria-label')).toContain(hostComponent.title);
}); });
it('should call copier service when clicked', () => { it('should call copier service when clicked', () => {
const copierService: CopierService = TestBed.get(CopierService); const copierService: CopierService = TestBed.get(CopierService);
const spy = spyOn(copierService, 'copyText'); const spy = spyOn(copierService, 'copyText');
const button = fixture.debugElement.query(By.css('button')).nativeElement as HTMLButtonElement;
expect(spy.calls.count()).toBe(0, 'before click'); expect(spy.calls.count()).toBe(0, 'before click');
button.click(); getButton().click();
expect(spy.calls.count()).toBe(1, 'after click'); expect(spy.calls.count()).toBe(1, 'after click');
}); });
it('should copy code text when clicked', () => { it('should copy code text when clicked', () => {
const copierService: CopierService = TestBed.get(CopierService); const copierService: CopierService = TestBed.get(CopierService);
const spy = spyOn(copierService, 'copyText'); const spy = spyOn(copierService, 'copyText');
const button = fixture.debugElement.query(By.css('button')).nativeElement as HTMLButtonElement; getButton().click();
button.click();
expect(spy.calls.argsFor(0)[0]).toEqual(oneLineCode, 'after click'); expect(spy.calls.argsFor(0)[0]).toEqual(oneLineCode, 'after click');
}); });
@ -207,8 +226,7 @@ describe('CodeComponent', () => {
const copierService: CopierService = TestBed.get(CopierService); const copierService: CopierService = TestBed.get(CopierService);
spyOn(snackBar, 'open'); spyOn(snackBar, 'open');
spyOn(copierService, 'copyText').and.returnValue(true); spyOn(copierService, 'copyText').and.returnValue(true);
const button = fixture.debugElement.query(By.css('button')).nativeElement as HTMLButtonElement; getButton().click();
button.click();
expect(snackBar.open).toHaveBeenCalledWith('Code Copied', '', { duration: 800 }); expect(snackBar.open).toHaveBeenCalledWith('Code Copied', '', { duration: 800 });
}); });
@ -217,8 +235,7 @@ describe('CodeComponent', () => {
const copierService: CopierService = TestBed.get(CopierService); const copierService: CopierService = TestBed.get(CopierService);
spyOn(snackBar, 'open'); spyOn(snackBar, 'open');
spyOn(copierService, 'copyText').and.returnValue(false); spyOn(copierService, 'copyText').and.returnValue(false);
const button = fixture.debugElement.query(By.css('button')).nativeElement as HTMLButtonElement; getButton().click();
button.click();
expect(snackBar.open).toHaveBeenCalledWith('Copy failed. Please try again!', '', { duration: 800 }); expect(snackBar.open).toHaveBeenCalledWith('Copy failed. Please try again!', '', { duration: 800 });
}); });
}); });
@ -230,16 +247,18 @@ describe('CodeComponent', () => {
selector: 'aio-host-comp', selector: 'aio-host-comp',
template: ` template: `
<aio-code md-no-ink [code]="code" [language]="language" <aio-code md-no-ink [code]="code" [language]="language"
[linenums]="linenums" [path]="path" [region]="region" [hideCopy]="hideCopy"></aio-code> [linenums]="linenums" [path]="path" [region]="region"
[hideCopy]="hideCopy" [title]="title"></aio-code>
` `
}) })
class HostComponent { class HostComponent {
code = oneLineCode; code = oneLineCode;
hideCopy: boolean;
language: string; language: string;
linenums: boolean | number | string; linenums: boolean | number | string;
path: string; path: string;
region: string; region: string;
hideCopy: boolean; title: string;
} }
class TestLogger { class TestLogger {

View File

@ -31,19 +31,32 @@ const defaultLineNumsCount = 10; // by default, show linenums over this number
selector: 'aio-code', selector: 'aio-code',
template: ` template: `
<pre class="prettyprint lang-{{language}}"> <pre class="prettyprint lang-{{language}}">
<button *ngIf="!hideCopy" class="material-icons copy-button" (click)="doCopy()">content_copy</button> <button *ngIf="!hideCopy" class="material-icons copy-button"
title="Copy code snippet"
[attr.aria-label]="ariaLabel"
(click)="doCopy()">
<span aria-hidden="true">content_copy</span>
</button>
<code class="animated fadeIn" #codeContainer></code> <code class="animated fadeIn" #codeContainer></code>
</pre> </pre>
` `
}) })
export class CodeComponent implements OnChanges { export class CodeComponent implements OnChanges {
ariaLabel = '';
/** /**
* The code to be formatted, this should already be HTML encoded * The code to be formatted, this should already be HTML encoded
*/ */
@Input() @Input()
code: string; code: string;
/**
* set to true if the copy button is not to be shown
*/
@Input()
hideCopy: boolean;
/** /**
* The language of the code to render * The language of the code to render
* (could be javascript, dart, typescript, etc) * (could be javascript, dart, typescript, etc)
@ -73,10 +86,10 @@ export class CodeComponent implements OnChanges {
region: string; region: string;
/** /**
* set to true if the copy button is not to be shown * title for this snippet (optional)
*/ */
@Input() @Input()
hideCopy: boolean; title: string;
/** /**
* The element in the template that will display the formatted code * The element in the template that will display the formatted code
@ -91,6 +104,7 @@ export class CodeComponent implements OnChanges {
ngOnChanges() { ngOnChanges() {
this.code = this.code && leftAlign(this.code); this.code = this.code && leftAlign(this.code);
this.ariaLabel = this.title ? `Copy code snippet from ${this.title}` : '';
if (!this.code) { if (!this.code) {
const src = this.path ? this.path + (this.region ? '#' + this.region : '') : ''; const src = this.path ? this.path + (this.region ? '#' + this.region : '') : '';

View File

@ -104,7 +104,7 @@ code ol {
top: 6px; top: 6px;
right: 8px; right: 8px;
color: $lightgray; color: $blue-grey-200;
background-color: transparent; background-color: transparent;
border: none; border: none;
cursor: pointer; cursor: pointer;
@ -239,4 +239,4 @@ code-tabs md-tab-group *.mat-ripple-element, code-tabs md-tab-group *.mat-tab-bo
.sidenav-content code a { .sidenav-content code a {
color: inherit; color: inherit;
font-size: inherit; font-size: inherit;
} }