diff --git a/src/components/icon/fake-svgs.ts b/src/components/icon/fake-svgs.ts new file mode 100644 index 000000000000..791996498b63 --- /dev/null +++ b/src/components/icon/fake-svgs.ts @@ -0,0 +1,58 @@ +import { + Response, + ResponseOptions} from 'angular2/http'; + +/** + * Fake URLs and associated SVG documents used by tests. + */ +const FAKE_SVGS = (() => { + const svgs = new Map(); + svgs.set('cat.svg', + ''); + + svgs.set('dog.svg', + ''); + + svgs.set('farm-set-1.svg', ` + + + + + + + `); + + svgs.set('farm-set-2.svg', ` + + + + + + + `); + + svgs.set('arrow-set.svg', ` + + + + + + + `); + + return svgs; +})(); + +/** + * Returns an HTTP response for a fake SVG URL. + */ +export function getFakeSvgHttpResponse(url: string) { + if (FAKE_SVGS.has(url)) { + return new Response(new ResponseOptions({ + status: 200, + body: FAKE_SVGS.get(url), + })); + } else { + return new Response(new ResponseOptions({status: 404})); + } +} diff --git a/src/components/icon/icon-registry.ts b/src/components/icon/icon-registry.ts new file mode 100644 index 000000000000..d9246074b4ab --- /dev/null +++ b/src/components/icon/icon-registry.ts @@ -0,0 +1,361 @@ +import {Injectable} from 'angular2/core'; +import {BaseException} from 'angular2/src/facade/exceptions'; +import {Http} from 'angular2/http'; +import {Observable} from 'rxjs/Rx'; + + +/** Exception thrown when attempting to load an icon with a name that cannot be found. */ +export class MdIconNameNotFoundException extends BaseException { + constructor(iconName: string) { + super(`Unable to find icon with the name "${iconName}"`); + } +} + +/** + * Exception thrown when attempting to load SVG content that does not contain the expected + * tag. + */ +export class MdIconSvgTagNotFoundException extends BaseException { + constructor() { + super(' tag not found'); + } +} + +/** + * Configuration for an icon, including the URL and possibly the cached SVG element. + * @internal + */ +class SvgIconConfig { + svgElement: SVGElement = null; + constructor(public url: string) { + } +} + +/** Returns the cache key to use for an icon namespace and name. */ +const iconKey = (namespace: string, name: string) => namespace + ':' + name; + +/** + * Service to register and display icons used by the component. + * - Registers icon URLs by namespace and name. + * - Registers icon set URLs by namespace. + * - Registers aliases for CSS classes, for use with icon fonts. + * - Loads icons from URLs and extracts individual icons from icon sets. + */ +@Injectable() +export class MdIconRegistry { + /** + * URLs and cached SVG elements for individual icons. Keys are of the format "[namespace]:[icon]". + */ + private _svgIconConfigs = new Map(); + + /** + * SvgIconConfig objects and cached SVG elements for icon sets, keyed by namespace. + * Multiple icon sets can be registered under the same namespace. + */ + private _iconSetConfigs = new Map(); + + /** Cache for icons loaded by direct URLs. */ + private _cachedIconsByUrl = new Map(); + + /** In-progress icon fetches. Used to coalesce multiple requests to the same URL. */ + private _inProgressUrlFetches = new Map>(); + + /** Map from font identifiers to their CSS class names. Used for icon fonts. */ + private _fontCssClassesByAlias = new Map(); + + /** + * The CSS class to apply when an component has no icon name, url, or font specified. + * The default 'material-icons' value assumes that the material icon font has been loaded as + * described at http://google.github.io/material-design-icons/#icon-font-for-the-web + */ + private _defaultFontSetClass = 'material-icons'; + + constructor(private _http: Http) {} + + /** Registers an icon by URL in the default namespace. */ + addSvgIcon(iconName: string, url: string): this { + return this.addSvgIconInNamespace('', iconName, url); + } + + /** Registers an icon by URL in the specified namespace. */ + addSvgIconInNamespace(namespace: string, iconName: string, url: string): this { + const key = iconKey(namespace, iconName); + this._svgIconConfigs.set(key, new SvgIconConfig(url)); + return this; + } + + /** Registers an icon set by URL in the default namespace. */ + addSvgIconSet(url: string): this { + return this.addSvgIconSetInNamespace('', url); + } + + /** Registers an icon set by URL in the specified namespace. */ + addSvgIconSetInNamespace(namespace: string, url: string): this { + const config = new SvgIconConfig(url); + if (this._iconSetConfigs.has(namespace)) { + this._iconSetConfigs.get(namespace).push(config); + } else { + this._iconSetConfigs.set(namespace, [config]); + } + return this; + } + + /** + * Defines an alias for a CSS class name to be used for icon fonts. Creating an mdIcon + * component with the alias as the fontSet input will cause the class name to be applied + * to the element. + */ + registerFontClassAlias(alias: string, className = alias): this { + this._fontCssClassesByAlias.set(alias, className); + return this; + } + + /** + * Returns the CSS class name associated with the alias by a previous call to + * registerFontClassAlias. If no CSS class has been associated, returns the alias unmodified. + */ + classNameForFontAlias(alias: string): string { + return this._fontCssClassesByAlias.get(alias) || alias; + } + + /** + * Sets the CSS class name to be used for icon fonts when an component does not + * have a fontSet input value, and is not loading an icon by name or URL. + */ + setDefaultFontSetClass(className: string): this { + this._defaultFontSetClass = className; + return this; + } + + /** + * Returns the CSS class name to be used for icon fonts when an component does not + * have a fontSet input value, and is not loading an icon by name or URL. + */ + getDefaultFontSetClass(): string { + return this._defaultFontSetClass; + } + + /** + * Returns an Observable that produces the icon (as an DOM element) from the given URL. + * The response from the URL may be cached so this will not always cause an HTTP request, but + * the produced element will always be a new copy of the originally fetched icon. (That is, + * it will not contain any modifications made to elements previously returned). + */ + getSvgIconFromUrl(url: string): Observable { + if (this._cachedIconsByUrl.has(url)) { + return Observable.of(this._cachedIconsByUrl.get(url).cloneNode(true)); + } + return this._loadSvgIconFromConfig(new SvgIconConfig(url)) + .do(svg => this._cachedIconsByUrl.set(url, svg)) + .map(svg => svg.cloneNode(true)); + } + + /** + * Returns an Observable that produces the icon (as an DOM element) with the given name + * and namespace. The icon must have been previously registered with addIcon or addIconSet; + * if not, the Observable will throw an MdIconNameNotFoundException. + */ + getNamedSvgIcon(name: string, namespace = ''): Observable { + // Return (copy of) cached icon if possible. + const key = iconKey(namespace, name); + if (this._svgIconConfigs.has(key)) { + return this._getSvgFromConfig(this._svgIconConfigs.get(key)); + } + // See if we have any icon sets registered for the namespace. + const iconSetConfigs = this._iconSetConfigs.get(namespace); + if (iconSetConfigs) { + return this._getSvgFromIconSetConfigs(name, iconSetConfigs); + } + return Observable.throw(new MdIconNameNotFoundException(key)); + } + + /** + * Returns the cached icon for a SvgIconConfig if available, or fetches it from its URL if not. + */ + private _getSvgFromConfig(config: SvgIconConfig): Observable { + if (config.svgElement) { + // We already have the SVG element for this icon, return a copy. + return Observable.of(config.svgElement.cloneNode(true)); + } else { + // Fetch the icon from the config's URL, cache it, and return a copy. + return this._loadSvgIconFromConfig(config) + .do(svg => config.svgElement = svg) + .map(svg => svg.cloneNode(true)); + } + } + + /** + * Attempts to find an icon with the specified name in any of the SVG icon sets. + * First searches the available cached icons for a nested element with a matching name, and + * if found copies the element to a new element. If not found, fetches all icon sets + * that have not been cached, and searches again after all fetches are completed. + * The returned Observable produces the SVG element if possible, and throws + * MdIconNameNotFoundException if no icon with the specified name can be found. + */ + private _getSvgFromIconSetConfigs(name: string, iconSetConfigs: SvgIconConfig[]): + Observable { + // For all the icon set SVG elements we've fetched, see if any contain an icon with the + // requested name. + const namedIcon = this._extractIconWithNameFromAnySet(name, iconSetConfigs); + if (namedIcon) { + // We could cache namedIcon in _svgIconConfigs, but since we have to make a copy every + // time anyway, there's probably not much advantage compared to just always extracting + // it from the icon set. + return Observable.of(namedIcon); + } + // Not found in any cached icon sets. If there are icon sets with URLs that we haven't + // fetched, fetch them now and look for iconName in the results. + const iconSetFetchRequests: Observable[] = iconSetConfigs + .filter(iconSetConfig => !iconSetConfig.svgElement) + .map(iconSetConfig => + this._loadSvgIconSetFromConfig(iconSetConfig) + .catch((err: any, source: any, caught: any): Observable => { + // Swallow errors fetching individual URLs so the combined Observable won't + // necessarily fail. + console.log(`Loading icon set URL: ${iconSetConfig.url} failed: ${err}`); + return Observable.of(null); + }) + .do(svg => { + // Cache SVG element. + if (svg) { + iconSetConfig.svgElement = svg; + } + })); + // Fetch all the icon set URLs. When the requests complete, every IconSet should have a + // cached SVG element (unless the request failed), and we can check again for the icon. + return Observable.forkJoin(iconSetFetchRequests) + .map((ignoredResults: any) => { + const foundIcon = this._extractIconWithNameFromAnySet(name, iconSetConfigs); + if (!foundIcon) { + throw new MdIconNameNotFoundException(name); + } + return foundIcon; + }); + } + + /** + * Searches the cached SVG elements for the given icon sets for a nested icon element whose "id" + * tag matches the specified name. If found, copies the nested element to a new SVG element and + * returns it. Returns null if no matching element is found. + */ + private _extractIconWithNameFromAnySet(iconName: string, iconSetConfigs: SvgIconConfig[]): + SVGElement { + // Iterate backwards, so icon sets added later have precedence. + for (let i = iconSetConfigs.length - 1; i >= 0; i--) { + const config = iconSetConfigs[i]; + if (config.svgElement) { + const foundIcon = this._extractSvgIconFromSet(config.svgElement, iconName, config); + if (foundIcon) { + return foundIcon; + } + } + } + return null; + } + + /** + * Loads the content of the icon URL specified in the SvgIconConfig and creates an SVG element + * from it. + */ + private _loadSvgIconFromConfig(config: SvgIconConfig): Observable { + return this._fetchUrl(config.url) + .map(svgText => this._createSvgElementForSingleIcon(svgText, config)); + } + + /** + * Loads the content of the icon set URL specified in the SvgIconConfig and creates an SVG element + * from it. + */ + private _loadSvgIconSetFromConfig(config: SvgIconConfig): Observable { + // TODO: Document that icons should only be loaded from trusted sources. + return this._fetchUrl(config.url) + .map((svgText) => this._svgElementFromString(svgText)); + } + + /** + * Creates a DOM element from the given SVG string, and adds default attributes. + */ + private _createSvgElementForSingleIcon(responseText: string, config: SvgIconConfig): SVGElement { + const svg = this._svgElementFromString(responseText); + this._setSvgAttributes(svg, config); + return svg; + } + + /** + * Searches the cached element of the given SvgIconConfig for a nested icon element whose "id" + * tag matches the specified name. If found, copies the nested element to a new SVG element and + * returns it. Returns null if no matching element is found. + */ + private _extractSvgIconFromSet( + iconSet: SVGElement, iconName: string, config: SvgIconConfig): SVGElement { + const iconNode = iconSet.querySelector('#' + iconName); + if (!iconNode) { + return null; + } + // If the icon node is itself an node, clone and return it directly. If not, set it as + // the content of a new node. + if (iconNode.tagName.toLowerCase() == 'svg') { + return this._setSvgAttributes(iconNode.cloneNode(true), config); + } + // createElement('SVG') doesn't work as expected; the DOM ends up with + // the correct nodes, but the SVG content doesn't render. Instead we + // have to create an empty SVG node using innerHTML and append its content. + // Elements created using DOMParser.parseFromString have the same problem. + // http://stackoverflow.com/questions/23003278/svg-innerhtml-in-firefox-can-not-display + const svg = this._svgElementFromString(''); + // Clone the node so we don't remove it from the parent icon set element. + svg.appendChild(iconNode.cloneNode(true)); + return this._setSvgAttributes(svg, config); + } + + /** + * Creates a DOM element from the given SVG string. + */ + private _svgElementFromString(str: string): SVGElement { + // TODO: Is there a better way than innerHTML? Renderer doesn't appear to have a method for + // creating an element from an HTML string. + const div = document.createElement('DIV'); + div.innerHTML = str; + const svg = div.querySelector('svg'); + if (!svg) { + throw new MdIconSvgTagNotFoundException(); + } + return svg; + } + + /** + * Sets the default attributes for an SVG element to be used as an icon. + */ + private _setSvgAttributes(svg: SVGElement, config: SvgIconConfig): SVGElement { + if (!svg.getAttribute('xmlns')) { + svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + } + svg.setAttribute('fit', ''); + svg.setAttribute('height', '100%'); + svg.setAttribute('width', '100%'); + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + svg.setAttribute('focusable', 'false'); // Disable IE11 default behavior to make SVGs focusable. + return svg; + } + + /** + * Returns an Observable which produces the string contents of the given URL. Results may be + * cached, so future calls with the same URL may not cause another HTTP request. + */ + private _fetchUrl(url: string): Observable { + // Store in-progress fetches to avoid sending a duplicate request for a URL when there is + // already a request in progress for that URL. It's necessary to call share() on the + // Observable returned by http.get() so that multiple subscribers don't cause multiple XHRs. + if (this._inProgressUrlFetches.has(url)) { + return this._inProgressUrlFetches.get(url); + } + const req = this._http.get(url) + .map((response) => response.text()) + .finally(() => { + this._inProgressUrlFetches.delete(url); + }) + .share(); + this._inProgressUrlFetches.set(url, req); + return req; + } +} diff --git a/src/components/icon/icon.scss b/src/components/icon/icon.scss new file mode 100644 index 000000000000..1fa23d71bc13 --- /dev/null +++ b/src/components/icon/icon.scss @@ -0,0 +1,19 @@ +@import "variables"; +@import "default-theme"; + +/** The width/height of the icon element. */ +$md-icon-size: 24px !default; + +/** +This works because we're using ViewEncapsulation.None. If we used the default +encapsulation, the selector would need to be ":host". +*/ +md-icon { + background-repeat: no-repeat; + display: inline-block; + fill: currentColor; + height: $md-icon-size; + margin: auto; + vertical-align: middle; + width: $md-icon-size; +} diff --git a/src/components/icon/icon.spec.ts b/src/components/icon/icon.spec.ts new file mode 100644 index 000000000000..8405400fc807 --- /dev/null +++ b/src/components/icon/icon.spec.ts @@ -0,0 +1,476 @@ +import { + it, + describe, + expect, + beforeEach, + beforeEachProviders, + inject, + TestComponentBuilder, +} from 'angular2/testing'; +import { + HTTP_PROVIDERS, + XHRBackend} from 'angular2/http'; +import {MockBackend} from 'angular2/http/testing'; +import { + provide, + Component} from 'angular2/core'; + +import {MdIcon} from './icon'; +import {MdIconRegistry} from './icon-registry'; +import {getFakeSvgHttpResponse} from './fake-svgs'; + +/** Returns the CSS classes assigned to an element as a sorted array. */ +const sortedClassNames = (elem: Element) => elem.className.split(' ').sort(); + +/** + * Verifies that an element contains a single child element, and returns that child. + */ +const verifyAndGetSingleSvgChild = (element: SVGElement): any => { + expect(element.childNodes.length).toBe(1); + const svgChild = element.childNodes[0]; + expect(svgChild.tagName.toLowerCase()).toBe('svg'); + return svgChild; +}; + +/** + * Verifies that an element contains a single child element whose "id" attribute has + * the specified value. + */ +const verifyPathChildElement = (element: Element, attributeValue: string) => { + expect(element.childNodes.length).toBe(1); + const pathElement = element.childNodes[0]; + expect(pathElement.tagName.toLowerCase()).toBe('path'); + expect(pathElement.getAttribute('id')).toBe(attributeValue); +}; + +export function main() { + describe('MdIcon', () => { + + beforeEachProviders(() => [ + MdIconRegistry, + HTTP_PROVIDERS, + MockBackend, + provide(XHRBackend, {useExisting: MockBackend}), + ]); + + let builder: TestComponentBuilder; + let mdIconRegistry: MdIconRegistry; + let httpRequestUrls: string[]; + + beforeEach( + inject([TestComponentBuilder, MdIconRegistry, MockBackend], + (tcb: TestComponentBuilder, mir: MdIconRegistry, mockBackend: MockBackend) => { + builder = tcb; + mdIconRegistry = mir; + // Keep track of requests so we can verify caching behavior. + // Return responses for the SVGs defined in fake-svgs.ts. + httpRequestUrls = []; + mockBackend.connections.subscribe((connection: any) => { + const url = connection.request.url; + httpRequestUrls.push(url); + connection.mockRespond(getFakeSvgHttpResponse(url)); + }); + })); + + describe('Ligature icons', () => { + it('should add material-icons class by default', (done: () => void) => { + return builder.createAsync(MdIconLigatureTestApp).then((fixture) => { + const testComponent = fixture.debugElement.componentInstance; + const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon'); + testComponent.iconName = 'home'; + fixture.detectChanges(); + expect(sortedClassNames(mdIconElement)).toEqual(['material-icons']); + done(); + }); + }); + + it('should use alternate icon font if set', (done: () => void) => { + mdIconRegistry.setDefaultFontSetClass('myfont'); + return builder.createAsync(MdIconLigatureTestApp).then((fixture) => { + const testComponent = fixture.debugElement.componentInstance; + const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon'); + testComponent.iconName = 'home'; + fixture.detectChanges(); + expect(sortedClassNames(mdIconElement)).toEqual(['myfont']); + done(); + }); + }); + }); + + describe('Icons from URLs', () => { + it('should fetch SVG icon from URL and inline the content', (done: () => void) => { + return builder.createAsync(MdIconFromSvgUrlTestApp).then((fixture) => { + const testComponent = fixture.debugElement.componentInstance; + const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon'); + let svgElement: any; + + testComponent.iconUrl = 'cat.svg'; + fixture.detectChanges(); + // An element should have been added as a child of . + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + // Default attributes should be set. + expect(svgElement.getAttribute('height')).toBe('100%'); + expect(svgElement.getAttribute('height')).toBe('100%'); + // Make sure SVG content is taken from response. + verifyPathChildElement(svgElement, 'meow'); + + // Change the icon, and the SVG element should be replaced. + testComponent.iconUrl = 'dog.svg'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + verifyPathChildElement(svgElement, 'woof'); + + expect(httpRequestUrls).toEqual(['cat.svg', 'dog.svg']); + // Using an icon from a previously loaded URL should not cause another HTTP request. + testComponent.iconUrl = 'cat.svg'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + verifyPathChildElement(svgElement, 'meow'); + expect(httpRequestUrls).toEqual(['cat.svg', 'dog.svg']); + + done(); + }); + }); + + it('should register icon URLs by name', (done: () => void) => { + mdIconRegistry.addSvgIcon('fluffy', 'cat.svg'); + mdIconRegistry.addSvgIcon('fido', 'dog.svg'); + return builder.createAsync(MdIconFromSvgNameTestApp).then((fixture) => { + const testComponent = fixture.debugElement.componentInstance; + const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon'); + let svgElement: SVGElement; + + testComponent.iconName = 'fido'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + verifyPathChildElement(svgElement, 'woof'); + // The aria label should be taken from the icon name. + expect(mdIconElement.getAttribute('aria-label')).toBe('fido'); + + // Change the icon, and the SVG element should be replaced. + testComponent.iconName = 'fluffy'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + verifyPathChildElement(svgElement, 'meow'); + expect(mdIconElement.getAttribute('aria-label')).toBe('fluffy'); + + expect(httpRequestUrls).toEqual(['dog.svg', 'cat.svg']); + // Using an icon from a previously loaded URL should not cause another HTTP request. + testComponent.iconName = 'fido'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + verifyPathChildElement(svgElement, 'woof'); + expect(httpRequestUrls).toEqual(['dog.svg', 'cat.svg']); + + done(); + }); + }); + + it('should extract icon from SVG icon set', (done: () => void) => { + mdIconRegistry.addSvgIconSetInNamespace('farm', 'farm-set-1.svg'); + return builder.createAsync(MdIconFromSvgNameTestApp).then((fixture) => { + const testComponent = fixture.debugElement.componentInstance; + const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon'); + let svgElement: any; + let svgChild: any; + + testComponent.iconName = 'farm:pig'; + fixture.detectChanges(); + + expect(mdIconElement.childNodes.length).toBe(1); + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + expect(svgElement.childNodes.length).toBe(1); + svgChild = svgElement.childNodes[0]; + // The first child should be the element. + expect(svgChild.tagName.toLowerCase()).toBe('g'); + expect(svgChild.getAttribute('id')).toBe('pig'); + verifyPathChildElement(svgChild, 'oink'); + // The aria label should be taken from the icon name (without the icon set portion). + expect(mdIconElement.getAttribute('aria-label')).toBe('pig'); + + // Change the icon, and the SVG element should be replaced. + testComponent.iconName = 'farm:cow'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + svgChild = svgElement.childNodes[0]; + // The first child should be the element. + expect(svgChild.tagName.toLowerCase()).toBe('g'); + expect(svgChild.getAttribute('id')).toBe('cow'); + verifyPathChildElement(svgChild, 'moo'); + expect(mdIconElement.getAttribute('aria-label')).toBe('cow'); + + done(); + }); + }); + + it('should allow multiple icon sets in a namespace', (done: () => void) => { + mdIconRegistry.addSvgIconSetInNamespace('farm', 'farm-set-1.svg'); + mdIconRegistry.addSvgIconSetInNamespace('farm', 'farm-set-2.svg'); + mdIconRegistry.addSvgIconSetInNamespace('arrows', 'arrow-set.svg'); + return builder.createAsync(MdIconFromSvgNameTestApp).then((fixture) => { + const testComponent = fixture.debugElement.componentInstance; + const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon'); + let svgElement: any; + let svgChild: any; + + testComponent.iconName = 'farm:pig'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + expect(svgElement.childNodes.length).toBe(1); + svgChild = svgElement.childNodes[0]; + // The child should be the element. + expect(svgChild.tagName.toLowerCase()).toBe('g'); + expect(svgChild.getAttribute('id')).toBe('pig'); + expect(svgChild.childNodes.length).toBe(1); + verifyPathChildElement(svgChild, 'oink'); + // The aria label should be taken from the icon name (without the namespace). + expect(mdIconElement.getAttribute('aria-label')).toBe('pig'); + + // Both icon sets registered in the 'farm' namespace should have been fetched. + expect(httpRequestUrls.sort()).toEqual(['farm-set-1.svg', 'farm-set-2.svg']); + + // Change the icon name to one that appears in both icon sets. The icon from the set that + // was registered last should be used (with id attribute of 'moo moo' instead of 'moo'), + // and no additional HTTP request should be made. + testComponent.iconName = 'farm:cow'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + svgChild = svgElement.childNodes[0]; + // The first child should be the element. + expect(svgChild.tagName.toLowerCase()).toBe('g'); + expect(svgChild.getAttribute('id')).toBe('cow'); + expect(svgChild.childNodes.length).toBe(1); + verifyPathChildElement(svgChild, 'moo moo'); + expect(mdIconElement.getAttribute('aria-label')).toBe('cow'); + expect(httpRequestUrls.sort()).toEqual(['farm-set-1.svg', 'farm-set-2.svg']); + + done(); + }); + }); + + it('should not wrap elements in icon sets in another svg tag', (done: () => void) => { + mdIconRegistry.addSvgIconSet('arrow-set.svg'); + return builder.createAsync(MdIconFromSvgNameTestApp).then((fixture) => { + const testComponent = fixture.debugElement.componentInstance; + const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon'); + let svgElement: any; + + testComponent.iconName = 'left-arrow'; + fixture.detectChanges(); + // arrow-set.svg stores its icons as nested elements, so they should be used + // directly and not wrapped in an outer tag like the elements in other sets. + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + verifyPathChildElement(svgElement, 'left'); + expect(mdIconElement.getAttribute('aria-label')).toBe('left-arrow'); + + done(); + }); + }); + + it('should return unmodified copies of icons from URLs', (done: () => void) => { + return builder.createAsync(MdIconFromSvgUrlTestApp).then((fixture) => { + const testComponent = fixture.debugElement.componentInstance; + const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon'); + let svgElement: any; + + testComponent.iconUrl = 'cat.svg'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + verifyPathChildElement(svgElement, 'meow'); + // Modify the SVG element by setting a viewBox attribute. + svgElement.setAttribute('viewBox', '0 0 100 100'); + + // Switch to a different icon. + testComponent.iconUrl = 'dog.svg'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + verifyPathChildElement(svgElement, 'woof'); + + // Switch back to the first icon. The viewBox attribute should not be present. + testComponent.iconUrl = 'cat.svg'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + verifyPathChildElement(svgElement, 'meow'); + expect(svgElement.getAttribute('viewBox')).toBeFalsy(); + + done(); + }); + }); + + it('should return unmodified copies of icons from icon sets', (done: () => void) => { + mdIconRegistry.addSvgIconSet('arrow-set.svg'); + return builder.createAsync(MdIconFromSvgNameTestApp).then((fixture) => { + const testComponent = fixture.debugElement.componentInstance; + const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon'); + let svgElement: any; + + testComponent.iconName = 'left-arrow'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + verifyPathChildElement(svgElement, 'left'); + // Modify the SVG element by setting a viewBox attribute. + svgElement.setAttribute('viewBox', '0 0 100 100'); + + // Switch to a different icon. + testComponent.iconName = 'right-arrow'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + verifyPathChildElement(svgElement, 'right'); + + // Switch back to the first icon. The viewBox attribute should not be present. + testComponent.iconName = 'left-arrow'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(mdIconElement); + verifyPathChildElement(svgElement, 'left'); + expect(svgElement.getAttribute('viewBox')).toBeFalsy(); + + done(); + }); + }); + }); + + describe('custom fonts', () => { + it('should apply CSS classes for custom font and icon', (done: () => void) => { + mdIconRegistry.registerFontClassAlias('f1', 'font1'); + mdIconRegistry.registerFontClassAlias('f2'); + return builder.createAsync(MdIconCustomFontCssTestApp).then((fixture) => { + const testComponent = fixture.debugElement.componentInstance; + const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon'); + testComponent.fontSet = 'f1'; + testComponent.fontIcon = 'house'; + fixture.detectChanges(); + expect(sortedClassNames(mdIconElement)).toEqual(['font1', 'house']); + expect(mdIconElement.getAttribute('aria-label')).toBe('house'); + + testComponent.fontSet = 'f2'; + testComponent.fontIcon = 'igloo'; + fixture.detectChanges(); + expect(sortedClassNames(mdIconElement)).toEqual(['f2', 'igloo']); + expect(mdIconElement.getAttribute('aria-label')).toBe('igloo'); + + testComponent.fontSet = 'f3'; + testComponent.fontIcon = 'tent'; + fixture.detectChanges(); + expect(sortedClassNames(mdIconElement)).toEqual(['f3', 'tent']); + expect(mdIconElement.getAttribute('aria-label')).toBe('tent'); + + done(); + }); + }); + }); + + describe('aria label', () => { + it('should set aria label from text content if not specified', (done: () => void) => { + return builder.createAsync(MdIconLigatureTestApp).then((fixture) => { + const testComponent = fixture.debugElement.componentInstance; + const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon'); + testComponent.iconName = 'home'; + fixture.detectChanges(); + expect(mdIconElement.getAttribute('aria-label')).toBe('home'); + + testComponent.iconName = 'hand'; + fixture.detectChanges(); + expect(mdIconElement.getAttribute('aria-label')).toBe('hand'); + + done(); + }); + }); + + it('should use alt tag if aria label is not specified', (done: () => void) => { + return builder.createAsync(MdIconLigatureWithAriaBindingTestApp).then((fixture) => { + const testComponent = fixture.debugElement.componentInstance; + const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon'); + testComponent.iconName = 'home'; + testComponent.altText = 'castle'; + fixture.detectChanges(); + expect(mdIconElement.getAttribute('aria-label')).toBe('castle'); + + testComponent.ariaLabel = 'house'; + fixture.detectChanges(); + expect(mdIconElement.getAttribute('aria-label')).toBe('house'); + + done(); + }); + }); + + it('should use provided aria label rather than icon name', (done: () => void) => { + return builder.createAsync(MdIconLigatureWithAriaBindingTestApp).then((fixture) => { + const testComponent = fixture.debugElement.componentInstance; + const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon'); + testComponent.iconName = 'home'; + testComponent.ariaLabel = 'house'; + fixture.detectChanges(); + expect(mdIconElement.getAttribute('aria-label')).toBe('house'); + done(); + }); + }); + + it('should use provided aria label rather than font icon', (done: () => void) => { + return builder.createAsync(MdIconCustomFontCssTestApp).then((fixture) => { + const testComponent = fixture.debugElement.componentInstance; + const mdIconElement = fixture.debugElement.nativeElement.querySelector('md-icon'); + testComponent.fontSet = 'f1'; + testComponent.fontIcon = 'house'; + testComponent.ariaLabel = 'home'; + fixture.detectChanges(); + expect(mdIconElement.getAttribute('aria-label')).toBe('home'); + done(); + }); + }); + }); + }); +} + +/** Test components that contain an MdIcon. */ +@Component({ + selector: 'test-app', + template: `{{iconName}}`, + directives: [MdIcon], +}) +class MdIconLigatureTestApp { + ariaLabel: string = null; + iconName = ''; +} + +@Component({ + selector: 'test-app', + template: `{{iconName}}`, + directives: [MdIcon], +}) +class MdIconLigatureWithAriaBindingTestApp { + ariaLabel: string = null; + iconName = ''; +} + +@Component({ + selector: 'test-app', + template: ` + + `, + directives: [MdIcon], +}) +class MdIconCustomFontCssTestApp { + ariaLabel: string = null; + fontSet = ''; + fontIcon = ''; +} + +@Component({ + selector: 'test-app', + template: ``, + directives: [MdIcon], +}) +class MdIconFromSvgUrlTestApp { + ariaLabel: string = null; + iconUrl = ''; +} + +@Component({ + selector: 'test-app', + template: ``, + directives: [MdIcon], +}) +class MdIconFromSvgNameTestApp { + ariaLabel: string = null; + iconName = ''; +} diff --git a/src/components/icon/icon.ts b/src/components/icon/icon.ts new file mode 100644 index 000000000000..44d4bd4b5047 --- /dev/null +++ b/src/components/icon/icon.ts @@ -0,0 +1,223 @@ +import { + AfterContentChecked, + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + OnChanges, + OnInit, + Renderer, + SimpleChange, + ViewEncapsulation, +} from 'angular2/core'; +import {NgClass} from 'angular2/common'; +import {BaseException} from 'angular2/src/facade/exceptions'; + +import {MdIconRegistry} from './icon-registry'; + + +/** Exception thrown when an invalid icon name is passed to an md-icon component. */ +export class MdIconInvalidNameException extends BaseException { + constructor(iconName: string) { + super(`Invalid icon name: "${name}"`); + } +} + +/** + * Component to display an icon. It can be used in the following ways: + * - Specify the svgSrc input to load an SVG icon from a URL. The SVG content is directly inlined + * as a child of the component, so that CSS styles can easily be applied to it. + * The URL is loaded via an XMLHttpRequest, so it must be on the same domain as the page or its + * server must be configured to allow cross-domain requests. + * Example: + * + * + * - Specify the svgIcon input to load an SVG icon from a URL previously registered with the + * addSvgIcon, addSvgIconInNamespace, addSvgIconSet, or addSvgIconSetInNamespace methods of + * MdIconRegistry. If the svgIcon value contains a colon it is assumed to be in the format + * "[namespace]:[name]", if not the value will be the name of an icon in the default namespace. + * Examples: + * + * + * + * - Use a font ligature as an icon by putting the ligature text in the content of the + * component. By default the Material icons font is used as described at + * http://google.github.io/material-design-icons/#icon-font-for-the-web. You can specify an + * alternate font by setting the fontSet input to either the CSS class to apply to use the + * desired font, or to an alias previously registered with MdIconRegistry.registerFontClassAlias. + * Examples: + * home + * sun + * + * - Specify a font glyph to be included via CSS rules by setting the fontSet input to specify the + * font, and the fontIcon input to specify the icon. Typically the fontIcon will specify a + * CSS class which causes the glyph to be displayed via a :before selector, as in + * https://fortawesome.github.io/Font-Awesome/examples/ + * Example: + * + */ +@Component({ + template: '', + selector: 'md-icon', + styleUrls: ['./components/icon/icon.css'], + host: { + 'role': 'img', + }, + directives: [NgClass], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MdIcon implements OnChanges, OnInit, AfterContentChecked { + @Input() svgSrc: string; + @Input() svgIcon: string; + @Input() fontSet: string; + @Input() fontIcon: string; + @Input() alt: string; + + @Input('aria-label') hostAriaLabel: string = ''; + + private _previousFontSetClass: string; + private _previousFontIconClass: string; + + constructor( + private _element: ElementRef, + private _renderer: Renderer, + private _mdIconRegistry: MdIconRegistry) { + } + + /** + * Splits an svgIcon binding value into its icon set and icon name components. + * Returns a 2-element array of [(icon set), (icon name)]. + * The separator for the two fields is ':'. If there is no separator, an empty + * string is returned for the icon set and the entire value is returned for + * the icon name. If the argument is falsy, returns an array of two empty strings. + * Throws a MdIconInvalidNameException if the name contains two or more ':' separators. + * Examples: + * 'social:cake' -> ['social', 'cake'] + * 'penguin' -> ['', 'penguin'] + * null -> ['', ''] + * 'a:b:c' -> (throws MdIconInvalidNameException) + */ + private _splitIconName(iconName: string): [string, string] { + if (!iconName) { + return ['', '']; + } + const parts = iconName.split(':'); + switch (parts.length) { + case 1: + // Use default namespace. + return ['', parts[0]]; + case 2: + return <[string, string]>parts; + default: + throw new MdIconInvalidNameException(iconName); + } + } + + ngOnChanges(changes: { [propertyName: string]: SimpleChange }) { + const changedInputs = Object.keys(changes); + // Only update the inline SVG icon if the inputs changed, to avoid unnecessary DOM operations. + if (changedInputs.indexOf('svgIcon') != -1 || changedInputs.indexOf('svgSrc') != -1) { + if (this.svgIcon) { + const [namespace, iconName] = this._splitIconName(this.svgIcon); + this._mdIconRegistry.getNamedSvgIcon(iconName, namespace).subscribe( + svg => this._setSvgElement(svg), + (err: any) => console.log(`Error retrieving icon: ${err}`)); + } else if (this.svgSrc) { + this._mdIconRegistry.getSvgIconFromUrl(this.svgSrc).subscribe( + svg => this._setSvgElement(svg), + (err: any) => console.log(`Error retrieving icon: ${err}`)); + } + } + if (this._usingFontIcon()) { + this._updateFontIconClasses(); + } + this._updateAriaLabel(); + } + + ngOnInit() { + // Update font classes because ngOnChanges won't be called if none of the inputs are present, + // e.g. arrow. In this case we need to add a CSS class for the default font. + if (this._usingFontIcon()) { + this._updateFontIconClasses(); + } + } + + ngAfterContentChecked() { + // Update aria label here because it may depend on the projected text content. + // (e.g. home should use 'home'). + this._updateAriaLabel(); + } + + private _updateAriaLabel() { + const ariaLabel = this._getAriaLabel(); + if (ariaLabel) { + this._renderer.setElementAttribute(this._element.nativeElement, 'aria-label', ariaLabel); + } + } + + private _getAriaLabel() { + // If the parent provided an aria-label attribute value, use it as-is. Otherwise look for a + // reasonable value from the alt attribute, font icon name, SVG icon name, or (for ligatures) + // the text content of the directive. + const label = + this.hostAriaLabel || + this.alt || + this.fontIcon || + this._splitIconName(this.svgIcon)[1]; + if (label) { + return label; + } + // The "content" of an SVG icon is not a useful label. + if (this._usingFontIcon()) { + const text = this._element.nativeElement.textContent; + if (text) { + return text; + } + } + // TODO: Warn here in dev mode. + return null; + } + + private _usingFontIcon(): boolean { + return !(this.svgIcon || this.svgSrc); + } + + private _setSvgElement(svg: SVGElement) { + const layoutElement = this._element.nativeElement; + // Remove existing child nodes and add the new SVG element. + // We would use renderer.detachView(Array.from(layoutElement.childNodes)) here, + // but it fails in IE11: https://github.com/angular/angular/issues/6327 + layoutElement.innerHTML = ''; + this._renderer.projectNodes(layoutElement, [svg]); + } + + private _updateFontIconClasses() { + if (!this._usingFontIcon()) { + return; + } + const elem = this._element.nativeElement; + const fontSetClass = this.fontSet ? + this._mdIconRegistry.classNameForFontAlias(this.fontSet) : + this._mdIconRegistry.getDefaultFontSetClass(); + if (fontSetClass != this._previousFontSetClass) { + if (this._previousFontSetClass) { + this._renderer.setElementClass(elem, this._previousFontSetClass, false); + } + if (fontSetClass) { + this._renderer.setElementClass(elem, fontSetClass, true); + } + this._previousFontSetClass = fontSetClass; + } + + if (this.fontIcon != this._previousFontIconClass) { + if (this._previousFontIconClass) { + this._renderer.setElementClass(elem, this._previousFontIconClass, false); + } + if (this.fontIcon) { + this._renderer.setElementClass(elem, this.fontIcon, true); + } + this._previousFontIconClass = this.fontIcon; + } + } +} diff --git a/src/demo-app/demo-app.html b/src/demo-app/demo-app.html index 5f4a9f97ed5b..c983bdd25be9 100644 --- a/src/demo-app/demo-app.html +++ b/src/demo-app/demo-app.html @@ -6,6 +6,7 @@ Checkbox Gestures Grid List + Icon Input List Live Announcer @@ -22,7 +23,7 @@
- menu + menu

Angular Material 2 Demos