From 8e71f6be883ea8d47232a2d53af415d9e5ceaad0 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Fri, 6 Jan 2023 12:26:48 -0500 Subject: [PATCH 01/11] Adds new image component --- apps/nextjs/gql/fragment-masking.ts | 10 +- apps/nextjs/gql/gql.ts | 26 ++ packages/react/src/ImageNew.stories.tsx | 0 packages/react/src/ImageNew.tsx | 379 ++++++++++++++++++++++++ 4 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/ImageNew.stories.tsx create mode 100644 packages/react/src/ImageNew.tsx diff --git a/apps/nextjs/gql/fragment-masking.ts b/apps/nextjs/gql/fragment-masking.ts index af0fecab..0df1ecfb 100644 --- a/apps/nextjs/gql/fragment-masking.ts +++ b/apps/nextjs/gql/fragment-masking.ts @@ -1,4 +1,4 @@ -import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +import { TypedDocumentNode as DocumentNode, ResultOf } from '@graphql-typed-document-node/core'; export type FragmentType> = TDocumentType extends DocumentNode< @@ -38,3 +38,11 @@ export function useFragment( ): TType | ReadonlyArray | null | undefined { return fragmentType as any } + + +export function makeFragmentData< + F extends DocumentNode, + FT extends ResultOf +>(data: FT, _fragment: F): FragmentType { + return data as FragmentType; +} \ No newline at end of file diff --git a/apps/nextjs/gql/gql.ts b/apps/nextjs/gql/gql.ts index 257616a8..72313fce 100644 --- a/apps/nextjs/gql/gql.ts +++ b/apps/nextjs/gql/gql.ts @@ -2,13 +2,39 @@ import * as types from './graphql'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +/** + * Map of all GraphQL operations in the project. + * + * This map has several performance disadvantages: + * 1. It is not tree-shakeable, so it will include all operations in the project. + * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. + * 3. It does not support dead code elimination, so it will add unused operations. + * + * Therefore it is highly recommended to use the babel-plugin for production. + */ const documents = { "\n query IndexQuery {\n shop {\n name\n }\n products(first: 1) {\n nodes {\n # if you uncomment 'blah', it should have a GraphQL validation error in your IDE if you have a GraphQL plugin. It should also give an error during 'npm run dev'\n # blah\n id\n title\n publishedAt\n handle\n variants(first: 1) {\n nodes {\n id\n image {\n url\n altText\n width\n height\n }\n }\n }\n }\n }\n }\n": types.IndexQueryDocument, }; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ export function graphql(source: "\n query IndexQuery {\n shop {\n name\n }\n products(first: 1) {\n nodes {\n # if you uncomment 'blah', it should have a GraphQL validation error in your IDE if you have a GraphQL plugin. It should also give an error during 'npm run dev'\n # blah\n id\n title\n publishedAt\n handle\n variants(first: 1) {\n nodes {\n id\n image {\n url\n altText\n width\n height\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query IndexQuery {\n shop {\n name\n }\n products(first: 1) {\n nodes {\n # if you uncomment 'blah', it should have a GraphQL validation error in your IDE if you have a GraphQL plugin. It should also give an error during 'npm run dev'\n # blah\n id\n title\n publishedAt\n handle\n variants(first: 1) {\n nodes {\n id\n image {\n url\n altText\n width\n height\n }\n }\n }\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + * + * + * @example + * ```ts + * const query = gql(`query GetUser($id: ID!) { user(id: $id) { name } }`); + * ``` + * + * The query argument is unknown! + * Please regenerate the types. +**/ export function graphql(source: string): unknown; + export function graphql(source: string) { return (documents as any)[source] ?? {}; } diff --git a/packages/react/src/ImageNew.stories.tsx b/packages/react/src/ImageNew.stories.tsx new file mode 100644 index 00000000..e69de29b diff --git a/packages/react/src/ImageNew.tsx b/packages/react/src/ImageNew.tsx new file mode 100644 index 00000000..5cc85886 --- /dev/null +++ b/packages/react/src/ImageNew.tsx @@ -0,0 +1,379 @@ +import * as React from 'react'; + +/* + * An optional prop you can use to change the + * default srcSet generation behaviour + */ +interface ImageConfig { + intervals: number; + startingWidth: number; + incrementSize: number; + placeholderWidth: number; +} + +/* + * TODO: Expand to include focal point support; + * or switch this to be an SF API type + */ + +type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right'; + +export function Image({ + 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 that fills + * the width of its container. + */ + width = '100%', + 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, + config = { + intervals: 10, + startingWidth: 300, + incrementSize: 300, + placeholderWidth: 100, + }, + alt, + loading = 'lazy', + ...passthroughProps +}: { + as?: 'img' | 'source'; + src: string; + loader?: Function; + width?: string | number; + height?: string | number; + crop?: Crop; + sizes?: string; + aspectRatio?: string; + config?: ImageConfig; + alt?: string; + loading?: 'lazy' | 'eager'; +}) { + /* + * Sanitizes width and height inputs to account for 'number' type + */ + let normalizedWidth: string = + getUnitValueParts(width.toString()).number + + getUnitValueParts(width.toString()).unit; + + let normalizedHeight: string = + height === undefined + ? 'auto' + : getUnitValueParts(height.toString()).number + + getUnitValueParts(height.toString()).unit; + + const {intervals, startingWidth, incrementSize, placeholderWidth} = config; + + /* + * This function creates an array of widths to be used in srcSet + */ + const widths = 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(width)) { + let intWidth: number | undefined = getNormalizedFixedUnit(width); + let 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(width, height) + ? `${intWidth}/${intHeight}` + : 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 = + widths === undefined + ? undefined + : generateSizes(widths, fixedAspectRatio, crop); + + return React.createElement(Component, { + srcSet: generateShopifySrcSet(src, sizesArray), + src: loader( + src, + intWidth, + intHeight + ? intHeight + : aspectRatio && intWidth + ? intWidth * (parseAspectRatio(aspectRatio) ?? 1) + : undefined, + normalizedHeight === 'auto' ? undefined : crop + ), + alt, + sizes: sizes || normalizedWidth, + style: { + width: normalizedWidth, + height: normalizedHeight, + aspectRatio, + }, + loading, + ...passthroughProps, + }); + } else { + const sizesArray = + widths === undefined + ? undefined + : generateSizes(widths, aspectRatio, crop); + + return React.createElement(Component, { + srcSet: generateShopifySrcSet(src, sizesArray), + src: loader( + src, + placeholderWidth, + aspectRatio && placeholderWidth + ? placeholderWidth * (parseAspectRatio(aspectRatio) ?? 1) + : undefined + ), + alt, + sizes, + style: { + width: normalizedWidth, + height: normalizedHeight, + aspectRatio, + }, + loading, + ...passthroughProps, + }); + } +} + +function unitsMatch( + width: string | number = '100%', + height: string | number = 'auto' +) { + return ( + getUnitValueParts(width.toString()).unit === + getUnitValueParts(height.toString()).unit + ); + /* + Given: + width = '100px' + height = 'auto' + Returns: + false + + Given: + width = '100px' + height = '50px' + Returns: + true + */ +} + +function getUnitValueParts(value: string) { + 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) { + if (value === undefined) { + return; + } + + 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 + + Given: + value = 100% + Returns: + undefined + */ +} + +function isFixedWidth(width: string | number) { + const fixedEndings = new RegExp('px|em|rem', 'g'); + 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}> +) { + if (sizesArray?.length === 0 || !sizesArray) { + return src; + } + + return sizesArray + .map( + (size) => + shopifyLoader(src, size.width, size.height, size.crop) + + ' ' + + size.width + + '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' + */ +} + +export function generateImageWidths( + width: string | number = '100%', + intervals: number = 20, + startingWidth: number = 200, + incrementSize: number = 100 +) { + 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) { + 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( + widths?: number[], + aspectRatio?: string, + crop: Crop = 'center' +) { + if (!widths) return; + const sizes = widths.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 = 'https://cdn.shopify.com/static/sample-images/garnished.jpeg', + width?: number, + height?: number, + crop?: Crop +) { + 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' + */ +} From be8e3d2c95ab5bf00a2e907f9a6f8e6046f876e5 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Fri, 6 Jan 2023 12:35:40 -0500 Subject: [PATCH 02/11] Adds stories for new image --- packages/react/src/ImageNew.stories.tsx | 55 +++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/react/src/ImageNew.stories.tsx b/packages/react/src/ImageNew.stories.tsx index e69de29b..2baf12b6 100644 --- a/packages/react/src/ImageNew.stories.tsx +++ b/packages/react/src/ImageNew.stories.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import type {Story} from '@ladle/react'; +import {Image, shopifyLoader} from './ImageNew.js'; + +type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right'; + +type ImageConfig = { + intervals: number; + startingWidth: number; + incrementSize: number; + placeholderWidth: number; +}; + +const Template: Story<{ + as?: 'img' | 'source'; + src: string; + // eslint-disable-next-line @typescript-eslint/ban-types + loader?: Function /* Should be a Function */; + width?: string | number; + height?: string | number; + crop?: Crop; + sizes?: string; + aspectRatio?: string; + config?: ImageConfig; + alt?: string; + loading?: 'lazy' | 'eager'; +}> = (props) => { + return ( + <> + + + + + + + ); +}; + +export const Default = Template.bind({}); +Default.args = { + as: 'img', + src: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', + width: '100%', + height: undefined, + config: { + intervals: 10, + startingWidth: 300, + incrementSize: 300, + placeholderWidth: 100, + }, + sizes: '100vw', + aspectRatio: '1/1', + crop: 'center', + loader: shopifyLoader, +}; From e4606b6de904e7230bce0328eb9ebe36dde4d3f8 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Fri, 6 Jan 2023 12:47:00 -0500 Subject: [PATCH 03/11] Adds basic test structure --- packages/react/src/ImageNew.stories.tsx | 4 +- packages/react/src/ImageNew.test.tsx | 67 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 packages/react/src/ImageNew.test.tsx diff --git a/packages/react/src/ImageNew.stories.tsx b/packages/react/src/ImageNew.stories.tsx index 2baf12b6..a976329b 100644 --- a/packages/react/src/ImageNew.stories.tsx +++ b/packages/react/src/ImageNew.stories.tsx @@ -41,15 +41,13 @@ Default.args = { as: 'img', src: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', width: '100%', - height: undefined, config: { intervals: 10, startingWidth: 300, incrementSize: 300, placeholderWidth: 100, }, - sizes: '100vw', - aspectRatio: '1/1', crop: 'center', + loading: 'lazy', loader: shopifyLoader, }; diff --git a/packages/react/src/ImageNew.test.tsx b/packages/react/src/ImageNew.test.tsx new file mode 100644 index 00000000..9f41cd6a --- /dev/null +++ b/packages/react/src/ImageNew.test.tsx @@ -0,0 +1,67 @@ +import {render, screen} from '@testing-library/react'; +import {Image} from './ImageNew.js'; + +const defaultProps = { + src: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', +}; + +describe('', () => { + it('renders an `img` element', () => { + render(); + + const image = screen.getByRole('img'); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('loading', 'lazy'); + }); + + it('renders an `img` element with provided `id`', () => { + const image = screen.getByRole('img'); + render(); + expect(image).toBeInTheDocument(); + }); + + it('renders an `img` element with provided `loading` value', () => { + const image = screen.getByRole('img'); + render(); + expect(image).toBeInTheDocument(); + }); + + it('renders an `img` with `width` and `height` values', () => { + const image = screen.getByRole('img'); + 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(); + }); + + describe('Loaders', () => { + it('calls `shopifyImageLoader()` when no `loader` prop is provided', () => { + const image = screen.getByRole('img'); + render(); + expect(image).toBeInTheDocument(); + }); + }); + + 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(); + }); + + it('generates a default srcset up to the image height and width', () => { + const image = screen.getByRole('img'); + render(); + expect(image).toBeInTheDocument(); + }); +}); From 42ef516307c3fcb81850dedd2aa19a211755d371 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 6 Feb 2023 16:33:35 -0500 Subject: [PATCH 04/11] Makes new Image component default, and adds in legacy prop support --- .../react/src/ExternalVideo.test.helpers.ts | 2 +- packages/react/src/Image.stories.tsx | 78 +-- packages/react/src/Image.test.tsx | 296 +--------- packages/react/src/Image.tsx | 555 ++++++++++++------ packages/react/src/ImageLegacy.stories.tsx | 45 ++ ...helpers.ts => ImageLegacy.test.helpers.ts} | 0 packages/react/src/ImageLegacy.test.tsx | 337 +++++++++++ packages/react/src/ImageLegacy.tsx | 216 +++++++ packages/react/src/ImageNew.stories.tsx | 53 -- packages/react/src/ImageNew.test.tsx | 67 --- packages/react/src/ImageNew.tsx | 379 ------------ packages/react/src/MediaFile.test.helpers.ts | 2 +- packages/react/src/MediaFile.tsx | 2 +- packages/react/src/Metafield.tsx | 2 +- .../react/src/ModelViewer.test.helpers.ts | 2 +- .../react/src/ProductProvider.test.helpers.ts | 2 +- packages/react/src/Video.test.helpers.ts | 2 +- packages/react/src/image-size.ts | 2 +- packages/react/src/index.ts | 2 +- 19 files changed, 1051 insertions(+), 993 deletions(-) create mode 100644 packages/react/src/ImageLegacy.stories.tsx rename packages/react/src/{Image.test.helpers.ts => ImageLegacy.test.helpers.ts} (100%) create mode 100644 packages/react/src/ImageLegacy.test.tsx create mode 100644 packages/react/src/ImageLegacy.tsx delete mode 100644 packages/react/src/ImageNew.stories.tsx delete mode 100644 packages/react/src/ImageNew.test.tsx delete mode 100644 packages/react/src/ImageNew.tsx diff --git a/packages/react/src/ExternalVideo.test.helpers.ts b/packages/react/src/ExternalVideo.test.helpers.ts index 1c353696..c2ba1f11 100644 --- a/packages/react/src/ExternalVideo.test.helpers.ts +++ b/packages/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/react/src/Image.stories.tsx b/packages/react/src/Image.stories.tsx index 33648fcd..7b433fcc 100644 --- a/packages/react/src/Image.stories.tsx +++ b/packages/react/src/Image.stories.tsx @@ -1,45 +1,53 @@ 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} from './Image.js'; +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'; + 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 ( + <> + + + + + +

Tests to use the old component

+ + + ); }; 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, - }, + src: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', }; diff --git a/packages/react/src/Image.test.tsx b/packages/react/src/Image.test.tsx index c8d9ee12..5b80fb7f 100644 --- a/packages/react/src/Image.test.tsx +++ b/packages/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/react/src/Image.tsx b/packages/react/src/Image.tsx index ff3974a1..7fde95d3 100644 --- a/packages/react/src/Image.tsx +++ b/packages/react/src/Image.tsx @@ -1,12 +1,17 @@ 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 ImageConfig { + intervals: number; + startingWidth: number; + incrementSize: number; + placeholderWidth: number; +} type HtmlImageProps = React.ImgHTMLAttributes; @@ -16,201 +21,417 @@ export type ShopifyLoaderOptions = { width?: HtmlImageProps['width'] | ImageType['width']; height?: HtmlImageProps['height'] | ImageType['height']; }; + export type ShopifyLoaderParams = Simplify< ShopifyLoaderOptions & { src: ImageType['url']; + width: number; + height: number; + crop: Crop; } >; -export type ShopifyImageProps = Omit & { + +/* + * TODO: Expand to include focal point support; + * or switch this to be an SF API type + */ + +type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right'; + +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: PartialDeep; - /** A custom function that generates the image URL. Parameters passed in - * are `ShopifyLoaderParams` + data, + as: Component = 'img', + src, + /* + * Supports third party loaders, which are expected to provide + * a function that can generate a URL 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`. + 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. */ - loaderOptions?: ShopifyLoaderOptions; - /** - * `src` isn't used, and should instead be passed as part of the `data` object + 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. */ - src?: never; - /** - * An array of pixel widths to overwrite the default generated srcset. For example, `[300, 600, 800]`. + aspectRatio, + /* + * An optional prop you can use to change + * the default srcSet generation behaviour */ + config = { + intervals: 10, + startingWidth: 300, + incrementSize: 300, + placeholderWidth: 100, + }, + alt, + loading = 'lazy', + ...passthroughProps +}: { + as?: 'img' | 'source'; + data?: PartialDeep; + src: string; + // TODO: Fix this type to be more specific + // eslint-disable-next-line @typescript-eslint/ban-types + loader?: Function; + 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'])[]; -}; - -/** - * 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) { - 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); +}) { + /* + * Deprecated Props from original Image component + */ + if (passthroughProps?.loaderOptions || passthroughProps?.widths) { + /* + * If either of these are used, check if experimental is true + * otherwise check for new props, if either exist, throw an error + */ + if (aspectRatio || typeof width === 'string') { + console.warn( + 'The `loaderOptions` and `widths` props are deprecated. Please use the config prop instead.' + ); } - - 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 - }` - ); - } + /* + * Sanitizes width and height inputs to account for 'number' type + */ + const normalizedWidthProp: string | number | undefined = + width || data?.width || undefined; - const {width: imgElementWidth, height: imgElementHeight} = - getShopifyImageDimensions({ - data, - loaderOptions, - elementProps: { - width, - height, - }, - }); + const normalizedWidth: string = normalizedWidthProp + ? getUnitValueParts(normalizedWidthProp.toString()).number + + getUnitValueParts(normalizedWidthProp.toString()).unit + : '100%'; - 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 - }` - ); - } + const normalizedHeight: string = + height === undefined + ? 'auto' + : getUnitValueParts(height.toString()).number + + getUnitValueParts(height.toString()).unit; - let finalSrc = data.url; + const normalizedSrc: string = data?.url && !src ? data?.url : src; - if (loader) { - finalSrc = loader({ - ...loaderOptions, - src: data.url, - width: imgElementWidth, - height: imgElementHeight, + const normalizedAlt: string = + data?.altText && !alt ? data?.altText : alt || ''; + + const {intervals, startingWidth, incrementSize, placeholderWidth} = config; + + /* + * 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(width, height) + ? `${intWidth}/${intHeight}` + : 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( + normalizedSrc, + intWidth, + intHeight + ? intHeight + : aspectRatio && intWidth + ? intWidth * (parseAspectRatio(aspectRatio) ?? 1) + : undefined, + normalizedHeight === 'auto' ? undefined : crop + ), + alt: normalizedAlt, + sizes: sizes || normalizedWidth, + style: { + width: normalizedWidth, + height: normalizedHeight, + aspectRatio, + }, + loading, + ...passthroughProps, }); - if (typeof finalSrc !== 'string' || !finalSrc) { - throw new Error( - `: 'loader' did not return a valid string. Image: ${ - data.id ?? data.url - }` - ); - } - } + } else { + const sizesArray = + imageWidths === undefined + ? undefined + : generateSizes(imageWidths, aspectRatio, crop); - // 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, + return React.createElement(Component, { + srcSet: generateShopifySrcSet(normalizedSrc, sizesArray), + src: loader( + normalizedSrc, + placeholderWidth, + aspectRatio && placeholderWidth + ? placeholderWidth * (parseAspectRatio(aspectRatio) ?? 1) + : undefined + ), + alt: normalizedAlt, + sizes, + style: { + width: normalizedWidth, + height: normalizedHeight, + aspectRatio, + }, + loading, + ...passthroughProps, }); + } +} - /* eslint-disable hydrogen/prefer-image-component */ +function unitsMatch( + width: string | number = '100%', + height: string | number = 'auto' +) { 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 internalImageSrcSet({ - src, - width, - crop, - scale, - widths, - loader, - height, -}: InternalShopifySrcSetGeneratorsParams) { - const hasCustomWidths = widths && Array.isArray(widths); - if (hasCustomWidths && widths.some((size) => isNaN(size as number))) { - throw new Error( - `: the 'widths' must be an array of numbers. Image: ${src}` - ); +function getUnitValueParts(value: string) { + 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) { + if (value === undefined) { + return; } - let aspectRatio = 1; - if (width && height) { - aspectRatio = Number(height) / Number(width); + 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 + + Given: + value = 100% + Returns: + undefined + */ +} + +function isFixedWidth(width: string | number) { + const fixedEndings = new RegExp('px|em|rem', 'g'); + return ( + typeof width === 'number' || + (typeof width === 'string' && fixedEndings.test(width)) + ); + /* + Given: + width = 100 | '100px' | '100em' | '100rem' + Returns: + true + */ +} - 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); +export function generateShopifySrcSet( + src: string, + sizesArray?: Array<{width?: number; height?: number; crop?: Crop}> +) { + if (sizesArray?.length === 0 || !sizesArray) { + return src; } - const srcGenerator = loader ? loader : addImageSizeParametersToUrl; - return setSizes + + return sizesArray .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` + shopifyLoader(src, size.width, size.height, 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 +) { + 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) { + 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' +) { + 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 = 'https://cdn.shopify.com/static/sample-images/garnished.jpeg', + width?: number, + height?: number, + crop?: Crop +) { + 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/react/src/ImageLegacy.stories.tsx b/packages/react/src/ImageLegacy.stories.tsx new file mode 100644 index 00000000..2801085a --- /dev/null +++ b/packages/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/react/src/Image.test.helpers.ts b/packages/react/src/ImageLegacy.test.helpers.ts similarity index 100% rename from packages/react/src/Image.test.helpers.ts rename to packages/react/src/ImageLegacy.test.helpers.ts diff --git a/packages/react/src/ImageLegacy.test.tsx b/packages/react/src/ImageLegacy.test.tsx new file mode 100644 index 00000000..e31c58bc --- /dev/null +++ b/packages/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/react/src/ImageLegacy.tsx b/packages/react/src/ImageLegacy.tsx new file mode 100644 index 00000000..ff3974a1 --- /dev/null +++ b/packages/react/src/ImageLegacy.tsx @@ -0,0 +1,216 @@ +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< + ShopifyLoaderOptions & { + src: ImageType['url']; + } +>; +export type ShopifyImageProps = Omit & { + /** 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) { + 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) { + 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/react/src/ImageNew.stories.tsx b/packages/react/src/ImageNew.stories.tsx deleted file mode 100644 index a976329b..00000000 --- a/packages/react/src/ImageNew.stories.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from 'react'; -import type {Story} from '@ladle/react'; -import {Image, shopifyLoader} from './ImageNew.js'; - -type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right'; - -type ImageConfig = { - intervals: number; - startingWidth: number; - incrementSize: number; - placeholderWidth: number; -}; - -const Template: Story<{ - as?: 'img' | 'source'; - src: string; - // eslint-disable-next-line @typescript-eslint/ban-types - loader?: Function /* Should be a Function */; - width?: string | number; - height?: string | number; - crop?: Crop; - sizes?: string; - aspectRatio?: string; - config?: ImageConfig; - alt?: string; - loading?: 'lazy' | 'eager'; -}> = (props) => { - return ( - <> - - - - - - - ); -}; - -export const Default = Template.bind({}); -Default.args = { - as: 'img', - src: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', - width: '100%', - config: { - intervals: 10, - startingWidth: 300, - incrementSize: 300, - placeholderWidth: 100, - }, - crop: 'center', - loading: 'lazy', - loader: shopifyLoader, -}; diff --git a/packages/react/src/ImageNew.test.tsx b/packages/react/src/ImageNew.test.tsx deleted file mode 100644 index 9f41cd6a..00000000 --- a/packages/react/src/ImageNew.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import {render, screen} from '@testing-library/react'; -import {Image} from './ImageNew.js'; - -const defaultProps = { - src: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', -}; - -describe('', () => { - it('renders an `img` element', () => { - render(); - - const image = screen.getByRole('img'); - - expect(image).toBeInTheDocument(); - expect(image).toHaveAttribute('loading', 'lazy'); - }); - - it('renders an `img` element with provided `id`', () => { - const image = screen.getByRole('img'); - render(); - expect(image).toBeInTheDocument(); - }); - - it('renders an `img` element with provided `loading` value', () => { - const image = screen.getByRole('img'); - render(); - expect(image).toBeInTheDocument(); - }); - - it('renders an `img` with `width` and `height` values', () => { - const image = screen.getByRole('img'); - 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(); - }); - - describe('Loaders', () => { - it('calls `shopifyImageLoader()` when no `loader` prop is provided', () => { - const image = screen.getByRole('img'); - render(); - expect(image).toBeInTheDocument(); - }); - }); - - 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(); - }); - - it('generates a default srcset up to the image height and width', () => { - const image = screen.getByRole('img'); - render(); - expect(image).toBeInTheDocument(); - }); -}); diff --git a/packages/react/src/ImageNew.tsx b/packages/react/src/ImageNew.tsx deleted file mode 100644 index 5cc85886..00000000 --- a/packages/react/src/ImageNew.tsx +++ /dev/null @@ -1,379 +0,0 @@ -import * as React from 'react'; - -/* - * An optional prop you can use to change the - * default srcSet generation behaviour - */ -interface ImageConfig { - intervals: number; - startingWidth: number; - incrementSize: number; - placeholderWidth: number; -} - -/* - * TODO: Expand to include focal point support; - * or switch this to be an SF API type - */ - -type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right'; - -export function Image({ - 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 that fills - * the width of its container. - */ - width = '100%', - 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, - config = { - intervals: 10, - startingWidth: 300, - incrementSize: 300, - placeholderWidth: 100, - }, - alt, - loading = 'lazy', - ...passthroughProps -}: { - as?: 'img' | 'source'; - src: string; - loader?: Function; - width?: string | number; - height?: string | number; - crop?: Crop; - sizes?: string; - aspectRatio?: string; - config?: ImageConfig; - alt?: string; - loading?: 'lazy' | 'eager'; -}) { - /* - * Sanitizes width and height inputs to account for 'number' type - */ - let normalizedWidth: string = - getUnitValueParts(width.toString()).number + - getUnitValueParts(width.toString()).unit; - - let normalizedHeight: string = - height === undefined - ? 'auto' - : getUnitValueParts(height.toString()).number + - getUnitValueParts(height.toString()).unit; - - const {intervals, startingWidth, incrementSize, placeholderWidth} = config; - - /* - * This function creates an array of widths to be used in srcSet - */ - const widths = 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(width)) { - let intWidth: number | undefined = getNormalizedFixedUnit(width); - let 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(width, height) - ? `${intWidth}/${intHeight}` - : 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 = - widths === undefined - ? undefined - : generateSizes(widths, fixedAspectRatio, crop); - - return React.createElement(Component, { - srcSet: generateShopifySrcSet(src, sizesArray), - src: loader( - src, - intWidth, - intHeight - ? intHeight - : aspectRatio && intWidth - ? intWidth * (parseAspectRatio(aspectRatio) ?? 1) - : undefined, - normalizedHeight === 'auto' ? undefined : crop - ), - alt, - sizes: sizes || normalizedWidth, - style: { - width: normalizedWidth, - height: normalizedHeight, - aspectRatio, - }, - loading, - ...passthroughProps, - }); - } else { - const sizesArray = - widths === undefined - ? undefined - : generateSizes(widths, aspectRatio, crop); - - return React.createElement(Component, { - srcSet: generateShopifySrcSet(src, sizesArray), - src: loader( - src, - placeholderWidth, - aspectRatio && placeholderWidth - ? placeholderWidth * (parseAspectRatio(aspectRatio) ?? 1) - : undefined - ), - alt, - sizes, - style: { - width: normalizedWidth, - height: normalizedHeight, - aspectRatio, - }, - loading, - ...passthroughProps, - }); - } -} - -function unitsMatch( - width: string | number = '100%', - height: string | number = 'auto' -) { - return ( - getUnitValueParts(width.toString()).unit === - getUnitValueParts(height.toString()).unit - ); - /* - Given: - width = '100px' - height = 'auto' - Returns: - false - - Given: - width = '100px' - height = '50px' - Returns: - true - */ -} - -function getUnitValueParts(value: string) { - 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) { - if (value === undefined) { - return; - } - - 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 - - Given: - value = 100% - Returns: - undefined - */ -} - -function isFixedWidth(width: string | number) { - const fixedEndings = new RegExp('px|em|rem', 'g'); - 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}> -) { - if (sizesArray?.length === 0 || !sizesArray) { - return src; - } - - return sizesArray - .map( - (size) => - shopifyLoader(src, size.width, size.height, size.crop) + - ' ' + - size.width + - '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' - */ -} - -export function generateImageWidths( - width: string | number = '100%', - intervals: number = 20, - startingWidth: number = 200, - incrementSize: number = 100 -) { - 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) { - 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( - widths?: number[], - aspectRatio?: string, - crop: Crop = 'center' -) { - if (!widths) return; - const sizes = widths.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 = 'https://cdn.shopify.com/static/sample-images/garnished.jpeg', - width?: number, - height?: number, - crop?: Crop -) { - 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/react/src/MediaFile.test.helpers.ts b/packages/react/src/MediaFile.test.helpers.ts index 1d6ad4e0..27b74552 100644 --- a/packages/react/src/MediaFile.test.helpers.ts +++ b/packages/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/react/src/MediaFile.tsx b/packages/react/src/MediaFile.tsx index 40d097da..4895ac2f 100644 --- a/packages/react/src/MediaFile.tsx +++ b/packages/react/src/MediaFile.tsx @@ -1,4 +1,4 @@ -import {Image, type ShopifyImageProps} from './Image.js'; +import {Image, type ShopifyImageProps} from './ImageLegacy.js'; import {Video} from './Video.js'; import {ExternalVideo} from './ExternalVideo.js'; import {ModelViewer} from './ModelViewer.js'; diff --git a/packages/react/src/Metafield.tsx b/packages/react/src/Metafield.tsx index b26d9e33..abcc49d4 100644 --- a/packages/react/src/Metafield.tsx +++ b/packages/react/src/Metafield.tsx @@ -1,6 +1,6 @@ import {type ElementType, useMemo, type ComponentPropsWithoutRef} from 'react'; import {useShop} from './ShopifyProvider.js'; -import {Image} from './Image.js'; +import {Image} from './ImageLegacy.js'; import type { MediaImage, Page, diff --git a/packages/react/src/ModelViewer.test.helpers.ts b/packages/react/src/ModelViewer.test.helpers.ts index ae3201da..a3de7586 100644 --- a/packages/react/src/ModelViewer.test.helpers.ts +++ b/packages/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/react/src/ProductProvider.test.helpers.ts b/packages/react/src/ProductProvider.test.helpers.ts index 1e8bca4d..d1022b6a 100644 --- a/packages/react/src/ProductProvider.test.helpers.ts +++ b/packages/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 './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/react/src/Video.test.helpers.ts b/packages/react/src/Video.test.helpers.ts index 4d562dc8..973e828a 100644 --- a/packages/react/src/Video.test.helpers.ts +++ b/packages/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/react/src/image-size.ts b/packages/react/src/image-size.ts index 974964d5..284de65f 100644 --- a/packages/react/src/image-size.ts +++ b/packages/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 = [ diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 64cb4ff2..46dfb7e8 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -12,7 +12,7 @@ export {CartProvider, useCart} from './CartProvider.js'; export {storefrontApiCustomScalars} from './codegen.helpers.js'; export {ExternalVideo} from './ExternalVideo.js'; export {flattenConnection} from './flatten-connection.js'; -export {Image} from './Image.js'; +export {Image} from './ImageLegacy.js'; export {MediaFile} from './MediaFile.js'; export {metafieldParser, type ParsedMetafields} from './metafield-parser.js'; export {Metafield, parseMetafield, parseMetafieldValue} from './Metafield.js'; From c0a7eba9f72c3da8cfbf1c647d3a9853df9ee678 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 6 Feb 2023 21:07:59 -0500 Subject: [PATCH 05/11] Warn when missing performance props and support old props --- packages/react/src/Image.stories.tsx | 17 ++++++++- packages/react/src/Image.tsx | 56 +++++++++++++++++++--------- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/packages/react/src/Image.stories.tsx b/packages/react/src/Image.stories.tsx index 7b433fcc..15dd4eb7 100644 --- a/packages/react/src/Image.stories.tsx +++ b/packages/react/src/Image.stories.tsx @@ -37,12 +37,27 @@ const Template: Story<{ aspectRatio="1/1" sizes="100vw" /> + -

Tests to use the old component

+ ); }; diff --git a/packages/react/src/Image.tsx b/packages/react/src/Image.tsx index 7fde95d3..a91cc8ea 100644 --- a/packages/react/src/Image.tsx +++ b/packages/react/src/Image.tsx @@ -81,11 +81,23 @@ 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 }: { as?: 'img' | 'source'; data?: PartialDeep; - src: string; + src?: string; // TODO: Fix this type to be more specific // eslint-disable-next-line @typescript-eslint/ban-types loader?: Function; @@ -103,28 +115,38 @@ export function Image({ /* * Deprecated Props from original Image component */ - if (passthroughProps?.loaderOptions || passthroughProps?.widths) { - /* - * If either of these are used, check if experimental is true - * otherwise check for new props, if either exist, throw an error - */ - if (aspectRatio || typeof width === 'string') { - console.warn( - 'The `loaderOptions` and `widths` props are deprecated. Please use the config prop instead.' - ); - } + if (loaderOptions) { + console.warn( + `Deprecated property from original Image component in use: ` + + `Use the flat \`crop\`, \`width\`, \`height\`, and src props, or` + + `the \`data\` prop to achieve the same result. Image used is ${src}` + ); + } + + 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}` + ); + } + + if (!sizes) { + console.warn( + 'No sizes prop provided to Image component, ' + + 'you may be loading unnecessarily large images.' + + `Image used is ${src}` + ); } /* * Sanitizes width and height inputs to account for 'number' type */ - const normalizedWidthProp: string | number | undefined = - width || data?.width || undefined; + const normalizedWidthProp: string | number = width || data?.width || '100%'; - const normalizedWidth: string = normalizedWidthProp - ? getUnitValueParts(normalizedWidthProp.toString()).number + - getUnitValueParts(normalizedWidthProp.toString()).unit - : '100%'; + const normalizedWidth: string = + getUnitValueParts(normalizedWidthProp.toString()).number + + getUnitValueParts(normalizedWidthProp.toString()).unit; const normalizedHeight: string = height === undefined From 2a365254593f8169f4214af581979ad5d69dd047 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 6 Feb 2023 21:34:10 -0500 Subject: [PATCH 06/11] Clean up and redundancy to support both 'data' and flat inputs --- packages/react/src/Image.stories.tsx | 21 ++++++------- packages/react/src/Image.tsx | 44 +++++++++++++++++++++------- 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/packages/react/src/Image.stories.tsx b/packages/react/src/Image.stories.tsx index 15dd4eb7..43c539e8 100644 --- a/packages/react/src/Image.stories.tsx +++ b/packages/react/src/Image.stories.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import type {Story} from '@ladle/react'; import {Image, ShopifyLoaderOptions} 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'; @@ -16,6 +17,7 @@ type HtmlImageProps = React.ImgHTMLAttributes; const Template: Story<{ as?: 'img' | 'source'; + data?: PartialDeep; src: string; width?: string | number; height?: string | number; @@ -30,6 +32,7 @@ const Template: Story<{ }> = (props) => { return ( <> + {/* Standard Usage */} - + {/* */} + @@ -64,5 +60,10 @@ const Template: Story<{ export const Default = Template.bind({}); Default.args = { - src: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg', + 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/react/src/Image.tsx b/packages/react/src/Image.tsx index a91cc8ea..cdff9912 100644 --- a/packages/react/src/Image.tsx +++ b/packages/react/src/Image.tsx @@ -139,10 +139,22 @@ export function Image({ ); } + /* 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); + /* * Sanitizes width and height inputs to account for 'number' type */ - const normalizedWidthProp: string | number = width || data?.width || '100%'; + + const normalizedWidthProp: string | number = + width || (dataUnitsMatch && data?.width) || '100%'; const normalizedWidth: string = getUnitValueParts(normalizedWidthProp.toString()).number + @@ -154,11 +166,19 @@ export function Image({ : getUnitValueParts(height.toString()).number + getUnitValueParts(height.toString()).unit; - const normalizedSrc: string = data?.url && !src ? data?.url : src; + const normalizedSrc: string = src || data?.url; 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} = config; /* @@ -188,8 +208,10 @@ export function Image({ */ const fixedAspectRatio = aspectRatio ? aspectRatio - : unitsMatch(width, height) + : unitsMatch(normalizedWidth, normalizedHeight) ? `${intWidth}/${intHeight}` + : normalizedAspectRatio + ? normalizedAspectRatio : undefined; /* @@ -208,8 +230,8 @@ export function Image({ intWidth, intHeight ? intHeight - : aspectRatio && intWidth - ? intWidth * (parseAspectRatio(aspectRatio) ?? 1) + : fixedAspectRatio && intWidth + ? intWidth * (parseAspectRatio(fixedAspectRatio) ?? 1) : undefined, normalizedHeight === 'auto' ? undefined : crop ), @@ -218,7 +240,7 @@ export function Image({ style: { width: normalizedWidth, height: normalizedHeight, - aspectRatio, + aspectRatio: fixedAspectRatio, }, loading, ...passthroughProps, @@ -227,15 +249,15 @@ export function Image({ const sizesArray = imageWidths === undefined ? undefined - : generateSizes(imageWidths, aspectRatio, crop); + : generateSizes(imageWidths, normalizedAspectRatio, crop); return React.createElement(Component, { srcSet: generateShopifySrcSet(normalizedSrc, sizesArray), src: loader( normalizedSrc, placeholderWidth, - aspectRatio && placeholderWidth - ? placeholderWidth * (parseAspectRatio(aspectRatio) ?? 1) + normalizedAspectRatio && placeholderWidth + ? placeholderWidth * (parseAspectRatio(normalizedAspectRatio) ?? 1) : undefined ), alt: normalizedAlt, @@ -243,7 +265,7 @@ export function Image({ style: { width: normalizedWidth, height: normalizedHeight, - aspectRatio, + aspectRatio: normalizedAspectRatio, }, loading, ...passthroughProps, @@ -437,7 +459,7 @@ export function generateSizes( * (or any others that accept equivalent configuration) */ export function shopifyLoader( - src = 'https://cdn.shopify.com/static/sample-images/garnished.jpeg', + src: string, width?: number, height?: number, crop?: Crop From 1000e0e0a433769907bdc79c5618a5946b2a2098 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 6 Feb 2023 21:38:31 -0500 Subject: [PATCH 07/11] Updates error messages --- packages/react/src/Image.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/react/src/Image.tsx b/packages/react/src/Image.tsx index cdff9912..d3a3f8b4 100644 --- a/packages/react/src/Image.tsx +++ b/packages/react/src/Image.tsx @@ -119,7 +119,9 @@ export function Image({ console.warn( `Deprecated property from original Image component in use: ` + `Use the flat \`crop\`, \`width\`, \`height\`, and src props, or` + - `the \`data\` prop to achieve the same result. Image used is ${src}` + `the \`data\` prop to achieve the same result. Image used is ${ + src || data?.url + }` ); } @@ -127,15 +129,15 @@ export function Image({ 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}` + `config and width props. Image used is ${src || data?.url}` ); } if (!sizes) { console.warn( 'No sizes prop provided to Image component, ' + - 'you may be loading unnecessarily large images.' + - `Image used is ${src}` + 'you may be loading unnecessarily large images. ' + + `Image used is ${src || data?.url}` ); } @@ -150,7 +152,8 @@ export function Image({ const dataUnitsMatch: boolean = unitsMatch(dataWidth, dataHeight); /* - * Sanitizes width and height inputs to account for 'number' type + * 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 = @@ -166,7 +169,11 @@ export function Image({ : getUnitValueParts(height.toString()).number + getUnitValueParts(height.toString()).unit; - const normalizedSrc: string = src || data?.url; + const normalizedSrc: string | undefined = src || data?.url; + + if (!normalizedSrc) { + console.error(`No src or data.url provided to Image component.`); + } const normalizedAlt: string = data?.altText && !alt ? data?.altText : alt || ''; From 69095db48efd91a0ac218499e8a07de26e7e4a73 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 6 Feb 2023 21:41:50 -0500 Subject: [PATCH 08/11] Don't use data.width for style width --- packages/react/src/Image.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react/src/Image.tsx b/packages/react/src/Image.tsx index d3a3f8b4..b06d322a 100644 --- a/packages/react/src/Image.tsx +++ b/packages/react/src/Image.tsx @@ -156,8 +156,7 @@ export function Image({ * supporting the presence of `data` in addition to flat props. */ - const normalizedWidthProp: string | number = - width || (dataUnitsMatch && data?.width) || '100%'; + const normalizedWidthProp: string | number = width || '100%'; const normalizedWidth: string = getUnitValueParts(normalizedWidthProp.toString()).number + From f88f05aff7a177aabd5321e731859241b97108e0 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 6 Feb 2023 21:53:10 -0500 Subject: [PATCH 09/11] Fix loader type --- packages/react/src/Image.tsx | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/react/src/Image.tsx b/packages/react/src/Image.tsx index b06d322a..9c373410 100644 --- a/packages/react/src/Image.tsx +++ b/packages/react/src/Image.tsx @@ -98,9 +98,12 @@ export function Image({ as?: 'img' | 'source'; data?: PartialDeep; src?: string; - // TODO: Fix this type to be more specific - // eslint-disable-next-line @typescript-eslint/ban-types - loader?: Function; + loader?: ( + src?: string, + width?: number, + height?: number, + crop?: Crop + ) => string; width?: string | number; height?: string | number; crop?: Crop; @@ -368,9 +371,13 @@ function isFixedWidth(width: string | number) { } export function generateShopifySrcSet( - src: string, + src?: string, sizesArray?: Array<{width?: number; height?: number; crop?: Crop}> ) { + if (!src) { + return ''; + } + if (sizesArray?.length === 0 || !sizesArray) { return src; } @@ -465,11 +472,15 @@ export function generateSizes( * (or any others that accept equivalent configuration) */ export function shopifyLoader( - src: string, + 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()); From b31300858af767fef0119e9447909bb28537e436 Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 6 Feb 2023 22:52:09 -0500 Subject: [PATCH 10/11] Collapse loader params to a single argument --- packages/react/src/Image.stories.tsx | 10 +++- packages/react/src/Image.tsx | 81 +++++++++++++++------------- 2 files changed, 53 insertions(+), 38 deletions(-) diff --git a/packages/react/src/Image.stories.tsx b/packages/react/src/Image.stories.tsx index 43c539e8..393d54a1 100644 --- a/packages/react/src/Image.stories.tsx +++ b/packages/react/src/Image.stories.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import type {Story} from '@ladle/react'; -import {Image, ShopifyLoaderOptions} from './Image.js'; +import {Image, ShopifyLoaderOptions, ShopifyLoaderParams} from './Image.js'; import type {PartialDeep} from 'type-fest'; import type {Image as ImageType} from './storefront-api-types.js'; @@ -18,6 +18,7 @@ type HtmlImageProps = React.ImgHTMLAttributes; const Template: Story<{ as?: 'img' | 'source'; data?: PartialDeep; + loader?: (params: ShopifyLoaderParams) => string; src: string; width?: string | number; height?: string | number; @@ -41,6 +42,13 @@ const Template: Story<{ sizes="100vw" /> {/* */} + diff --git a/packages/react/src/Image.tsx b/packages/react/src/Image.tsx index 9c373410..e78c5fa5 100644 --- a/packages/react/src/Image.tsx +++ b/packages/react/src/Image.tsx @@ -6,7 +6,7 @@ import type {Image as ImageType} from './storefront-api-types.js'; * An optional prop you can use to change the * default srcSet generation behaviour */ -interface ImageConfig { +interface SrcSetOptions { intervals: number; startingWidth: number; incrementSize: number; @@ -24,10 +24,10 @@ export type ShopifyLoaderOptions = { export type ShopifyLoaderParams = Simplify< ShopifyLoaderOptions & { - src: ImageType['url']; - width: number; - height: number; - crop: Crop; + src?: ImageType['url']; + width?: number; + height?: number; + crop?: Crop; } >; @@ -36,7 +36,7 @@ export type ShopifyLoaderParams = Simplify< * or switch this to be an SF API type */ -type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right'; +type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right' | undefined; export function Image({ /** An object with fields that correspond to the Storefront API's @@ -73,7 +73,7 @@ export function Image({ * An optional prop you can use to change * the default srcSet generation behaviour */ - config = { + srcSetOptions = { intervals: 10, startingWidth: 300, incrementSize: 300, @@ -98,18 +98,13 @@ export function Image({ as?: 'img' | 'source'; data?: PartialDeep; src?: string; - loader?: ( - src?: string, - width?: number, - height?: number, - crop?: Crop - ) => string; + loader?: (params: ShopifyLoaderParams) => string; width?: string | number; height?: string | number; crop?: Crop; sizes?: string; aspectRatio?: string; - config?: ImageConfig; + srcSetOptions?: SrcSetOptions; alt?: string; loading?: 'lazy' | 'eager'; loaderOptions?: ShopifyLoaderOptions; @@ -121,7 +116,7 @@ export function Image({ if (loaderOptions) { console.warn( `Deprecated property from original Image component in use: ` + - `Use the flat \`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 }` @@ -188,7 +183,8 @@ export function Image({ )}` : undefined; - const {intervals, startingWidth, incrementSize, placeholderWidth} = config; + const {intervals, startingWidth, incrementSize, placeholderWidth} = + srcSetOptions; /* * This function creates an array of widths to be used in srcSet @@ -234,16 +230,16 @@ export function Image({ return React.createElement(Component, { srcSet: generateShopifySrcSet(normalizedSrc, sizesArray), - src: loader( - normalizedSrc, - intWidth, - intHeight + src: loader({ + src: normalizedSrc, + width: intWidth, + height: intHeight ? intHeight : fixedAspectRatio && intWidth ? intWidth * (parseAspectRatio(fixedAspectRatio) ?? 1) : undefined, - normalizedHeight === 'auto' ? undefined : crop - ), + crop: normalizedHeight === 'auto' ? undefined : crop, + }), alt: normalizedAlt, sizes: sizes || normalizedWidth, style: { @@ -262,13 +258,14 @@ export function Image({ return React.createElement(Component, { srcSet: generateShopifySrcSet(normalizedSrc, sizesArray), - src: loader( - normalizedSrc, - placeholderWidth, - normalizedAspectRatio && placeholderWidth - ? placeholderWidth * (parseAspectRatio(normalizedAspectRatio) ?? 1) - : undefined - ), + src: loader({ + src: normalizedSrc, + width: placeholderWidth, + height: + normalizedAspectRatio && placeholderWidth + ? placeholderWidth * (parseAspectRatio(normalizedAspectRatio) ?? 1) + : undefined, + }), alt: normalizedAlt, sizes, style: { @@ -357,7 +354,7 @@ function getNormalizedFixedUnit(value?: string | number) { } function isFixedWidth(width: string | number) { - const fixedEndings = new RegExp('px|em|rem', 'g'); + const fixedEndings = /\d(px|em|rem)$/; return ( typeof width === 'number' || (typeof width === 'string' && fixedEndings.test(width)) @@ -385,7 +382,12 @@ export function generateShopifySrcSet( return sizesArray .map( (size) => - shopifyLoader(src, size.width, size.height, size.crop) + + shopifyLoader({ + src, + width: size.width, + height: size.height, + crop: size.crop, + }) + ' ' + size.width + 'w' @@ -471,12 +473,17 @@ 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?: string, - width?: number, - height?: number, - crop?: Crop -): string { +export function shopifyLoader({ + src, + width, + height, + crop, +}: { + src?: string; + width?: number; + height?: number; + crop?: Crop; +}): string { if (!src) { return ''; } From 14579863ef1d1c2cb8d17a5991f2441725d7884f Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Mon, 6 Feb 2023 23:09:05 -0500 Subject: [PATCH 11/11] Updates type to accept a crop region --- packages/react/src/Image.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/react/src/Image.tsx b/packages/react/src/Image.tsx index e78c5fa5..b2663a99 100644 --- a/packages/react/src/Image.tsx +++ b/packages/react/src/Image.tsx @@ -36,7 +36,14 @@ export type ShopifyLoaderParams = Simplify< * or switch this to be an SF API type */ -type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right' | undefined; +type Crop = + | 'center' + | 'top' + | 'bottom' + | 'left' + | 'right' + | {top: number; left: number; width: number; height: number} + | undefined; export function Image({ /** An object with fields that correspond to the Storefront API's