diff --git a/.changeset/bright-shrimps-hear.md b/.changeset/bright-shrimps-hear.md index 810224d148..393d66fded 100644 --- a/.changeset/bright-shrimps-hear.md +++ b/.changeset/bright-shrimps-hear.md @@ -3,4 +3,4 @@ '@shopify/hydrogen-react': major --- -Release 2023-04 +Releases 2023-04 diff --git a/.changeset/thirty-rice-sin.md b/.changeset/thirty-rice-sin.md new file mode 100644 index 0000000000..d152b32cf8 --- /dev/null +++ b/.changeset/thirty-rice-sin.md @@ -0,0 +1,193 @@ +--- +'@shopify/hydrogen-react': major +'@shopify/hydrogen': major +--- + +Adds a new `Image` component, replacing the existing one. While your existing implementation won't break, props `widths` and `loaderOptions` are now deprecated disregarded, with a new `aspectRatio` prop added. + +### Migrating to the new `Image` + +The new `Image` component is responsive by default, and requires less configuration to ensure the right image size is being rendered on all screen sizes. + +**Before** + +```jsx + +``` + +**After** + +```jsx + +``` + +Note that `widths` and `loaderOptions` have now been deprecated, declaring `width` is no longer necessary, and we’ve added an `aspectRatio` prop: + +- `widths` is now calculated automatically based on a new `srcSetOptions` prop (see below for details). +- `loaderOptions` has been removed in favour of declaring `crop` and `src` as props. `width` and `height` should only be set as props if rendering a fixed image size, with `width` otherwise defaulting to `100%`, and the loader calculating each dynamically. +- `aspectRatio` is calculated automatically using `data.width` and `data.height` (if available) — but if you want to present an image with an aspect ratio other than what was uploaded, you can set using the format `Int/Int` (e.g. `3/2`, [see MDN docs for more info](https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio), note that you must use the _fraction_ style of declaring aspect ratio, decimals are not supported); if you've set an `aspectRatio`, we will default the crop to be `crop: center` (in the example above we've specified this to use `left` instead). + +### Examples + + + +#### Basic Usage + +```jsx + +``` + +This would use all default props, which if exhaustively declared would be the same as typing: + +```jsx + +``` + +An alternative way to write this without using `data` would be to use the `src`, `alt`, and `aspectRatio` props. For example: + +```jsx +{data.altText} +``` + +Assuming `data` had the following shape: + +```json +{ + "url": "https://cdn.shopify.com/s/files/1/0551/4566/0472/products/Main.jpg", + "altText": "alt text", + "width": "4000", + "height": "4000" +} +``` + +All three above examples would result in the following HTML: + +```html +alt text +``` + +#### Fixed-size Images + +When using images that are meant to be a fixed size, like showing a preview image of a product in the cart, instead of using `aspectRatio`, you'll instead declare `width` and `height` manually with fixed values. For example: + +```jsx + +``` + +Instead of generating 15 images for a broad range of screen sizes, `Image` will instead only generate 3, for various screen pixel densities (1x, 2x, and 3x). The above example would result in the following HTML: + +```html +alt text +``` + +If you don't want to have a fixed aspect ratio, and instead respect whatever is returned from your query, the following syntax can also be used: + +```jsx + +``` + +Which would result in the same HTML as above, however the generated URLs inside the `src` and `srcset` attributes would not have `height` or `crop` parameters appended to them, and the generated `aspect-ratio` in `style` would be `4000 / 4000` (if using the same `data` values as our original example). + +#### Custom Loaders + +If your image isn't coming from the Storefront API, but you still want to take advantage of the `Image` component, you can pass a custom `loader` prop, provided the CDN you're working with supports URL-based transformations. + +The `loader` is a function which expects a `params` argument of the following type: + +```ts +type LoaderParams = { + /** The base URL of the image */ + src?: ImageType['url']; + /** The URL param that controls width */ + width?: number; + /** The URL param that controls height */ + height?: number; + /** The URL param that controls the cropping region */ + crop?: Crop; +}; +``` + +Here is an example of using `Image` with a custom loader function: + +```jsx +const customLoader = ({src, width, height, crop}) => { + return `${src}?w=${width}&h=${height}&gravity=${crop}`; +}; + +export default function CustomImage(props) { + ; +} + +// In Use: + +; +``` + +If your CDN happens to support the same semantics as Shopify (URL params of `width`, `height`, and `crop`) — the default loader will work a non-Shopify `src` attribute. + +An example output might look like: `https://mycdn.com/image.jpeg?width=100&height=100&crop=center` + +### Additional changes + +- Added the `srcSetOptions` prop used to create the image URLs used in `srcset`. It’s an object with the following keys and defaults: + + ```js + srcSetOptions = { + intervals: 15, // The number of sizes to generate + startingWidth: 200, // The smalles image size + incrementSize: 200, // The increment by to increase for each size, in pixesl + placeholderWidth: 100, // The size used for placeholder fallback images + }; + ``` + +- Added an export for `IMAGE_FRAGMENT`, which can be imported from Hydrogen and used in any Storefront API query, which will fetch the required fields needed by the component. + +- Added an export for `shopifyLoader` for using Storefront API responses in conjunction with alternative frameworks that already have their own `Image` component, like Next.js diff --git a/packages/hydrogen-react/docs/generated/generated_docs_data.json b/packages/hydrogen-react/docs/generated/generated_docs_data.json index 096c702ca5..230bb018e5 100644 --- a/packages/hydrogen-react/docs/generated/generated_docs_data.json +++ b/packages/hydrogen-react/docs/generated/generated_docs_data.json @@ -1338,7 +1338,7 @@ "url": "/api/hydrogen-react/components/mediafile" } ], - "description": "The `Image` component renders an image for the Storefront API's\n[Image object](https://shopify.dev/api/storefront/reference/common-objects/image) by using the `data` prop. You can [customize this component](https://shopify.dev/api/hydrogen/components#customizing-hydrogen-components) using passthrough props.\n\nAn image's width and height are determined using the following priority list:\n1. The width and height values for the `loaderOptions` prop\n2. The width and height values for bare props\n3. The width and height values for the `data` prop\n\nIf only one of `width` or `height` are defined, then the other will attempt to be calculated based on the image's aspect ratio,\nprovided that both `data.width` and `data.height` are available. If `data.width` and `data.height` aren't available, then the aspect ratio cannot be determined and the missing value will remain as `null`", + "description": "The `Image` component renders an image for the Storefront API's\n[Image object](https://shopify.dev/api/storefront/reference/common-objects/image) by using the `data` prop. You can [customize this component](https://shopify.dev/api/hydrogen/components#customizing-hydrogen-components) using passthrough props.\n\nImages default to being responsive automativally (`width: 100%, height: auto`), and expect an `aspectRatio` prop, which ensures your image doesn't create any layout shift. For fixed-size images, you can set `width` to an exact value, and a `srcSet` with 1x, 2x, and 3x DPI variants will automatically be generated for you.", "type": "component", "defaultExample": { "description": "I am the default example", @@ -1346,12 +1346,12 @@ "tabs": [ { "title": "JavaScript", - "code": "import {Image} from '@shopify/hydrogen-react';\n\nexport default function ProductImage({product}) {\n const image = product.featuredImage;\n\n if (!image) {\n return null;\n }\n\n return ;\n}\n", + "code": "import {Image, IMAGE_FRAGMENT} from '@shopify/hydrogen-react';\n\n// An example query that includes the image fragment\nconst IMAGE_QUERY = `#graphql\n ${IMAGE_FRAGMENT}\n query {\n product {\n featuredImage {\n ...Image\n }\n }\n }\n`;\n\nexport default function ProductImage({product}) {\n if (!image) {\n return null;\n }\n\n return (\n \n );\n}\n", "language": "jsx" }, { "title": "TypeScript", - "code": "import {Image} from '@shopify/hydrogen-react';\nimport type {Product} from '@shopify/hydrogen-react/storefront-api-types';\n\nexport default function ProductImage({product}: {product: Product}) {\n const image = product.featuredImage;\n\n if (!image) {\n return null;\n }\n\n return ;\n}\n", + "code": "import React from 'react';\nimport {Image, IMAGE_FRAGMENT} from '@shopify/hydrogen-react';\nimport type {Product} from '@shopify/hydrogen-react/storefront-api-types';\n\n// An example query that includes the image fragment\nconst IMAGE_QUERY = `#graphql\n ${IMAGE_FRAGMENT}\n query {\n product {\n featuredImage {\n ...Image\n }\n }\n }\n`;\n\nexport default function ProductImage({product}: {product: Product}) {\n if (!image) {\n return null;\n }\n\n return (\n \n );\n}\n", "language": "tsx" } ], @@ -1362,52 +1362,66 @@ { "title": "Props", "description": "", - "type": "ShopifyImageBaseProps", + "type": "HydrogenImageProps", "typeDefinitions": { - "ShopifyImageBaseProps": { + "HydrogenImageProps": { "filePath": "/Image.tsx", "syntaxKind": "TypeAliasDeclaration", - "name": "ShopifyImageBaseProps", - "value": "{\n /** An object with fields that correspond to the Storefront API's\n * [Image object](https://shopify.dev/api/storefront/reference/common-objects/image).\n * The `data` prop is required.\n */\n data: PartialDeep;\n /** A custom function that generates the image URL. Parameters passed in\n * are `ShopifyLoaderParams`\n */\n loader?: (params: ShopifyLoaderParams) => string;\n /** An object of `loader` function options. For example, if the `loader` function\n * requires a `scale` option, then the value can be a property of the\n * `loaderOptions` object (for example, `{scale: 2}`). The object shape is `ShopifyLoaderOptions`.\n */\n loaderOptions?: ShopifyLoaderOptions;\n /**\n * `src` isn't used, and should instead be passed as part of the `data` object\n */\n src?: never;\n /**\n * An array of pixel widths to overwrite the default generated srcset. For example, `[300, 600, 800]`.\n */\n widths?: (HtmlImageProps['width'] | ImageType['width'])[];\n}", + "name": "HydrogenImageProps", + "value": "React.ImgHTMLAttributes & {\n /** The aspect ratio of the image, in the format of `width/height`.\n *\n * @example\n * ```\n * \n * ```\n */\n aspectRatio?: string;\n /** The crop position of the image.\n *\n * @remarks\n * In the event that AspectRatio is set, without specifying a crop,\n * the Shopify CDN won't return the expected image.\n *\n * @defaultValue `center`\n */\n crop?: Crop;\n /** Data mapping to the Storefront API `Image` object. Must be an Image object.\n * Optionally, import the `IMAGE_FRAGMENT` to use in your GraphQL queries.\n *\n * @example\n * ```\n * import {IMAGE_FRAGMENT, Image} from '@shopify/hydrogen';\n *\n * export const IMAGE_QUERY = `#graphql\n * ${IMAGE_FRAGMENT}\n * query {\n * product {\n * featuredImage {\n * ...Image\n * }\n * }\n * }`\n *\n * \n * ```\n *\n * Image: {@link https://shopify.dev/api/storefront/reference/common-objects/image}\n */\n data?: PartialDeep;\n key?: React.Key;\n /** A function that returns a URL string for an image.\n *\n * @remarks\n * By default, this uses Shopify’s CDN {@link https://cdn.shopify.com/} but you can provide\n * your own function to use a another provider, as long as they support URL based image transformations.\n */\n loader?: Loader;\n /** @deprecated Use `crop`, `width`, `height`, and `src` props, and/or `data` prop */\n loaderOptions?: ShopifyLoaderOptions;\n /** An optional prop you can use to change the default srcSet generation behaviour */\n srcSetOptions?: SrcSetOptions;\n /** @deprecated Autocalculated, use only `width` prop, or srcSetOptions */\n widths?: (HtmlImageProps['width'] | ImageType['width'])[];\n}", + "description": "" + }, + "Crop": { + "filePath": "/Image.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "Crop", + "value": "'center' | 'top' | 'bottom' | 'left' | 'right'", + "description": "" + }, + "Loader": { + "filePath": "/Image.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "Loader", + "value": "(params: LoaderParams) => string", + "description": "" + }, + "LoaderParams": { + "filePath": "/Image.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "LoaderParams", + "value": "{\n /** The base URL of the image */\n src?: ImageType['url'];\n /** The URL param that controls width */\n width?: number;\n /** The URL param that controls height */\n height?: number;\n /** The URL param that controls the cropping region */\n crop?: Crop;\n}", "description": "", "members": [ { "filePath": "/Image.tsx", "syntaxKind": "PropertySignature", - "name": "data", - "value": "PartialObjectDeep", - "description": "An object with fields that correspond to the Storefront API's\n[Image object](https://shopify.dev/api/storefront/reference/common-objects/image).\nThe `data` prop is required." - }, - { - "filePath": "/Image.tsx", - "syntaxKind": "PropertySignature", - "name": "loader", - "value": "(params: { crop?: \"center\" | \"top\" | \"bottom\" | \"left\" | \"right\"; scale?: 2 | 3; width?: string | number; height?: string | number; src: string; }) => string", - "description": "A custom function that generates the image URL. Parameters passed in\nare `ShopifyLoaderParams`", + "name": "src", + "value": "string", + "description": "The base URL of the image", "isOptional": true }, { "filePath": "/Image.tsx", "syntaxKind": "PropertySignature", - "name": "loaderOptions", - "value": "ShopifyLoaderOptions", - "description": "An object of `loader` function options. For example, if the `loader` function\nrequires a `scale` option, then the value can be a property of the\n`loaderOptions` object (for example, `{scale: 2}`). The object shape is `ShopifyLoaderOptions`.", + "name": "width", + "value": "number", + "description": "The URL param that controls width", "isOptional": true }, { "filePath": "/Image.tsx", "syntaxKind": "PropertySignature", - "name": "src", - "value": "never", - "description": "`src` isn't used, and should instead be passed as part of the `data` object", + "name": "height", + "value": "number", + "description": "The URL param that controls height", "isOptional": true }, { "filePath": "/Image.tsx", "syntaxKind": "PropertySignature", - "name": "widths", - "value": "(string | number)[]", - "description": "An array of pixel widths to overwrite the default generated srcset. For example, `[300, 600, 800]`.", + "name": "crop", + "value": "Crop", + "description": "The URL param that controls the cropping region", "isOptional": true } ] @@ -1416,42 +1430,86 @@ "filePath": "/Image.tsx", "syntaxKind": "TypeAliasDeclaration", "name": "ShopifyLoaderOptions", - "value": "{\n crop?: 'top' | 'bottom' | 'left' | 'right' | 'center';\n scale?: 2 | 3;\n width?: HtmlImageProps['width'] | ImageType['width'];\n height?: HtmlImageProps['height'] | ImageType['height'];\n}", - "description": "", + "value": "{\n /** The base URL of the image */\n src?: ImageType['url'];\n /** The URL param that controls width */\n width?: HtmlImageProps['width'] | ImageType['width'];\n /** The URL param that controls height */\n height?: HtmlImageProps['height'] | ImageType['height'];\n /** The URL param that controls the cropping region */\n crop?: Crop;\n}", + "description": "Legacy type for backwards compatibility *", "members": [ { "filePath": "/Image.tsx", "syntaxKind": "PropertySignature", - "name": "crop", - "value": "\"center\" | \"top\" | \"bottom\" | \"left\" | \"right\"", - "description": "", + "name": "src", + "value": "string", + "description": "The base URL of the image", "isOptional": true }, { "filePath": "/Image.tsx", "syntaxKind": "PropertySignature", - "name": "scale", - "value": "2 | 3", - "description": "", + "name": "width", + "value": "string | number", + "description": "The URL param that controls width", "isOptional": true }, { "filePath": "/Image.tsx", "syntaxKind": "PropertySignature", - "name": "width", + "name": "height", "value": "string | number", - "description": "", + "description": "The URL param that controls height", "isOptional": true }, { "filePath": "/Image.tsx", "syntaxKind": "PropertySignature", - "name": "height", - "value": "string | number", - "description": "", + "name": "crop", + "value": "Crop", + "description": "The URL param that controls the cropping region", "isOptional": true } ] + }, + "SrcSetOptions": { + "filePath": "/Image.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "SrcSetOptions", + "value": "{\n intervals: number;\n startingWidth: number;\n incrementSize: number;\n placeholderWidth: number;\n}", + "description": "", + "members": [ + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "intervals", + "value": "number", + "description": "" + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "startingWidth", + "value": "number", + "description": "" + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "incrementSize", + "value": "number", + "description": "" + }, + { + "filePath": "/Image.tsx", + "syntaxKind": "PropertySignature", + "name": "placeholderWidth", + "value": "number", + "description": "" + } + ] + }, + "HtmlImageProps": { + "filePath": "/Image.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "HtmlImageProps", + "value": "React.DetailedHTMLProps<\n React.ImgHTMLAttributes,\n HTMLImageElement\n>", + "description": "" } } } @@ -3596,14 +3654,14 @@ "filePath": "/MediaFile.tsx", "syntaxKind": "TypeAliasDeclaration", "name": "MediaOptions", - "value": "{\n /** Props that will only apply when an `` is rendered */\n image?: Omit;\n /** Props that will only apply when a `