From c7dc644059206e7080c33d9f7e0096c168ae593e Mon Sep 17 00:00:00 2001 From: Emilio Franco Date: Tue, 7 Jun 2022 12:40:32 -0400 Subject: [PATCH] Generate a default srcset for an image returned by the Shopify CDN (#1330) * feat: add default img srcset to Shopify images * feat: add custom widths to Image srcset * doc: add srcset documentation * doc: add change set * feat: lift widths prop to Image level * feat: use the available loader to generate srcset * feat:reduce srcset to max-width without distortion * test: add default img srcset test * Update packages/hydrogen/src/components/Image/Image.tsx Co-authored-by: Michelle Vinci * doc: update Image docs with widths prop changes * feat: generate srcset for extarnal images * Update docs/components/primitive/image.md Co-authored-by: Michelle Vinci * test: add test for images using src Co-authored-by: Michelle Vinci Co-authored-by: Anthony Frehner --- .changeset/wild-rabbits-reflect.md | 5 + docs/components/primitive/image.md | 1 + .../hydrogen/src/components/Image/Image.tsx | 103 +++++++++++++++++- .../src/components/Image/tests/Image.test.tsx | 64 +++++++++++ 4 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 .changeset/wild-rabbits-reflect.md diff --git a/.changeset/wild-rabbits-reflect.md b/.changeset/wild-rabbits-reflect.md new file mode 100644 index 0000000000..71acb9788d --- /dev/null +++ b/.changeset/wild-rabbits-reflect.md @@ -0,0 +1,5 @@ +--- +'@shopify/hydrogen': patch +--- + +[#1245] - Generate a default srcset for an image returned by the Shopify CDN on the Image component and allow using a custom set of `widths.` diff --git a/docs/components/primitive/image.md b/docs/components/primitive/image.md index 03e54ff83a..5b0aff6191 100644 --- a/docs/components/primitive/image.md +++ b/docs/components/primitive/image.md @@ -74,6 +74,7 @@ export default function ExternalImageWithLoader() { | height | height | string | The integer or string value for the height of the image. This is a required prop when `src` is present. | | loader? | (props: ShopifyLoaderParams | LoaderOptions) => string | A custom function that generates the image URL. Parameters passed in are either `ShopifyLoaderParams` if using the `data` prop, or the `LoaderOptions` object that you pass to `loaderOptions`. | | loaderOptions? | ShopifyLoaderOptions | LoaderOptions | An object of `loader` function options. For example, if the `loader` function requires a `scale` option, then the value can be a property of the `loaderOptions` object (for example, `{scale: 2}`). When the `data` prop is used, the object shape will be `ShopifyLoaderOptions`. When the `src` prop is used, the data shape is whatever you define it to be, and this shape will be passed to `loader`. | +| widths? | (number | string)[] | An array of pixel widths to overwrite the default generated srcset. For example, `[300, 600, 800]`. It only applies to images from Shopify CDN. ## Component type diff --git a/packages/hydrogen/src/components/Image/Image.tsx b/packages/hydrogen/src/components/Image/Image.tsx index b48cedef3c..7686ad8aab 100644 --- a/packages/hydrogen/src/components/Image/Image.tsx +++ b/packages/hydrogen/src/components/Image/Image.tsx @@ -1,5 +1,9 @@ import * as React from 'react'; -import {getShopifyImageDimensions, shopifyImageLoader} from '../../utilities'; +import { + getShopifyImageDimensions, + shopifyImageLoader, + addImageSizeParametersToUrl, +} from '../../utilities'; import type {Image as ImageType} from '../../storefront-api-types'; import type {PartialDeep, Simplify, SetRequired} from 'type-fest'; @@ -62,6 +66,10 @@ export type ShopifyImageProps = Omit & { * 'src' shouldn't be passed when 'data' is used. */ src?: never; + /** + * An array of pixel widths to overwrite the default generated srcset. For example, `[300, 600, 800]`. + */ + widths?: (HtmlImageProps['width'] | ImageType['width'])[]; }; function ShopifyImage({ @@ -71,6 +79,7 @@ function ShopifyImage({ loading, loader = shopifyImageLoader, loaderOptions, + widths, ...rest }: ShopifyImageProps) { if (!data.url) { @@ -116,6 +125,20 @@ function ShopifyImage({ } } + // determining what the intended width of the image is. For example, if the width is specified and lower than the image width, then that is the maximum image width + // to prevent generating a srcset with widths bigger than needed or to generate images that would distort because of being larger than original + const maxWidth = + width && finalWidth && width < finalWidth ? width : finalWidth; + const finalSrcset = + rest.srcSet ?? + internalImageSrcSet({ + ...loaderOptions, + widths, + src: data.url, + width: maxWidth, + loader, + }); + /* eslint-disable hydrogen/prefer-image-component */ return ( ); /* eslint-enable hydrogen/prefer-image-component */ @@ -176,6 +200,10 @@ type ExternalImageProps = SetRequired< * 'data' shouldn't be passed when 'src' is used. */ data?: never; + /** + * An array of pixel widths to generate a srcset. For example, `[300, 600, 800]`. + */ + widths?: HtmlImageProps['width'][]; }; function ExternalImage({ @@ -185,6 +213,7 @@ function ExternalImage({ alt, loader, loaderOptions, + widths, loading, ...rest }: ExternalImageProps) { @@ -200,6 +229,15 @@ function ExternalImage({ ); } + if ( + widths && + Array.isArray(widths) && + widths.some((size) => isNaN(size as number)) + ) + throw new Error( + `: the 'widths' property must be an array of numbers` + ); + let finalSrc = src; if (loader) { @@ -208,6 +246,25 @@ function ExternalImage({ throw new Error(`: 'loader' did not return a valid string`); } } + let finalSrcset = rest.srcSet ?? undefined; + + if (!finalSrcset && loader && widths) { + // Height is a requirement in the LoaderProps, so to keep the aspect ratio, we must determine the height based on the default values + const heightToWidthRatio = + parseInt(height as string) / parseInt(width as string); + finalSrcset = widths + ?.map((width) => parseInt(width as string, 10)) + ?.map( + (width) => + `${loader({ + ...loaderOptions, + src, + width, + height: Math.floor(width * heightToWidthRatio), + })} ${width}w` + ) + .join(', '); + } /* eslint-disable hydrogen/prefer-image-component */ return ( @@ -218,7 +275,51 @@ function ExternalImage({ height={height} alt={alt ?? ''} loading={loading ?? 'lazy'} + srcSet={finalSrcset} /> ); /* eslint-enable hydrogen/prefer-image-component */ } + +type InternalShopifySrcSetGeneratorsParams = Simplify< + ShopifyLoaderOptions & { + src: ImageType['url']; + widths?: (HtmlImageProps['width'] | ImageType['width'])[]; + loader?: (params: ShopifyLoaderParams) => string; + } +>; +// based on the default width sizes used by the Shopify liquid HTML tag img_tag plus a 2560 width to account for 2k resolutions +// reference: https://shopify.dev/api/liquid/filters/html-filters#image_tag +const IMG_SRC_SET_SIZES = [352, 832, 1200, 1920, 2560]; +function internalImageSrcSet({ + src, + width, + crop, + scale, + widths, + loader, +}: InternalShopifySrcSetGeneratorsParams) { + const hasCustomWidths = widths && Array.isArray(widths); + if (hasCustomWidths && widths.some((size) => isNaN(size as number))) + throw new Error(`: the 'widths' must be an array of numbers`); + + let setSizes = hasCustomWidths ? widths : IMG_SRC_SET_SIZES; + if ( + !hasCustomWidths && + width && + width < IMG_SRC_SET_SIZES[IMG_SRC_SET_SIZES.length - 1] + ) + setSizes = IMG_SRC_SET_SIZES.filter((size) => size <= width); + const srcGenerator = loader ? loader : addImageSizeParametersToUrl; + return setSizes + .map( + (size) => + `${srcGenerator({ + src, + width: size, + crop, + scale, + })} ${size}w` + ) + .join(', '); +} diff --git a/packages/hydrogen/src/components/Image/tests/Image.test.tsx b/packages/hydrogen/src/components/Image/tests/Image.test.tsx index 020734df09..55ad298f2f 100644 --- a/packages/hydrogen/src/components/Image/tests/Image.test.tsx +++ b/packages/hydrogen/src/components/Image/tests/Image.test.tsx @@ -144,6 +144,23 @@ describe('', () => { alt: 'Fancy image', }); }); + + it('generates a default srcset', () => { + const mockUrl = 'https://cdn.shopify.com/someimage.jpg'; + const sizes = [352, 832, 1200, 1920, 2560]; + const expectedSrcset = sizes + .map((size) => `${mockUrl}?width=${size} ${size}w`) + .join(', '); + const image = getPreviewImage({ + url: mockUrl, + }); + + const component = mount(); + + expect(component).toContainReactComponent('img', { + srcSet: expectedSrcset, + }); + }); }); describe('External image', () => { @@ -281,6 +298,53 @@ describe('', () => { alt: 'Fancy image', }); }); + + it('generates a srcset when a loader and a widths prop are provided', () => { + const mockUrl = 'https://cdn.externalImg.com/someimage.jpg'; + const sizes = [352, 832, 1200]; + const loaderOptions = { + width: 600, + height: 800, + scale: 1, + }; + + const loader = (loaderOptions: { + src: string; + width: number; + height: number; + scale: number; + }): string => + `${loaderOptions.src}?w=${loaderOptions.width}&h=${loaderOptions.height}`; + + const heightToWidthRatio = loaderOptions.height / loaderOptions.width; + const expectedSrcset = sizes + .map( + (size) => + `${loader({ + src: mockUrl, + width: size, + height: Math.floor(size * heightToWidthRatio), + scale: loaderOptions.scale, + })} ${size}w` + ) + .join(', '); + + const component = mount( + {'Fancy + ); + + expect(component).toContainReactComponent('img', { + srcSet: expectedSrcset, + }); + }); }); // eslint-disable-next-line jest/expect-expect