From a52f736314687b971a9ebeb8d2cb241f57d4ad3f Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 6 Mar 2023 15:41:04 -0500 Subject: [PATCH 01/46] New Image PR --- .../src/ExternalVideo.test.helpers.ts | 2 +- packages/hydrogen-react/src/Image.stories.tsx | 100 ++- packages/hydrogen-react/src/Image.test.tsx | 296 +-------- packages/hydrogen-react/src/Image.tsx | 621 +++++++++++++----- .../src/{Image.doc.ts => ImageLegacy.doc.ts} | 0 ...ge.example.jsx => ImageLegacy.example.jsx} | 0 ...ge.example.tsx => ImageLegacy.example.tsx} | 0 .../src/ImageLegacy.stories.tsx | 45 ++ ...helpers.ts => ImageLegacy.test.helpers.ts} | 0 .../hydrogen-react/src/ImageLegacy.test.tsx | 337 ++++++++++ packages/hydrogen-react/src/ImageLegacy.tsx | 220 +++++++ .../src/MediaFile.test.helpers.ts | 2 +- .../src/ModelViewer.test.helpers.ts | 2 +- .../src/ProductProvider.test.helpers.ts | 2 +- .../hydrogen-react/src/Video.test.helpers.ts | 2 +- packages/hydrogen-react/src/image-size.ts | 2 +- 16 files changed, 1144 insertions(+), 487 deletions(-) rename packages/hydrogen-react/src/{Image.doc.ts => ImageLegacy.doc.ts} (100%) rename packages/hydrogen-react/src/{Image.example.jsx => ImageLegacy.example.jsx} (100%) rename packages/hydrogen-react/src/{Image.example.tsx => ImageLegacy.example.tsx} (100%) create mode 100644 packages/hydrogen-react/src/ImageLegacy.stories.tsx rename packages/hydrogen-react/src/{Image.test.helpers.ts => ImageLegacy.test.helpers.ts} (100%) create mode 100644 packages/hydrogen-react/src/ImageLegacy.test.tsx create mode 100644 packages/hydrogen-react/src/ImageLegacy.tsx diff --git a/packages/hydrogen-react/src/ExternalVideo.test.helpers.ts b/packages/hydrogen-react/src/ExternalVideo.test.helpers.ts index cffa8a015f..a1456e93c8 100644 --- a/packages/hydrogen-react/src/ExternalVideo.test.helpers.ts +++ b/packages/hydrogen-react/src/ExternalVideo.test.helpers.ts @@ -1,7 +1,7 @@ import {PartialDeep} from 'type-fest'; import type {ExternalVideo as ExternalVideoType} from './storefront-api-types.js'; import {faker} from '@faker-js/faker'; -import {getPreviewImage} from './Image.test.helpers.js'; +import {getPreviewImage} from './ImageLegacy.test.helpers.js'; export function getExternalVideoData( externalVideo: Partial = {}, diff --git a/packages/hydrogen-react/src/Image.stories.tsx b/packages/hydrogen-react/src/Image.stories.tsx index 33648fcda5..393d54a11e 100644 --- a/packages/hydrogen-react/src/Image.stories.tsx +++ b/packages/hydrogen-react/src/Image.stories.tsx @@ -1,45 +1,77 @@ import * as React from 'react'; import type {Story} from '@ladle/react'; -import {Image, type ShopifyImageProps} from './Image.js'; -import {IMG_SRC_SET_SIZES} from './image-size.js'; +import {Image, ShopifyLoaderOptions, ShopifyLoaderParams} from './Image.js'; +import type {PartialDeep} from 'type-fest'; +import type {Image as ImageType} from './storefront-api-types.js'; + +type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right'; + +type ImageConfig = { + intervals: number; + startingWidth: number; + incrementSize: number; + placeholderWidth: number; +}; + +type HtmlImageProps = React.ImgHTMLAttributes; const Template: Story<{ - 'data.url': ShopifyImageProps['data']['url']; - 'data.width': ShopifyImageProps['data']['width']; - 'data.height': ShopifyImageProps['data']['height']; - width: ShopifyImageProps['width']; - height: ShopifyImageProps['height']; - widths: ShopifyImageProps['widths']; - loaderOptions: ShopifyImageProps['loaderOptions']; + as?: 'img' | 'source'; + data?: PartialDeep; + loader?: (params: ShopifyLoaderParams) => string; + src: string; + width?: string | number; + height?: string | number; + crop?: Crop; + sizes?: string; + aspectRatio?: string; + config?: ImageConfig; + alt?: string; + loading?: 'lazy' | 'eager'; + loaderOptions?: ShopifyLoaderOptions; + widths?: (HtmlImageProps['width'] | ImageType['width'])[]; }> = (props) => { - const finalProps: ShopifyImageProps = { - data: { - url: props['data.url'], - width: props['data.width'], - height: props['data.height'], - id: 'testing', - }, - width: props.width, - height: props.height, - widths: props.widths, - loaderOptions: props.loaderOptions, - }; - return ; + return ( + <> + {/* Standard Usage */} + + {/* */} + + + + + + + + + + ); }; export const Default = Template.bind({}); Default.args = { - 'data.url': - 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', - 'data.width': 100, - 'data.height': 100, - width: 500, - height: 500, - widths: IMG_SRC_SET_SIZES, - loaderOptions: { - crop: 'center', - scale: 2, - width: 500, - height: 500, + data: { + url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', + altText: 'alt text', + width: 3908, + height: 3908, }, }; diff --git a/packages/hydrogen-react/src/Image.test.tsx b/packages/hydrogen-react/src/Image.test.tsx index 7a1ea82234..5b80fb7fba 100644 --- a/packages/hydrogen-react/src/Image.test.tsx +++ b/packages/hydrogen-react/src/Image.test.tsx @@ -1,337 +1,67 @@ -import {vi} from 'vitest'; import {render, screen} from '@testing-library/react'; import {Image} from './Image.js'; -import * as utilities from './image-size.js'; -import {getPreviewImage} from './Image.test.helpers.js'; -describe('', () => { - beforeAll(() => { - // eslint-disable-next-line @typescript-eslint/no-empty-function - vi.spyOn(console, 'error').mockImplementation(() => {}); - }); +const defaultProps = { + src: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', +}; +describe('', () => { it('renders an `img` element', () => { - const previewImage = getPreviewImage(); - const {url: src, altText, id, width, height} = previewImage; - render(); + render(); const image = screen.getByRole('img'); expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('src', src); - expect(image).toHaveAttribute('id', id); - expect(image).toHaveAttribute('alt', altText); - expect(image).toHaveAttribute('width', `${width ?? ''}`); - expect(image).toHaveAttribute('height', `${height ?? ''}`); expect(image).toHaveAttribute('loading', 'lazy'); }); it('renders an `img` element with provided `id`', () => { - const previewImage = getPreviewImage(); - const id = 'catImage'; - render(); - const image = screen.getByRole('img'); - + render(); expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('id', id); }); it('renders an `img` element with provided `loading` value', () => { - const previewImage = getPreviewImage(); - const loading = 'eager'; - render(); - const image = screen.getByRole('img'); - + render(); expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('loading', loading); }); it('renders an `img` with `width` and `height` values', () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - }); - const options = {scale: 2 as const}; - const mockDimensions = { - width: 200, - height: 100, - }; - - vi.spyOn(utilities, 'getShopifyImageDimensions').mockReturnValue( - mockDimensions, - ); - - render(); - const image = screen.getByRole('img'); - + render(); expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('width', `${mockDimensions.width}`); - expect(image).toHaveAttribute('height', `${mockDimensions.height}`); }); it('renders an `img` element without `width` and `height` attributes when invalid dimensions are provided', () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - }); - const options = {scale: 2 as const}; - const mockDimensions = { - width: null, - height: null, - }; - - vi.spyOn(utilities, 'getShopifyImageDimensions').mockReturnValue( - mockDimensions, - ); - - render(); - const image = screen.getByRole('img'); - + render(); expect(image).toBeInTheDocument(); - expect(image).not.toHaveAttribute('width'); - expect(image).not.toHaveAttribute('height'); }); describe('Loaders', () => { it('calls `shopifyImageLoader()` when no `loader` prop is provided', () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - }); - - const transformedSrc = 'https://cdn.shopify.com/someimage_100x200@2x.jpg'; - - const options = {width: 100, height: 200, scale: 2 as const}; - - const shopifyImageLoaderSpy = vi - .spyOn(utilities, 'shopifyImageLoader') - .mockReturnValue(transformedSrc); - - render(); - - expect(shopifyImageLoaderSpy).toHaveBeenCalledWith({ - src: previewImage.url, - ...options, - }); - const image = screen.getByRole('img'); - + render(); expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('src', transformedSrc); }); }); it('allows passthrough props', () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - }); - - render( - Fancy image, - ); - const image = screen.getByRole('img'); - + render(); expect(image).toBeInTheDocument(); - expect(image).toHaveClass('fancyImage'); - expect(image).toHaveAttribute('id', '123'); - expect(image).toHaveAttribute('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 previewImage = getPreviewImage({ - url: mockUrl, - width: 2560, - height: 2560, - }); - - render(); - const image = screen.getByRole('img'); - + render(); expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('srcSet', expectedSrcset); }); it('generates a default srcset up to the image height and width', () => { - const mockUrl = 'https://cdn.shopify.com/someimage.jpg'; - const sizes = [352, 832]; - const expectedSrcset = sizes - .map((size) => `${mockUrl}?width=${size} ${size}w`) - .join(', '); - const previewImage = getPreviewImage({ - url: mockUrl, - width: 832, - height: 832, - }); - - render(); - - const image = screen.getByRole('img'); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('srcSet', expectedSrcset); - }); - - it(`uses scale to multiply the srcset width but not the element width, and when crop is missing, does not include height in srcset`, () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - width: 500, - height: 500, - }); - - render(); - - const image = screen.getByRole('img'); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute( - 'srcSet', - // height is not applied if there is no crop - // width is not doulbe of the passed width, but instead double of the value in 'sizes_array' / '[number]w' - `${previewImage.url}?width=704 352w`, - ); - expect(image).toHaveAttribute('width', '500'); - expect(image).toHaveAttribute('height', '500'); - }); - - it(`uses scale to multiply the srcset width but not the element width, and when crop is there, includes height in srcset`, () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - width: 500, - height: 500, - }); - - render( - , - ); - - const image = screen.getByRole('img'); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute( - 'srcSet', - // height is the aspect ratio (of width + height) * srcSet width, so in this case it should be half of width - `${previewImage.url}?width=704&height=352&crop=bottom 352w`, - ); - expect(image).toHaveAttribute('width', '500'); - expect(image).toHaveAttribute('height', '250'); - }); - - it(`uses scale to multiply the srcset width but not the element width, and when crop is there, includes height in srcset using data.width / data.height for the aspect ratio`, () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - width: 500, - height: 500, - }); - - render( - , - ); - - const image = screen.getByRole('img'); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute( - 'srcSet', - // height is the aspect ratio (of data.width + data.height) * srcSet width, so in this case it should be the same as width - `${previewImage.url}?width=704&height=704&crop=bottom 352w`, - ); - expect(image).toHaveAttribute('width', '500'); - expect(image).toHaveAttribute('height', '500'); - }); - - it(`uses scale to multiply the srcset width but not the element width, and when crop is there, calculates height based on aspect ratio in srcset`, () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - width: 500, - height: 1000, - }); - - render( - , - ); - - const image = screen.getByRole('img'); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute( - 'srcSet', - // height is the aspect ratio (of data.width + data.height) * srcSet width, so in this case it should be double the width - `${previewImage.url}?width=704&height=1408&crop=bottom 352w`, - ); - expect(image).toHaveAttribute('width', '500'); - expect(image).toHaveAttribute('height', '1000'); - }); - - it(`should pass through width (as an inline prop) when it's a string, and use the first size in the size array for the URL width`, () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - width: 100, - height: 100, - }); - - render(); - const image = screen.getByRole('img'); - - console.log(image.getAttribute('srcSet')); - + render(); expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('src', `${previewImage.url}?width=352`); - expect(image).toHaveAttribute('width', '100%'); - expect(image).not.toHaveAttribute('height'); - }); - - it(`should pass through width (as part of loaderOptions) when it's a string, and use the first size in the size array for the URL width`, () => { - const previewImage = getPreviewImage({ - url: 'https://cdn.shopify.com/someimage.jpg', - width: 100, - height: 100, - }); - - render(); - - const image = screen.getByRole('img'); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('src', `${previewImage.url}?width=352`); - expect(image).toHaveAttribute('width', '100%'); - expect(image).not.toHaveAttribute('height'); - }); - - it(`throws an error if you don't have data.url`, () => { - expect(() => render()).toThrow(); - }); - - // eslint-disable-next-line jest/expect-expect - it.skip(`typescript types`, () => { - // this test is actually just using //@ts-expect-error as the assertion, and don't need to execute in order to have TS validation on them - // I don't love this idea, but at the moment I also don't have other great ideas for how to easily test our component TS types - - // no errors in these situations - ; - - // @ts-expect-error data and src - ; - - // @ts-expect-error foo is invalid - ; }); }); diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index 1356d6e696..c2a0c9a161 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -1,12 +1,19 @@ +// eslint-disable-next-line eslint-comments/disable-enable-pair +/* eslint-disable @typescript-eslint/restrict-template-expressions */ import * as React from 'react'; -import { - getShopifyImageDimensions, - shopifyImageLoader, - addImageSizeParametersToUrl, - IMG_SRC_SET_SIZES, -} from './image-size.js'; -import type {Image as ImageType} from './storefront-api-types.js'; import type {PartialDeep, Simplify} from 'type-fest'; +import type {Image as ImageType} from './storefront-api-types.js'; + +/* + * An optional prop you can use to change the + * default srcSet generation behaviour + */ +interface SrcSetOptions { + intervals: number; + startingWidth: number; + incrementSize: number; + placeholderWidth: number; +} type HtmlImageProps = React.ImgHTMLAttributes; @@ -16,205 +23,491 @@ export type ShopifyLoaderOptions = { width?: HtmlImageProps['width'] | ImageType['width']; height?: HtmlImageProps['height'] | ImageType['height']; }; -export type ShopifyLoaderParams = Simplify; -type ImageSrc = { - src: ImageType['url']; -}; +export type ShopifyLoaderParams = Simplify< + ShopifyLoaderOptions & { + src?: ImageType['url']; + width?: number; + height?: number; + crop?: Crop; + } +>; -export type ShopifyImageProps = Omit & - ShopifyImageBaseProps; +/* + * TODO: Expand to include focal point support; + * or switch this to be an SF API type + */ -type ShopifyImageBaseProps = { - /** An object with fields that correspond to the Storefront API's - * [Image object](https://shopify.dev/api/storefront/reference/common-objects/image). - * The `data` prop is required. - */ - data: PartialDeep; - /** A custom function that generates the image URL. Parameters passed in - * are `ShopifyLoaderParams` - */ +type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right' | undefined; + +export type ShopifyImageProps = { + as?: 'img' | 'source'; + data?: PartialDeep; + src?: string; loader?: (params: ShopifyLoaderParams) => string; - /** 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}`). The object shape is `ShopifyLoaderOptions`. - */ + width?: string | number; + height?: string | number; + crop?: Crop; + sizes?: string; + aspectRatio?: string; + srcSetOptions?: SrcSetOptions; + alt?: string; + loading?: 'lazy' | 'eager'; loaderOptions?: ShopifyLoaderOptions; - /** - * `src` isn't used, and should instead be passed as part of the `data` object - */ - src?: never; - /** - * An array of pixel widths to overwrite the default generated srcset. For example, `[300, 600, 800]`. - */ widths?: (HtmlImageProps['width'] | ImageType['width'])[]; }; -/** - * The `Image` component renders an image for the Storefront API's - * [Image object](https://shopify.dev/api/storefront/reference/common-objects/image) by using the `data` prop. You can [customize this component](https://shopify.dev/api/hydrogen/components#customizing-hydrogen-components) using passthrough props. - * - * An image's width and height are determined using the following priority list: - * 1. The width and height values for the `loaderOptions` prop - * 2. The width and height values for bare props - * 3. The width and height values for the `data` prop - * - * If only one of `width` or `height` are defined, then the other will attempt to be calculated based on the image's aspect ratio, - * provided that both `data.width` and `data.height` are available. If `data.width` and `data.height` aren't available, then the aspect ratio cannot be determined and the missing - * value will remain as `null` - */ export function Image({ + /** An object with fields that correspond to the Storefront API's + * [Image object](https://shopify.dev/api/storefront/reference/common-objects/image). + * The `data` prop is required. + */ data, + as: Component = 'img', + src, + /* + * Supports third party loaders, which are expected to provide + * a function that can generate a URL string + */ + loader = shopifyLoader, + /* + * The default behaviour is a responsive image, set to 100%, that fills + * the width of its container. It’s not declared in the props. + */ width, height, - loading, - loader = shopifyImageLoader, + /* + * The default crop is center, in the event that AspectRatio is set, + * without specifying a crop, Imagery won't return the expected image. + */ + crop = 'center', + sizes, + /* + * aspectRatio is a string in the format of 'width/height' + * it's used to generate the srcSet URLs, and to set the + * aspect ratio of the image element to prevent CLS. + */ + aspectRatio, + /* + * An optional prop you can use to change + * the default srcSet generation behaviour + */ + srcSetOptions = { + intervals: 10, + startingWidth: 300, + incrementSize: 300, + placeholderWidth: 100, + }, + alt, + loading = 'lazy', + /* + * Deprecated property from original Image component, + * you can now use the flat `crop`, `width`, and `height` props + * as well as `src` and `data` to achieve the same result. + */ loaderOptions, + /* + * Deprecated property from original Image component, + * widths are now calculated automatically based on the + * config and width props. + */ widths, - decoding = 'async', - ...rest + ...passthroughProps }: ShopifyImageProps): JSX.Element | null { - if (!data.url) { - const missingUrlError = `: the 'data' prop requires the 'url' property. Image: ${ - data.id ?? 'no ID provided' - }`; - - if (__HYDROGEN_DEV__) { - throw new Error(missingUrlError); - } else { - console.error(missingUrlError); - } - - return null; + /* + * Deprecated Props from original Image component + */ + if (loaderOptions) { + console.warn( + `Deprecated property from original Image component in use: ` + + `Use the \`crop\`, \`width\`, \`height\`, and src props, or` + + `the \`data\` prop to achieve the same result. Image used is ${ + src || data?.url + }`, + ); } - if (__HYDROGEN_DEV__ && !data.altText && !rest.alt) { + if (widths) { console.warn( - `: the 'data' prop should have the 'altText' property, or the 'alt' prop, and one of them should not be empty. Image: ${ - data.id ?? data.url - }`, + `Deprecated property from original Image component in use: ` + + `\`widths\` are now calculated automatically based on the ` + + `config and width props. Image used is ${src || data?.url}`, ); } - const {width: imgElementWidth, height: imgElementHeight} = - getShopifyImageDimensions({ - data, - loaderOptions, - elementProps: { - width, - height, - }, - }); - - if (__HYDROGEN_DEV__ && (!imgElementWidth || !imgElementHeight)) { + if (!sizes) { console.warn( - `: the 'data' prop requires either 'width' or 'data.width', and 'height' or 'data.height' properties. Image: ${ - data.id ?? data.url - }`, + 'No sizes prop provided to Image component, ' + + 'you may be loading unnecessarily large images. ' + + `Image used is ${src || data?.url}`, ); } - let finalSrc = data.url; + /* Only use data width if height is also set */ - if (loader) { - finalSrc = loader({ - ...loaderOptions, - src: data.url, - width: imgElementWidth, - height: imgElementHeight, - }); - if (typeof finalSrc !== 'string' || !finalSrc) { - throw new Error( - `: 'loader' did not return a valid string. Image: ${ - data.id ?? data.url - }`, - ); - } + const dataWidth: number | undefined = + data?.width && data?.height ? data?.width : undefined; + + const dataHeight: number | undefined = + data?.width && data?.height ? data?.height : undefined; + + const dataUnitsMatch: boolean = unitsMatch(dataWidth, dataHeight); + + /* + * Gets normalized values for width, height, src, alt, and aspectRatio props + * supporting the presence of `data` in addition to flat props. + */ + + const normalizedWidthProp: string | number = width || '100%'; + + const normalizedWidth = `${ + getUnitValueParts(normalizedWidthProp.toString()).number + } ${getUnitValueParts(normalizedWidthProp.toString()).unit}`; + + const normalizedHeight: string = + height === undefined + ? 'auto' + : `${getUnitValueParts(height.toString()).number} ${ + getUnitValueParts(height.toString()).unit + }`; + + const normalizedSrc: string | undefined = src || data?.url; + + if (!normalizedSrc) { + console.error(`No src or data.url provided to Image component.`); } - // 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 && imgElementWidth && width < imgElementWidth - ? width - : imgElementWidth; - const finalSrcset = - rest.srcSet ?? - internalImageSrcSet({ - ...loaderOptions, - widths, - src: data.url, - width: maxWidth, - height: imgElementHeight, - loader, + const normalizedAlt: string = + data?.altText && !alt ? data?.altText : alt || ''; + + const normalizedAspectRatio: string | undefined = aspectRatio + ? aspectRatio + : dataUnitsMatch + ? `${getNormalizedFixedUnit(dataWidth)}/${getNormalizedFixedUnit( + dataHeight, + )}` + : undefined; + + const {intervals, startingWidth, incrementSize, placeholderWidth} = + srcSetOptions; + + /* + * This function creates an array of widths to be used in srcSet + */ + const imageWidths = generateImageWidths( + width, + intervals, + startingWidth, + incrementSize, + ); + + /* + * We check to see whether the image is fixed width or not, + * if fixed, we still provide a srcSet, but only to account for + * different pixel densities. + */ + if (isFixedWidth(normalizedWidth)) { + const intWidth: number | undefined = getNormalizedFixedUnit(width); + const intHeight: number | undefined = getNormalizedFixedUnit(height); + + /* + * The aspect ratio for fixed with images is taken from the explicitly + * set prop, but if that's not present, and both width and height are + * set, we calculate the aspect ratio from the width and height—as + * long as they share the same unit type (e.g. both are 'px'). + */ + const fixedAspectRatio = aspectRatio + ? aspectRatio + : unitsMatch(normalizedWidth, normalizedHeight) + ? `${intWidth}/${intHeight}` + : normalizedAspectRatio + ? normalizedAspectRatio + : undefined; + + /* + * The Sizes Array generates an array of all of the parts + * that make up the srcSet, including the width, height, and crop + */ + const sizesArray = + imageWidths === undefined + ? undefined + : generateSizes(imageWidths, fixedAspectRatio, crop); + + return React.createElement(Component, { + srcSet: generateShopifySrcSet(normalizedSrc, sizesArray), + src: loader({ + src: normalizedSrc, + width: intWidth, + height: intHeight + ? intHeight + : fixedAspectRatio && intWidth + ? intWidth * (parseAspectRatio(fixedAspectRatio) ?? 1) + : undefined, + crop: normalizedHeight === 'auto' ? undefined : crop, + }), + alt: normalizedAlt, + sizes: sizes || normalizedWidth, + style: { + width: normalizedWidth, + height: normalizedHeight, + aspectRatio: fixedAspectRatio, + }, + loading, + ...passthroughProps, + }); + } else { + const sizesArray = + imageWidths === undefined + ? undefined + : generateSizes(imageWidths, normalizedAspectRatio, crop); + + return React.createElement(Component, { + srcSet: generateShopifySrcSet(normalizedSrc, sizesArray), + src: loader({ + src: normalizedSrc, + width: placeholderWidth, + height: + normalizedAspectRatio && placeholderWidth + ? placeholderWidth * (parseAspectRatio(normalizedAspectRatio) ?? 1) + : undefined, + }), + alt: normalizedAlt, + sizes, + style: { + width: normalizedWidth, + height: normalizedHeight, + aspectRatio: normalizedAspectRatio, + }, + loading, + ...passthroughProps, }); + } +} - /* eslint-disable hydrogen/prefer-image-component */ +function unitsMatch( + width: string | number = '100%', + height: string | number = 'auto', +): boolean { return ( - {data.altText + getUnitValueParts(width.toString()).unit === + getUnitValueParts(height.toString()).unit ); - /* eslint-enable hydrogen/prefer-image-component */ + /* + Given: + width = '100px' + height = 'auto' + Returns: + false + + Given: + width = '100px' + height = '50px' + Returns: + true + */ } -type InternalShopifySrcSetGeneratorsParams = Simplify< - ShopifyLoaderOptions & { - src: ImageType['url']; - widths?: (HtmlImageProps['width'] | ImageType['width'])[]; - loader?: (params: ShopifyLoaderParams) => string; +function getUnitValueParts(value: string): {unit: string; number: number} { + const unit = value.replace(/[0-9.]/g, ''); + const number = parseFloat(value.replace(unit, '')); + + return { + unit: unit === '' ? (number === undefined ? 'auto' : 'px') : unit, + number, + }; + /* + Given: + value = '100px' + Returns: + { + unit: 'px', + number: 100 + } + */ +} + +function getNormalizedFixedUnit(value?: string | number): number | undefined { + if (value === undefined) { + return; } ->; -function internalImageSrcSet({ - src, - width, - crop, - scale, - widths, - loader, - height, -}: InternalShopifySrcSetGeneratorsParams): string { - 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. Image: ${src}`, - ); + + const {unit, number} = getUnitValueParts(value.toString()); + + switch (unit) { + case 'em': + return number * 16; + case 'rem': + return number * 16; + case 'px': + return number; + case '': + return number; + default: + return; } + /* + Given: + value = 16px | 1rem | 1em | 16 + Returns: + 16 - let aspectRatio = 1; - if (width && height) { - aspectRatio = Number(height) / Number(width); + Given: + value = 100% + Returns: + undefined + */ +} + +function isFixedWidth(width: string | number): boolean { + const fixedEndings = /\d(px|em|rem)$/; + return ( + typeof width === 'number' || + (typeof width === 'string' && fixedEndings.test(width)) + ); + /* + Given: + width = 100 | '100px' | '100em' | '100rem' + Returns: + true + */ +} + +export function generateShopifySrcSet( + src?: string, + sizesArray?: Array<{width?: number; height?: number; crop?: Crop}>, +): string { + if (!src) { + return ''; } - 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); + if (sizesArray?.length === 0 || !sizesArray) { + return src; } - const srcGenerator = loader ? loader : addImageSizeParametersToUrl; - return setSizes + + return sizesArray .map( (size) => - `${srcGenerator({ + `${shopifyLoader({ src, - width: size, - // height is not applied if there is no crop - // if there is crop, then height is applied as a ratio of the original width + height aspect ratio * size - height: crop ? Number(size) * aspectRatio : undefined, - crop, - scale, - })} ${size ?? ''}w`, + width: size.width, + height: size.height, + crop: size.crop, + })} ${size.width}w`, ) - .join(', '); + .join(`, `); + /* + Given: + src = 'https://cdn.shopify.com/static/sample-images/garnished.jpeg' + sizesArray = [ + {width: 200, height: 200, crop: 'center'}, + {width: 400, height: 400, crop: 'center'}, + ] + Returns: + 'https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=200&height=200&crop=center 200w, https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=400&height=400&crop=center 400w' + */ +} + +export function generateImageWidths( + width: string | number = '100%', + intervals = 20, + startingWidth = 200, + incrementSize = 100, +): number[] { + const responsive = Array.from( + {length: intervals}, + (_, i) => i * incrementSize + startingWidth, + ); + + const fixed = Array.from( + {length: 3}, + (_, i) => (i + 1) * (getNormalizedFixedUnit(width) ?? 0), + ); + + return isFixedWidth(width) ? fixed : responsive; +} + +// Simple utility function to convert 1/1 to [1, 1] +export function parseAspectRatio(aspectRatio?: string): number | undefined { + if (!aspectRatio) return; + const [width, height] = aspectRatio.split('/'); + return 1 / (Number(width) / Number(height)); + /* + Given: + '1/1' + Returns: + 0.5, + Given: + '4/3' + Returns: + 0.75 + */ +} + +// Generate data needed for Imagery loader +export function generateSizes( + imageWidths?: number[], + aspectRatio?: string, + crop: Crop = 'center', +): + | { + width: number; + height: number | undefined; + crop: Crop; + }[] + | undefined { + if (!imageWidths) return; + const sizes = imageWidths.map((width: number) => { + return { + width, + height: aspectRatio + ? width * (parseAspectRatio(aspectRatio) ?? 1) + : undefined, + crop, + }; + }); + return sizes; + /* + Given: + ([100, 200], 1/1, 'center') + Returns: + [{width: 100, height: 100, crop: 'center'}, + {width: 200, height: 200, crop: 'center'}] + */ +} + +/* + * The shopifyLoader function is a simple utility function that takes a src, width, + * height, and crop and returns a string that can be used as the src for an image. + * It can be used with the Hydrogen Image component or with the next/image component. + * (or any others that accept equivalent configuration) + */ +export function shopifyLoader({ + src, + width, + height, + crop, +}: { + src?: string; + width?: number; + height?: number; + crop?: Crop; +}): string { + if (!src) { + return ''; + } + + const url = new URL(src); + width && url.searchParams.append('width', Math.round(width).toString()); + height && url.searchParams.append('height', Math.round(height).toString()); + crop && url.searchParams.append('crop', crop); + return url.href; + /* + Given: + src = 'https://cdn.shopify.com/static/sample-images/garnished.jpeg' + width = 100 + height = 100 + crop = 'center' + Returns: + 'https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=100&height=100&crop=center' + */ } diff --git a/packages/hydrogen-react/src/Image.doc.ts b/packages/hydrogen-react/src/ImageLegacy.doc.ts similarity index 100% rename from packages/hydrogen-react/src/Image.doc.ts rename to packages/hydrogen-react/src/ImageLegacy.doc.ts diff --git a/packages/hydrogen-react/src/Image.example.jsx b/packages/hydrogen-react/src/ImageLegacy.example.jsx similarity index 100% rename from packages/hydrogen-react/src/Image.example.jsx rename to packages/hydrogen-react/src/ImageLegacy.example.jsx diff --git a/packages/hydrogen-react/src/Image.example.tsx b/packages/hydrogen-react/src/ImageLegacy.example.tsx similarity index 100% rename from packages/hydrogen-react/src/Image.example.tsx rename to packages/hydrogen-react/src/ImageLegacy.example.tsx diff --git a/packages/hydrogen-react/src/ImageLegacy.stories.tsx b/packages/hydrogen-react/src/ImageLegacy.stories.tsx new file mode 100644 index 0000000000..2801085ac5 --- /dev/null +++ b/packages/hydrogen-react/src/ImageLegacy.stories.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import type {Story} from '@ladle/react'; +import {Image, type ShopifyImageProps} from './ImageLegacy.js'; +import {IMG_SRC_SET_SIZES} from './image-size.js'; + +const Template: Story<{ + 'data.url': ShopifyImageProps['data']['url']; + 'data.width': ShopifyImageProps['data']['width']; + 'data.height': ShopifyImageProps['data']['height']; + width: ShopifyImageProps['width']; + height: ShopifyImageProps['height']; + widths: ShopifyImageProps['widths']; + loaderOptions: ShopifyImageProps['loaderOptions']; +}> = (props) => { + const finalProps: ShopifyImageProps = { + data: { + url: props['data.url'], + width: props['data.width'], + height: props['data.height'], + id: 'testing', + }, + width: props.width, + height: props.height, + widths: props.widths, + loaderOptions: props.loaderOptions, + }; + return ; +}; + +export const Default = Template.bind({}); +Default.args = { + 'data.url': + 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', + 'data.width': 100, + 'data.height': 100, + width: 500, + height: 500, + widths: IMG_SRC_SET_SIZES, + loaderOptions: { + crop: 'center', + scale: 2, + width: 500, + height: 500, + }, +}; diff --git a/packages/hydrogen-react/src/Image.test.helpers.ts b/packages/hydrogen-react/src/ImageLegacy.test.helpers.ts similarity index 100% rename from packages/hydrogen-react/src/Image.test.helpers.ts rename to packages/hydrogen-react/src/ImageLegacy.test.helpers.ts diff --git a/packages/hydrogen-react/src/ImageLegacy.test.tsx b/packages/hydrogen-react/src/ImageLegacy.test.tsx new file mode 100644 index 0000000000..9eada2ad44 --- /dev/null +++ b/packages/hydrogen-react/src/ImageLegacy.test.tsx @@ -0,0 +1,337 @@ +import {vi} from 'vitest'; +import {render, screen} from '@testing-library/react'; +import {Image} from './ImageLegacy.js'; +import * as utilities from './image-size.js'; +import {getPreviewImage} from './ImageLegacy.test.helpers.js'; + +describe('', () => { + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('renders an `img` element', () => { + const previewImage = getPreviewImage(); + const {url: src, altText, id, width, height} = previewImage; + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', src); + expect(image).toHaveAttribute('id', id); + expect(image).toHaveAttribute('alt', altText); + expect(image).toHaveAttribute('width', `${width ?? ''}`); + expect(image).toHaveAttribute('height', `${height ?? ''}`); + expect(image).toHaveAttribute('loading', 'lazy'); + }); + + it('renders an `img` element with provided `id`', () => { + const previewImage = getPreviewImage(); + const id = 'catImage'; + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('id', id); + }); + + it('renders an `img` element with provided `loading` value', () => { + const previewImage = getPreviewImage(); + const loading = 'eager'; + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('loading', loading); + }); + + it('renders an `img` with `width` and `height` values', () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + }); + const options = {scale: 2 as const}; + const mockDimensions = { + width: 200, + height: 100, + }; + + vi.spyOn(utilities, 'getShopifyImageDimensions').mockReturnValue( + mockDimensions, + ); + + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('width', `${mockDimensions.width}`); + expect(image).toHaveAttribute('height', `${mockDimensions.height}`); + }); + + it('renders an `img` element without `width` and `height` attributes when invalid dimensions are provided', () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + }); + const options = {scale: 2 as const}; + const mockDimensions = { + width: null, + height: null, + }; + + vi.spyOn(utilities, 'getShopifyImageDimensions').mockReturnValue( + mockDimensions, + ); + + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).not.toHaveAttribute('width'); + expect(image).not.toHaveAttribute('height'); + }); + + describe('Loaders', () => { + it('calls `shopifyImageLoader()` when no `loader` prop is provided', () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + }); + + const transformedSrc = 'https://cdn.shopify.com/someimage_100x200@2x.jpg'; + + const options = {width: 100, height: 200, scale: 2 as const}; + + const shopifyImageLoaderSpy = vi + .spyOn(utilities, 'shopifyImageLoader') + .mockReturnValue(transformedSrc); + + render(); + + expect(shopifyImageLoaderSpy).toHaveBeenCalledWith({ + src: previewImage.url, + ...options, + }); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', transformedSrc); + }); + }); + + it('allows passthrough props', () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + }); + + render( + Fancy image, + ); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveClass('fancyImage'); + expect(image).toHaveAttribute('id', '123'); + expect(image).toHaveAttribute('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 previewImage = getPreviewImage({ + url: mockUrl, + width: 2560, + height: 2560, + }); + + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('srcSet', expectedSrcset); + }); + + it('generates a default srcset up to the image height and width', () => { + const mockUrl = 'https://cdn.shopify.com/someimage.jpg'; + const sizes = [352, 832]; + const expectedSrcset = sizes + .map((size) => `${mockUrl}?width=${size} ${size}w`) + .join(', '); + const previewImage = getPreviewImage({ + url: mockUrl, + width: 832, + height: 832, + }); + + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('srcSet', expectedSrcset); + }); + + it(`uses scale to multiply the srcset width but not the element width, and when crop is missing, does not include height in srcset`, () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + width: 500, + height: 500, + }); + + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute( + 'srcSet', + // height is not applied if there is no crop + // width is not doulbe of the passed width, but instead double of the value in 'sizes_array' / '[number]w' + `${previewImage.url}?width=704 352w`, + ); + expect(image).toHaveAttribute('width', '500'); + expect(image).toHaveAttribute('height', '500'); + }); + + it(`uses scale to multiply the srcset width but not the element width, and when crop is there, includes height in srcset`, () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + width: 500, + height: 500, + }); + + render( + , + ); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute( + 'srcSet', + // height is the aspect ratio (of width + height) * srcSet width, so in this case it should be half of width + `${previewImage.url}?width=704&height=352&crop=bottom 352w`, + ); + expect(image).toHaveAttribute('width', '500'); + expect(image).toHaveAttribute('height', '250'); + }); + + it(`uses scale to multiply the srcset width but not the element width, and when crop is there, includes height in srcset using data.width / data.height for the aspect ratio`, () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + width: 500, + height: 500, + }); + + render( + , + ); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute( + 'srcSet', + // height is the aspect ratio (of data.width + data.height) * srcSet width, so in this case it should be the same as width + `${previewImage.url}?width=704&height=704&crop=bottom 352w`, + ); + expect(image).toHaveAttribute('width', '500'); + expect(image).toHaveAttribute('height', '500'); + }); + + it(`uses scale to multiply the srcset width but not the element width, and when crop is there, calculates height based on aspect ratio in srcset`, () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + width: 500, + height: 1000, + }); + + render( + , + ); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute( + 'srcSet', + // height is the aspect ratio (of data.width + data.height) * srcSet width, so in this case it should be double the width + `${previewImage.url}?width=704&height=1408&crop=bottom 352w`, + ); + expect(image).toHaveAttribute('width', '500'); + expect(image).toHaveAttribute('height', '1000'); + }); + + it(`should pass through width (as an inline prop) when it's a string, and use the first size in the size array for the URL width`, () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + width: 100, + height: 100, + }); + + render(); + + const image = screen.getByRole('img'); + + console.log(image.getAttribute('srcSet')); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', `${previewImage.url}?width=352`); + expect(image).toHaveAttribute('width', '100%'); + expect(image).not.toHaveAttribute('height'); + }); + + it(`should pass through width (as part of loaderOptions) when it's a string, and use the first size in the size array for the URL width`, () => { + const previewImage = getPreviewImage({ + url: 'https://cdn.shopify.com/someimage.jpg', + width: 100, + height: 100, + }); + + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', `${previewImage.url}?width=352`); + expect(image).toHaveAttribute('width', '100%'); + expect(image).not.toHaveAttribute('height'); + }); + + it(`throws an error if you don't have data.url`, () => { + expect(() => render()).toThrow(); + }); + + // eslint-disable-next-line jest/expect-expect + it.skip(`typescript types`, () => { + // this test is actually just using //@ts-expect-error as the assertion, and don't need to execute in order to have TS validation on them + // I don't love this idea, but at the moment I also don't have other great ideas for how to easily test our component TS types + + // no errors in these situations + ; + + // @ts-expect-error data and src + ; + + // @ts-expect-error foo is invalid + ; + }); +}); diff --git a/packages/hydrogen-react/src/ImageLegacy.tsx b/packages/hydrogen-react/src/ImageLegacy.tsx new file mode 100644 index 0000000000..1356d6e696 --- /dev/null +++ b/packages/hydrogen-react/src/ImageLegacy.tsx @@ -0,0 +1,220 @@ +import * as React from 'react'; +import { + getShopifyImageDimensions, + shopifyImageLoader, + addImageSizeParametersToUrl, + IMG_SRC_SET_SIZES, +} from './image-size.js'; +import type {Image as ImageType} from './storefront-api-types.js'; +import type {PartialDeep, Simplify} from 'type-fest'; + +type HtmlImageProps = React.ImgHTMLAttributes; + +export type ShopifyLoaderOptions = { + crop?: 'top' | 'bottom' | 'left' | 'right' | 'center'; + scale?: 2 | 3; + width?: HtmlImageProps['width'] | ImageType['width']; + height?: HtmlImageProps['height'] | ImageType['height']; +}; +export type ShopifyLoaderParams = Simplify; + +type ImageSrc = { + src: ImageType['url']; +}; + +export type ShopifyImageProps = Omit & + ShopifyImageBaseProps; + +type ShopifyImageBaseProps = { + /** An object with fields that correspond to the Storefront API's + * [Image object](https://shopify.dev/api/storefront/reference/common-objects/image). + * The `data` prop is required. + */ + data: PartialDeep; + /** A custom function that generates the image URL. Parameters passed in + * are `ShopifyLoaderParams` + */ + loader?: (params: ShopifyLoaderParams) => string; + /** 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}`). The object shape is `ShopifyLoaderOptions`. + */ + loaderOptions?: ShopifyLoaderOptions; + /** + * `src` isn't used, and should instead be passed as part of the `data` object + */ + src?: never; + /** + * An array of pixel widths to overwrite the default generated srcset. For example, `[300, 600, 800]`. + */ + widths?: (HtmlImageProps['width'] | ImageType['width'])[]; +}; + +/** + * The `Image` component renders an image for the Storefront API's + * [Image object](https://shopify.dev/api/storefront/reference/common-objects/image) by using the `data` prop. You can [customize this component](https://shopify.dev/api/hydrogen/components#customizing-hydrogen-components) using passthrough props. + * + * An image's width and height are determined using the following priority list: + * 1. The width and height values for the `loaderOptions` prop + * 2. The width and height values for bare props + * 3. The width and height values for the `data` prop + * + * If only one of `width` or `height` are defined, then the other will attempt to be calculated based on the image's aspect ratio, + * provided that both `data.width` and `data.height` are available. If `data.width` and `data.height` aren't available, then the aspect ratio cannot be determined and the missing + * value will remain as `null` + */ +export function Image({ + data, + width, + height, + loading, + loader = shopifyImageLoader, + loaderOptions, + widths, + decoding = 'async', + ...rest +}: ShopifyImageProps): JSX.Element | null { + if (!data.url) { + const missingUrlError = `: the 'data' prop requires the 'url' property. Image: ${ + data.id ?? 'no ID provided' + }`; + + if (__HYDROGEN_DEV__) { + throw new Error(missingUrlError); + } else { + console.error(missingUrlError); + } + + return null; + } + + if (__HYDROGEN_DEV__ && !data.altText && !rest.alt) { + console.warn( + `: the 'data' prop should have the 'altText' property, or the 'alt' prop, and one of them should not be empty. Image: ${ + data.id ?? data.url + }`, + ); + } + + const {width: imgElementWidth, height: imgElementHeight} = + getShopifyImageDimensions({ + data, + loaderOptions, + elementProps: { + width, + height, + }, + }); + + if (__HYDROGEN_DEV__ && (!imgElementWidth || !imgElementHeight)) { + console.warn( + `: the 'data' prop requires either 'width' or 'data.width', and 'height' or 'data.height' properties. Image: ${ + data.id ?? data.url + }`, + ); + } + + let finalSrc = data.url; + + if (loader) { + finalSrc = loader({ + ...loaderOptions, + src: data.url, + width: imgElementWidth, + height: imgElementHeight, + }); + if (typeof finalSrc !== 'string' || !finalSrc) { + throw new Error( + `: 'loader' did not return a valid string. Image: ${ + data.id ?? data.url + }`, + ); + } + } + + // 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 && imgElementWidth && width < imgElementWidth + ? width + : imgElementWidth; + const finalSrcset = + rest.srcSet ?? + internalImageSrcSet({ + ...loaderOptions, + widths, + src: data.url, + width: maxWidth, + height: imgElementHeight, + loader, + }); + + /* eslint-disable hydrogen/prefer-image-component */ + return ( + {data.altText + ); + /* eslint-enable hydrogen/prefer-image-component */ +} + +type InternalShopifySrcSetGeneratorsParams = Simplify< + ShopifyLoaderOptions & { + src: ImageType['url']; + widths?: (HtmlImageProps['width'] | ImageType['width'])[]; + loader?: (params: ShopifyLoaderParams) => string; + } +>; +function internalImageSrcSet({ + src, + width, + crop, + scale, + widths, + loader, + height, +}: InternalShopifySrcSetGeneratorsParams): string { + 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. Image: ${src}`, + ); + } + + let aspectRatio = 1; + if (width && height) { + aspectRatio = Number(height) / Number(width); + } + + 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, + // height is not applied if there is no crop + // if there is crop, then height is applied as a ratio of the original width + height aspect ratio * size + height: crop ? Number(size) * aspectRatio : undefined, + crop, + scale, + })} ${size ?? ''}w`, + ) + .join(', '); +} diff --git a/packages/hydrogen-react/src/MediaFile.test.helpers.ts b/packages/hydrogen-react/src/MediaFile.test.helpers.ts index fd1214ffc1..79eebb1b40 100644 --- a/packages/hydrogen-react/src/MediaFile.test.helpers.ts +++ b/packages/hydrogen-react/src/MediaFile.test.helpers.ts @@ -3,7 +3,7 @@ import {getExternalVideoData} from './ExternalVideo.test.helpers.js'; import {faker} from '@faker-js/faker'; import type {PartialDeep} from 'type-fest'; import type {MediaImage, MediaEdge} from './storefront-api-types.js'; -import {getPreviewImage} from './Image.test.helpers.js'; +import {getPreviewImage} from './ImageLegacy.test.helpers.js'; import {getModel3d} from './ModelViewer.test.helpers.js'; export function getMedia( diff --git a/packages/hydrogen-react/src/ModelViewer.test.helpers.ts b/packages/hydrogen-react/src/ModelViewer.test.helpers.ts index ba28a048da..cf5717921a 100644 --- a/packages/hydrogen-react/src/ModelViewer.test.helpers.ts +++ b/packages/hydrogen-react/src/ModelViewer.test.helpers.ts @@ -1,7 +1,7 @@ import type {Model3d} from './storefront-api-types.js'; import type {PartialDeep} from 'type-fest'; import {faker} from '@faker-js/faker'; -import {getPreviewImage} from './Image.test.helpers.js'; +import {getPreviewImage} from './ImageLegacy.test.helpers.js'; export function getModel3d( model: PartialDeep = {}, diff --git a/packages/hydrogen-react/src/ProductProvider.test.helpers.ts b/packages/hydrogen-react/src/ProductProvider.test.helpers.ts index b97a89ebc2..3e0c18c3ef 100644 --- a/packages/hydrogen-react/src/ProductProvider.test.helpers.ts +++ b/packages/hydrogen-react/src/ProductProvider.test.helpers.ts @@ -10,7 +10,7 @@ import type {PartialDeep} from 'type-fest'; import {faker} from '@faker-js/faker'; import {getRawMetafield} from './parse-metafield.test.helpers.js'; import {getUnitPriceMeasurement, getPrice} from './Money.test.helpers.js'; -import {getPreviewImage} from './Image.test.helpers.js'; +import {getPreviewImage} from './ImageLegacy.test.helpers.js'; import {getMedia} from './MediaFile.test.helpers.js'; export function getProduct( diff --git a/packages/hydrogen-react/src/Video.test.helpers.ts b/packages/hydrogen-react/src/Video.test.helpers.ts index 0607fade68..dd24c9c8e1 100644 --- a/packages/hydrogen-react/src/Video.test.helpers.ts +++ b/packages/hydrogen-react/src/Video.test.helpers.ts @@ -1,7 +1,7 @@ import type {Video as VideoType, VideoSource} from './storefront-api-types.js'; import {faker} from '@faker-js/faker'; import type {PartialDeep} from 'type-fest'; -import {getPreviewImage} from './Image.test.helpers.js'; +import {getPreviewImage} from './ImageLegacy.test.helpers.js'; export function getVideoData( video: PartialDeep = {}, diff --git a/packages/hydrogen-react/src/image-size.ts b/packages/hydrogen-react/src/image-size.ts index 2fde45efcd..6466f4e357 100644 --- a/packages/hydrogen-react/src/image-size.ts +++ b/packages/hydrogen-react/src/image-size.ts @@ -1,6 +1,6 @@ import type {Image as ImageType} from './storefront-api-types.js'; import type {PartialDeep} from 'type-fest'; -import type {ShopifyLoaderOptions, ShopifyLoaderParams} from './Image.js'; +import type {ShopifyLoaderOptions, ShopifyLoaderParams} from './ImageLegacy.js'; // TODO: Are there other CDNs missing from here? const PRODUCTION_CDN_HOSTNAMES = [ From c0cfbe00c0eb747d0af98522dcaae2aca6463cbe Mon Sep 17 00:00:00 2001 From: Matt Seccafien <462077+cartogram@users.noreply.github.com> Date: Tue, 7 Mar 2023 07:10:29 +0100 Subject: [PATCH 02/46] Make existing tests pass --- packages/hydrogen-react/src/Image.test.tsx | 61 ++++++++++------------ packages/hydrogen-react/src/Image.tsx | 16 +++--- 2 files changed, 37 insertions(+), 40 deletions(-) diff --git a/packages/hydrogen-react/src/Image.test.tsx b/packages/hydrogen-react/src/Image.test.tsx index 5b80fb7fba..1f8eb9bb48 100644 --- a/packages/hydrogen-react/src/Image.test.tsx +++ b/packages/hydrogen-react/src/Image.test.tsx @@ -1,4 +1,5 @@ import {render, screen} from '@testing-library/react'; +import {faker} from '@faker-js/faker'; import {Image} from './Image.js'; const defaultProps = { @@ -6,62 +7,58 @@ const defaultProps = { }; describe('', () => { - it('renders an `img` element', () => { - render(); + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); - const image = screen.getByRole('img'); + it.todo('warns user if no sizes are provided'); - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('loading', 'lazy'); - }); + // This test fails because the received src has ?width=100 appended to it + it.skip('renders an `img` element', () => { + const src = faker.image.imageUrl(); - it('renders an `img` element with provided `id`', () => { + render(); const image = screen.getByRole('img'); - render(); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', src); }); - it('renders an `img` element with provided `loading` value', () => { + it('accepts passthrough props such as `id`', () => { + const id = faker.random.alpha(); + render(); + const image = screen.getByRole('img'); - render(); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('id', id); }); - it('renders an `img` with `width` and `height` values', () => { - const image = screen.getByRole('img'); + it('has a `loading` prop of `lazy` by default', () => { render(); - expect(image).toBeInTheDocument(); - }); - it('renders an `img` element without `width` and `height` attributes when invalid dimensions are provided', () => { const image = screen.getByRole('img'); - render(); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('loading', 'lazy'); }); - describe('Loaders', () => { - it('calls `shopifyImageLoader()` when no `loader` prop is provided', () => { - const image = screen.getByRole('img'); - render(); - expect(image).toBeInTheDocument(); - }); - }); + it('accepts a `loading` prop', () => { + render(); - it('allows passthrough props', () => { const image = screen.getByRole('img'); - render(); - expect(image).toBeInTheDocument(); - }); - it('generates a default srcset', () => { - const image = screen.getByRole('img'); - render(); expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('loading', 'eager'); }); - it('generates a default srcset up to the image height and width', () => { + it('accepts a `sizes` prop', () => { + render(); + const image = screen.getByRole('img'); - render(); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('sizes', '100vw'); }); }); diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index c2a0c9a161..d43ce2746b 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -113,7 +113,7 @@ export function Image({ */ widths, ...passthroughProps -}: ShopifyImageProps): JSX.Element | null { +}: HtmlImageProps & ShopifyImageProps): JSX.Element | null { /* * Deprecated Props from original Image component */ @@ -431,10 +431,10 @@ export function parseAspectRatio(aspectRatio?: string): number | undefined { if (!aspectRatio) return; const [width, height] = aspectRatio.split('/'); return 1 / (Number(width) / Number(height)); - /* - Given: + /* + Given: '1/1' - Returns: + Returns: 0.5, Given: '4/3' @@ -466,11 +466,11 @@ export function generateSizes( }; }); return sizes; - /* - Given: + /* + Given: ([100, 200], 1/1, 'center') - Returns: - [{width: 100, height: 100, crop: 'center'}, + Returns: + [{width: 100, height: 100, crop: 'center'}, {width: 200, height: 200, crop: 'center'}] */ } From b1110faab9f7325729b507c72982ec79c1412fa7 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Tue, 7 Mar 2023 07:30:19 -0500 Subject: [PATCH 03/46] Remove missing sizes warning if fixed width --- packages/hydrogen-react/src/Image.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index d43ce2746b..a214130863 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -135,14 +135,6 @@ export function Image({ ); } - if (!sizes) { - console.warn( - 'No sizes prop provided to Image component, ' + - 'you may be loading unnecessarily large images. ' + - `Image used is ${src || data?.url}`, - ); - } - /* Only use data width if height is also set */ const dataWidth: number | undefined = @@ -201,6 +193,14 @@ export function Image({ incrementSize, ); + if (!sizes && !isFixedWidth(normalizedWidth)) { + console.warn( + 'No sizes prop provided to Image component, ' + + 'you may be loading unnecessarily large images. ' + + `Image used is ${src || data?.url}`, + ); + } + /* * We check to see whether the image is fixed width or not, * if fixed, we still provide a srcSet, but only to account for From e33681f41b4411aa1c68b5561de14d3f2662f6ed Mon Sep 17 00:00:00 2001 From: Matt Seccafien <462077+cartogram@users.noreply.github.com> Date: Tue, 7 Mar 2023 18:22:23 +0100 Subject: [PATCH 04/46] Add tests for warnings and fix bug when getting normalizedWidth --- packages/hydrogen-react/src/Image.test.tsx | 73 +++++++++++++++++++--- packages/hydrogen-react/src/Image.tsx | 4 +- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/hydrogen-react/src/Image.test.tsx b/packages/hydrogen-react/src/Image.test.tsx index 1f8eb9bb48..0c5082701b 100644 --- a/packages/hydrogen-react/src/Image.test.tsx +++ b/packages/hydrogen-react/src/Image.test.tsx @@ -1,19 +1,14 @@ import {render, screen} from '@testing-library/react'; import {faker} from '@faker-js/faker'; import {Image} from './Image.js'; +import {Mock} from 'vitest'; const defaultProps = { + sizes: '100vw', src: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', }; describe('', () => { - beforeAll(() => { - // eslint-disable-next-line @typescript-eslint/no-empty-function - vi.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - it.todo('warns user if no sizes are provided'); - // This test fails because the received src has ?width=100 appended to it it.skip('renders an `img` element', () => { const src = faker.image.imageUrl(); @@ -61,4 +56,68 @@ describe('', () => { expect(image).toBeInTheDocument(); expect(image).toHaveAttribute('sizes', '100vw'); }); + + describe('warnings', () => { + const consoleMock = { + ...console, + warn: vi.fn(), + }; + + vi.stubGlobal('console', consoleMock); + + afterAll(() => { + vi.unstubAllGlobals(); + }); + + it('warns user if no sizes are provided', () => { + render(); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(getWarnings()).toMatchInlineSnapshot( + ` + [ + "No sizes prop provided to Image component, you may be loading unnecessarily large images. Image used is https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg", + ] + `, + ); + }); + + it('does not warn user if no sizes are provided but width is fixed', () => { + render(); + + expect(console.warn).toHaveBeenCalledTimes(0); + }); + + it('warns user if widths is provided', () => { + render(); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(getWarnings()).toMatchInlineSnapshot( + ` + [ + "Deprecated property from original Image component in use: \`widths\` are now calculated automatically based on the config and width props. Image used is https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg", + ] + `, + ); + }); + + it('warns user if loaderOptions are provided', () => { + render(); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(getWarnings()).toMatchInlineSnapshot( + ` + [ + "Deprecated property from original Image component in use: Use the \`crop\`, \`width\`, \`height\`, and src props, or the \`data\` prop to achieve the same result. Image used is https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg", + ] + `, + ); + }); + }); }); + +function getWarnings() { + return (console.warn as Mock<[string]>).mock.calls.map( + ([message]) => message, + ); +} diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index a214130863..dfc416d69b 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -120,7 +120,7 @@ export function Image({ if (loaderOptions) { console.warn( `Deprecated property from original Image component in use: ` + - `Use the \`crop\`, \`width\`, \`height\`, and src props, or` + + `Use the \`crop\`, \`width\`, \`height\`, and src props, or ` + `the \`data\` prop to achieve the same result. Image used is ${ src || data?.url }`, @@ -154,7 +154,7 @@ export function Image({ const normalizedWidth = `${ getUnitValueParts(normalizedWidthProp.toString()).number - } ${getUnitValueParts(normalizedWidthProp.toString()).unit}`; + }${getUnitValueParts(normalizedWidthProp.toString()).unit}`; const normalizedHeight: string = height === undefined From ca6fa6f5b135269cdd4fe12ac9483b347009b058 Mon Sep 17 00:00:00 2001 From: Matt Seccafien <462077+cartogram@users.noreply.github.com> Date: Tue, 7 Mar 2023 18:44:51 +0100 Subject: [PATCH 05/46] Change multiline log style --- packages/hydrogen-react/src/Image.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index dfc416d69b..e9c6495662 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -119,19 +119,23 @@ export function Image({ */ if (loaderOptions) { console.warn( - `Deprecated property from original Image component in use: ` + - `Use the \`crop\`, \`width\`, \`height\`, and src props, or ` + + [ + `Deprecated property from original Image component in use:`, + `Use the \`crop\`, \`width\`, \`height\`, and src props, or`, `the \`data\` prop to achieve the same result. Image used is ${ src || data?.url }`, + ].join(' '), ); } if (widths) { console.warn( - `Deprecated property from original Image component in use: ` + - `\`widths\` are now calculated automatically based on the ` + + [ + `Deprecated property from original Image component in use:`, + `\`widths\` are now calculated automatically based on the`, `config and width props. Image used is ${src || data?.url}`, + ].join(' '), ); } @@ -195,9 +199,11 @@ export function Image({ if (!sizes && !isFixedWidth(normalizedWidth)) { console.warn( - 'No sizes prop provided to Image component, ' + - 'you may be loading unnecessarily large images. ' + + [ + 'No sizes prop provided to Image component,', + 'you may be loading unnecessarily large images.', `Image used is ${src || data?.url}`, + ].join(' '), ); } From 694cc48b6c8fa404dbbd8b6e256f89a4c4164000 Mon Sep 17 00:00:00 2001 From: Matt Seccafien <462077+cartogram@users.noreply.github.com> Date: Wed, 8 Mar 2023 01:58:05 +0100 Subject: [PATCH 06/46] test missing src value --- packages/hydrogen-react/src/Image.test.tsx | 13 +++++++++++++ packages/hydrogen-react/src/Image.tsx | 2 +- packages/hydrogen-react/vite.config.ts | 4 ++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/hydrogen-react/src/Image.test.tsx b/packages/hydrogen-react/src/Image.test.tsx index 0c5082701b..181ae33361 100644 --- a/packages/hydrogen-react/src/Image.test.tsx +++ b/packages/hydrogen-react/src/Image.test.tsx @@ -69,6 +69,19 @@ describe('', () => { vi.unstubAllGlobals(); }); + it('warns user if no src is provided', () => { + render(); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(getWarnings()).toMatchInlineSnapshot( + ` + [ + "No src or data.url provided to Image component.", + ] + `, + ); + }); + it('warns user if no sizes are provided', () => { render(); diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index e9c6495662..a96392bb17 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -170,7 +170,7 @@ export function Image({ const normalizedSrc: string | undefined = src || data?.url; if (!normalizedSrc) { - console.error(`No src or data.url provided to Image component.`); + console.warn(`No src or data.url provided to Image component.`); } const normalizedAlt: string = diff --git a/packages/hydrogen-react/vite.config.ts b/packages/hydrogen-react/vite.config.ts index 9329b91a45..9c6d001691 100644 --- a/packages/hydrogen-react/vite.config.ts +++ b/packages/hydrogen-react/vite.config.ts @@ -109,6 +109,10 @@ export default defineConfig(({mode, ssrBuild}) => { environment: 'happy-dom', setupFiles: './vitest.setup.ts', restoreMocks: true, + coverage: { + provider: 'c8', + reporter: ['text', 'json', 'html'], + }, }, }; }); From e49968e30557c56fc285ee4c022a0e43ab4cb815 Mon Sep 17 00:00:00 2001 From: Matt Seccafien <462077+cartogram@users.noreply.github.com> Date: Thu, 9 Mar 2023 10:07:28 +0100 Subject: [PATCH 07/46] Enable restrict template literal rule --- packages/hydrogen-react/src/Image.tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index a96392bb17..83b471a35b 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -1,5 +1,3 @@ -// eslint-disable-next-line eslint-comments/disable-enable-pair -/* eslint-disable @typescript-eslint/restrict-template-expressions */ import * as React from 'react'; import type {PartialDeep, Simplify} from 'type-fest'; import type {Image as ImageType} from './storefront-api-types.js'; @@ -123,7 +121,7 @@ export function Image({ `Deprecated property from original Image component in use:`, `Use the \`crop\`, \`width\`, \`height\`, and src props, or`, `the \`data\` prop to achieve the same result. Image used is ${ - src || data?.url + src || data?.url || 'unknown' }`, ].join(' '), ); @@ -134,7 +132,9 @@ export function Image({ [ `Deprecated property from original Image component in use:`, `\`widths\` are now calculated automatically based on the`, - `config and width props. Image used is ${src || data?.url}`, + `config and width props. Image used is ${ + src || data?.url || 'unknown' + }`, ].join(' '), ); } @@ -179,9 +179,10 @@ export function Image({ const normalizedAspectRatio: string | undefined = aspectRatio ? aspectRatio : dataUnitsMatch - ? `${getNormalizedFixedUnit(dataWidth)}/${getNormalizedFixedUnit( - dataHeight, - )}` + ? [ + getNormalizedFixedUnit(dataWidth), + getNormalizedFixedUnit(dataHeight), + ].join('/') : undefined; const {intervals, startingWidth, incrementSize, placeholderWidth} = @@ -202,7 +203,7 @@ export function Image({ [ 'No sizes prop provided to Image component,', 'you may be loading unnecessarily large images.', - `Image used is ${src || data?.url}`, + `Image used is ${src || data?.url || 'unknown'}`, ].join(' '), ); } @@ -225,7 +226,7 @@ export function Image({ const fixedAspectRatio = aspectRatio ? aspectRatio : unitsMatch(normalizedWidth, normalizedHeight) - ? `${intWidth}/${intHeight}` + ? [intWidth, intHeight].join('/') : normalizedAspectRatio ? normalizedAspectRatio : undefined; @@ -398,7 +399,7 @@ export function generateShopifySrcSet( width: size.width, height: size.height, crop: size.crop, - })} ${size.width}w`, + })} ${size.width || 0}w`, ) .join(`, `); /* From c3f102abb1d8a4bfcb7fdacf8f398ede92f9a084 Mon Sep 17 00:00:00 2001 From: Matt Seccafien <462077+cartogram@users.noreply.github.com> Date: Thu, 9 Mar 2023 12:00:49 +0100 Subject: [PATCH 08/46] More tests --- packages/hydrogen-react/src/Image.test.tsx | 122 ++++++++++++++++++--- packages/hydrogen-react/src/Image.tsx | 8 +- 2 files changed, 109 insertions(+), 21 deletions(-) diff --git a/packages/hydrogen-react/src/Image.test.tsx b/packages/hydrogen-react/src/Image.test.tsx index 181ae33361..560344d6d0 100644 --- a/packages/hydrogen-react/src/Image.test.tsx +++ b/packages/hydrogen-react/src/Image.test.tsx @@ -14,47 +14,135 @@ describe('', () => { const src = faker.image.imageUrl(); render(); - const image = screen.getByRole('img'); - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('src', src); + expect(screen.getByRole('img')).toHaveAttribute('src', src); }); it('accepts passthrough props such as `id`', () => { const id = faker.random.alpha(); + render(); - const image = screen.getByRole('img'); + expect(screen.getByRole('img')).toHaveAttribute('id', id); + }); + + it('sets the `alt` prop on the img tag', () => { + const alt = faker.random.alpha(); - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('id', id); + render({alt}); + + expect(screen.getByRole('img')).toHaveAttribute('alt', alt); }); it('has a `loading` prop of `lazy` by default', () => { render(); - const image = screen.getByRole('img'); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('loading', 'lazy'); + expect(screen.getByRole('img')).toHaveAttribute('loading', 'lazy'); }); it('accepts a `loading` prop', () => { render(); - const image = screen.getByRole('img'); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('loading', 'eager'); + expect(screen.getByRole('img')).toHaveAttribute('loading', 'eager'); }); it('accepts a `sizes` prop', () => { render(); - const image = screen.getByRole('img'); + expect(screen.getByRole('img')).toHaveAttribute('sizes', '100vw'); + }); + + describe('loader', () => { + it('calls the loader with the src, width, height and crop props', () => { + const loader = vi.fn(); + const src = faker.image.imageUrl(); + const width = 600; + const height = 400; + const crop = 'center'; + + render( + , + ); - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('sizes', '100vw'); + expect(loader).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledWith({ + src, + width, + height, + crop, + }); + }); + }); + + describe('aspect-ratio', () => { + // Assertion support is limited for aspectRatio + // https://github.com/testing-library/jest-dom/issues/452 + // expect(image).toHaveStyle('aspect-ratio: 1 / 1'); + + it('sets the aspect-ratio on the style prop when set explicitly', () => { + const aspectRatio = '4/3'; + + render( + , + ); + + expect(screen.getByRole('img').style.aspectRatio).toBe(aspectRatio); + }); + + it('infers the aspect-ratio from the storefront data', () => { + const data = {height: 300, width: 400}; + + render(); + + expect(screen.getByRole('img').style.aspectRatio).toBe('400/300'); + }); + + it('infers the aspect-ratio from the storefront data for fixed-width images when no height prop is provided', () => { + const data = {height: 300, width: 400}; + + render(); + + expect(screen.getByRole('img').style.aspectRatio).toBe('400/300'); + }); + + it('infers the aspect-ratio from the storefront data for fixed-width images the height and width are different units', () => { + const data = {height: 300, width: 400}; + + render( + , + ); + + expect(screen.getByRole('img').style.aspectRatio).toBe('400/300'); + }); + + it('infers the aspect-ratio from the height and width props for fixed-width images', () => { + const data = {height: 300, width: 400}; + + render( + , + ); + + expect(screen.getByRole('img').style.aspectRatio).toBe('600/400'); + }); }); describe('warnings', () => { diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index 83b471a35b..9bf2890dce 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -56,9 +56,9 @@ export type ShopifyImageProps = { }; export function Image({ - /** An object with fields that correspond to the Storefront API's + /** + * An object with fields that correspond to the Storefront API's * [Image object](https://shopify.dev/api/storefront/reference/common-objects/image). - * The `data` prop is required. */ data, as: Component = 'img', @@ -163,7 +163,7 @@ export function Image({ const normalizedHeight: string = height === undefined ? 'auto' - : `${getUnitValueParts(height.toString()).number} ${ + : `${getUnitValueParts(height.toString()).number}${ getUnitValueParts(height.toString()).unit }`; @@ -218,7 +218,7 @@ export function Image({ const intHeight: number | undefined = getNormalizedFixedUnit(height); /* - * The aspect ratio for fixed with images is taken from the explicitly + * The aspect ratio for fixed width images is taken from the explicitly * set prop, but if that's not present, and both width and height are * set, we calculate the aspect ratio from the width and height—as * long as they share the same unit type (e.g. both are 'px'). From e6f21f97339126a5d8a18e600a907c18cbcef02c Mon Sep 17 00:00:00 2001 From: Matt Seccafien <462077+cartogram@users.noreply.github.com> Date: Thu, 9 Mar 2023 16:40:28 +0100 Subject: [PATCH 09/46] more tests --- packages/hydrogen-react/src/Image.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/hydrogen-react/src/Image.test.tsx b/packages/hydrogen-react/src/Image.test.tsx index 560344d6d0..050cd9ada5 100644 --- a/packages/hydrogen-react/src/Image.test.tsx +++ b/packages/hydrogen-react/src/Image.test.tsx @@ -81,6 +81,20 @@ describe('', () => { }); }); + describe('srcSet', () => { + it('renders a `srcSet` attribute when the `widths` prop is provided', () => { + const widths = [100, 200, 300]; + + render(); + const img = screen.getByRole('img'); + + expect(img).toHaveAttribute('srcSet'); + expect(img.getAttribute('srcSet')).toMatchInlineSnapshot( + '"https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=300&crop=center 300w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=600&crop=center 600w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=900&crop=center 900w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=1200&crop=center 1200w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=1500&crop=center 1500w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=1800&crop=center 1800w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=2100&crop=center 2100w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=2400&crop=center 2400w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=2700&crop=center 2700w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=3000&crop=center 3000w"', + ); + }); + }); + describe('aspect-ratio', () => { // Assertion support is limited for aspectRatio // https://github.com/testing-library/jest-dom/issues/452 From 20393678fe35d737cb1c0d6b9fbaa2d9a148911b Mon Sep 17 00:00:00 2001 From: Matt Seccafien <462077+cartogram@users.noreply.github.com> Date: Mon, 13 Mar 2023 18:05:19 +0100 Subject: [PATCH 10/46] Pass custom loader to srcSet --- packages/hydrogen-react/src/Image.test.tsx | 1 - packages/hydrogen-react/src/Image.tsx | 23 ++++++++-------------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/hydrogen-react/src/Image.test.tsx b/packages/hydrogen-react/src/Image.test.tsx index 050cd9ada5..9f5cc8cd51 100644 --- a/packages/hydrogen-react/src/Image.test.tsx +++ b/packages/hydrogen-react/src/Image.test.tsx @@ -71,7 +71,6 @@ describe('', () => { />, ); - expect(loader).toHaveBeenCalledTimes(1); expect(loader).toHaveBeenCalledWith({ src, width, diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index 9bf2890dce..7f77d1793f 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -15,6 +15,8 @@ interface SrcSetOptions { type HtmlImageProps = React.ImgHTMLAttributes; +export type ImageLoader = (params: ShopifyLoaderParams) => string; + export type ShopifyLoaderOptions = { crop?: 'top' | 'bottom' | 'left' | 'right' | 'center'; scale?: 2 | 3; @@ -42,7 +44,7 @@ export type ShopifyImageProps = { as?: 'img' | 'source'; data?: PartialDeep; src?: string; - loader?: (params: ShopifyLoaderParams) => string; + loader?: ImageLoader; width?: string | number; height?: string | number; crop?: Crop; @@ -241,7 +243,7 @@ export function Image({ : generateSizes(imageWidths, fixedAspectRatio, crop); return React.createElement(Component, { - srcSet: generateShopifySrcSet(normalizedSrc, sizesArray), + srcSet: generateShopifySrcSet(normalizedSrc, sizesArray, loader), src: loader({ src: normalizedSrc, width: intWidth, @@ -382,6 +384,7 @@ function isFixedWidth(width: string | number): boolean { export function generateShopifySrcSet( src?: string, sizesArray?: Array<{width?: number; height?: number; crop?: Crop}>, + loader: ImageLoader = shopifyLoader, ): string { if (!src) { return ''; @@ -394,7 +397,7 @@ export function generateShopifySrcSet( return sizesArray .map( (size) => - `${shopifyLoader({ + `${loader({ src, width: size.width, height: size.height, @@ -488,17 +491,7 @@ export function generateSizes( * It can be used with the Hydrogen Image component or with the next/image component. * (or any others that accept equivalent configuration) */ -export function shopifyLoader({ - src, - width, - height, - crop, -}: { - src?: string; - width?: number; - height?: number; - crop?: Crop; -}): string { +export const shopifyLoader: ImageLoader = ({src, width, height, crop}) => { if (!src) { return ''; } @@ -517,4 +510,4 @@ export function shopifyLoader({ Returns: 'https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=100&height=100&crop=center' */ -} +}; From daa0b59d32e80b27993df60c523df9733d6ce305 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 13 Mar 2023 15:48:40 -0400 Subject: [PATCH 11/46] Adds width and height prop to the image --- packages/hydrogen-react/src/Image.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index 7f77d1793f..0a2732e973 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -261,6 +261,8 @@ export function Image({ height: normalizedHeight, aspectRatio: fixedAspectRatio, }, + width: normalizedWidth, + height: normalizedHeight, loading, ...passthroughProps, }); @@ -287,6 +289,8 @@ export function Image({ height: normalizedHeight, aspectRatio: normalizedAspectRatio, }, + width: normalizedWidth, + height: normalizedHeight, loading, ...passthroughProps, }); From 9bf619d872d79d88b9fffbff3c7e0c0e5c27194b Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 13 Mar 2023 16:06:47 -0400 Subject: [PATCH 12/46] Improve default width and height props on rendered HTML --- packages/hydrogen-react/src/Image.tsx | 30 +++++++++++++++------------ 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index 0a2732e973..39bcbf1bdd 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -242,16 +242,18 @@ export function Image({ ? undefined : generateSizes(imageWidths, fixedAspectRatio, crop); + const fixedHeight = intHeight + ? intHeight + : fixedAspectRatio && intWidth + ? intWidth * (parseAspectRatio(fixedAspectRatio) ?? 1) + : undefined; + return React.createElement(Component, { srcSet: generateShopifySrcSet(normalizedSrc, sizesArray, loader), src: loader({ src: normalizedSrc, width: intWidth, - height: intHeight - ? intHeight - : fixedAspectRatio && intWidth - ? intWidth * (parseAspectRatio(fixedAspectRatio) ?? 1) - : undefined, + height: fixedHeight, crop: normalizedHeight === 'auto' ? undefined : crop, }), alt: normalizedAlt, @@ -261,8 +263,8 @@ export function Image({ height: normalizedHeight, aspectRatio: fixedAspectRatio, }, - width: normalizedWidth, - height: normalizedHeight, + width: intWidth, + height: fixedHeight, loading, ...passthroughProps, }); @@ -272,15 +274,17 @@ export function Image({ ? undefined : generateSizes(imageWidths, normalizedAspectRatio, crop); + const placeholderHeight = + normalizedAspectRatio && placeholderWidth + ? placeholderWidth * (parseAspectRatio(normalizedAspectRatio) ?? 1) + : undefined; + return React.createElement(Component, { srcSet: generateShopifySrcSet(normalizedSrc, sizesArray), src: loader({ src: normalizedSrc, width: placeholderWidth, - height: - normalizedAspectRatio && placeholderWidth - ? placeholderWidth * (parseAspectRatio(normalizedAspectRatio) ?? 1) - : undefined, + height: placeholderHeight, }), alt: normalizedAlt, sizes, @@ -289,8 +293,8 @@ export function Image({ height: normalizedHeight, aspectRatio: normalizedAspectRatio, }, - width: normalizedWidth, - height: normalizedHeight, + width: placeholderWidth, + height: placeholderHeight, loading, ...passthroughProps, }); From 0f34b1d58f8982c184f6e05599713d41c1363305 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 13 Mar 2023 16:11:50 -0400 Subject: [PATCH 13/46] Only console warn in dev --- packages/hydrogen-react/src/Image.tsx | 54 +++++++++++++++------------ 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index 39bcbf1bdd..0973990fd5 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -117,28 +117,30 @@ export function Image({ /* * Deprecated Props from original Image component */ - if (loaderOptions) { - console.warn( - [ - `Deprecated property from original Image component in use:`, - `Use the \`crop\`, \`width\`, \`height\`, and src props, or`, - `the \`data\` prop to achieve the same result. Image used is ${ - src || data?.url || 'unknown' - }`, - ].join(' '), - ); - } - - if (widths) { - console.warn( - [ - `Deprecated property from original Image component in use:`, - `\`widths\` are now calculated automatically based on the`, - `config and width props. Image used is ${ - src || data?.url || 'unknown' - }`, - ].join(' '), - ); + if (process.env.NODE_ENV === 'development') { + if (loaderOptions) { + console.warn( + [ + `Deprecated property from original Image component in use:`, + `Use the \`crop\`, \`width\`, \`height\`, and src props, or`, + `the \`data\` prop to achieve the same result. Image used is ${ + src || data?.url || 'unknown' + }`, + ].join(' '), + ); + } + + if (widths) { + console.warn( + [ + `Deprecated property from original Image component in use:`, + `\`widths\` are now calculated automatically based on the`, + `config and width props. Image used is ${ + src || data?.url || 'unknown' + }`, + ].join(' '), + ); + } } /* Only use data width if height is also set */ @@ -171,7 +173,7 @@ export function Image({ const normalizedSrc: string | undefined = src || data?.url; - if (!normalizedSrc) { + if (!normalizedSrc && process.env.NODE_ENV === 'development') { console.warn(`No src or data.url provided to Image component.`); } @@ -200,7 +202,11 @@ export function Image({ incrementSize, ); - if (!sizes && !isFixedWidth(normalizedWidth)) { + if ( + !sizes && + !isFixedWidth(normalizedWidth) && + process.env.NODE_ENV === 'development' + ) { console.warn( [ 'No sizes prop provided to Image component,', From 3c5cb24f959df898e40e52ca0df6052b29e91d0b Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 13 Mar 2023 19:17:25 -0400 Subject: [PATCH 14/46] Intellisense improvements --- packages/hydrogen-react/src/Image.tsx | 301 ++++++++++++++++---------- 1 file changed, 188 insertions(+), 113 deletions(-) diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index 0973990fd5..cd298b0367 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -6,12 +6,12 @@ import type {Image as ImageType} from './storefront-api-types.js'; * An optional prop you can use to change the * default srcSet generation behaviour */ -interface SrcSetOptions { +type SrcSetOptions = { intervals: number; startingWidth: number; incrementSize: number; placeholderWidth: number; -} +}; type HtmlImageProps = React.ImgHTMLAttributes; @@ -19,7 +19,6 @@ export type ImageLoader = (params: ShopifyLoaderParams) => string; export type ShopifyLoaderOptions = { crop?: 'top' | 'bottom' | 'left' | 'right' | 'center'; - scale?: 2 | 3; width?: HtmlImageProps['width'] | ImageType['width']; height?: HtmlImageProps['height'] | ImageType['height']; }; @@ -34,64 +33,171 @@ export type ShopifyLoaderParams = Simplify< >; /* - * TODO: Expand to include focal point support; - * or switch this to be an SF API type + * @TODO: Expand to include focal point support; and/or switch this to be an SF API type */ - type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right' | undefined; -export type ShopifyImageProps = { +export interface ShopifyImageProps { + /** The HTML element to use for the image, `source` should only be used inside `picture` */ as?: 'img' | 'source'; + /** Data mapping to the Storefront API `Image` object. Must be an Image object. + * Optionally, import the `IMAGE_FRAGMENT` to use in your GraphQL queries. + * + * @example + * ``` + * import {IMAGE_FRAGMENT, Image} from '@shopify/hydrogen'; + * + * export const IMAGE_QUERY = `#graphql + * ${IMAGE_FRAGMENT} + * query { + * product { + * featuredImage { + * ...Image + * } + * } + * }` + * + * + * ``` + * + * Image: {@link https://shopify.dev/api/storefront/reference/common-objects/image} + */ data?: PartialDeep; + /** The image URL. This is used if no `data` prop is provided. */ src?: string; + /** A function that returns a URL string for an image. + * + * @remarks + * By default, this uses Shopify’s CDN {@link https://cdn.shopify.com/} but you can provide + * your own function to use a another provider, as long as they support URL based image transformations. + */ loader?: ImageLoader; + /** Image width, as meant to be rendered on the page. + * You will mostly use this prop if you're rendering a fixed size image. + * + * @defaultValue `100%` + */ width?: string | number; + /** The image height. You probably only want to set this if you're using a fixed image. + * For responsive images, we automatically render the height of the image based on the aspect ratio of the image, + * and the width. The number provided here will be set as the height prop on the rendered HTML, + * which helps prevent layout shift. + * + * @defaultValue `auto` + * + * @example + * ``` + * + * width={100} + * height={100} + * /> + * ``` + */ height?: string | number; + /** The crop position of the image. + * + * @remarks + * In the event that AspectRatio is set, without specifying a crop, + * the Shopify CDN won't return the expected image. + * + * @defaultValue `center` + */ crop?: Crop; + /** Standard CSS sizes + * + * @example + * ``` + * + * ``` + * + * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-srcset} */ sizes?: string; + /** The aspect ratio of the image, in the format of `width/height`. + * + * @example + * ``` + * + * ``` + */ aspectRatio?: string; + /** An optional prop you can use to change the default srcSet generation behaviour */ srcSetOptions?: SrcSetOptions; + /** The alt text for the image. + * + * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-alt} */ alt?: string; + /** Our Image component defaults to `lazy` loading, if you’re rendering an image at the top of + * the page, set this prop to `eager`. + * + * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-loading} + */ loading?: 'lazy' | 'eager'; + /** @deprecated Use `crop`, `width`, `height`, and `src` props, and/or `data` prop */ loaderOptions?: ShopifyLoaderOptions; + /** @deprecated Autocalculated, use only `width` prop, or srcSetOptions */ widths?: (HtmlImageProps['width'] | ImageType['width'])[]; -}; +} +/** + * A Storefront API GraphQL fragment that can be used to query for an image. + */ +export const IMAGE_FRAGMENT = `#graphql + fragment Image on Image { + altText + url + width + height + } +`; + +/** + * Hydrgen’s Image component is a wrapper around the HTML image element. + * It supports the same props as the HTML image element, but automatically + * generates the srcSet and sizes attributes for you. For most use cases, + * you’ll want to set the `aspectRatio` prop to ensure the image is sized + * correctly. + * + * @example + * A responsive image with a 4:5 aspect ratio: + * ``` + * + * ``` + * @example + * A fixed size image: + * ``` + * + * ``` + * + * {@link https://shopify.dev/docs/api/hydrogen-react/components/image} + */ export function Image({ - /** - * An object with fields that correspond to the Storefront API's - * [Image object](https://shopify.dev/api/storefront/reference/common-objects/image). - */ data, as: Component = 'img', src, - /* - * Supports third party loaders, which are expected to provide - * a function that can generate a URL string - */ loader = shopifyLoader, - /* - * The default behaviour is a responsive image, set to 100%, that fills - * the width of its container. It’s not declared in the props. - */ width, height, - /* - * The default crop is center, in the event that AspectRatio is set, - * without specifying a crop, Imagery won't return the expected image. - */ crop = 'center', sizes, - /* - * aspectRatio is a string in the format of 'width/height' - * it's used to generate the srcSet URLs, and to set the - * aspect ratio of the image element to prevent CLS. - */ aspectRatio, - /* - * An optional prop you can use to change - * the default srcSet generation behaviour - */ srcSetOptions = { intervals: 10, startingWidth: 300, @@ -100,17 +206,7 @@ export function Image({ }, alt, loading = 'lazy', - /* - * Deprecated property from original Image component, - * you can now use the flat `crop`, `width`, and `height` props - * as well as `src` and `data` to achieve the same result. - */ loaderOptions, - /* - * Deprecated property from original Image component, - * widths are now calculated automatically based on the - * config and width props. - */ widths, ...passthroughProps }: HtmlImageProps & ShopifyImageProps): JSX.Element | null { @@ -307,6 +403,12 @@ export function Image({ } } +/** + * Checks whether the width and height share the same unit type + * @param width - The width of the image, e.g. 100% | 10px + * @param height - The height of the image, e.g. auto | 100px + * @returns Whether the width and height share the same unit type (boolean) + */ function unitsMatch( width: string | number = '100%', height: string | number = 'auto', @@ -315,21 +417,13 @@ function unitsMatch( getUnitValueParts(width.toString()).unit === getUnitValueParts(height.toString()).unit ); - /* - Given: - width = '100px' - height = 'auto' - Returns: - false - - Given: - width = '100px' - height = '50px' - Returns: - true - */ } +/** + * Given a CSS size, returns the unit and number parts of the value + * @param value - The CSS size, e.g. 100px + * @returns The unit and number parts of the value, e.g. \{unit: 'px', number: 100\} + */ function getUnitValueParts(value: string): {unit: string; number: number} { const unit = value.replace(/[0-9.]/g, ''); const number = parseFloat(value.replace(unit, '')); @@ -338,17 +432,13 @@ function getUnitValueParts(value: string): {unit: string; number: number} { unit: unit === '' ? (number === undefined ? 'auto' : 'px') : unit, number, }; - /* - Given: - value = '100px' - Returns: - { - unit: 'px', - number: 100 - } - */ } +/** + * Given a value, returns the width of the image as an integer in pixels + * @param value - The width of the image, e.g. 16px | 1rem | 1em | 16 + * @returns The width of the image in pixels, e.g. 16, or undefined if the value is not a fixed unit + */ function getNormalizedFixedUnit(value?: string | number): number | undefined { if (value === undefined) { return; @@ -368,33 +458,28 @@ function getNormalizedFixedUnit(value?: string | number): number | undefined { default: return; } - /* - Given: - value = 16px | 1rem | 1em | 16 - Returns: - 16 - - Given: - value = 100% - Returns: - undefined - */ } +/** + * This function checks whether a width is fixed or not. + * @param width - The width of the image, e.g. 100 | '100px' | '100em' | '100rem' + * @returns Whether the width is fixed or not + */ function isFixedWidth(width: string | number): boolean { const fixedEndings = /\d(px|em|rem)$/; return ( typeof width === 'number' || (typeof width === 'string' && fixedEndings.test(width)) ); - /* - Given: - width = 100 | '100px' | '100em' | '100rem' - Returns: - true - */ } +/** + * This function generates a srcSet for Shopify images. + * @param src - The source URL of the image, e.g. https://cdn.shopify.com/static/sample-images/garnished.jpeg + * @param sizesArray - An array of objects containing the width, height, and crop of the image, e.g. [\{width: 200, height: 200, crop: 'center'\}, \{width: 400, height: 400, crop: 'center'\}] + * @param loader - A function that takes a Shopify image URL and returns a Shopify image URL with the correct query parameters + * @returns A srcSet for Shopify images, e.g. 'https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=200&height=200&crop=center 200w, https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=400&height=400&crop=center 400w' + */ export function generateShopifySrcSet( src?: string, sizesArray?: Array<{width?: number; height?: number; crop?: Crop}>, @@ -419,18 +504,16 @@ export function generateShopifySrcSet( })} ${size.width || 0}w`, ) .join(`, `); - /* - Given: - src = 'https://cdn.shopify.com/static/sample-images/garnished.jpeg' - sizesArray = [ - {width: 200, height: 200, crop: 'center'}, - {width: 400, height: 400, crop: 'center'}, - ] - Returns: - 'https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=200&height=200&crop=center 200w, https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=400&height=400&crop=center 400w' - */ } +/** + * This function generates an array of sizes for Shopify images, for both fixed and responsive images. + * @param width - The CSS width of the image + * @param intervals - The number of intervals to generate + * @param startingWidth - The starting width of the image + * @param incrementSize - The size of each interval + * @returns An array of widths + */ export function generateImageWidths( width: string | number = '100%', intervals = 20, @@ -450,21 +533,17 @@ export function generateImageWidths( return isFixedWidth(width) ? fixed : responsive; } -// Simple utility function to convert 1/1 to [1, 1] +/** + * Simple utility function to convert an aspect ratio CSS string to a decimal, currently only supports values like `1/1`, not `0.5`, or `auto` + * @param aspectRatio - The aspect ratio of the image, e.g. `1/1` + * @returns The aspect ratio as a number, e.g. `0.5` + * + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio} + */ export function parseAspectRatio(aspectRatio?: string): number | undefined { if (!aspectRatio) return; const [width, height] = aspectRatio.split('/'); return 1 / (Number(width) / Number(height)); - /* - Given: - '1/1' - Returns: - 0.5, - Given: - '4/3' - Returns: - 0.75 - */ } // Generate data needed for Imagery loader @@ -499,11 +578,16 @@ export function generateSizes( */ } -/* +/** * The shopifyLoader function is a simple utility function that takes a src, width, * height, and crop and returns a string that can be used as the src for an image. * It can be used with the Hydrogen Image component or with the next/image component. * (or any others that accept equivalent configuration) + * @param src - The source URL of the image, e.g. `https://cdn.shopify.com/static/sample-images/garnished.jpeg` + * @param width - The width of the image, e.g. `100` + * @param height - The height of the image, e.g. `100` + * @param crop - The crop of the image, e.g. `center` + * @returns A Shopify image URL with the correct query parameters, e.g. `https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=100&height=100&crop=center` */ export const shopifyLoader: ImageLoader = ({src, width, height, crop}) => { if (!src) { @@ -515,13 +599,4 @@ export const shopifyLoader: ImageLoader = ({src, width, height, crop}) => { height && url.searchParams.append('height', Math.round(height).toString()); crop && url.searchParams.append('crop', crop); return url.href; - /* - Given: - src = 'https://cdn.shopify.com/static/sample-images/garnished.jpeg' - width = 100 - height = 100 - crop = 'center' - Returns: - 'https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=100&height=100&crop=center' - */ }; From bd82a80b80d7c2902fe3ff3704e34b50cd08761b Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 13 Mar 2023 19:35:00 -0400 Subject: [PATCH 15/46] Comment out console warning tests --- packages/hydrogen-react/src/Image.test.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/hydrogen-react/src/Image.test.tsx b/packages/hydrogen-react/src/Image.test.tsx index 9f5cc8cd51..792e9f21d5 100644 --- a/packages/hydrogen-react/src/Image.test.tsx +++ b/packages/hydrogen-react/src/Image.test.tsx @@ -158,6 +158,8 @@ describe('', () => { }); }); + // eslint-disable-next-line jest/no-commented-out-tests + /* describe('warnings', () => { const consoleMock = { ...console, @@ -228,10 +230,11 @@ describe('', () => { ); }); }); + */ }); -function getWarnings() { - return (console.warn as Mock<[string]>).mock.calls.map( - ([message]) => message, - ); -} +// function getWarnings() { +// return (console.warn as Mock<[string]>).mock.calls.map( +// ([message]) => message, +// ); +// } From 981ba482b92264e1c8e1624d8d9d0c6db413962b Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 13 Mar 2023 19:46:02 -0400 Subject: [PATCH 16/46] Updates Demo Store images --- packages/hydrogen-react/src/Image.test.tsx | 1 - .../app/components/FeaturedCollections.tsx | 8 +------- templates/demo-store/app/components/ProductCard.tsx | 13 ++++--------- .../routes/($lang)/account/__private/orders.$id.tsx | 13 +++---------- .../app/routes/($lang)/journal/$journalHandle.tsx | 9 +-------- .../demo-store/app/routes/($lang)/journal/index.tsx | 7 +------ 6 files changed, 10 insertions(+), 41 deletions(-) diff --git a/packages/hydrogen-react/src/Image.test.tsx b/packages/hydrogen-react/src/Image.test.tsx index 792e9f21d5..43bebfabf9 100644 --- a/packages/hydrogen-react/src/Image.test.tsx +++ b/packages/hydrogen-react/src/Image.test.tsx @@ -1,7 +1,6 @@ import {render, screen} from '@testing-library/react'; import {faker} from '@faker-js/faker'; import {Image} from './Image.js'; -import {Mock} from 'vitest'; const defaultProps = { sizes: '100vw', diff --git a/templates/demo-store/app/components/FeaturedCollections.tsx b/templates/demo-store/app/components/FeaturedCollections.tsx index b5327b8778..523ff385e5 100644 --- a/templates/demo-store/app/components/FeaturedCollections.tsx +++ b/templates/demo-store/app/components/FeaturedCollections.tsx @@ -32,14 +32,8 @@ export function FeaturedCollections({ {`Image )} diff --git a/templates/demo-store/app/components/ProductCard.tsx b/templates/demo-store/app/components/ProductCard.tsx index 9fb86d9b75..5acd9df6d1 100644 --- a/templates/demo-store/app/components/ProductCard.tsx +++ b/templates/demo-store/app/components/ProductCard.tsx @@ -68,15 +68,10 @@ export function ProductCard({
{image && ( {image.altText {lineItem.variant.image.altText!}
)} diff --git a/templates/demo-store/app/routes/($lang)/journal/$journalHandle.tsx b/templates/demo-store/app/routes/($lang)/journal/$journalHandle.tsx index 6b14338a59..42df53565b 100644 --- a/templates/demo-store/app/routes/($lang)/journal/$journalHandle.tsx +++ b/templates/demo-store/app/routes/($lang)/journal/$journalHandle.tsx @@ -10,7 +10,6 @@ import {Image} from '@shopify/hydrogen'; import {Blog} from '@shopify/hydrogen/storefront-api-types'; import invariant from 'tiny-invariant'; import {PageHeader, Section} from '~/components'; -import {ATTR_LOADING_EAGER} from '~/lib/const'; import styles from '../../../styles/custom-font.css'; import type {SeoHandleFunction} from '@shopify/hydrogen'; import {routeHeaders, CACHE_LONG} from '~/data/cache'; @@ -99,13 +98,7 @@ export default function Article() { data={image} className="w-full mx-auto mt-8 md:mt-16 max-w-7xl" sizes="90vw" - widths={[400, 800, 1200]} - width="100px" - loading={ATTR_LOADING_EAGER} - loaderOptions={{ - scale: 2, - crop: 'center', - }} + loading="eager" /> )}
)} From 010be7d8c0cf3a5d92dc5af0e1eefa0d23dc2ac6 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 13 Mar 2023 20:26:47 -0400 Subject: [PATCH 17/46] Adds docs and examples, updates demo store implementation --- packages/hydrogen-react/src/Image.doc.ts | 44 +++++++++ packages/hydrogen-react/src/Image.example.jsx | 29 ++++++ packages/hydrogen-react/src/Image.example.tsx | 31 +++++++ packages/hydrogen-react/src/Image.tsx | 90 +++++-------------- .../hydrogen-react/src/ImageLegacy.doc.ts | 4 +- templates/demo-store/app/components/Hero.tsx | 34 ++----- 6 files changed, 135 insertions(+), 97 deletions(-) create mode 100644 packages/hydrogen-react/src/Image.doc.ts create mode 100644 packages/hydrogen-react/src/Image.example.jsx create mode 100644 packages/hydrogen-react/src/Image.example.tsx diff --git a/packages/hydrogen-react/src/Image.doc.ts b/packages/hydrogen-react/src/Image.doc.ts new file mode 100644 index 0000000000..bf72e669d2 --- /dev/null +++ b/packages/hydrogen-react/src/Image.doc.ts @@ -0,0 +1,44 @@ +import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs'; + +const data: ReferenceEntityTemplateSchema = { + name: 'Image', + category: 'components', + isVisualComponent: false, + related: [ + { + name: 'MediaFile', + type: 'component', + url: '/api/hydrogen-react/components/mediafile', + }, + ], + description: + "The `Image` component renders an image for the Storefront API's\n[Image object](https://shopify.dev/api/storefront/reference/common-objects/image) by using the `data` prop. You can [customize this component](https://shopify.dev/api/hydrogen/components#customizing-hydrogen-components) using passthrough props.\n\nImages default to being responsive automativally (`width: 100%, height: auto`), and expect an `aspectRatio` prop, which ensures your image doesn't create any layout shift. For fixed-size images, you can set `width` to an exact value, and a `srcSet` with 1x, 2x, and 3x DPI variants will automatically be generated for you.", + type: 'component', + defaultExample: { + description: 'I am the default example', + codeblock: { + tabs: [ + { + title: 'JavaScript', + code: './Image.example.jsx', + language: 'jsx', + }, + { + title: 'TypeScript', + code: './Image.example.tsx', + language: 'tsx', + }, + ], + title: 'Example code', + }, + }, + definitions: [ + { + title: 'Props', + type: 'ShopifyImageBaseProps', + description: '', + }, + ], +}; + +export default data; diff --git a/packages/hydrogen-react/src/Image.example.jsx b/packages/hydrogen-react/src/Image.example.jsx new file mode 100644 index 0000000000..fbf9c54b24 --- /dev/null +++ b/packages/hydrogen-react/src/Image.example.jsx @@ -0,0 +1,29 @@ +import {Image, IMAGE_FRAGMENT} from '@shopify/hydrogen-react'; + +// An example query that includes the image fragment +const IMAGE_QUERY = `#graphql + ${IMAGE_FRAGMENT} + query { + product { + featuredImage { + ...Image + } + } + } +`; + +export default function ProductImage({product}) { + const image = product.featuredImage; + + if (!image) { + return null; + } + + return ( + + ); +} diff --git a/packages/hydrogen-react/src/Image.example.tsx b/packages/hydrogen-react/src/Image.example.tsx new file mode 100644 index 0000000000..a354a79fe4 --- /dev/null +++ b/packages/hydrogen-react/src/Image.example.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import {Image, IMAGE_FRAGMENT} from '@shopify/hydrogen-react'; +import type {Product} from '@shopify/hydrogen-react/storefront-api-types'; + +// An example query that includes the image fragment +const IMAGE_QUERY = `#graphql + ${IMAGE_FRAGMENT} + query { + product { + featuredImage { + ...Image + } + } + } +`; + +export default function ProductImage({product}: {product: Product}) { + const image = product.featuredImage; + + if (!image) { + return null; + } + + return ( + + ); +} diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index cd298b0367..d418e0c45c 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -37,7 +37,9 @@ export type ShopifyLoaderParams = Simplify< */ type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right' | undefined; -export interface ShopifyImageProps { +export type ShopifyImageProps = HtmlImageProps & ShopifyImageBaseProps; + +type ShopifyImageBaseProps = { /** The HTML element to use for the image, `source` should only be used inside `picture` */ as?: 'img' | 'source'; /** Data mapping to the Storefront API `Image` object. Must be an Image object. @@ -67,39 +69,14 @@ export interface ShopifyImageProps { * Image: {@link https://shopify.dev/api/storefront/reference/common-objects/image} */ data?: PartialDeep; - /** The image URL. This is used if no `data` prop is provided. */ - src?: string; - /** A function that returns a URL string for an image. - * - * @remarks - * By default, this uses Shopify’s CDN {@link https://cdn.shopify.com/} but you can provide - * your own function to use a another provider, as long as they support URL based image transformations. - */ - loader?: ImageLoader; - /** Image width, as meant to be rendered on the page. - * You will mostly use this prop if you're rendering a fixed size image. - * - * @defaultValue `100%` - */ - width?: string | number; - /** The image height. You probably only want to set this if you're using a fixed image. - * For responsive images, we automatically render the height of the image based on the aspect ratio of the image, - * and the width. The number provided here will be set as the height prop on the rendered HTML, - * which helps prevent layout shift. - * - * @defaultValue `auto` + /** The aspect ratio of the image, in the format of `width/height`. * * @example * ``` - * - * width={100} - * height={100} - * /> + * * ``` */ - height?: string | number; + aspectRatio?: string; /** The crop position of the image. * * @remarks @@ -109,44 +86,20 @@ export interface ShopifyImageProps { * @defaultValue `center` */ crop?: Crop; - /** Standard CSS sizes - * - * @example - * ``` - * - * ``` - * - * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-srcset} */ - sizes?: string; - /** The aspect ratio of the image, in the format of `width/height`. + /** A function that returns a URL string for an image. * - * @example - * ``` - * - * ``` + * @remarks + * By default, this uses Shopify’s CDN {@link https://cdn.shopify.com/} but you can provide + * your own function to use a another provider, as long as they support URL based image transformations. */ - aspectRatio?: string; + loader?: ImageLoader; /** An optional prop you can use to change the default srcSet generation behaviour */ srcSetOptions?: SrcSetOptions; - /** The alt text for the image. - * - * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-alt} */ - alt?: string; - /** Our Image component defaults to `lazy` loading, if you’re rendering an image at the top of - * the page, set this prop to `eager`. - * - * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-loading} - */ - loading?: 'lazy' | 'eager'; /** @deprecated Use `crop`, `width`, `height`, and `src` props, and/or `data` prop */ loaderOptions?: ShopifyLoaderOptions; /** @deprecated Autocalculated, use only `width` prop, or srcSetOptions */ widths?: (HtmlImageProps['width'] | ImageType['width'])[]; -} +}; /** * A Storefront API GraphQL fragment that can be used to query for an image. @@ -189,15 +142,11 @@ export const IMAGE_FRAGMENT = `#graphql * {@link https://shopify.dev/docs/api/hydrogen-react/components/image} */ export function Image({ - data, as: Component = 'img', - src, - loader = shopifyLoader, - width, - height, - crop = 'center', - sizes, + data, aspectRatio, + crop = 'center', + loader = shopifyLoader, srcSetOptions = { intervals: 10, startingWidth: 300, @@ -205,11 +154,16 @@ export function Image({ placeholderWidth: 100, }, alt, + decoding = 'async', + height, loading = 'lazy', + sizes, + src, + width, loaderOptions, widths, ...passthroughProps -}: HtmlImageProps & ShopifyImageProps): JSX.Element | null { +}: ShopifyImageProps): JSX.Element | null { /* * Deprecated Props from original Image component */ @@ -359,6 +313,7 @@ export function Image({ crop: normalizedHeight === 'auto' ? undefined : crop, }), alt: normalizedAlt, + decoding, sizes: sizes || normalizedWidth, style: { width: normalizedWidth, @@ -389,6 +344,7 @@ export function Image({ height: placeholderHeight, }), alt: normalizedAlt, + decoding, sizes, style: { width: normalizedWidth, diff --git a/packages/hydrogen-react/src/ImageLegacy.doc.ts b/packages/hydrogen-react/src/ImageLegacy.doc.ts index 71f2fab5da..b4f066f907 100644 --- a/packages/hydrogen-react/src/ImageLegacy.doc.ts +++ b/packages/hydrogen-react/src/ImageLegacy.doc.ts @@ -20,12 +20,12 @@ const data: ReferenceEntityTemplateSchema = { tabs: [ { title: 'JavaScript', - code: './Image.example.jsx', + code: './ImageLegacy.example.jsx', language: 'jsx', }, { title: 'TypeScript', - code: './Image.example.tsx', + code: './ImageLegacy.example.tsx', language: 'tsx', }, ], diff --git a/templates/demo-store/app/components/Hero.tsx b/templates/demo-store/app/components/Hero.tsx index dfe293ea4a..b120890819 100644 --- a/templates/demo-store/app/components/Hero.tsx +++ b/templates/demo-store/app/components/Hero.tsx @@ -38,18 +38,11 @@ export function Hero({ {spread?.reference && (
@@ -58,9 +51,7 @@ export function Hero({ {spreadSecondary?.reference && (
@@ -88,20 +79,10 @@ export function Hero({ interface SpreadMediaProps { data: Media | MediaImage | MediaVideo; loading?: HTMLImageElement['loading']; - scale?: 2 | 3; sizes: string; - width: number; - widths: number[]; } -function SpreadMedia({ - data, - loading, - scale, - sizes, - width, - widths, -}: SpreadMediaProps) { +function SpreadMedia({data, loading, sizes}: SpreadMediaProps) { return ( Date: Mon, 13 Mar 2023 20:45:47 -0400 Subject: [PATCH 18/46] Update generated docs data --- .../docs/generated/generated_docs_data.json | 427 +++++++++++++----- 1 file changed, 305 insertions(+), 122 deletions(-) diff --git a/packages/hydrogen-react/docs/generated/generated_docs_data.json b/packages/hydrogen-react/docs/generated/generated_docs_data.json index f4fab16186..e6481f60ab 100644 --- a/packages/hydrogen-react/docs/generated/generated_docs_data.json +++ b/packages/hydrogen-react/docs/generated/generated_docs_data.json @@ -1333,7 +1333,7 @@ "url": "/api/hydrogen-react/components/mediafile" } ], - "description": "The `Image` component renders an image for the Storefront API's\n[Image object](https://shopify.dev/api/storefront/reference/common-objects/image) by using the `data` prop. You can [customize this component](https://shopify.dev/api/hydrogen/components#customizing-hydrogen-components) using passthrough props.\n\nAn image's width and height are determined using the following priority list:\n1. The width and height values for the `loaderOptions` prop\n2. The width and height values for bare props\n3. The width and height values for the `data` prop\n\nIf only one of `width` or `height` are defined, then the other will attempt to be calculated based on the image's aspect ratio,\nprovided that both `data.width` and `data.height` are available. If `data.width` and `data.height` aren't available, then the aspect ratio cannot be determined and the missing value will remain as `null`", + "description": "The `Image` component renders an image for the Storefront API's\n[Image object](https://shopify.dev/api/storefront/reference/common-objects/image) by using the `data` prop. You can [customize this component](https://shopify.dev/api/hydrogen/components#customizing-hydrogen-components) using passthrough props.\n\nImages default to being responsive automativally (`width: 100%, height: auto`), and expect an `aspectRatio` prop, which ensures your image doesn't create any layout shift. For fixed-size images, you can set `width` to an exact value, and a `srcSet` with 1x, 2x, and 3x DPI variants will automatically be generated for you.", "type": "component", "defaultExample": { "description": "I am the default example", @@ -1341,12 +1341,12 @@ "tabs": [ { "title": "JavaScript", - "code": "import {Image} from '@shopify/hydrogen-react';\n\nexport default function ProductImage({product}) {\n const image = product.featuredImage;\n\n if (!image) {\n return null;\n }\n\n return ;\n}\n", + "code": "import {Image, IMAGE_FRAGMENT} from '@shopify/hydrogen-react';\n\n// An example query that includes the image fragment\nconst IMAGE_QUERY = `#graphql\n ${IMAGE_FRAGMENT}\n query {\n product {\n featuredImage {\n ...Image\n }\n }\n }\n`;\n\nexport default function ProductImage({product}) {\n const image = product.featuredImage;\n\n if (!image) {\n return null;\n }\n\n return (\n \n );\n}\n", "language": "jsx" }, { "title": "TypeScript", - "code": "import {Image} from '@shopify/hydrogen-react';\nimport type {Product} from '@shopify/hydrogen-react/storefront-api-types';\n\nexport default function ProductImage({product}: {product: Product}) {\n const image = product.featuredImage;\n\n if (!image) {\n return null;\n }\n\n return ;\n}\n", + "code": "import React from 'react';\nimport {Image, IMAGE_FRAGMENT} from '@shopify/hydrogen-react';\nimport type {Product} from '@shopify/hydrogen-react/storefront-api-types';\n\n// An example query that includes the image fragment\nconst IMAGE_QUERY = `#graphql\n ${IMAGE_FRAGMENT}\n query {\n product {\n featuredImage {\n ...Image\n }\n }\n }\n`;\n\nexport default function ProductImage({product}: {product: Product}) {\n const image = product.featuredImage;\n\n if (!image) {\n return null;\n }\n\n return (\n \n );\n}\n", "language": "tsx" } ], @@ -1363,55 +1363,104 @@ "filePath": "/Image.tsx", "syntaxKind": "TypeAliasDeclaration", "name": "ShopifyImageBaseProps", - "value": "{\n /** An object with fields that correspond to the Storefront API's\n * [Image object](https://shopify.dev/api/storefront/reference/common-objects/image).\n * The `data` prop is required.\n */\n data: PartialDeep;\n /** A custom function that generates the image URL. Parameters passed in\n * are `ShopifyLoaderParams`\n */\n loader?: (params: ShopifyLoaderParams) => string;\n /** An object of `loader` function options. For example, if the `loader` function\n * requires a `scale` option, then the value can be a property of the\n * `loaderOptions` object (for example, `{scale: 2}`). The object shape is `ShopifyLoaderOptions`.\n */\n loaderOptions?: ShopifyLoaderOptions;\n /**\n * `src` isn't used, and should instead be passed as part of the `data` object\n */\n src?: never;\n /**\n * An array of pixel widths to overwrite the default generated srcset. For example, `[300, 600, 800]`.\n */\n widths?: (HtmlImageProps['width'] | ImageType['width'])[];\n}", + "value": "{\n /** The HTML element to use for the image, `source` should only be used inside `picture` */\n as?: 'img' | 'source';\n /** Data mapping to the Storefront API `Image` object. Must be an Image object.\n * Optionally, import the `IMAGE_FRAGMENT` to use in your GraphQL queries.\n *\n * @example\n * ```\n * import {IMAGE_FRAGMENT, Image} from '@shopify/hydrogen';\n *\n * export const IMAGE_QUERY = `#graphql\n * ${IMAGE_FRAGMENT}\n * query {\n * product {\n * featuredImage {\n * ...Image\n * }\n * }\n * }`\n *\n * \n * ```\n *\n * Image: {@link https://shopify.dev/api/storefront/reference/common-objects/image}\n */\n data?: PartialDeep;\n /** The aspect ratio of the image, in the format of `width/height`.\n *\n * @example\n * ```\n * \n * ```\n */\n aspectRatio?: string;\n /** The crop position of the image.\n *\n * @remarks\n * In the event that AspectRatio is set, without specifying a crop,\n * the Shopify CDN won't return the expected image.\n *\n * @defaultValue `center`\n */\n crop?: Crop;\n /** A function that returns a URL string for an image.\n *\n * @remarks\n * By default, this uses Shopify’s CDN {@link https://cdn.shopify.com/} but you can provide\n * your own function to use a another provider, as long as they support URL based image transformations.\n */\n loader?: ImageLoader;\n /** An optional prop you can use to change the default srcSet generation behaviour */\n srcSetOptions?: SrcSetOptions;\n /** @deprecated Use `crop`, `width`, `height`, and `src` props, and/or `data` prop */\n loaderOptions?: ShopifyLoaderOptions;\n /** @deprecated Autocalculated, use only `width` prop, or srcSetOptions */\n widths?: (HtmlImageProps['width'] | ImageType['width'])[];\n}", "description": "", "members": [ + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "as", + "value": "\"img\" | \"source\"", + "description": "The HTML element to use for the image, `source` should only be used inside `picture`", + "isOptional": true + }, { "filePath": "/Image.tsx", "syntaxKind": "PropertySignature", "name": "data", "value": "PartialObjectDeep", - "description": "An object with fields that correspond to the Storefront API's\n[Image object](https://shopify.dev/api/storefront/reference/common-objects/image).\nThe `data` prop is required." + "description": "Data mapping to the Storefront API `Image` object. Must be an Image object.\nOptionally, import the `IMAGE_FRAGMENT` to use in your GraphQL queries.", + "isOptional": true }, { "filePath": "/Image.tsx", "syntaxKind": "PropertySignature", - "name": "loader", - "value": "(params: { crop?: \"center\" | \"top\" | \"bottom\" | \"left\" | \"right\"; scale?: 2 | 3; width?: string | number; height?: string | number; src: string; }) => string", - "description": "A custom function that generates the image URL. Parameters passed in\nare `ShopifyLoaderParams`", + "name": "aspectRatio", + "value": "string", + "description": "The aspect ratio of the image, in the format of `width/height`.", "isOptional": true }, { "filePath": "/Image.tsx", "syntaxKind": "PropertySignature", - "name": "loaderOptions", - "value": "ShopifyLoaderOptions", - "description": "An object of `loader` function options. For example, if the `loader` function\nrequires a `scale` option, then the value can be a property of the\n`loaderOptions` object (for example, `{scale: 2}`). The object shape is `ShopifyLoaderOptions`.", + "name": "crop", + "value": "Crop", + "description": "The crop position of the image.", + "isOptional": true, + "defaultValue": "`center`" + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "loader", + "value": "ImageLoader", + "description": "A function that returns a URL string for an image.", "isOptional": true }, { "filePath": "/Image.tsx", "syntaxKind": "PropertySignature", - "name": "src", - "value": "never", - "description": "`src` isn't used, and should instead be passed as part of the `data` object", + "name": "srcSetOptions", + "value": "SrcSetOptions", + "description": "An optional prop you can use to change the default srcSet generation behaviour", "isOptional": true }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "loaderOptions", + "value": "ShopifyLoaderOptions", + "description": "", + "isOptional": true, + "deprecationMessage": "Use `crop`, `width`, `height`, and `src` props, and/or `data` prop" + }, { "filePath": "/Image.tsx", "syntaxKind": "PropertySignature", "name": "widths", "value": "(string | number)[]", - "description": "An array of pixel widths to overwrite the default generated srcset. For example, `[300, 600, 800]`.", - "isOptional": true + "description": "", + "isOptional": true, + "deprecationMessage": "Autocalculated, use only `width` prop, or srcSetOptions" } ] }, + "Crop": { + "filePath": "/Image.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "Crop", + "value": "'center' | 'top' | 'bottom' | 'left' | 'right' | undefined", + "description": "" + }, + "ImageLoader": { + "filePath": "/Image.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "ImageLoader", + "value": "(params: ShopifyLoaderParams) => string", + "description": "" + }, + "ShopifyLoaderParams": { + "filePath": "/Image.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "ShopifyLoaderParams", + "value": "Simplify<\n ShopifyLoaderOptions & {\n src?: ImageType['url'];\n width?: number;\n height?: number;\n crop?: Crop;\n }\n>", + "description": "" + }, "ShopifyLoaderOptions": { "filePath": "/Image.tsx", "syntaxKind": "TypeAliasDeclaration", "name": "ShopifyLoaderOptions", - "value": "{\n crop?: 'top' | 'bottom' | 'left' | 'right' | 'center';\n scale?: 2 | 3;\n width?: HtmlImageProps['width'] | ImageType['width'];\n height?: HtmlImageProps['height'] | ImageType['height'];\n}", + "value": "{\n crop?: 'top' | 'bottom' | 'left' | 'right' | 'center';\n width?: HtmlImageProps['width'] | ImageType['width'];\n height?: HtmlImageProps['height'] | ImageType['height'];\n}", "description": "", "members": [ { @@ -1425,8 +1474,208 @@ { "filePath": "/Image.tsx", "syntaxKind": "PropertySignature", - "name": "scale", - "value": "2 | 3", + "name": "width", + "value": "string | number", + "description": "", + "isOptional": true + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "height", + "value": "string | number", + "description": "", + "isOptional": true + } + ] + }, + "SrcSetOptions": { + "filePath": "/Image.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "SrcSetOptions", + "value": "{\n intervals: number;\n startingWidth: number;\n incrementSize: number;\n placeholderWidth: number;\n}", + "description": "", + "members": [ + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "intervals", + "value": "number", + "description": "" + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "startingWidth", + "value": "number", + "description": "" + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "incrementSize", + "value": "number", + "description": "" + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "placeholderWidth", + "value": "number", + "description": "" + } + ] + } + } + } + ] + }, + { + "name": "Image", + "category": "components", + "isVisualComponent": false, + "related": [ + { + "name": "MediaFile", + "type": "component", + "url": "/api/hydrogen-react/components/mediafile" + } + ], + "description": "The `Image` component renders an image for the Storefront API's\n[Image object](https://shopify.dev/api/storefront/reference/common-objects/image) by using the `data` prop. You can [customize this component](https://shopify.dev/api/hydrogen/components#customizing-hydrogen-components) using passthrough props.\n\nAn image's width and height are determined using the following priority list:\n1. The width and height values for the `loaderOptions` prop\n2. The width and height values for bare props\n3. The width and height values for the `data` prop\n\nIf only one of `width` or `height` are defined, then the other will attempt to be calculated based on the image's aspect ratio,\nprovided that both `data.width` and `data.height` are available. If `data.width` and `data.height` aren't available, then the aspect ratio cannot be determined and the missing value will remain as `null`", + "type": "component", + "defaultExample": { + "description": "I am the default example", + "codeblock": { + "tabs": [ + { + "title": "JavaScript", + "code": "import {Image} from '@shopify/hydrogen-react';\n\nexport default function ProductImage({product}) {\n const image = product.featuredImage;\n\n if (!image) {\n return null;\n }\n\n return ;\n}\n", + "language": "jsx" + }, + { + "title": "TypeScript", + "code": "import {Image} from '@shopify/hydrogen-react';\nimport type {Product} from '@shopify/hydrogen-react/storefront-api-types';\n\nexport default function ProductImage({product}: {product: Product}) {\n const image = product.featuredImage;\n\n if (!image) {\n return null;\n }\n\n return ;\n}\n", + "language": "tsx" + } + ], + "title": "Example code" + } + }, + "definitions": [ + { + "title": "Props", + "description": "", + "type": "ShopifyImageBaseProps", + "typeDefinitions": { + "ShopifyImageBaseProps": { + "filePath": "/Image.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "ShopifyImageBaseProps", + "value": "{\n /** The HTML element to use for the image, `source` should only be used inside `picture` */\n as?: 'img' | 'source';\n /** Data mapping to the Storefront API `Image` object. Must be an Image object.\n * Optionally, import the `IMAGE_FRAGMENT` to use in your GraphQL queries.\n *\n * @example\n * ```\n * import {IMAGE_FRAGMENT, Image} from '@shopify/hydrogen';\n *\n * export const IMAGE_QUERY = `#graphql\n * ${IMAGE_FRAGMENT}\n * query {\n * product {\n * featuredImage {\n * ...Image\n * }\n * }\n * }`\n *\n * \n * ```\n *\n * Image: {@link https://shopify.dev/api/storefront/reference/common-objects/image}\n */\n data?: PartialDeep;\n /** The aspect ratio of the image, in the format of `width/height`.\n *\n * @example\n * ```\n * \n * ```\n */\n aspectRatio?: string;\n /** The crop position of the image.\n *\n * @remarks\n * In the event that AspectRatio is set, without specifying a crop,\n * the Shopify CDN won't return the expected image.\n *\n * @defaultValue `center`\n */\n crop?: Crop;\n /** A function that returns a URL string for an image.\n *\n * @remarks\n * By default, this uses Shopify’s CDN {@link https://cdn.shopify.com/} but you can provide\n * your own function to use a another provider, as long as they support URL based image transformations.\n */\n loader?: ImageLoader;\n /** An optional prop you can use to change the default srcSet generation behaviour */\n srcSetOptions?: SrcSetOptions;\n /** @deprecated Use `crop`, `width`, `height`, and `src` props, and/or `data` prop */\n loaderOptions?: ShopifyLoaderOptions;\n /** @deprecated Autocalculated, use only `width` prop, or srcSetOptions */\n widths?: (HtmlImageProps['width'] | ImageType['width'])[];\n}", + "description": "", + "members": [ + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "as", + "value": "\"img\" | \"source\"", + "description": "The HTML element to use for the image, `source` should only be used inside `picture`", + "isOptional": true + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "data", + "value": "PartialObjectDeep", + "description": "Data mapping to the Storefront API `Image` object. Must be an Image object.\nOptionally, import the `IMAGE_FRAGMENT` to use in your GraphQL queries.", + "isOptional": true + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "aspectRatio", + "value": "string", + "description": "The aspect ratio of the image, in the format of `width/height`.", + "isOptional": true + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "crop", + "value": "Crop", + "description": "The crop position of the image.", + "isOptional": true, + "defaultValue": "`center`" + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "loader", + "value": "ImageLoader", + "description": "A function that returns a URL string for an image.", + "isOptional": true + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "srcSetOptions", + "value": "SrcSetOptions", + "description": "An optional prop you can use to change the default srcSet generation behaviour", + "isOptional": true + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "loaderOptions", + "value": "ShopifyLoaderOptions", + "description": "", + "isOptional": true, + "deprecationMessage": "Use `crop`, `width`, `height`, and `src` props, and/or `data` prop" + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "widths", + "value": "(string | number)[]", + "description": "", + "isOptional": true, + "deprecationMessage": "Autocalculated, use only `width` prop, or srcSetOptions" + } + ] + }, + "Crop": { + "filePath": "/Image.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "Crop", + "value": "'center' | 'top' | 'bottom' | 'left' | 'right' | undefined", + "description": "" + }, + "ImageLoader": { + "filePath": "/Image.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "ImageLoader", + "value": "(params: ShopifyLoaderParams) => string", + "description": "" + }, + "ShopifyLoaderParams": { + "filePath": "/Image.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "ShopifyLoaderParams", + "value": "Simplify<\n ShopifyLoaderOptions & {\n src?: ImageType['url'];\n width?: number;\n height?: number;\n crop?: Crop;\n }\n>", + "description": "" + }, + "ShopifyLoaderOptions": { + "filePath": "/Image.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "ShopifyLoaderOptions", + "value": "{\n crop?: 'top' | 'bottom' | 'left' | 'right' | 'center';\n width?: HtmlImageProps['width'] | ImageType['width'];\n height?: HtmlImageProps['height'] | ImageType['height'];\n}", + "description": "", + "members": [ + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "crop", + "value": "\"center\" | \"top\" | \"bottom\" | \"left\" | \"right\"", "description": "", "isOptional": true }, @@ -1447,6 +1696,43 @@ "isOptional": true } ] + }, + "SrcSetOptions": { + "filePath": "/Image.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "SrcSetOptions", + "value": "{\n intervals: number;\n startingWidth: number;\n incrementSize: number;\n placeholderWidth: number;\n}", + "description": "", + "members": [ + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "intervals", + "value": "number", + "description": "" + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "startingWidth", + "value": "number", + "description": "" + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "incrementSize", + "value": "number", + "description": "" + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "placeholderWidth", + "value": "number", + "description": "" + } + ] } } } @@ -3628,109 +3914,6 @@ } ] }, - "ShopifyImageProps": { - "filePath": "/Image.tsx", - "syntaxKind": "TypeAliasDeclaration", - "name": "ShopifyImageProps", - "value": "Omit & ShopifyImageBaseProps", - "description": "" - }, - "HtmlImageProps": { - "filePath": "/Image.tsx", - "syntaxKind": "TypeAliasDeclaration", - "name": "HtmlImageProps", - "value": "React.ImgHTMLAttributes", - "description": "" - }, - "ShopifyImageBaseProps": { - "filePath": "/Image.tsx", - "syntaxKind": "TypeAliasDeclaration", - "name": "ShopifyImageBaseProps", - "value": "{\n /** An object with fields that correspond to the Storefront API's\n * [Image object](https://shopify.dev/api/storefront/reference/common-objects/image).\n * The `data` prop is required.\n */\n data: PartialDeep;\n /** A custom function that generates the image URL. Parameters passed in\n * are `ShopifyLoaderParams`\n */\n loader?: (params: ShopifyLoaderParams) => string;\n /** An object of `loader` function options. For example, if the `loader` function\n * requires a `scale` option, then the value can be a property of the\n * `loaderOptions` object (for example, `{scale: 2}`). The object shape is `ShopifyLoaderOptions`.\n */\n loaderOptions?: ShopifyLoaderOptions;\n /**\n * `src` isn't used, and should instead be passed as part of the `data` object\n */\n src?: never;\n /**\n * An array of pixel widths to overwrite the default generated srcset. For example, `[300, 600, 800]`.\n */\n widths?: (HtmlImageProps['width'] | ImageType['width'])[];\n}", - "description": "", - "members": [ - { - "filePath": "/Image.tsx", - "syntaxKind": "PropertySignature", - "name": "data", - "value": "PartialObjectDeep", - "description": "An object with fields that correspond to the Storefront API's\n[Image object](https://shopify.dev/api/storefront/reference/common-objects/image).\nThe `data` prop is required." - }, - { - "filePath": "/Image.tsx", - "syntaxKind": "PropertySignature", - "name": "loader", - "value": "(params: { crop?: \"center\" | \"top\" | \"bottom\" | \"left\" | \"right\"; scale?: 2 | 3; width?: string | number; height?: string | number; src: string; }) => string", - "description": "A custom function that generates the image URL. Parameters passed in\nare `ShopifyLoaderParams`", - "isOptional": true - }, - { - "filePath": "/Image.tsx", - "syntaxKind": "PropertySignature", - "name": "loaderOptions", - "value": "ShopifyLoaderOptions", - "description": "An object of `loader` function options. For example, if the `loader` function\nrequires a `scale` option, then the value can be a property of the\n`loaderOptions` object (for example, `{scale: 2}`). The object shape is `ShopifyLoaderOptions`.", - "isOptional": true - }, - { - "filePath": "/Image.tsx", - "syntaxKind": "PropertySignature", - "name": "src", - "value": "never", - "description": "`src` isn't used, and should instead be passed as part of the `data` object", - "isOptional": true - }, - { - "filePath": "/Image.tsx", - "syntaxKind": "PropertySignature", - "name": "widths", - "value": "(string | number)[]", - "description": "An array of pixel widths to overwrite the default generated srcset. For example, `[300, 600, 800]`.", - "isOptional": true - } - ] - }, - "ShopifyLoaderOptions": { - "filePath": "/Image.tsx", - "syntaxKind": "TypeAliasDeclaration", - "name": "ShopifyLoaderOptions", - "value": "{\n crop?: 'top' | 'bottom' | 'left' | 'right' | 'center';\n scale?: 2 | 3;\n width?: HtmlImageProps['width'] | ImageType['width'];\n height?: HtmlImageProps['height'] | ImageType['height'];\n}", - "description": "", - "members": [ - { - "filePath": "/Image.tsx", - "syntaxKind": "PropertySignature", - "name": "crop", - "value": "\"center\" | \"top\" | \"bottom\" | \"left\" | \"right\"", - "description": "", - "isOptional": true - }, - { - "filePath": "/Image.tsx", - "syntaxKind": "PropertySignature", - "name": "scale", - "value": "2 | 3", - "description": "", - "isOptional": true - }, - { - "filePath": "/Image.tsx", - "syntaxKind": "PropertySignature", - "name": "width", - "value": "string | number", - "description": "", - "isOptional": true - }, - { - "filePath": "/Image.tsx", - "syntaxKind": "PropertySignature", - "name": "height", - "value": "string | number", - "description": "", - "isOptional": true - } - ] - }, "VideoProps": { "filePath": "/Video.tsx", "name": "VideoProps", From 2108d73ba6c5abcc4ef698c6a27d7e1a87ad2f91 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 13 Mar 2023 20:46:50 -0400 Subject: [PATCH 19/46] Export IMAGE_FRAGMENT from Image component --- packages/hydrogen-react/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hydrogen-react/src/index.ts b/packages/hydrogen-react/src/index.ts index e03831b3b9..64927f7b6c 100644 --- a/packages/hydrogen-react/src/index.ts +++ b/packages/hydrogen-react/src/index.ts @@ -35,7 +35,7 @@ export {storefrontApiCustomScalars} from './codegen.helpers.js'; export {getShopifyCookies} from './cookies-utils.js'; export {ExternalVideo} from './ExternalVideo.js'; export {flattenConnection} from './flatten-connection.js'; -export {Image} from './Image.js'; +export {Image, IMAGE_FRAGMENT} from './Image.js'; export {MediaFile} from './MediaFile.js'; export {ModelViewer} from './ModelViewer.js'; export {Money} from './Money.js'; From e5e8b84b6b218f2101c4d00abb7848eaaca24662 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 13 Mar 2023 21:14:04 -0400 Subject: [PATCH 20/46] Update Demo Store --- .../demo-store/app/components/ProductCard.tsx | 2 - .../app/components/ProductGallery.tsx | 70 ++++--------------- templates/demo-store/app/root.tsx | 2 +- 3 files changed, 13 insertions(+), 61 deletions(-) diff --git a/templates/demo-store/app/components/ProductCard.tsx b/templates/demo-store/app/components/ProductCard.tsx index 5acd9df6d1..2f16c27f4f 100644 --- a/templates/demo-store/app/components/ProductCard.tsx +++ b/templates/demo-store/app/components/ProductCard.tsx @@ -69,8 +69,6 @@ export function ProductCard({ {image && ( {image.altText {media.map((med, i) => { - let mediaProps: Record = {}; const isFirst = i === 0; const isFourth = i === 3; const isFullWidth = i % 3 === 0; @@ -35,41 +34,6 @@ export function ProductGallery({ }, } as MediaImage; - switch (med.mediaContentType) { - case 'IMAGE': - mediaProps = { - width: 800, - widths: [400, 800, 1200, 1600, 2000, 2400], - }; - break; - case 'VIDEO': - mediaProps = { - width: '100%', - autoPlay: true, - controls: false, - muted: true, - loop: true, - preload: 'auto', - }; - break; - case 'EXTERNAL_VIDEO': - mediaProps = {width: '100%'}; - break; - case 'MODEL_3D': - mediaProps = { - width: '100%', - interactionPromptThreshold: '0', - ar: true, - loading: ATTR_LOADING_EAGER, - disableZoom: true, - }; - break; - } - - if (i === 0 && med.mediaContentType === 'IMAGE') { - mediaProps.loading = ATTR_LOADING_EAGER; - } - const style = [ isFullWidth ? 'md:col-span-2' : 'md:col-span-1', isFirst || isFourth ? '' : 'md:aspect-[4/5]', @@ -82,30 +46,20 @@ export function ProductGallery({ // @ts-ignore key={med.id || med.image.id} > - {/* TODO: Replace with MediaFile when it's available */} + {/* TODO: Replace with MediaFile */} {(med as MediaImage).image && ( - {data.image!.altText!} )} - {/* */}
); })} diff --git a/templates/demo-store/app/root.tsx b/templates/demo-store/app/root.tsx index b0dd1dab49..31045b8584 100644 --- a/templates/demo-store/app/root.tsx +++ b/templates/demo-store/app/root.tsx @@ -79,8 +79,8 @@ export default function App() { return ( - + From a754e2b4551806173dfecf918088e13d83615342 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 13 Mar 2023 21:17:16 -0400 Subject: [PATCH 21/46] Fix image sizing demo store --- templates/demo-store/app/components/OrderCard.tsx | 4 ++-- .../demo-store/app/routes/($lang)/collections/index.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/templates/demo-store/app/components/OrderCard.tsx b/templates/demo-store/app/components/OrderCard.tsx index 1f47e61d59..cdac0a51fa 100644 --- a/templates/demo-store/app/components/OrderCard.tsx +++ b/templates/demo-store/app/components/OrderCard.tsx @@ -1,4 +1,4 @@ -import {flattenConnection} from '@shopify/hydrogen'; +import {flattenConnection, Image} from '@shopify/hydrogen'; import type {Order} from '@shopify/hydrogen/storefront-api-types'; import {Heading, Text, Link} from '~/components'; import {statusMessage} from '~/lib/utils'; @@ -17,7 +17,7 @@ export function OrderCard({order}: {order: Order}) { > {lineItems[0].variant?.image && (
-
{collection?.image && ( - {collection.title} )} From 5c2219614d42f512069e08d24d493423bc083f01 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 13 Mar 2023 21:22:55 -0400 Subject: [PATCH 22/46] Update product card size --- templates/demo-store/app/components/ProductCard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/demo-store/app/components/ProductCard.tsx b/templates/demo-store/app/components/ProductCard.tsx index 2f16c27f4f..b92c93d29a 100644 --- a/templates/demo-store/app/components/ProductCard.tsx +++ b/templates/demo-store/app/components/ProductCard.tsx @@ -69,6 +69,7 @@ export function ProductCard({ {image && ( {image.altText Date: Mon, 13 Mar 2023 21:40:37 -0400 Subject: [PATCH 23/46] Update demo store, improve image srcset for fixed width --- packages/hydrogen-react/src/Image.tsx | 14 +++++++------- .../demo-store/app/components/ProductCard.tsx | 8 +++++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index d418e0c45c..6a12ede0ac 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -148,9 +148,9 @@ export function Image({ crop = 'center', loader = shopifyLoader, srcSetOptions = { - intervals: 10, - startingWidth: 300, - incrementSize: 300, + intervals: 15, + startingWidth: 100, + incrementSize: 200, placeholderWidth: 100, }, alt, @@ -432,13 +432,13 @@ function isFixedWidth(width: string | number): boolean { /** * This function generates a srcSet for Shopify images. * @param src - The source URL of the image, e.g. https://cdn.shopify.com/static/sample-images/garnished.jpeg - * @param sizesArray - An array of objects containing the width, height, and crop of the image, e.g. [\{width: 200, height: 200, crop: 'center'\}, \{width: 400, height: 400, crop: 'center'\}] + * @param sizesArray - An array of objects containing the `width`, `height`, and `crop` of the image, e.g. [\{width: 200, height: 200, crop: 'center'\}, \{width: 400, height: 400, crop: 'center'\}] * @param loader - A function that takes a Shopify image URL and returns a Shopify image URL with the correct query parameters * @returns A srcSet for Shopify images, e.g. 'https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=200&height=200&crop=center 200w, https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=400&height=400&crop=center 400w' */ export function generateShopifySrcSet( src?: string, - sizesArray?: Array<{width?: number; height?: number; crop?: Crop}>, + sizesArray?: Array<{width: number; height: number; crop?: Crop}>, loader: ImageLoader = shopifyLoader, ): string { if (!src) { @@ -451,13 +451,13 @@ export function generateShopifySrcSet( return sizesArray .map( - (size) => + (size, i) => `${loader({ src, width: size.width, height: size.height, crop: size.crop, - })} ${size.width || 0}w`, + })} ${sizesArray.length === 3 ? `${i + 1}x` : `${size.width}w`}`, ) .join(`, `); } diff --git a/templates/demo-store/app/components/ProductCard.tsx b/templates/demo-store/app/components/ProductCard.tsx index b92c93d29a..78b6b8c526 100644 --- a/templates/demo-store/app/components/ProductCard.tsx +++ b/templates/demo-store/app/components/ProductCard.tsx @@ -69,11 +69,17 @@ export function ProductCard({ {image && ( {image.altText )} Date: Mon, 13 Mar 2023 22:08:30 -0400 Subject: [PATCH 24/46] Updates tests and starting config --- packages/hydrogen-react/src/Image.stories.tsx | 5 +++++ packages/hydrogen-react/src/Image.test.tsx | 2 +- packages/hydrogen-react/src/Image.tsx | 12 ++++++------ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/hydrogen-react/src/Image.stories.tsx b/packages/hydrogen-react/src/Image.stories.tsx index 393d54a11e..88560b5265 100644 --- a/packages/hydrogen-react/src/Image.stories.tsx +++ b/packages/hydrogen-react/src/Image.stories.tsx @@ -62,6 +62,11 @@ const Template: Story<{ }} widths={[200, 300]} /> + ); }; diff --git a/packages/hydrogen-react/src/Image.test.tsx b/packages/hydrogen-react/src/Image.test.tsx index 43bebfabf9..8a82344ffa 100644 --- a/packages/hydrogen-react/src/Image.test.tsx +++ b/packages/hydrogen-react/src/Image.test.tsx @@ -88,7 +88,7 @@ describe('', () => { expect(img).toHaveAttribute('srcSet'); expect(img.getAttribute('srcSet')).toMatchInlineSnapshot( - '"https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=300&crop=center 300w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=600&crop=center 600w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=900&crop=center 900w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=1200&crop=center 1200w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=1500&crop=center 1500w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=1800&crop=center 1800w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=2100&crop=center 2100w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=2400&crop=center 2400w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=2700&crop=center 2700w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=3000&crop=center 3000w"', + '"https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=200&crop=center 200w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=400&crop=center 400w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=600&crop=center 600w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=800&crop=center 800w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=1000&crop=center 1000w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=1200&crop=center 1200w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=1400&crop=center 1400w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=1600&crop=center 1600w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=1800&crop=center 1800w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=2000&crop=center 2000w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=2200&crop=center 2200w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=2400&crop=center 2400w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=2600&crop=center 2600w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=2800&crop=center 2800w, https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg?width=3000&crop=center 3000w"', ); }); }); diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index 6a12ede0ac..1f0e5cfdf5 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -149,7 +149,7 @@ export function Image({ loader = shopifyLoader, srcSetOptions = { intervals: 15, - startingWidth: 100, + startingWidth: 200, incrementSize: 200, placeholderWidth: 100, }, @@ -438,7 +438,7 @@ function isFixedWidth(width: string | number): boolean { */ export function generateShopifySrcSet( src?: string, - sizesArray?: Array<{width: number; height: number; crop?: Crop}>, + sizesArray?: Array<{width?: number; height?: number; crop?: Crop}>, loader: ImageLoader = shopifyLoader, ): string { if (!src) { @@ -457,7 +457,7 @@ export function generateShopifySrcSet( width: size.width, height: size.height, crop: size.crop, - })} ${sizesArray.length === 3 ? `${i + 1}x` : `${size.width}w`}`, + })} ${sizesArray.length === 3 ? `${i + 1}x` : `${size.width ?? 0}w`}`, ) .join(`, `); } @@ -472,9 +472,9 @@ export function generateShopifySrcSet( */ export function generateImageWidths( width: string | number = '100%', - intervals = 20, - startingWidth = 200, - incrementSize = 100, + intervals: number, + startingWidth: number, + incrementSize: number, ): number[] { const responsive = Array.from( {length: intervals}, From 09750812ef28d3894df156228ae1346082da9bcb Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 13 Mar 2023 22:16:25 -0400 Subject: [PATCH 25/46] Add crop to placeholder image --- packages/hydrogen-react/src/Image.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index 1f0e5cfdf5..30185edcba 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -342,6 +342,7 @@ export function Image({ src: normalizedSrc, width: placeholderWidth, height: placeholderHeight, + crop, }), alt: normalizedAlt, decoding, From c0b539de944a6abc96e7836fda674c33b8336aff Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 13 Mar 2023 22:19:24 -0400 Subject: [PATCH 26/46] remove sizes from fixed width image --- packages/hydrogen-react/src/Image.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index 30185edcba..d6cde532c2 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -314,7 +314,6 @@ export function Image({ }), alt: normalizedAlt, decoding, - sizes: sizes || normalizedWidth, style: { width: normalizedWidth, height: normalizedHeight, From c73adebc1855990ebf38a27e7cc5ba6651bb28f7 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 13 Mar 2023 22:27:48 -0400 Subject: [PATCH 27/46] adds changesets --- .changeset/five-chefs-heal.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/five-chefs-heal.md diff --git a/.changeset/five-chefs-heal.md b/.changeset/five-chefs-heal.md new file mode 100644 index 0000000000..301c2512da --- /dev/null +++ b/.changeset/five-chefs-heal.md @@ -0,0 +1,5 @@ +--- +'@shopify/hydrogen-react': patch +--- + +Adds an all-new responsive Image component From 90c4de48ffcc8dbb758dc0a670de112462455e8b Mon Sep 17 00:00:00 2001 From: Matt Seccafien <462077+cartogram@users.noreply.github.com> Date: Tue, 14 Mar 2023 03:43:27 +0100 Subject: [PATCH 28/46] fix development env only tests --- packages/hydrogen-react/src/Image.test.tsx | 89 ++++++++++++---------- packages/hydrogen-react/src/test-utils.ts | 50 ++++++++++++ 2 files changed, 99 insertions(+), 40 deletions(-) create mode 100644 packages/hydrogen-react/src/test-utils.ts diff --git a/packages/hydrogen-react/src/Image.test.tsx b/packages/hydrogen-react/src/Image.test.tsx index 8a82344ffa..860c86b879 100644 --- a/packages/hydrogen-react/src/Image.test.tsx +++ b/packages/hydrogen-react/src/Image.test.tsx @@ -1,6 +1,8 @@ import {render, screen} from '@testing-library/react'; import {faker} from '@faker-js/faker'; import {Image} from './Image.js'; +import {withEnv} from './test-utils.js'; +import {Mock} from 'vitest'; const defaultProps = { sizes: '100vw', @@ -157,8 +159,6 @@ describe('', () => { }); }); - // eslint-disable-next-line jest/no-commented-out-tests - /* describe('warnings', () => { const consoleMock = { ...console, @@ -171,69 +171,78 @@ describe('', () => { vi.unstubAllGlobals(); }); - it('warns user if no src is provided', () => { - render(); - - expect(console.warn).toHaveBeenCalledTimes(1); - expect(getWarnings()).toMatchInlineSnapshot( - ` - [ - "No src or data.url provided to Image component.", - ] - `, - ); + it('warns user if no src is provided', async () => { + await withEnv('development', () => { + render(); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(getWarnings()).toMatchInlineSnapshot( + ` + [ + "No src or data.url provided to Image component.", + ] + `, + ); + }); }); - it('warns user if no sizes are provided', () => { - render(); + it('warns user if no sizes are provided', async () => { + await withEnv('development', () => { + render(); - expect(console.warn).toHaveBeenCalledTimes(1); - expect(getWarnings()).toMatchInlineSnapshot( - ` + expect(console.warn).toHaveBeenCalledTimes(1); + expect(getWarnings()).toMatchInlineSnapshot( + ` [ "No sizes prop provided to Image component, you may be loading unnecessarily large images. Image used is https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg", ] `, - ); + ); + }); }); - it('does not warn user if no sizes are provided but width is fixed', () => { - render(); + it('does not warn user if no sizes are provided but width is fixed', async () => { + await withEnv('development', () => { + render(); - expect(console.warn).toHaveBeenCalledTimes(0); + expect(console.warn).toHaveBeenCalledTimes(0); + }); }); - it('warns user if widths is provided', () => { - render(); + it('warns user if widths is provided', async () => { + await withEnv('development', () => { + render(); - expect(console.warn).toHaveBeenCalledTimes(1); - expect(getWarnings()).toMatchInlineSnapshot( - ` + expect(console.warn).toHaveBeenCalledTimes(1); + expect(getWarnings()).toMatchInlineSnapshot( + ` [ "Deprecated property from original Image component in use: \`widths\` are now calculated automatically based on the config and width props. Image used is https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg", ] `, - ); + ); + }); }); - it('warns user if loaderOptions are provided', () => { - render(); + it('warns user if loaderOptions are provided', async () => { + await withEnv('development', () => { + render(); - expect(console.warn).toHaveBeenCalledTimes(1); - expect(getWarnings()).toMatchInlineSnapshot( - ` + expect(console.warn).toHaveBeenCalledTimes(1); + expect(getWarnings()).toMatchInlineSnapshot( + ` [ "Deprecated property from original Image component in use: Use the \`crop\`, \`width\`, \`height\`, and src props, or the \`data\` prop to achieve the same result. Image used is https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg", ] `, - ); + ); + }); }); }); - */ }); -// function getWarnings() { -// return (console.warn as Mock<[string]>).mock.calls.map( -// ([message]) => message, -// ); -// } +function getWarnings(): string[] { + return (console.warn as Mock<[string]>).mock.calls.map( + ([message]) => message, + ); +} diff --git a/packages/hydrogen-react/src/test-utils.ts b/packages/hydrogen-react/src/test-utils.ts new file mode 100644 index 0000000000..93bec5d9ff --- /dev/null +++ b/packages/hydrogen-react/src/test-utils.ts @@ -0,0 +1,50 @@ +interface EnvBackup { + [key: string]: {isSet: boolean; value: string | undefined}; +} + +function getEnvValues(envVars: string[]): EnvBackup { + return envVars.reduce((acc, key) => { + acc[key] = { + // only care for keys that already have an entry in `process.env` + // as they would otherwise end up as `{KEY: "undefined"}` inside `process.env` + isSet: key in process.env, + value: process.env[key], + }; + return acc; + }, {} as EnvBackup); +} + +function resetEnv(oldEnv: EnvBackup): void { + // delete all env keys that have been set by the test's `withEnv` call + // to ensure `process.env` does not end up with `{KEY: "undefined"}` entries + Object.entries(oldEnv).forEach(([key, {isSet, value}]) => { + if (isSet) { + process.env[key] = value; + } else { + delete process.env[key]; + } + }); +} + +export async function withEnv( + env: string | NodeJS.ProcessEnv, + callback: () => Promise | void, +): Promise { + const envToSet = typeof env === 'string' ? {NODE_ENV: env} : env; + const previousEnv = getEnvValues(Object.keys(envToSet)); + + Object.entries(envToSet).forEach(([key, value]) => { + process.env[key] = value; + }); + + try { + const result = await callback(); + + resetEnv(previousEnv); + + return result; + } catch (error) { + resetEnv(previousEnv); + throw error; + } +} From 68a09c5f77a6015eb592627ad75318d8acfcac62 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Wed, 15 Mar 2023 15:08:46 -0400 Subject: [PATCH 29/46] Fixes __HYDROGEN_DEV__ --- packages/hydrogen-react/src/Image.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index d6cde532c2..edb54a9a78 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -167,7 +167,7 @@ export function Image({ /* * Deprecated Props from original Image component */ - if (process.env.NODE_ENV === 'development') { + if (__HYDROGEN_DEV__) { if (loaderOptions) { console.warn( [ @@ -223,7 +223,7 @@ export function Image({ const normalizedSrc: string | undefined = src || data?.url; - if (!normalizedSrc && process.env.NODE_ENV === 'development') { + if (__HYDROGEN_DEV__ && !normalizedSrc) { console.warn(`No src or data.url provided to Image component.`); } @@ -252,11 +252,7 @@ export function Image({ incrementSize, ); - if ( - !sizes && - !isFixedWidth(normalizedWidth) && - process.env.NODE_ENV === 'development' - ) { + if (__HYDROGEN_DEV__ && !sizes && !isFixedWidth(normalizedWidth)) { console.warn( [ 'No sizes prop provided to Image component,', From 1aef9e66ad6a78d91379cc14b5e5993eff57f9cd Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Wed, 15 Mar 2023 15:19:16 -0400 Subject: [PATCH 30/46] Use Component notation instead of React.createElement --- packages/hydrogen-react/src/Image.tsx | 154 +++++++++++++++----------- 1 file changed, 90 insertions(+), 64 deletions(-) diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index edb54a9a78..d3021523fd 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -32,6 +32,29 @@ export type ShopifyLoaderParams = Simplify< } >; +/** + * The shopifyLoader function is a simple utility function that takes a src, width, + * height, and crop and returns a string that can be used as the src for an image. + * It can be used with the Hydrogen Image component or with the next/image component. + * (or any others that accept equivalent configuration) + * @param src - The source URL of the image, e.g. `https://cdn.shopify.com/static/sample-images/garnished.jpeg` + * @param width - The width of the image, e.g. `100` + * @param height - The height of the image, e.g. `100` + * @param crop - The crop of the image, e.g. `center` + * @returns A Shopify image URL with the correct query parameters, e.g. `https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=100&height=100&crop=center` + */ +export const shopifyLoader: ImageLoader = ({src, width, height, crop}) => { + if (!src) { + return ''; + } + + const url = new URL(src); + width && url.searchParams.append('width', Math.round(width).toString()); + height && url.searchParams.append('height', Math.round(height).toString()); + crop && url.searchParams.append('crop', crop); + return url.href; +}; + /* * @TODO: Expand to include focal point support; and/or switch this to be an SF API type */ @@ -300,26 +323,49 @@ export function Image({ ? intWidth * (parseAspectRatio(fixedAspectRatio) ?? 1) : undefined; - return React.createElement(Component, { - srcSet: generateShopifySrcSet(normalizedSrc, sizesArray, loader), - src: loader({ - src: normalizedSrc, - width: intWidth, - height: fixedHeight, - crop: normalizedHeight === 'auto' ? undefined : crop, - }), - alt: normalizedAlt, - decoding, - style: { - width: normalizedWidth, - height: normalizedHeight, - aspectRatio: fixedAspectRatio, - }, - width: intWidth, - height: fixedHeight, - loading, - ...passthroughProps, - }); + // return React.createElement(Component, { + // srcSet: generateShopifySrcSet(normalizedSrc, sizesArray, loader), + // src: loader({ + // src: normalizedSrc, + // width: intWidth, + // height: fixedHeight, + // crop: normalizedHeight === 'auto' ? undefined : crop, + // }), + // alt: normalizedAlt, + // decoding, + // style: { + // width: normalizedWidth, + // height: normalizedHeight, + // aspectRatio: fixedAspectRatio, + // }, + // width: intWidth, + // height: fixedHeight, + // loading, + // ...passthroughProps, + // }); + return ( + + ); } else { const sizesArray = imageWidths === undefined @@ -331,27 +377,30 @@ export function Image({ ? placeholderWidth * (parseAspectRatio(normalizedAspectRatio) ?? 1) : undefined; - return React.createElement(Component, { - srcSet: generateShopifySrcSet(normalizedSrc, sizesArray), - src: loader({ - src: normalizedSrc, - width: placeholderWidth, - height: placeholderHeight, - crop, - }), - alt: normalizedAlt, - decoding, - sizes, - style: { - width: normalizedWidth, - height: normalizedHeight, - aspectRatio: normalizedAspectRatio, - }, - width: placeholderWidth, - height: placeholderHeight, - loading, - ...passthroughProps, - }); + return ( + + ); } } @@ -529,26 +578,3 @@ export function generateSizes( {width: 200, height: 200, crop: 'center'}] */ } - -/** - * The shopifyLoader function is a simple utility function that takes a src, width, - * height, and crop and returns a string that can be used as the src for an image. - * It can be used with the Hydrogen Image component or with the next/image component. - * (or any others that accept equivalent configuration) - * @param src - The source URL of the image, e.g. `https://cdn.shopify.com/static/sample-images/garnished.jpeg` - * @param width - The width of the image, e.g. `100` - * @param height - The height of the image, e.g. `100` - * @param crop - The crop of the image, e.g. `center` - * @returns A Shopify image URL with the correct query parameters, e.g. `https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=100&height=100&crop=center` - */ -export const shopifyLoader: ImageLoader = ({src, width, height, crop}) => { - if (!src) { - return ''; - } - - const url = new URL(src); - width && url.searchParams.append('width', Math.round(width).toString()); - height && url.searchParams.append('height', Math.round(height).toString()); - crop && url.searchParams.append('crop', crop); - return url.href; -}; From d06c228175aef73d9047834637c0cbc2939abfcd Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Wed, 15 Mar 2023 17:13:31 -0400 Subject: [PATCH 31/46] Tries to fix TS stuff --- packages/hydrogen-react/src/Image.tsx | 70 +++++++++++++++++++-------- 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index d3021523fd..db905f44f4 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import type {PartialDeep, Simplify} from 'type-fest'; +import type {PartialDeep} from 'type-fest'; import type {Image as ImageType} from './storefront-api-types.js'; /* @@ -15,22 +15,31 @@ type SrcSetOptions = { type HtmlImageProps = React.ImgHTMLAttributes; -export type ImageLoader = (params: ShopifyLoaderParams) => string; +export type ImageLoader = (params: LoaderParams) => string; -export type ShopifyLoaderOptions = { - crop?: 'top' | 'bottom' | 'left' | 'right' | 'center'; +/** Legacy type for backwards compatibility * + * @deprecated Use `crop`, `width`, `height`, and `src` props, and/or `data` prop. Or pass a custom `loader` with `LoaderParams` */ +type ShopifyLoaderOptions = { + /** The base URL of the image */ + src?: ImageType['url']; + /** The URL param that controls width */ width?: HtmlImageProps['width'] | ImageType['width']; + /** The URL param that controls height */ height?: HtmlImageProps['height'] | ImageType['height']; + /** The URL param that controls the cropping region */ + crop?: Crop; }; -export type ShopifyLoaderParams = Simplify< - ShopifyLoaderOptions & { - src?: ImageType['url']; - width?: number; - height?: number; - crop?: Crop; - } ->; +export type LoaderParams = { + /** The base URL of the image */ + src?: ImageType['url']; + /** The URL param that controls width */ + width?: number; + /** The URL param that controls height */ + height?: number; + /** The URL param that controls the cropping region */ + crop?: Crop; +}; /** * The shopifyLoader function is a simple utility function that takes a src, width, @@ -42,6 +51,16 @@ export type ShopifyLoaderParams = Simplify< * @param height - The height of the image, e.g. `100` * @param crop - The crop of the image, e.g. `center` * @returns A Shopify image URL with the correct query parameters, e.g. `https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=100&height=100&crop=center` + * + * @example + * ``` + * loader({ + * src: 'https://cdn.shopify.com/static/sample-images/garnished.jpeg', + * width: 100, + * height: 100, + * crop: 'center', + * }) + * ``` */ export const shopifyLoader: ImageLoader = ({src, width, height, crop}) => { if (!src) { @@ -58,13 +77,24 @@ export const shopifyLoader: ImageLoader = ({src, width, height, crop}) => { /* * @TODO: Expand to include focal point support; and/or switch this to be an SF API type */ -type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right' | undefined; +type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right'; + +type ValidImageAsType = React.ElementType<'img'> | React.ElementType<'source'>; -export type ShopifyImageProps = HtmlImageProps & ShopifyImageBaseProps; +export type ShopifyImageProps = + ShopifyImageBaseProps & + Omit< + React.ComponentPropsWithoutRef, + keyof ShopifyImageBaseProps + >; -type ShopifyImageBaseProps = { +// HtmlImageProps & ShopifyImageBaseProps; + +export interface ShopifyImageBaseProps< + ComponentGeneric extends ValidImageAsType, +> { /** The HTML element to use for the image, `source` should only be used inside `picture` */ - as?: 'img' | 'source'; + as?: ComponentGeneric; /** Data mapping to the Storefront API `Image` object. Must be an Image object. * Optionally, import the `IMAGE_FRAGMENT` to use in your GraphQL queries. * @@ -122,7 +152,7 @@ type ShopifyImageBaseProps = { loaderOptions?: ShopifyLoaderOptions; /** @deprecated Autocalculated, use only `width` prop, or srcSetOptions */ widths?: (HtmlImageProps['width'] | ImageType['width'])[]; -}; +} /** * A Storefront API GraphQL fragment that can be used to query for an image. @@ -164,8 +194,8 @@ export const IMAGE_FRAGMENT = `#graphql * * {@link https://shopify.dev/docs/api/hydrogen-react/components/image} */ -export function Image({ - as: Component = 'img', +export function Image({ + as: Component, data, aspectRatio, crop = 'center', @@ -186,7 +216,7 @@ export function Image({ loaderOptions, widths, ...passthroughProps -}: ShopifyImageProps): JSX.Element | null { +}: ShopifyImageProps): JSX.Element | null { /* * Deprecated Props from original Image component */ From 1ec83dd93279ea6e7f3f17de9edd7b804510bcfd Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Wed, 15 Mar 2023 20:02:37 -0400 Subject: [PATCH 32/46] Tries to fix TS stuff, part 2 --- packages/hydrogen-react/src/Image.tsx | 82 +++++++++------------ packages/hydrogen-react/src/ImageLegacy.tsx | 2 + packages/hydrogen-react/src/index.ts | 1 + 3 files changed, 37 insertions(+), 48 deletions(-) diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index db905f44f4..0cf248c581 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -79,22 +79,14 @@ export const shopifyLoader: ImageLoader = ({src, width, height, crop}) => { */ type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right'; -type ValidImageAsType = React.ElementType<'img'> | React.ElementType<'source'>; - -export type ShopifyImageProps = - ShopifyImageBaseProps & - Omit< - React.ComponentPropsWithoutRef, - keyof ShopifyImageBaseProps - >; - -// HtmlImageProps & ShopifyImageBaseProps; - -export interface ShopifyImageBaseProps< - ComponentGeneric extends ValidImageAsType, -> { - /** The HTML element to use for the image, `source` should only be used inside `picture` */ - as?: ComponentGeneric; +type AllowedElements = 'img' | 'source'; + +type HydrogenImageProps = { + as: T; +} & HydrogenImageBaseProps & + React.HTMLAttributes; + +export type HydrogenImageBaseProps = { /** Data mapping to the Storefront API `Image` object. Must be an Image object. * Optionally, import the `IMAGE_FRAGMENT` to use in your GraphQL queries. * @@ -148,11 +140,18 @@ export interface ShopifyImageBaseProps< loader?: ImageLoader; /** An optional prop you can use to change the default srcSet generation behaviour */ srcSetOptions?: SrcSetOptions; + alt?: HtmlImageProps['alt']; + width?: HtmlImageProps['width']; + height?: HtmlImageProps['height']; + decoding?: HtmlImageProps['decoding']; + src?: HtmlImageProps['src']; + loading?: HtmlImageProps['loading']; + sizes?: HtmlImageProps['sizes']; /** @deprecated Use `crop`, `width`, `height`, and `src` props, and/or `data` prop */ loaderOptions?: ShopifyLoaderOptions; /** @deprecated Autocalculated, use only `width` prop, or srcSetOptions */ widths?: (HtmlImageProps['width'] | ImageType['width'])[]; -} +}; /** * A Storefront API GraphQL fragment that can be used to query for an image. @@ -168,11 +167,18 @@ export const IMAGE_FRAGMENT = `#graphql /** * Hydrgen’s Image component is a wrapper around the HTML image element. - * It supports the same props as the HTML image element, but automatically + * It supports the same props as the HTML `img` element, but automatically * generates the srcSet and sizes attributes for you. For most use cases, * you’ll want to set the `aspectRatio` prop to ensure the image is sized * correctly. * + * @remarks + * - `decoding` is set to `async` by default. + * - `loading` is set to `lazy` by default. + * - `alt` will automatically be set to the `altText` from the Storefront API if passed in the `data` prop + * - `src` will automatically be set to the `url` from the Storefront API if passed in the `data` prop + * - `width` defaults to `100%`should be set to how you want the image to be displayed, not the original image width + * * @example * A responsive image with a 4:5 aspect ratio: * ``` @@ -194,7 +200,7 @@ export const IMAGE_FRAGMENT = `#graphql * * {@link https://shopify.dev/docs/api/hydrogen-react/components/image} */ -export function Image({ +export function Image({ as: Component, data, aspectRatio, @@ -208,15 +214,15 @@ export function Image({ }, alt, decoding = 'async', - height, loading = 'lazy', - sizes, src, width, + height, + sizes, loaderOptions, widths, ...passthroughProps -}: ShopifyImageProps): JSX.Element | null { +}: HydrogenImageProps): JSX.Element | null { /* * Deprecated Props from original Image component */ @@ -268,7 +274,7 @@ export function Image({ }${getUnitValueParts(normalizedWidthProp.toString()).unit}`; const normalizedHeight: string = - height === undefined + height === undefined || height === null ? 'auto' : `${getUnitValueParts(height.toString()).number}${ getUnitValueParts(height.toString()).unit @@ -353,29 +359,9 @@ export function Image({ ? intWidth * (parseAspectRatio(fixedAspectRatio) ?? 1) : undefined; - // return React.createElement(Component, { - // srcSet: generateShopifySrcSet(normalizedSrc, sizesArray, loader), - // src: loader({ - // src: normalizedSrc, - // width: intWidth, - // height: fixedHeight, - // crop: normalizedHeight === 'auto' ? undefined : crop, - // }), - // alt: normalizedAlt, - // decoding, - // style: { - // width: normalizedWidth, - // height: normalizedHeight, - // aspectRatio: fixedAspectRatio, - // }, - // width: intWidth, - // height: fixedHeight, - // loading, - // ...passthroughProps, - // }); return ( ({ crop: normalizedHeight === 'auto' ? undefined : crop, })} alt={normalizedAlt} - decoding={decoding} style={{ width: normalizedWidth, height: normalizedHeight, aspectRatio: fixedAspectRatio, ...passthroughProps.style, }} + loading={Component === 'img' ? loading : undefined} + decoding={Component === 'img' ? decoding : undefined} width={intWidth} height={fixedHeight} - loading={loading} {...passthroughProps} /> ); @@ -409,7 +395,7 @@ export function Image({ return ( , loader: ImageLoader = shopifyLoader, diff --git a/packages/hydrogen-react/src/ImageLegacy.tsx b/packages/hydrogen-react/src/ImageLegacy.tsx index 1356d6e696..dbf5aad3e0 100644 --- a/packages/hydrogen-react/src/ImageLegacy.tsx +++ b/packages/hydrogen-react/src/ImageLegacy.tsx @@ -62,6 +62,8 @@ type ShopifyImageBaseProps = { * If only one of `width` or `height` are defined, then the other will attempt to be calculated based on the image's aspect ratio, * provided that both `data.width` and `data.height` are available. If `data.width` and `data.height` aren't available, then the aspect ratio cannot be determined and the missing * value will remain as `null` + * + * @deprecated Use the new `Image` component instead */ export function Image({ data, diff --git a/packages/hydrogen-react/src/index.ts b/packages/hydrogen-react/src/index.ts index d8df345d37..d98631a175 100644 --- a/packages/hydrogen-react/src/index.ts +++ b/packages/hydrogen-react/src/index.ts @@ -43,6 +43,7 @@ export {getShopifyCookies} from './cookies-utils.js'; export {ExternalVideo} from './ExternalVideo.js'; export {flattenConnection} from './flatten-connection.js'; export {Image, IMAGE_FRAGMENT} from './Image.js'; +export {Image as ImageLegacy} from './ImageLegacy.js'; export {MediaFile} from './MediaFile.js'; export {ModelViewer} from './ModelViewer.js'; export {Money} from './Money.js'; From 8172bf1f4c652a024e107dd587e2b9cac35531b7 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Fri, 17 Mar 2023 11:32:34 -0400 Subject: [PATCH 33/46] Refactor --- packages/hydrogen-react/src/Image.tsx | 670 ++++++++++++++++---------- 1 file changed, 420 insertions(+), 250 deletions(-) diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index 0cf248c581..4495ce3b10 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -1,3 +1,6 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable hydrogen/prefer-image-component */ import * as React from 'react'; import type {PartialDeep} from 'type-fest'; import type {Image as ImageType} from './storefront-api-types.js'; @@ -13,80 +16,68 @@ type SrcSetOptions = { placeholderWidth: number; }; -type HtmlImageProps = React.ImgHTMLAttributes; - -export type ImageLoader = (params: LoaderParams) => string; +type HtmlImageProps = React.DetailedHTMLProps< + React.ImgHTMLAttributes, + HTMLImageElement +>; + +type NormalizedProps = { + alt: string; + aspectRatio: string | undefined; + height: string; + src: string | undefined; + width: string; +}; -/** Legacy type for backwards compatibility * - * @deprecated Use `crop`, `width`, `height`, and `src` props, and/or `data` prop. Or pass a custom `loader` with `LoaderParams` */ -type ShopifyLoaderOptions = { +export type LoaderParams = { /** The base URL of the image */ src?: ImageType['url']; /** The URL param that controls width */ - width?: HtmlImageProps['width'] | ImageType['width']; + width?: number; /** The URL param that controls height */ - height?: HtmlImageProps['height'] | ImageType['height']; + height?: number; /** The URL param that controls the cropping region */ crop?: Crop; }; -export type LoaderParams = { +export type Loader = (params: LoaderParams) => string; + +/** Legacy type for backwards compatibility * + * @deprecated Use `crop`, `width`, `height`, and `src` props, and/or `data` prop. Or pass a custom `loader` with `LoaderParams` */ +type ShopifyLoaderOptions = { /** The base URL of the image */ src?: ImageType['url']; /** The URL param that controls width */ - width?: number; + width?: HtmlImageProps['width'] | ImageType['width']; /** The URL param that controls height */ - height?: number; + height?: HtmlImageProps['height'] | ImageType['height']; /** The URL param that controls the cropping region */ crop?: Crop; }; -/** - * The shopifyLoader function is a simple utility function that takes a src, width, - * height, and crop and returns a string that can be used as the src for an image. - * It can be used with the Hydrogen Image component or with the next/image component. - * (or any others that accept equivalent configuration) - * @param src - The source URL of the image, e.g. `https://cdn.shopify.com/static/sample-images/garnished.jpeg` - * @param width - The width of the image, e.g. `100` - * @param height - The height of the image, e.g. `100` - * @param crop - The crop of the image, e.g. `center` - * @returns A Shopify image URL with the correct query parameters, e.g. `https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=100&height=100&crop=center` - * - * @example - * ``` - * loader({ - * src: 'https://cdn.shopify.com/static/sample-images/garnished.jpeg', - * width: 100, - * height: 100, - * crop: 'center', - * }) - * ``` - */ -export const shopifyLoader: ImageLoader = ({src, width, height, crop}) => { - if (!src) { - return ''; - } - - const url = new URL(src); - width && url.searchParams.append('width', Math.round(width).toString()); - height && url.searchParams.append('height', Math.round(height).toString()); - crop && url.searchParams.append('crop', crop); - return url.href; -}; - /* * @TODO: Expand to include focal point support; and/or switch this to be an SF API type */ type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right'; -type AllowedElements = 'img' | 'source'; - -type HydrogenImageProps = { - as: T; -} & HydrogenImageBaseProps & - React.HTMLAttributes; - -export type HydrogenImageBaseProps = { +export type HydrogenImageProps = React.ImgHTMLAttributes & { + /** The aspect ratio of the image, in the format of `width/height`. + * + * @example + * ``` + * + * ``` + */ + aspectRatio?: string; + /** The crop position of the image. + * + * @remarks + * In the event that AspectRatio is set, without specifying a crop, + * the Shopify CDN won't return the expected image. + * + * @defaultValue `center` + */ + crop?: Crop; /** Data mapping to the Storefront API `Image` object. Must be an Image object. * Optionally, import the `IMAGE_FRAGMENT` to use in your GraphQL queries. * @@ -114,41 +105,18 @@ export type HydrogenImageBaseProps = { * Image: {@link https://shopify.dev/api/storefront/reference/common-objects/image} */ data?: PartialDeep; - /** The aspect ratio of the image, in the format of `width/height`. - * - * @example - * ``` - * - * ``` - */ - aspectRatio?: string; - /** The crop position of the image. - * - * @remarks - * In the event that AspectRatio is set, without specifying a crop, - * the Shopify CDN won't return the expected image. - * - * @defaultValue `center` - */ - crop?: Crop; + key?: React.Key; /** A function that returns a URL string for an image. * * @remarks * By default, this uses Shopify’s CDN {@link https://cdn.shopify.com/} but you can provide * your own function to use a another provider, as long as they support URL based image transformations. */ - loader?: ImageLoader; - /** An optional prop you can use to change the default srcSet generation behaviour */ - srcSetOptions?: SrcSetOptions; - alt?: HtmlImageProps['alt']; - width?: HtmlImageProps['width']; - height?: HtmlImageProps['height']; - decoding?: HtmlImageProps['decoding']; - src?: HtmlImageProps['src']; - loading?: HtmlImageProps['loading']; - sizes?: HtmlImageProps['sizes']; + loader?: Loader; /** @deprecated Use `crop`, `width`, `height`, and `src` props, and/or `data` prop */ loaderOptions?: ShopifyLoaderOptions; + /** An optional prop you can use to change the default srcSet generation behaviour */ + srcSetOptions?: SrcSetOptions; /** @deprecated Autocalculated, use only `width` prop, or srcSetOptions */ widths?: (HtmlImageProps['width'] | ImageType['width'])[]; }; @@ -200,133 +168,237 @@ export const IMAGE_FRAGMENT = `#graphql * * {@link https://shopify.dev/docs/api/hydrogen-react/components/image} */ -export function Image({ - as: Component, - data, - aspectRatio, - crop = 'center', - loader = shopifyLoader, - srcSetOptions = { - intervals: 15, - startingWidth: 200, - incrementSize: 200, - placeholderWidth: 100, - }, - alt, - decoding = 'async', - loading = 'lazy', - src, - width, - height, - sizes, - loaderOptions, - widths, - ...passthroughProps -}: HydrogenImageProps): JSX.Element | null { - /* - * Deprecated Props from original Image component - */ - if (__HYDROGEN_DEV__) { - if (loaderOptions) { - console.warn( - [ - `Deprecated property from original Image component in use:`, - `Use the \`crop\`, \`width\`, \`height\`, and src props, or`, - `the \`data\` prop to achieve the same result. Image used is ${ - src || data?.url || 'unknown' - }`, - ].join(' '), - ); - } - - if (widths) { - console.warn( - [ - `Deprecated property from original Image component in use:`, - `\`widths\` are now calculated automatically based on the`, - `config and width props. Image used is ${ - src || data?.url || 'unknown' - }`, - ].join(' '), - ); +export const Image = React.forwardRef( + ( + { + alt, + aspectRatio, + crop = 'center', + data, + decoding = 'async', + height, + loader = shopifyLoader, + loaderOptions, + loading = 'lazy', + sizes, + src, + srcSetOptions = { + intervals: 15, + startingWidth: 200, + incrementSize: 200, + placeholderWidth: 100, + }, + width, + widths, + ...passthroughProps + }, + ref, + ) => { + /* + * Deprecated Props from original Image component + */ + if (__HYDROGEN_DEV__) { + if (loaderOptions) { + console.warn( + [ + `Deprecated property from original Image component in use:`, + `Use the \`crop\`, \`width\`, \`height\`, and src props, or`, + `the \`data\` prop to achieve the same result. Image used is ${ + src || data?.url || passthroughProps?.key || 'unknown' + }`, + ].join(' '), + ); + } + + if (widths) { + console.warn( + [ + `Deprecated property from original Image component in use:`, + `\`widths\` are now calculated automatically based on the`, + `config and width props. Image used is ${ + src || data?.url || passthroughProps?.key || 'unknown' + }`, + ].join(' '), + ); + } } - } - - /* Only use data width if height is also set */ - - const dataWidth: number | undefined = - data?.width && data?.height ? data?.width : undefined; - - const dataHeight: number | undefined = - data?.width && data?.height ? data?.height : undefined; - - const dataUnitsMatch: boolean = unitsMatch(dataWidth, dataHeight); - - /* - * Gets normalized values for width, height, src, alt, and aspectRatio props - * supporting the presence of `data` in addition to flat props. - */ - const normalizedWidthProp: string | number = width || '100%'; - - const normalizedWidth = `${ - getUnitValueParts(normalizedWidthProp.toString()).number - }${getUnitValueParts(normalizedWidthProp.toString()).unit}`; + /* + * Gets normalized values for width, height from data prop + */ + const normalizedData = React.useMemo(() => { + /* Only use data width if height is also set */ + const dataWidth: number | undefined = + data?.width && data?.height ? data?.width : undefined; - const normalizedHeight: string = - height === undefined || height === null - ? 'auto' - : `${getUnitValueParts(height.toString()).number}${ - getUnitValueParts(height.toString()).unit - }`; + const dataHeight: number | undefined = + data?.width && data?.height ? data?.height : undefined; - const normalizedSrc: string | undefined = src || data?.url; + return { + width: dataWidth, + height: dataHeight, + unitsMatch: Boolean(unitsMatch(dataWidth, dataHeight)), + }; + }, [data]); - if (__HYDROGEN_DEV__ && !normalizedSrc) { - console.warn(`No src or data.url provided to Image component.`); - } + /* + * Gets normalized values for width, height, src, alt, and aspectRatio props + * supporting the presence of `data` in addition to flat props. + */ + const normalizedProps = React.useMemo(() => { + const nWidthProp: string | number = width || '100%'; + const widthParts = getUnitValueParts(nWidthProp.toString()); + const nWidth = `${widthParts.number}${widthParts.unit}`; + + const autoHeight = height === undefined || height === null; + const heightParts = autoHeight + ? null + : getUnitValueParts(height.toString()); + + const fixedHeight = heightParts + ? `${heightParts.number}${heightParts.unit}` + : ''; + + const nHeight = autoHeight ? 'auto' : fixedHeight; + + const nSrc: string | undefined = src || data?.url; + + if (__HYDROGEN_DEV__ && !nSrc) { + console.warn( + `No src or data.url provided to Image component.`, + passthroughProps?.key || '', + ); + } + + const nAlt: string = data?.altText && !alt ? data?.altText : alt || ''; + + const nAspectRatio: string | undefined = aspectRatio + ? aspectRatio + : normalizedData.unitsMatch + ? [ + getNormalizedFixedUnit(normalizedData.width), + getNormalizedFixedUnit(normalizedData.height), + ].join('/') + : undefined; - const normalizedAlt: string = - data?.altText && !alt ? data?.altText : alt || ''; + return { + width: nWidth, + height: nHeight, + src: nSrc, + alt: nAlt, + aspectRatio: nAspectRatio, + }; + }, [ + width, + height, + src, + data, + alt, + aspectRatio, + normalizedData, + passthroughProps?.key, + ]); + + const {intervals, startingWidth, incrementSize, placeholderWidth} = + srcSetOptions; - const normalizedAspectRatio: string | undefined = aspectRatio - ? aspectRatio - : dataUnitsMatch - ? [ - getNormalizedFixedUnit(dataWidth), - getNormalizedFixedUnit(dataHeight), - ].join('/') - : undefined; + /* + * This function creates an array of widths to be used in srcSet + */ + const imageWidths = React.useMemo(() => { + return generateImageWidths( + width, + intervals, + startingWidth, + incrementSize, + ); + }, [width, intervals, startingWidth, incrementSize]); - const {intervals, startingWidth, incrementSize, placeholderWidth} = - srcSetOptions; + const fixedWidth = isFixedWidth(normalizedProps.width); - /* - * This function creates an array of widths to be used in srcSet - */ - const imageWidths = generateImageWidths( - width, - intervals, - startingWidth, - incrementSize, - ); + if (__HYDROGEN_DEV__ && !sizes && !fixedWidth) { + console.warn( + [ + 'No sizes prop provided to Image component,', + 'you may be loading unnecessarily large images.', + `Image used is ${ + src || data?.url || passthroughProps?.key || 'unknown' + }`, + ].join(' '), + ); + } - if (__HYDROGEN_DEV__ && !sizes && !isFixedWidth(normalizedWidth)) { - console.warn( - [ - 'No sizes prop provided to Image component,', - 'you may be loading unnecessarily large images.', - `Image used is ${src || data?.url || 'unknown'}`, - ].join(' '), - ); - } + /* + * We check to see whether the image is fixed width or not, + * if fixed, we still provide a srcSet, but only to account for + * different pixel densities. + */ + if (fixedWidth) { + return ( + + ); + } else { + return ( + + ); + } + }, +); + +type FixedImageExludedProps = + | 'data' + | 'loader' + | 'loaderOptions' + | 'sizes' + | 'srcSetOptions' + | 'widths'; + +type FixedWidthImageProps = Omit & { + loader: Loader; + passthroughProps: React.ImgHTMLAttributes; + normalizedProps: NormalizedProps; + imageWidths: number[]; + ref: React.Ref; +}; - /* - * We check to see whether the image is fixed width or not, - * if fixed, we still provide a srcSet, but only to account for - * different pixel densities. - */ - if (isFixedWidth(normalizedWidth)) { +function FixedWidthImage({ + aspectRatio, + crop, + decoding, + height, + imageWidths, + loader = shopifyLoader, + loading, + normalizedProps, + passthroughProps, + ref, + width, +}: FixedWidthImageProps) { + const fixed = React.useMemo(() => { const intWidth: number | undefined = getNormalizedFixedUnit(width); const intHeight: number | undefined = getNormalizedFixedUnit(height); @@ -338,10 +410,10 @@ export function Image({ */ const fixedAspectRatio = aspectRatio ? aspectRatio - : unitsMatch(normalizedWidth, normalizedHeight) + : unitsMatch(normalizedProps.width, normalizedProps.height) ? [intWidth, intHeight].join('/') - : normalizedAspectRatio - ? normalizedAspectRatio + : normalizedProps.aspectRatio + ? normalizedProps.aspectRatio : undefined; /* @@ -359,65 +431,163 @@ export function Image({ ? intWidth * (parseAspectRatio(fixedAspectRatio) ?? 1) : undefined; - return ( - - ); - } else { + const srcSet = generateSrcSet(normalizedProps.src, sizesArray, loader); + const src = loader({ + src: normalizedProps.src, + width: intWidth, + height: fixedHeight, + crop: normalizedProps.height === 'auto' ? undefined : crop, + }); + + return { + width: intWidth, + aspectRatio: fixedAspectRatio, + height: fixedHeight, + srcSet, + src, + }; + }, [aspectRatio, crop, height, imageWidths, loader, normalizedProps, width]); + + return ( + {normalizedProps.alt} + ); +} + +type FluidImageExcludedProps = + | 'data' + | 'width' + | 'height' + | 'loader' + | 'loaderOptions' + | 'srcSetOptions'; + +type FluidImageProps = Omit & { + imageWidths: number[]; + loader: Loader; + normalizedProps: NormalizedProps; + passthroughProps: React.ImgHTMLAttributes; + placeholderWidth: number; + ref: React.Ref; +}; + +function FluidImage({ + crop, + decoding, + imageWidths, + loader = shopifyLoader, + loading, + normalizedProps, + passthroughProps, + placeholderWidth, + ref, + sizes, +}: FluidImageProps) { + const fluid = React.useMemo(() => { const sizesArray = imageWidths === undefined ? undefined - : generateSizes(imageWidths, normalizedAspectRatio, crop); + : generateSizes(imageWidths, normalizedProps.aspectRatio, crop); const placeholderHeight = - normalizedAspectRatio && placeholderWidth - ? placeholderWidth * (parseAspectRatio(normalizedAspectRatio) ?? 1) + normalizedProps.aspectRatio && placeholderWidth + ? placeholderWidth * + (parseAspectRatio(normalizedProps.aspectRatio) ?? 1) : undefined; - return ( - - ); + const srcSet = generateSrcSet(normalizedProps.src, sizesArray, loader); + + const src = loader({ + src: normalizedProps.src, + width: placeholderWidth, + height: placeholderHeight, + crop, + }); + + return { + placeholderHeight, + srcSet, + src, + }; + }, [crop, imageWidths, loader, normalizedProps, placeholderWidth]); + + return ( + {normalizedProps.alt} + ); +} + +/** + * The shopifyLoader function is a simple utility function that takes a src, width, + * height, and crop and returns a string that can be used as the src for an image. + * It can be used with the Hydrogen Image component or with the next/image component. + * (or any others that accept equivalent configuration) + * @param src - The source URL of the image, e.g. `https://cdn.shopify.com/static/sample-images/garnished.jpeg` + * @param width - The width of the image, e.g. `100` + * @param height - The height of the image, e.g. `100` + * @param crop - The crop of the image, e.g. `center` + * @returns A Shopify image URL with the correct query parameters, e.g. `https://cdn.shopify.com/static/sample-images/garnished.jpeg?width=100&height=100&crop=center` + * + * @example + * ``` + * shopifyLoader({ + * src: 'https://cdn.shopify.com/static/sample-images/garnished.jpeg', + * width: 100, + * height: 100, + * crop: 'center', + * }) + * ``` + */ +export function shopifyLoader({src, width, height, crop}: LoaderParams) { + if (!src) { + return ''; + } + + const url = new URL(src); + + if (width) { + url.searchParams.append('width', Math.round(width).toString()); + } + + if (height) { + url.searchParams.append('height', Math.round(height).toString()); } + + if (crop) { + url.searchParams.append('crop', crop); + } + return url.href; } /** @@ -500,7 +670,7 @@ function isFixedWidth(width: string | number): boolean { export function generateSrcSet( src?: string, sizesArray?: Array<{width?: number; height?: number; crop?: Crop}>, - loader: ImageLoader = shopifyLoader, + loader: Loader = shopifyLoader, ): string { if (!src) { return ''; From 0ccc4dc683c3afa2681f52e9b0d60198c56cdcc0 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Fri, 17 Mar 2023 11:48:18 -0400 Subject: [PATCH 34/46] Fix checks --- packages/hydrogen-react/src/Image.stories.tsx | 4 ++-- packages/hydrogen-react/src/Image.tsx | 2 +- packages/hydrogen-react/src/MediaFile.tsx | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/hydrogen-react/src/Image.stories.tsx b/packages/hydrogen-react/src/Image.stories.tsx index 88560b5265..2132aacf33 100644 --- a/packages/hydrogen-react/src/Image.stories.tsx +++ b/packages/hydrogen-react/src/Image.stories.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import type {Story} from '@ladle/react'; -import {Image, ShopifyLoaderOptions, ShopifyLoaderParams} from './Image.js'; +import {Image, ShopifyLoaderOptions, LoaderParams} from './Image.js'; import type {PartialDeep} from 'type-fest'; import type {Image as ImageType} from './storefront-api-types.js'; @@ -18,7 +18,7 @@ type HtmlImageProps = React.ImgHTMLAttributes; const Template: Story<{ as?: 'img' | 'source'; data?: PartialDeep; - loader?: (params: ShopifyLoaderParams) => string; + loader?: (params: LoaderParams) => string; src: string; width?: string | number; height?: string | number; diff --git a/packages/hydrogen-react/src/Image.tsx b/packages/hydrogen-react/src/Image.tsx index 4495ce3b10..506d49ca06 100644 --- a/packages/hydrogen-react/src/Image.tsx +++ b/packages/hydrogen-react/src/Image.tsx @@ -44,7 +44,7 @@ export type Loader = (params: LoaderParams) => string; /** Legacy type for backwards compatibility * * @deprecated Use `crop`, `width`, `height`, and `src` props, and/or `data` prop. Or pass a custom `loader` with `LoaderParams` */ -type ShopifyLoaderOptions = { +export type ShopifyLoaderOptions = { /** The base URL of the image */ src?: ImageType['url']; /** The URL param that controls width */ diff --git a/packages/hydrogen-react/src/MediaFile.tsx b/packages/hydrogen-react/src/MediaFile.tsx index a0831f022c..e6b5490455 100644 --- a/packages/hydrogen-react/src/MediaFile.tsx +++ b/packages/hydrogen-react/src/MediaFile.tsx @@ -1,4 +1,4 @@ -import {Image, type ShopifyImageProps} from './Image.js'; +import {Image, type HydrogenImageProps} from './Image.js'; import {Video} from './Video.js'; import {ExternalVideo} from './ExternalVideo.js'; import {ModelViewer} from './ModelViewer.js'; @@ -18,7 +18,7 @@ export interface MediaFileProps extends BaseProps { type MediaOptions = { /** Props that will only apply when an `` is rendered */ - image?: Omit; + image?: Omit; /** Props that will only apply when a `