Skip to content

Commit

Permalink
feat(api): add responsive API for img elements
Browse files Browse the repository at this point in the history
* add ImgSrcsetDirective
  *  to inject <source> elements to support responsive images
  *  Inject a <source> element for every srcset.<breakpoint alias> in the HTML markup of an <img> element contained in a <picture> elemen
  *  support usages without `<picture>` parents
* add responsive API to img.src:  src.md, src.lt-lg, src.gt-xs, etc.
* repackage API classes to easily distinguish flexbox APIs and  extended responsive APIs

Closes #366, Fixes #81, Fixes #376.
  • Loading branch information
ThomasBurleson committed Aug 24, 2017
1 parent 64a7c50 commit 6d82622
Show file tree
Hide file tree
Showing 4 changed files with 354 additions and 1 deletion.
216 changes: 216 additions & 0 deletions src/lib/api/ext/img-src.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/**
* @license
* Copyright Google Inc. 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.io/license
*/
import {Component} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ComponentFixture, TestBed, inject} from '@angular/core/testing';

import {DEFAULT_BREAKPOINTS_PROVIDER} from '../../media-query/breakpoints/break-points-provider';
import {BreakPointRegistry} from '../../media-query/breakpoints/break-point-registry';
import {MockMatchMedia} from '../../media-query/mock/mock-match-media';
import {MatchMedia} from '../../media-query/match-media';
import {FlexLayoutModule} from '../../module';

import {customMatchers} from '../../utils/testing/custom-matchers';
import {makeCreateTestComponent, queryFor} from '../../utils/testing/helpers';
import {expect} from '../../utils/testing/custom-matchers';
import {_dom as _} from '../../utils/testing/dom-tools';

const SRC_URLS = {
'xs': [
'https://dummyimage.com/300x200/c7751e/fff.png',
'https://dummyimage.com/300x200/c7751e/000.png'
],
'gt-xs': [
'https://dummyimage.com/400x250/c7c224/fff.png',
'https://dummyimage.com/400x250/c7c224/000.png'
],
'md': [
'https://dummyimage.com/500x300/76c720/fff.png',
'https://dummyimage.com/500x300/76c720/000.png'
],
'lt-lg': [
'https://dummyimage.com/600x350/25c794/fff.png',
'https://dummyimage.com/600x350/25c794/000.png'
],
'lg': [
'https://dummyimage.com/700x400/258cc7/fff.png',
'https://dummyimage.com/700x400/258cc7/000.png'
],
'lt-xl': [
'https://dummyimage.com/800x500/b925c7/ffffff.png',
'https://dummyimage.com/800x500/b925c7/000.png'
]
};
const DEFAULT_SRC = 'https://dummyimage.com/300x300/c72538/ffffff.png';

describe('img-src directive', () => {
let fixture: ComponentFixture<any>;
let matchMedia: MockMatchMedia;
let breakpoints: BreakPointRegistry;

let componentWithTemplate = (template: string) => {
fixture = makeCreateTestComponent(() => TestSrcComponent)(template);

inject([MatchMedia, BreakPointRegistry],
(_matchMedia: MockMatchMedia, _breakpoints: BreakPointRegistry) => {
matchMedia = _matchMedia;
breakpoints = _breakpoints;
})();
};

beforeEach(() => {
jasmine.addMatchers(customMatchers);

// Configure testbed to prepare services
TestBed.configureTestingModule({
imports: [CommonModule, FlexLayoutModule],
declarations: [TestSrcComponent],
providers: [
BreakPointRegistry, DEFAULT_BREAKPOINTS_PROVIDER,
{provide: MatchMedia, useClass: MockMatchMedia}
]
});
});

describe('with static api', () => {
it('should preserve the static src attribute', () => {
let url = 'https://dummyimage.com/300x300/c72538/ffffff.png';
componentWithTemplate(`
<img src="${url}">
`);
const img = queryFor(fixture, 'img')[0].nativeElement;

fixture.detectChanges();
expect(_.getAttribute( img, 'src')).toEqual(url);
});

it('should work with empty src attributes', () => {
componentWithTemplate(`
<img src="">
`);
const img = queryFor(fixture, 'img')[0].nativeElement;

fixture.detectChanges();
expect(img).toHaveAttributes({
src: ''
});
});

it('should work standard input bindings', () => {
componentWithTemplate(`
<img [src]="defaultSrc" [src.xs]="xsSrc">
`);
const img = queryFor(fixture, 'img')[0].nativeElement;

fixture.detectChanges();
expect(img).toHaveAttributes({
src: 'https://dummyimage.com/300x300/c72538/ffffff.png'
});
});

it('should work when `src` value is not defined', () => {
componentWithTemplate(`
<img src >
`);

const img = queryFor(fixture, 'img')[0].nativeElement;
fixture.detectChanges();
expect(img).toHaveAttributes({
src: ''
});
});

it('should only work with "<img>" elements.', () => {
componentWithTemplate(`
<iframe src.xs="none.png" >
`);

const img = queryFor(fixture, 'iframe')[0].nativeElement;
fixture.detectChanges();
expect(img).not.toHaveAttributes({
src: ''
});
});

});

describe('with responsive api', () => {

it('should work with a isolated image element and responsive srcs', () => {
componentWithTemplate(`
<img [src]="xsSrc"
[src.md]="mdSrc">
`);
fixture.detectChanges();

let img = queryFor(fixture, 'img')[0].nativeElement;

matchMedia.activate('md');
fixture.detectChanges();
expect(img).toBeDefined();
expect(img).toHaveAttributes({
src: SRC_URLS['md'][0]
});

// When activating an unused breakpoint, fallback to default [src] value
matchMedia.activate('xl');
fixture.detectChanges();
expect(img).toHaveAttributes({
src: SRC_URLS['xs'][0]
});
});

it('should work if default [src] is not defined', () => {
componentWithTemplate(`
<img [src.md]="mdSrc">
`);
fixture.detectChanges();
matchMedia.activate('md');
fixture.detectChanges();

let img = queryFor(fixture, 'img')[0].nativeElement;
expect(img).toBeDefined();
expect(img).toHaveAttributes({
src: SRC_URLS['md'][0]
});

// When activating an unused breakpoint, fallback to default [src] value
matchMedia.activate('xl');
fixture.detectChanges();
expect(img).toHaveAttributes({
src: ''
});
});

});
});

// *****************************************************************
// Template Component
// *****************************************************************

@Component({
selector: 'test-src-api',
template: ''
})
export class TestSrcComponent {
defaultSrc = '';
xsSrc = '';
mdSrc = '';
lgSrc = '';

constructor() {
this.defaultSrc = DEFAULT_SRC;
this.xsSrc = SRC_URLS['xs'][0];
this.mdSrc = SRC_URLS['md'][0];
this.lgSrc = SRC_URLS['lg'][0];

}
}


134 changes: 134 additions & 0 deletions src/lib/api/ext/img-src.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* @license
* Copyright Google Inc. 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.io/license
*/
import {
Directive,
ElementRef,
Input,
OnInit,
OnChanges,
Renderer2
} from '@angular/core';
import {ɵgetDOM as getDom} from '@angular/platform-browser';

import {BaseFxDirective} from '../core/base';
import {MediaMonitor} from '../../media-query/media-monitor';

/**
* This directive provides a responsive API for the HTML <img> 'src' attribute
* and will update the img.src property upon each responsive activation.
*
* e.g.
* <img src="defaultScene.jpg" src.xs="mobileScene.jpg"></img>
*
* @see https://css-tricks.com/responsive-images-youre-just-changing-resolutions-use-src/
*/
@Directive({
selector: `
img[src],
img[src.xs], img[src.sm], img[src.md], img[src.lg], img[src.xl],
img[src.lt-sm], img[src.lt-md], img[src.lt-lg], img[src.lt-xl],
img[src.gt-xs], img[src.gt-sm], img[src.gt-md], img[src.gt-lg]
`
})
export class ImgSrcDirective extends BaseFxDirective implements OnInit, OnChanges {

/* tslint:disable */
@Input('src') set srcBase(val) { this.cacheDefaultSrc(val); }

@Input('src.xs') set srcXs(val) { this._cacheInput('srcXs', val); }
@Input('src.sm') set srcSm(val) { this._cacheInput('srcSm', val); }
@Input('src.md') set srcMd(val) { this._cacheInput('srcMd', val); }
@Input('src.lg') set srcLg(val) { this._cacheInput('srcLg', val); }
@Input('src.xl') set srcXl(val) { this._cacheInput('srcXl', val); }

@Input('src.lt-sm') set srcLtSm(val) { this._cacheInput('srcLtSm', val); }
@Input('src.lt-md') set srcLtMd(val) { this._cacheInput('srcLtMd', val); }
@Input('src.lt-lg') set srcLtLg(val) { this._cacheInput('srcLtLg', val); }
@Input('src.lt-xl') set srcLtXl(val) { this._cacheInput('srcLtXl', val); }

@Input('src.gt-xs') set srcGtXs(val) { this._cacheInput('srcGtXs', val); }
@Input('src.gt-sm') set srcGtSm(val) { this._cacheInput('srcGtSm', val); }
@Input('src.gt-md') set srcGtMd(val) { this._cacheInput('srcGtMd', val); }
@Input('src.gt-lg') set srcGtLg(val) { this._cacheInput('srcGtLg', val); }
/* tslint:enable */

constructor(elRef: ElementRef, renderer: Renderer2, monitor: MediaMonitor) {
super(monitor, elRef, renderer);
}

/**
* Listen for responsive changes to update the img.src attribute
*/
ngOnInit() {
super.ngOnInit();

// Cache initial value of `src` to use as responsive fallback
this.cacheDefaultSrc(this.defaultSrc);

if (this.hasResponsiveKeys) {
// Listen for responsive changes
this._listenForMediaQueryChanges('src', this.defaultSrc, () => {
this._updateSrcFor();
});
}
this._updateSrcFor();
}

/**
* Update the 'src' property of the host <img> element
*/
ngOnChanges() {
if (this.hasInitialized) {
this._updateSrcFor();
}
}

/**
* Use the [responsively] activated input value to update
* the host img src attribute or assign a default `img.src=''`
* if the src has not been defined.
*
* Do nothing to standard `<img src="">` usages, only when responsive
* keys are present do we actually call `setAttribute()`
*/
protected _updateSrcFor() {
if (this.hasResponsiveKeys) {
let url = this._mqActivation ? this._mqActivation.activatedInput || '' : this.defaultSrc;
this._renderer.setAttribute(this.nativeElement, 'src', url);
}
}

/**
* Cache initial value of 'src', this will be used as fallback when breakpoint
* activations change.
* NOTE: The default 'src' property is not bound using @Input(), so perform
* a post-ngOnInit() lookup of the default src value (if any).
*/
protected cacheDefaultSrc(value?: string) {
const currentVal = this._queryInput('src');
if (typeof currentVal === 'undefined') {
this._cacheInput('src', value || '');
}
}

/**
* Empty values are maintained, undefined values are exposed as ''
*/
protected get defaultSrc(): string {
return this._queryInput('src') ||
getDom().getAttribute(this.nativeElement, 'src') || '';
}

/**
* Does the <img> have 1 or more src.<xxx> responsive inputs
* defined... these will be mapped to activated breakpoints.
*/
protected get hasResponsiveKeys() {
return Object.keys(this._inputMap).length > 1;
}
}
1 change: 1 addition & 0 deletions src/lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ export * from './flexbox/flex-order';
export * from './ext/class';
export * from './ext/style';
export * from './ext/show-hide';
export * from './ext/img-src';

4 changes: 3 additions & 1 deletion src/lib/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {LayoutGapDirective} from './api/flexbox/layout-gap';
import {ShowHideDirective} from './api/ext/show-hide';
import {ClassDirective} from './api/ext/class';
import {StyleDirective} from './api/ext/style';
import {ImgSrcDirective} from './api/ext/img-src';

/**
* Since the equivalent results are easily achieved with a css class attached to each
Expand All @@ -52,7 +53,8 @@ const ALL_DIRECTIVES = [
FlexAlignDirective,
ShowHideDirective,
ClassDirective,
StyleDirective
StyleDirective,
ImgSrcDirective
];

/**
Expand Down

0 comments on commit 6d82622

Please sign in to comment.