Repository: kaliber5/ember-responsive-image (ts)

Issue:
Set width/height attributes on <img>
See https://www.industrialempathy.com/posts/image-optimizations/#responsive-layout

Only as a default, user should be able to override.

Should we apply the auto-sizing CSS by default?

```CSS
max-width: 100%;
height: auto;
```
Add fixed size support
Support fluid and fixed size images, similar to https://www.gatsbyjs.com/plugins/gatsby-image/#two-types-of-responsive-images. 

Ideas:
* switch to fixed mode by `<ResponsiveImage @width={{500}} />` (sets `width` and `height` HTML attributes)
* render the fallback src at this size (or best matching), and srcset for retina queries (1x, 1.5x, 2x)
* add different config options for this use case, like
```js
{
  // ...
  fixedSize: 800,
  pixelDensity: ['1x', '2x', '3x']
}
```
 

Pull request description:
Support fixed and responsive layouts
This accumulates a few (breaking) changes:
* will add the CSS for responsive (fluid) image layout (the default)
* when passing `@width` and/or `@height` arguments, it will opt into fixed layout (closes #117)
* in fixed layout, it will pick the (fallback) `src` attribute based on the fixed width, pick `srcset` sources for that given width and pixel densities of `1x` and `2x`, and render `width` and `height` attributes with the appropriate fixed values (if one is missing, it will be computed based on the image's aspect ratio)
* in responsive layout, `width` and `height` attributes are rendered even though the *actual* size is responsive, to let the browser know the correct vertical size (even before the image has been loaded), to reduce [Cumulative Layout Shift](https://web.dev/cls/) (Closes #88)
* some (breaking) related changes in the service's API, removing `getImageBySize()`, renaming `getImageDataBySize()` to `getImageMetaBySize()` and introducing `getImageMetaByWidth()`

Patch:
diff --git a/addon/components/responsive-image.hbs b/addon/components/responsive-image.hbs
index 71c60a74..b19d33ce 100644
--- a/addon/components/responsive-image.hbs
+++ b/addon/components/responsive-image.hbs
@@ -8,6 +8,9 @@
   {{/each}}
   <img
     src={{this.src}}
+    width={{this.width}}
+    height={{this.height}}
+    class="eri-{{this.layout}}"
     loading="lazy"
     ...attributes
   />
diff --git a/addon/components/responsive-image.ts b/addon/components/responsive-image.ts
index 9c4e3a63..4ae3d9a9 100644
--- a/addon/components/responsive-image.ts
+++ b/addon/components/responsive-image.ts
@@ -1,11 +1,16 @@
 import Component from '@glimmer/component';
 import { inject as service } from '@ember/service';
-import ResponsiveImageService from 'ember-responsive-image/services/responsive-image';
+import ResponsiveImageService, {
+  ImageMeta,
+} from 'ember-responsive-image/services/responsive-image';
+import { assert } from '@ember/debug';
 
 interface ResponsiveImageComponentArgs {
   image: string;
   size?: number;
   sizes?: string;
+  width?: number;
+  height?: number;
 }
 
 interface PictureSource {
@@ -14,34 +19,124 @@ interface PictureSource {
   sizes?: string;
 }
 
+enum Layout {
+  RESPONSIVE = 'responsive',
+  FIXED = 'fixed',
+}
+
+const PIXEL_DENSITIES = [1, 2];
+
 export default class ResponsiveImageComponent extends Component<ResponsiveImageComponentArgs> {
   @service
   responsiveImage!: ResponsiveImageService;
 
+  constructor(owner: unknown, args: ResponsiveImageComponentArgs) {
+    super(owner, args);
+    assert('No image argument supplied for <ResponsiveImage>', args.image);
+  }
+
+  get layout(): Layout {
+    return this.args.width === undefined && this.args.height === undefined
+      ? Layout.RESPONSIVE
+      : Layout.FIXED;
+  }
+
   get sources(): PictureSource[] {
-    return this.responsiveImage
-      .getAvailableTypes(this.args.image)
-      .map((type) => {
-        const sources = this.responsiveImage
-          .getImages(this.args.image, type)
-          .map((imageMeta) => `${imageMeta.image} ${imageMeta.width}w`);
-
-        return {
-          srcset: sources.join(', '),
-          sizes:
-            this.args.sizes ??
-            (this.args.size ? `${this.args.size}vw` : undefined),
-          type: `image/${type}`,
-        };
-      });
+    if (this.layout === Layout.RESPONSIVE) {
+      return this.responsiveImage
+        .getAvailableTypes(this.args.image)
+        .map((type) => {
+          const sources: string[] = this.responsiveImage
+            .getImages(this.args.image, type)
+            .map((imageMeta) => `${imageMeta.image} ${imageMeta.width}w`);
+
+          return {
+            srcset: sources.join(', '),
+            sizes:
+              this.args.sizes ??
+              (this.args.size ? `${this.args.size}vw` : undefined),
+            type: `image/${type}`,
+          };
+        });
+    } else {
+      const width = this.width;
+      if (width === undefined) {
+        return [];
+      }
+
+      return this.responsiveImage
+        .getAvailableTypes(this.args.image)
+        .map((type) => {
+          const sources: string[] = PIXEL_DENSITIES.map((density) => {
+            const imageMeta = this.responsiveImage.getImageMetaByWidth(
+              this.args.image,
+              width * density,
+              type
+            )!;
+
+            return `${imageMeta.image} ${density}x`;
+          }).filter((source) => source !== undefined);
+
+          return {
+            srcset: sources.join(', '),
+            type: `image/${type}`,
+          };
+        });
+    }
+  }
+
+  get imageMeta(): ImageMeta | undefined {
+    if (this.layout === Layout.RESPONSIVE) {
+      return this.responsiveImage.getImageMetaBySize(
+        this.args.image,
+        this.args.size
+      );
+    } else {
+      return this.responsiveImage.getImageMetaByWidth(
+        this.args.image,
+        this.width ?? 0
+      );
+    }
   }
 
   /**
    * the image source which fits at best for the size and screen
    */
   get src(): string | undefined {
-    return this.args.image
-      ? this.responsiveImage.getImageBySize(this.args.image, this.args.size)
-      : undefined;
+    return this.imageMeta?.image;
+  }
+
+  get width(): number | undefined {
+    if (this.layout === Layout.RESPONSIVE) {
+      return this.imageMeta?.width;
+    } else {
+      if (this.args.width) {
+        return this.args.width;
+      }
+
+      const ar = this.responsiveImage.getAspectRatio(this.args.image);
+      if (ar !== undefined && ar !== 0 && this.args.height !== undefined) {
+        return this.args.height * ar;
+      }
+
+      return undefined;
+    }
+  }
+
+  get height(): number | undefined {
+    if (this.layout === Layout.RESPONSIVE) {
+      return this.imageMeta?.height;
+    } else {
+      if (this.args.height) {
+        return this.args.height;
+      }
+
+      const ar = this.responsiveImage.getAspectRatio(this.args.image);
+      if (ar !== undefined && ar !== 0 && this.args.width !== undefined) {
+        return this.args.width / ar;
+      }
+
+      return undefined;
+    }
   }
 }
diff --git a/addon/helpers/responsive-image-resolve.js b/addon/helpers/responsive-image-resolve.js
deleted file mode 100644
index 3ca452ae..00000000
--- a/addon/helpers/responsive-image-resolve.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { inject as service } from '@ember/service';
-import { htmlSafe } from '@ember/string';
-import Helper from '@ember/component/helper';
-
-/**
- * @class responsiveImageResolve
- * @namespace Helpers
- * @extends Ember.Helper
- * @public
- */
-export default class ResponsiveImageResolve extends Helper {
-  @service
-  responsiveImage;
-
-  compute(params /*, hash*/) {
-    let image = params[0];
-    let size = params[1] || 100;
-    let responsive = this.responsiveImage.getImageBySize(image, size);
-
-    return htmlSafe(responsive);
-  }
-}
diff --git a/addon/helpers/responsive-image-resolve.ts b/addon/helpers/responsive-image-resolve.ts
new file mode 100644
index 00000000..9a394c64
--- /dev/null
+++ b/addon/helpers/responsive-image-resolve.ts
@@ -0,0 +1,24 @@
+import { inject as service } from '@ember/service';
+import { htmlSafe } from '@ember/string';
+import Helper from '@ember/component/helper';
+import ResponsiveImageService from 'ember-responsive-image/services/responsive-image';
+
+/**
+ * @class responsiveImageResolve
+ * @namespace Helpers
+ * @extends Ember.Helper
+ * @public
+ */
+export default class ResponsiveImageResolve extends Helper {
+  @service
+  responsiveImage!: ResponsiveImageService;
+
+  compute(
+    [image, size]: [string, number | undefined] /*, hash*/
+  ): ReturnType<typeof htmlSafe> | undefined {
+    const responsive = this.responsiveImage.getImageMetaBySize(image, size)
+      ?.image;
+
+    return responsive ? htmlSafe(responsive) : undefined;
+  }
+}
diff --git a/addon/services/responsive-image.ts b/addon/services/responsive-image.ts
index d23bacdd..c7872a34 100644
--- a/addon/services/responsive-image.ts
+++ b/addon/services/responsive-image.ts
@@ -20,11 +20,6 @@ export interface Meta {
 
 /**
  * Service class to provides images generated by the responsive images package
- *
- * @class ResponsiveImageService
- * @extends Service
- * @module responsive-image
- * @public
  */
 export default class ResponsiveImageService extends Service {
   /**
@@ -32,30 +27,16 @@ export default class ResponsiveImageService extends Service {
    * This is the base value to calculate the image size.
    * That means the {{#crossLink "Services/ResponsiveImage:getImageBySize"}}getImageBySize{{/crossLink}} will return
    * an image that's close to `screenWidth *  window.devicePixelRatio * size / 100`
-   *
-   * @property screenWidth
-   * @type {Number}
-   * @public
    */
   screenWidth = screenWidth;
 
   /**
    * the physical width
-   *
-   * @property physicalWidth
-   * @type {Number}
-   * @public
    */
   physicalWidth = this.screenWidth * ((window && window.devicePixelRatio) || 1);
 
   /**
    * return the images with the different widths
-   *
-   * @method getImages
-   * @param {String} imageName The origin name of the Image
-   * @param {String} type The image type (jpeg, png, webp etc.). Optional, leave undefined to get all images
-   * @returns {Array} An array of objects with the image name and width, e.g. [{ width: 40, height: 20, image: myImage40w.jpg}, ...]
-   * @public
    */
   getImages(imageName: string, type?: ImageType): ImageMeta[] {
     assert(
@@ -90,85 +71,47 @@ export default class ResponsiveImageService extends Service {
   }
 
   /**
-   * returns the image which fits for given size
-   *
-   * @method getImageBySize
-   * @param {String} imageName The origin name of the Image
-   * @param {Number} size The width of the image in percent of the screenwidth
-   * @param {String} type The image type (jpeg, png, webp etc.)
-   * @return {String} The url of the image
-   * @public
+   * returns the image data which fits for given size (in vw)
    */
-  getImageBySize(
+  getImageMetaBySize(
     imageName: string,
     size?: number,
-    type?: ImageType
-  ): string | undefined {
-    return this.getImageDataBySize(imageName, size, type)?.image;
+    type: ImageType = this.getType(imageName)
+  ): ImageMeta | undefined {
+    const width = this.getDestinationWidthBySize(size ?? 0);
+    return this.getImageMetaByWidth(imageName, width, type);
   }
 
   /**
-   * returns the image data which fits for given size
-   *
-   * @method getImageDataBySize
-   * @param {String} imageName The origin name of the Image
-   * @param {Number} size The width of the image in percent of the screenwidth
-   * @param {String} type The image type (jpeg, png, webp etc.)
-   * @return {Object} The data with image,width and height
-   * @public
+   * returns the image data which fits for given width (in px)
    */
-  getImageDataBySize(
+  getImageMetaByWidth(
     imageName: string,
-    size?: number,
+    width: number,
     type: ImageType = this.getType(imageName)
   ): ImageMeta | undefined {
-    const width = this.getDestinationImageWidthBySize(imageName, size);
-    return this.getImages(imageName).find(
-      (img) => img.width === width && img.type === type
-    );
+    return this.getImages(imageName)
+      .filter((img) => img.type === type)
+      .reduce((prevValue: ImageMeta | undefined, imageMeta: ImageMeta) => {
+        if (prevValue === undefined) {
+          return imageMeta;
+        }
+
+        if (imageMeta.width >= width && prevValue.width >= width) {
+          return imageMeta.width >= prevValue.width ? prevValue : imageMeta;
+        } else {
+          return imageMeta.width >= prevValue.width ? imageMeta : prevValue;
+        }
+      }, undefined);
   }
 
-  /**
-   * returns the closest supported width to the screenwidth and size
-   *
-   * @method getDestinationImageWidthBySize
-   * @param {String} imageName The name of the image
-   * @param {Number} size The width of the image in percent of the screenwidth
-   * @return {Number} the supported width
-   * @private
-   */
-  getDestinationImageWidthBySize(imageName: string, size = 100): number {
-    const destinationWidth = this.getDestinationWidthBySize(size);
-    return this.getSupportedWidths(imageName).reduce((prevValue, item) => {
-      if (item >= destinationWidth && prevValue >= destinationWidth) {
-        return item >= prevValue ? prevValue : item;
-      } else {
-        return item >= prevValue ? item : prevValue;
-      }
-    }, 0);
-  }
+  getAspectRatio(imageName: string): number | undefined {
+    const meta = this.getImages(imageName)[0];
 
-  /**
-   * returns the supported widths of an image
-   *
-   * @method getSupportedWidths
-   * @param {String} imageName the name of the image
-   * @return {Number[]} the supported widths
-   * @private
-   */
-  getSupportedWidths(imageName: string): number[] {
-    return this.getImages(imageName).map((item) => {
-      return item.width;
-    });
+    return meta ? meta.width / meta.height : undefined;
   }
 
-  /**
-   * @method getDestinationWidthBySize
-   * @param {Number} size returns the physical width factored by size
-   * @returns {Number}
-   * @private
-   */
-  getDestinationWidthBySize(size: number): number {
+  private getDestinationWidthBySize(size: number): number {
     const physicalWidth = this.physicalWidth;
     const factor = (size || 100) / 100;
 
@@ -179,10 +122,6 @@ export default class ResponsiveImageService extends Service {
 
   /**
    * the meta values from build time
-   *
-   * @property meta
-   * @type {object}
-   * @private
    */
   get meta(): Record<string, Meta> {
     if (this._meta) {
diff --git a/addon/styles/ember-responsive-image.css b/addon/styles/ember-responsive-image.css
new file mode 100644
index 00000000..059e445c
--- /dev/null
+++ b/addon/styles/ember-responsive-image.css
@@ -0,0 +1,4 @@
+.eri-responsive {
+  width: 100%;
+  height: auto;
+}


Test patch:
diff --git a/tests/dummy/app/templates/index.hbs b/tests/dummy/app/templates/index.hbs
index 5bbf9694..6c66757f 100644
--- a/tests/dummy/app/templates/index.hbs
+++ b/tests/dummy/app/templates/index.hbs
@@ -1,1 +1,9 @@
+<h2>Fixed</h2>
+
+<ResponsiveImage @width={{200}} @image="assets/images/test.png"/>
+
+<h2>Responsive</h2>
+
 <ResponsiveImage @image="assets/images/test.png"/>
+
+
diff --git a/tests/integration/components/responsive-image-test.js b/tests/integration/components/responsive-image-test.js
index 4f73bfd5..04fe5083 100644
--- a/tests/integration/components/responsive-image-test.js
+++ b/tests/integration/components/responsive-image-test.js
@@ -1,169 +1,310 @@
-import { find, render } from '@ember/test-helpers';
+import { render } from '@ember/test-helpers';
 import { setupRenderingTest } from 'ember-qunit';
 import hbs from 'htmlbars-inline-precompile';
-import { test, module } from 'qunit';
+import { module, test } from 'qunit';
 
 module('Integration: Responsive Image Component', function (hooks) {
   setupRenderingTest(hooks);
 
-  test('it renders a source for every format', async function (assert) {
-    await render(hbs`<ResponsiveImage @image="assets/images/test.png"/>`);
+  module('responsive layout', function () {
+    test('it has responsive layout by default', async function (assert) {
+      await render(hbs`<ResponsiveImage @image="assets/images/test.png"/>`);
 
-    assert.dom('picture').exists({ count: 1 });
-    assert.dom('picture source').exists({ count: 2 });
-    assert.dom('picture source[type="image/png"]').exists({ count: 1 });
-    assert.dom('picture source[type="image/webp"]').exists({ count: 1 });
-  });
+      assert.dom('img').hasClass('eri-responsive');
+      assert.dom('img').hasNoClass('eri-fixed');
+    });
 
-  test('it renders the correct sourceset', async function (assert) {
-    await render(hbs`<ResponsiveImage @image="assets/images/test.png"/>`);
-    // png
-    assert
-      .dom('picture source[type="image/png"]')
-      .hasAttribute('srcset', new RegExp('/assets/images/test100w.png 100w'));
-    assert
-      .dom('picture source[type="image/png"]')
-      .hasAttribute('srcset', new RegExp('/assets/images/test50w.png 50w'));
-
-    // webp
-    assert
-      .dom('picture source[type="image/webp"]')
-      .hasAttribute('srcset', new RegExp('/assets/images/test100w.webp 100w'));
-    assert
-      .dom('picture source[type="image/webp"]')
-      .hasAttribute('srcset', new RegExp('/assets/images/test50w.webp 50w'));
-
-    await render(hbs`<ResponsiveImage @image="assets/images/small.png"/>`);
-    // png
-    assert
-      .dom('picture source[type="image/png"]')
-      .hasAttribute('srcset', new RegExp('/assets/images/small10w.png 10w'));
-    assert
-      .dom('picture source[type="image/png"]')
-      .hasAttribute('srcset', new RegExp('/assets/images/small25w.png 25w'));
-
-    // webp
-    assert
-      .dom('picture source[type="image/webp"]')
-      .hasAttribute('srcset', new RegExp('/assets/images/small10w.webp 10w'));
-    assert
-      .dom('picture source[type="image/webp"]')
-      .hasAttribute('srcset', new RegExp('/assets/images/small25w.webp 25w'));
-
-    await render(hbs`<ResponsiveImage @image="assets/images/small.png"/>`);
-    // png
-    assert
-      .dom('picture source[type="image/png"]')
-      .hasAttribute('srcset', new RegExp('/assets/images/small10w.png 10w'));
-    assert
-      .dom('picture source[type="image/png"]')
-      .hasAttribute('srcset', new RegExp('/assets/images/small25w.png 25w'));
-
-    // webp
-    assert
-      .dom('picture source[type="image/webp"]')
-      .hasAttribute('srcset', new RegExp('/assets/images/small10w.webp 10w'));
-    assert
-      .dom('picture source[type="image/webp"]')
-      .hasAttribute('srcset', new RegExp('/assets/images/small25w.webp 25w'));
+    test('it renders width and height attributes', async function (assert) {
+      await render(hbs`<ResponsiveImage @image="assets/images/test.png"/>`);
 
-    await render(
-      hbs`<ResponsiveImage @image="assets/images/recursive/dir/test.png"/>`
-    );
-    // png
-    assert
-      .dom('picture source[type="image/png"]')
-      .hasAttribute(
-        'srcset',
-        new RegExp('/assets/images/recursive/dir/test100w.png 100w')
-      );
-    assert
-      .dom('picture source[type="image/png"]')
-      .hasAttribute(
-        'srcset',
-        new RegExp('/assets/images/recursive/dir/test50w.png 50w')
-      );
-
-    // webp
-    assert
-      .dom('picture source[type="image/webp"]')
-      .hasAttribute(
-        'srcset',
-        new RegExp('/assets/images/recursive/dir/test100w.webp 100w')
-      );
-    assert
-      .dom('picture source[type="image/webp"]')
-      .hasAttribute(
-        'srcset',
-        new RegExp('/assets/images/recursive/dir/test50w.webp 50w')
+      assert.dom('img').hasAttribute('width', '640');
+      assert.dom('img').hasAttribute('height', '320');
+    });
+
+    test('it renders the correct sourceset with width descriptors', async function (assert) {
+      await render(hbs`<ResponsiveImage @image="assets/images/test.png"/>`);
+      // png
+      assert
+        .dom('picture source[type="image/png"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/test640w.png 640w'));
+      assert
+        .dom('picture source[type="image/png"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/test100w.png 100w'));
+      assert
+        .dom('picture source[type="image/png"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/test50w.png 50w'));
+
+      // webp
+      assert
+        .dom('picture source[type="image/webp"]')
+        .hasAttribute(
+          'srcset',
+          new RegExp('/assets/images/test640w.webp 640w')
+        );
+      assert
+        .dom('picture source[type="image/webp"]')
+        .hasAttribute(
+          'srcset',
+          new RegExp('/assets/images/test100w.webp 100w')
+        );
+      assert
+        .dom('picture source[type="image/webp"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/test50w.webp 50w'));
+
+      await render(hbs`<ResponsiveImage @image="assets/images/small.png"/>`);
+      // png
+      assert
+        .dom('picture source[type="image/png"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/small10w.png 10w'));
+      assert
+        .dom('picture source[type="image/png"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/small25w.png 25w'));
+
+      // webp
+      assert
+        .dom('picture source[type="image/webp"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/small10w.webp 10w'));
+      assert
+        .dom('picture source[type="image/webp"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/small25w.webp 25w'));
+
+      await render(
+        hbs`<ResponsiveImage @image="assets/images/recursive/dir/test.png"/>`
       );
-  });
+      // png
+      assert
+        .dom('picture source[type="image/png"]')
+        .hasAttribute(
+          'srcset',
+          new RegExp('/assets/images/recursive/dir/test100w.png 100w')
+        );
+      assert
+        .dom('picture source[type="image/png"]')
+        .hasAttribute(
+          'srcset',
+          new RegExp('/assets/images/recursive/dir/test50w.png 50w')
+        );
 
-  test('it renders a given size as sizes', async function (assert) {
-    await render(
-      hbs`<ResponsiveImage @image="assets/images/test.png" @size="40"/>`
-    );
-    assert
-      .dom('picture source[type="image/png"]')
-      .hasAttribute('sizes', '40vw');
-    assert
-      .dom('picture source[type="image/webp"]')
-      .hasAttribute('sizes', '40vw');
+      // webp
+      assert
+        .dom('picture source[type="image/webp"]')
+        .hasAttribute(
+          'srcset',
+          new RegExp('/assets/images/recursive/dir/test100w.webp 100w')
+        );
+      assert
+        .dom('picture source[type="image/webp"]')
+        .hasAttribute(
+          'srcset',
+          new RegExp('/assets/images/recursive/dir/test50w.webp 50w')
+        );
+    });
+
+    test('it renders the fallback src next to needed display size', async function (assert) {
+      let service = this.owner.lookup('service:responsive-image');
+      service.set('physicalWidth', 45);
+      await render(hbs`<ResponsiveImage @image="assets/images/test.png"/>`);
+      assert.dom('img').hasAttribute('src', '/assets/images/test50w.png');
+      service.set('physicalWidth', 51);
+      await render(hbs`<ResponsiveImage @image="assets/images/test.png"/>`);
+      assert.dom('img').hasAttribute('src', '/assets/images/test100w.png');
+      service.set('physicalWidth', 9);
+      await render(hbs`<ResponsiveImage @image="assets/images/small.png"/>`);
+      assert.dom('img').hasAttribute('src', '/assets/images/small10w.png');
+      service.set('physicalWidth', 11);
+      await render(hbs`<ResponsiveImage @image="assets/images/small.png"/>`);
+      assert.dom('img').hasAttribute('src', '/assets/images/small25w.png');
+      service.set('physicalWidth', 45);
+      await render(
+        hbs`<ResponsiveImage @image="assets/images/recursive/dir/test.png"/>`
+      );
+      assert
+        .dom('img')
+        .hasAttribute('src', '/assets/images/recursive/dir/test50w.png');
+      service.set('physicalWidth', 51);
+      await render(
+        hbs`<ResponsiveImage @image="assets/images/recursive/dir/test.png"/>`
+      );
+      assert
+        .dom('img')
+        .hasAttribute('src', '/assets/images/recursive/dir/test100w.png');
+    });
+
+    test('it renders a given size as sizes', async function (assert) {
+      await render(
+        hbs`<ResponsiveImage @image="assets/images/test.png" @size="40"/>`
+      );
+      assert
+        .dom('picture source[type="image/png"]')
+        .hasAttribute('sizes', '40vw');
+      assert
+        .dom('picture source[type="image/webp"]')
+        .hasAttribute('sizes', '40vw');
+    });
+
+    test('it renders with given sizes', async function (assert) {
+      await render(
+        hbs`<ResponsiveImage @image="assets/images/test.png" @sizes="(max-width: 767px) 100vw, 50vw"/>`
+      );
+      assert
+        .dom('picture source[type="image/png"]')
+        .hasAttribute('sizes', '(max-width: 767px) 100vw, 50vw');
+      assert
+        .dom('picture source[type="image/webp"]')
+        .hasAttribute('sizes', '(max-width: 767px) 100vw, 50vw');
+    });
   });
 
-  test('it renders with given sizes', async function (assert) {
-    await render(
-      hbs`<ResponsiveImage @image="assets/images/test.png" @sizes="(max-width: 767px) 100vw, 50vw"/>`
-    );
-    assert.equal(
-      find('picture source[type="image/png"]').getAttribute('sizes'),
-      '(max-width: 767px) 100vw, 50vw'
-    );
-    assert.equal(
-      find('picture source[type="image/webp"]').getAttribute('sizes'),
-      '(max-width: 767px) 100vw, 50vw'
-    );
+  module('fixed layout', function () {
+    test('it has fixed layout when width or height is provided', async function (assert) {
+      await render(
+        hbs`<ResponsiveImage @image="assets/images/test.png" @width={{100}}/>`
+      );
+
+      assert.dom('img').hasClass('eri-fixed');
+      assert.dom('img').hasNoClass('eri-responsive');
+
+      await render(
+        hbs`<ResponsiveImage @image="assets/images/test.png" @height={{100}}/>`
+      );
+
+      assert.dom('img').hasClass('eri-fixed');
+      assert.dom('img').hasNoClass('eri-responsive');
+    });
+
+    test('it renders width and height when given', async function (assert) {
+      await render(
+        hbs`<ResponsiveImage @image="assets/images/test.png" @width={{150}} @height={{50}} />`
+      );
+
+      assert.dom('img').hasAttribute('width', '150');
+      assert.dom('img').hasAttribute('height', '50');
+    });
+
+    test('it renders height when width is given according to aspect ratio', async function (assert) {
+      await render(
+        hbs`<ResponsiveImage @image="assets/images/test.png" @width={{150}} />`
+      );
+
+      assert.dom('img').hasAttribute('width', '150');
+      assert.dom('img').hasAttribute('height', '75');
+    });
+
+    test('it renders width when height is given according to aspect ratio', async function (assert) {
+      await render(
+        hbs`<ResponsiveImage @image="assets/images/test.png" @height={{100}} />`
+      );
+
+      assert.dom('img').hasAttribute('width', '200');
+      assert.dom('img').hasAttribute('height', '100');
+    });
+
+    test('it renders the correct sourceset with pixel densities', async function (assert) {
+      await render(
+        hbs`<ResponsiveImage @width={{50}} @image="assets/images/test.png"/>`
+      );
+      // png
+      assert
+        .dom('picture source[type="image/png"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/test100w.png 2x'));
+      assert
+        .dom('picture source[type="image/png"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/test50w.png 1x'));
+
+      // webp
+      assert
+        .dom('picture source[type="image/webp"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/test100w.webp 2x'));
+      assert
+        .dom('picture source[type="image/webp"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/test50w.webp 1x'));
+
+      await render(
+        hbs`<ResponsiveImage @width={{10}} @image="assets/images/small.png"/>`
+      );
+      // png
+      assert
+        .dom('picture source[type="image/png"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/small10w.png 1x'));
+      assert
+        .dom('picture source[type="image/png"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/small25w.png 2x'));
+
+      // webp
+      assert
+        .dom('picture source[type="image/webp"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/small10w.webp 1x'));
+      assert
+        .dom('picture source[type="image/webp"]')
+        .hasAttribute('srcset', new RegExp('/assets/images/small25w.webp 2x'));
+
+      await render(
+        hbs`<ResponsiveImage @image="assets/images/recursive/dir/test.png"/>`
+      );
+      // png
+      assert
+        .dom('picture source[type="image/png"]')
+        .hasAttribute(
+          'srcset',
+          new RegExp('/assets/images/recursive/dir/test100w.png 100w')
+        );
+      assert
+        .dom('picture source[type="image/png"]')
+        .hasAttribute(
+          'srcset',
+          new RegExp('/assets/images/recursive/dir/test50w.png 50w')
+        );
+
+      // webp
+      assert
+        .dom('picture source[type="image/webp"]')
+        .hasAttribute(
+          'srcset',
+          new RegExp('/assets/images/recursive/dir/test100w.webp 100w')
+        );
+      assert
+        .dom('picture source[type="image/webp"]')
+        .hasAttribute(
+          'srcset',
+          new RegExp('/assets/images/recursive/dir/test50w.webp 50w')
+        );
+    });
+
+    test('it renders the fallback src next to needed display size', async function (assert) {
+      await render(
+        hbs`<ResponsiveImage @width={{320}} @image="assets/images/test.png"/>`
+      );
+      assert.dom('img').hasAttribute('src', '/assets/images/test640w.png');
+
+      await render(
+        hbs`<ResponsiveImage @width={{101}} @image="assets/images/test.png"/>`
+      );
+      assert.dom('img').hasAttribute('src', '/assets/images/test640w.png');
+
+      await render(
+        hbs`<ResponsiveImage @width={{100}} @image="assets/images/test.png"/>`
+      );
+      assert.dom('img').hasAttribute('src', '/assets/images/test100w.png');
+
+      await render(
+        hbs`<ResponsiveImage @width={{51}} @image="assets/images/test.png"/>`
+      );
+      assert.dom('img').hasAttribute('src', '/assets/images/test100w.png');
+
+      await render(
+        hbs`<ResponsiveImage @width={{50}} @image="assets/images/test.png"/>`
+      );
+      assert.dom('img').hasAttribute('src', '/assets/images/test50w.png');
+    });
   });
 
-  test('it renders the fallback src next to needed display size', async function (assert) {
-    let service = this.owner.lookup('service:responsive-image');
-    service.set('physicalWidth', 45);
-    await render(hbs`<ResponsiveImage @image="assets/images/test.png"/>`);
-    assert.equal(find('img').getAttribute('src'), '/assets/images/test50w.png');
-    service.set('physicalWidth', 51);
+  test('it renders a source for every format', async function (assert) {
     await render(hbs`<ResponsiveImage @image="assets/images/test.png"/>`);
-    assert.equal(
-      find('img').getAttribute('src'),
-      '/assets/images/test100w.png'
-    );
-    service.set('physicalWidth', 9);
-    await render(hbs`<ResponsiveImage @image="assets/images/small.png"/>`);
-    assert.equal(
-      find('img').getAttribute('src'),
-      '/assets/images/small10w.png'
-    );
-    service.set('physicalWidth', 11);
-    await render(hbs`<ResponsiveImage @image="assets/images/small.png"/>`);
-    assert.equal(
-      find('img').getAttribute('src'),
-      '/assets/images/small25w.png'
-    );
-    service.set('physicalWidth', 45);
-    await render(
-      hbs`<ResponsiveImage @image="assets/images/recursive/dir/test.png"/>`
-    );
-    assert.equal(
-      find('img').getAttribute('src'),
-      '/assets/images/recursive/dir/test50w.png'
-    );
-    service.set('physicalWidth', 51);
-    await render(
-      hbs`<ResponsiveImage @image="assets/images/recursive/dir/test.png"/>`
-    );
-    assert.equal(
-      find('img').getAttribute('src'),
-      '/assets/images/recursive/dir/test100w.png'
-    );
+
+    assert.dom('picture').exists({ count: 1 });
+    assert.dom('picture source').exists({ count: 2 });
+    assert.dom('picture source[type="image/png"]').exists({ count: 1 });
+    assert.dom('picture source[type="image/webp"]').exists({ count: 1 });
   });
 
   test('it loads lazily by default', async function (assert) {
diff --git a/tests/unit/services/responsive-image-test.js b/tests/unit/services/responsive-image-test.js
index fbafb6f6..862d13ca 100644
--- a/tests/unit/services/responsive-image-test.js
+++ b/tests/unit/services/responsive-image-test.js
@@ -67,44 +67,22 @@ module('ResponsiveImageService', function (hooks) {
   test('retrieve generated image data by size', function (assert) {
     let service = this.owner.lookup('service:responsive-image');
     service.physicalWidth = 100;
-    let images = service.getImageDataBySize('test.png', 120);
+    let images = service.getImageMetaBySize('test.png', 120);
     assert.deepEqual(images, meta['test.png'].images[0]);
-    images = service.getImageDataBySize('test.png', 60);
+    images = service.getImageMetaBySize('test.png', 60);
     assert.deepEqual(images, meta['test.png'].images[0]);
-    images = service.getImageDataBySize('test.png', 45);
+    images = service.getImageMetaBySize('test.png', 45);
     assert.deepEqual(images, meta['test.png'].images[1]);
   });
 
   test('retrieve generated image data by size and type', function (assert) {
     let service = this.owner.lookup('service:responsive-image');
     service.physicalWidth = 100;
-    let images = service.getImageDataBySize('test.png', 120, 'webp');
+    let images = service.getImageMetaBySize('test.png', 120, 'webp');
     assert.deepEqual(images, meta['test.png'].images[2]);
-    images = service.getImageDataBySize('test.png', 60, 'webp');
+    images = service.getImageMetaBySize('test.png', 60, 'webp');
     assert.deepEqual(images, meta['test.png'].images[2]);
-    images = service.getImageDataBySize('test.png', 45, 'webp');
+    images = service.getImageMetaBySize('test.png', 45, 'webp');
     assert.deepEqual(images, meta['test.png'].images[3]);
   });
-
-  test('retrieve a generated image by size', function (assert) {
-    let service = this.owner.lookup('service:responsive-image');
-    service.physicalWidth = 100;
-    let image = service.getImageBySize('test.png', 120);
-    assert.equal(image, meta['test.png'].images[0].image);
-    image = service.getImageBySize('test.png', 60);
-    assert.equal(image, meta['test.png'].images[0].image);
-    image = service.getImageBySize('test.png', 45);
-    assert.equal(image, meta['test.png'].images[1].image);
-  });
-
-  test('retrieve a generated image by size and type', function (assert) {
-    let service = this.owner.lookup('service:responsive-image');
-    service.physicalWidth = 100;
-    let image = service.getImageBySize('test.png', 120, 'webp');
-    assert.equal(image, meta['test.png'].images[2].image);
-    image = service.getImageBySize('test.png', 60, 'webp');
-    assert.equal(image, meta['test.png'].images[2].image);
-    image = service.getImageBySize('test.png', 45, 'webp');
-    assert.equal(image, meta['test.png'].images[3].image);
-  });
 });