diff --git a/messages/alt-must-be-an-empty-string.md b/messages/alt-must-be-an-empty-string.md new file mode 100644 index 0000000..93f3c83 --- /dev/null +++ b/messages/alt-must-be-an-empty-string.md @@ -0,0 +1,28 @@ +# `alt` must be an empty string + +`` allows the the `alt` HTML attribute to be configured using two props: `alt` and `fallbackAlt`. + +Both `alt` and `fallbackAlt` can only be used to [declare an image as decorative][mdn-alt-decorative-image] by pasing an empty string. You may not pass arbitrary alternative text to the `alt` prop. + +```tsx +// Will render `alt` using the Image field's `alt` property. + + +// Will always render `alt=""`. + + +// Will render `alt=""` only if the Image field's alt property is empty. + +``` + +All images should have an alt value. `` will automatically use the Image field's `alt` property written in the Prismic Editor. If the Image field's `alt` property is empty, the `alt` HTML attribute will not be included _unless_ one of `alt` or `fallbackAlt` is used. + +For more details on decorative images, [see the MDN article on the `` HTMl element's `alt` attribute][mdn-alt-decorative-image]. + +## Deciding between `alt=""` and `fallbackAlt=""` + +`alt=""` will always mark the image as decorative, ignoring the provied Image field's `alt` property. Use this when the image is always used for decorative or presentational purposes. + +`fallbackAlt=""` will only mark the image as decorative if the provided Image field's `alt` property is empty. Use this when you want to mark the image as decorative _unless_ alternative text is provided in the Prismic Editor. **Generally speaking, this is discouraged**; prefer marking the image as decorative intentionally. + +[mdn-alt-decorative-image]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/alt#decorative_images diff --git a/package-lock.json b/package-lock.json index dacefda..2b23c33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,12 @@ "version": "2.2.0", "license": "Apache-2.0", "dependencies": { - "@prismicio/helpers": "^2.2.1", + "@prismicio/helpers": "^2.3.0", "@prismicio/richtext": "^2.0.1" }, "devDependencies": { "@prismicio/client": "^6.4.2", - "@prismicio/mock": "^0.0.9", + "@prismicio/mock": "^0.0.10", "@prismicio/types": "^0.1.27", "@size-limit/preset-small-lib": "^7.0.8", "@testing-library/react": "^12.1.4", @@ -720,9 +720,9 @@ } }, "node_modules/@prismicio/helpers": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@prismicio/helpers/-/helpers-2.2.1.tgz", - "integrity": "sha512-WFfS5CpPJ1PTNU/cqx3XnO8mnbfI8bEgAnWqD57IS5Pqy8YcK8oW5VSR7nw2wPowJjWTuf8j59YtE5uiHi6aCQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@prismicio/helpers/-/helpers-2.3.0.tgz", + "integrity": "sha512-/3YJ3F7bN56Pn9y2B4a3t0y4QD0zVPqaHf0Rzd5jCA7Bf0wi3oQ35KHGmP3DrYzz16aQNOZgQy/IO/80QsOYTg==", "dependencies": { "@prismicio/richtext": "^2.0.1", "@prismicio/types": "^0.1.27", @@ -734,9 +734,9 @@ } }, "node_modules/@prismicio/mock": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@prismicio/mock/-/mock-0.0.9.tgz", - "integrity": "sha512-X1K/Sn1R8GvobqPNq+1Nz37SNhJTxnNwaloL5mqOcQ2wD5cyz8r+9Qx7B25fsPgK8X9DMHxMWGSBgfT42GHb7A==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@prismicio/mock/-/mock-0.0.10.tgz", + "integrity": "sha512-ymipTNdjy8mU5Kf7BcbVV0PIrge6yfnxEPgB1+uyxh2Z2mabI0S3f/2yLxp5prMNYlpH5Q7jj8LmzviIouKgFQ==", "dev": true, "dependencies": { "@prismicio/types": "^0.1.27", @@ -11833,9 +11833,9 @@ } }, "@prismicio/helpers": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@prismicio/helpers/-/helpers-2.2.1.tgz", - "integrity": "sha512-WFfS5CpPJ1PTNU/cqx3XnO8mnbfI8bEgAnWqD57IS5Pqy8YcK8oW5VSR7nw2wPowJjWTuf8j59YtE5uiHi6aCQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@prismicio/helpers/-/helpers-2.3.0.tgz", + "integrity": "sha512-/3YJ3F7bN56Pn9y2B4a3t0y4QD0zVPqaHf0Rzd5jCA7Bf0wi3oQ35KHGmP3DrYzz16aQNOZgQy/IO/80QsOYTg==", "requires": { "@prismicio/richtext": "^2.0.1", "@prismicio/types": "^0.1.27", @@ -11844,9 +11844,9 @@ } }, "@prismicio/mock": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@prismicio/mock/-/mock-0.0.9.tgz", - "integrity": "sha512-X1K/Sn1R8GvobqPNq+1Nz37SNhJTxnNwaloL5mqOcQ2wD5cyz8r+9Qx7B25fsPgK8X9DMHxMWGSBgfT42GHb7A==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@prismicio/mock/-/mock-0.0.10.tgz", + "integrity": "sha512-ymipTNdjy8mU5Kf7BcbVV0PIrge6yfnxEPgB1+uyxh2Z2mabI0S3f/2yLxp5prMNYlpH5Q7jj8LmzviIouKgFQ==", "dev": true, "requires": { "@prismicio/types": "^0.1.27", diff --git a/package.json b/package.json index cf7dee3..aabd681 100644 --- a/package.json +++ b/package.json @@ -46,12 +46,12 @@ "unit": "nyc --reporter=lcovonly --reporter=text --exclude-after-remap=false ava" }, "dependencies": { - "@prismicio/helpers": "^2.2.1", + "@prismicio/helpers": "^2.3.0", "@prismicio/richtext": "^2.0.1" }, "devDependencies": { "@prismicio/client": "^6.4.2", - "@prismicio/mock": "^0.0.9", + "@prismicio/mock": "^0.0.10", "@prismicio/types": "^0.1.27", "@size-limit/preset-small-lib": "^7.0.8", "@testing-library/react": "^12.1.4", diff --git a/src/PrismicImage.tsx b/src/PrismicImage.tsx new file mode 100644 index 0000000..68259d9 --- /dev/null +++ b/src/PrismicImage.tsx @@ -0,0 +1,185 @@ +import * as React from "react"; +import * as prismicT from "@prismicio/types"; +import * as prismicH from "@prismicio/helpers"; + +import { __PRODUCTION__ } from "./lib/__PRODUCTION__"; +import { devMsg } from "./lib/devMsg"; + +/** + * Props for ``. + */ +export type PrismicImageProps = Omit< + React.DetailedHTMLProps< + React.ImgHTMLAttributes, + HTMLImageElement + >, + "src" | "srcset" | "alt" +> & { + /** + * The Prismic Image field or thumbnail to render. + */ + field: prismicT.ImageFieldImage | null | undefined; + + /** + * An object of Imgix URL API parameters to transform the image. + * + * See: https://docs.imgix.com/apis/rendering + */ + imgixParams?: Parameters[1]; + + /** + * Declare an image as decorative by providing `alt=""`. + * + * See: + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/alt#decorative_images + */ + alt?: ""; + + /** + * Declare an image as decorative only if the Image field does not have + * alternative text by providing `fallbackAlt=""`. + * + * See: + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/alt#decorative_images + */ + fallbackAlt?: ""; +} & ( + | { + /** + * Widths used to build a `srcset` value for the Image field. + * + * If a `widths` prop is not given or `"defaults"` is passed, the + * following widths will be used: 640, 750, 828, 1080, 1200, 1920, 2048, 3840. + * + * If the Image field contains responsive views, each responsive view + * can be used as a width in the resulting `srcset` by passing + * `"thumbnails"` as the `widths` prop. + */ + widths?: + | NonNullable< + Parameters[1] + >["widths"] + | "defaults"; + /** + * Not used when the `widths` prop is used. + */ + pixelDensities?: never; + } + | { + /** + * Not used when the `widths` prop is used. + */ + widths?: never; + /** + * Pixel densities used to build a `srcset` value for the Image field. + * + * If a `pixelDensities` prop is passed `"defaults"`, the following + * pixel densities will be used: 1, 2, 3. + */ + pixelDensities: + | NonNullable< + Parameters[1] + >["pixelDensities"] + | "defaults"; + } + ); + +const _PrismicImage = ( + props: PrismicImageProps, + ref: React.ForwardedRef, +): JSX.Element | null => { + const { + field, + alt, + fallbackAlt, + imgixParams, + widths, + pixelDensities, + ...restProps + } = props; + + if (!__PRODUCTION__) { + if (typeof alt === "string" && props.alt !== "") { + console.warn( + `[PrismicImage] The alt prop can only be used to declare an image as decorative by passing an empty string (alt=""). For more details, see ${devMsg( + "alt-must-be-an-empty-string", + )}`, + ); + } + + if (typeof fallbackAlt === "string" && fallbackAlt !== "") { + console.warn( + `[PrismicImage] The fallbackAlt prop can only be used to declare an image as decorative by passing an empty string (fallbackAlt=""). For more details, see ${devMsg( + "alt-must-be-an-empty-string", + )}`, + ); + } + + if (widths && pixelDensities) { + console.warn( + `[PrismicImage] Only one of "widths" or "pixelDensities" props can be provided. "widths" will be used in this case.`, + ); + } + } + + if (prismicH.isFilled.imageThumbnail(field)) { + let src: string | undefined; + let srcSet: string | undefined; + + if (widths || !pixelDensities) { + const res = prismicH.asImageWidthSrcSet(field, { + ...imgixParams, + widths: widths === "defaults" ? undefined : widths, + }); + + src = res.src; + srcSet = res.srcset; + } else if (pixelDensities) { + const res = prismicH.asImagePixelDensitySrcSet(field, { + ...imgixParams, + pixelDensities: + pixelDensities === "defaults" ? undefined : pixelDensities, + }); + + src = res.src; + srcSet = res.srcset; + } + + return ( + {alt + ); + } else { + return null; + } +}; + +if (!__PRODUCTION__) { + _PrismicImage.displayName = "PrismicImage"; +} + +/** + * React component that renders an image from a Prismic Image field or one of + * its thumbnails. It will automatically set the `alt` attribute using the Image + * field's `alt` property. + * + * By default, a widths-based srcset will be used to support responsive images. + * This ensures only the smallest image needed for a browser is downloaded. + * + * To use a pixel-density-based srcset, use the `pixelDensities` prop. Default + * pixel densities can be used by using `pixelDensities="defaults"`. + * + * **Note**: If you are using a framework that has a native image component, + * such as Next.js and Gatsby, prefer using those image components instead. They + * can provide deeper framework integration than ``. + * + * @param props - Props for the component. + * + * @returns A responsive image component for the given Image field. + */ +export const PrismicImage = React.forwardRef(_PrismicImage); diff --git a/src/PrismicLink.tsx b/src/PrismicLink.tsx index f51d480..1ef7660 100644 --- a/src/PrismicLink.tsx +++ b/src/PrismicLink.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import * as prismicH from "@prismicio/helpers"; import * as prismicT from "@prismicio/types"; +import { __PRODUCTION__ } from "./lib/__PRODUCTION__"; import { isInternalURL } from "./lib/isInternalURL"; import { usePrismicContext } from "./usePrismicContext"; @@ -118,21 +119,6 @@ const defaultInternalComponent = "a"; */ const defaultExternalComponent = "a"; -/** - * React component that renders a link from a Prismic Link field. - * - * Different components can be rendered depending on whether the link is - * internal or external. This is helpful when integrating with client-side - * routers, such as a router-specific Link component. - * - * If a link is configured to open in a new window using `target="_blank"`, - * `rel="noopener noreferrer"` is set by default. - * - * @param props - Props for the component. - * - * @returns The internal or external link component depending on whether the - * link is internal or external. - */ const _PrismicLink = < InternalComponent extends React.ElementType, ExternalComponent extends React.ElementType, @@ -215,6 +201,25 @@ const _PrismicLink = < ) : null; }; +if (!__PRODUCTION__) { + _PrismicLink.displayName = "PrismicLink"; +} + +/** + * React component that renders a link from a Prismic Link field. + * + * Different components can be rendered depending on whether the link is + * internal or external. This is helpful when integrating with client-side + * routers, such as a router-specific Link component. + * + * If a link is configured to open in a new window using `target="_blank"`, + * `rel="noopener noreferrer"` is set by default. + * + * @param props - Props for the component. + * + * @returns The internal or external link component depending on whether the + * link is internal or external. + */ export const PrismicLink = React.forwardRef(_PrismicLink) as < InternalComponent extends React.ElementType, ExternalComponent extends React.ElementType, diff --git a/src/index.ts b/src/index.ts index 7864099..4a91196 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,9 @@ export { Element }; // TODO: Remove in v3. export const Elements = Element; +export { PrismicImage } from "./PrismicImage"; +export type { PrismicImageProps } from "./PrismicImage"; + export { SliceZone, TODOSliceComponent } from "./SliceZone"; export type { SliceComponentProps, diff --git a/src/lib/devMsg.ts b/src/lib/devMsg.ts new file mode 100644 index 0000000..957627f --- /dev/null +++ b/src/lib/devMsg.ts @@ -0,0 +1,20 @@ +import { version } from "../../package.json"; + +/** + * Returns a `prismic.dev/msg` URL for a given message slug. + * + * @example + * + * ```ts + * devMsg("missing-param"); + * // => "https://prismic.dev/msg/react/v1.2.3/missing-param.md" + * ``` + * + * @param slug - Slug for the message. This corresponds to a Markdown file in + * the Git repository's `/messages` directory. + * + * @returns The `prismic.dev/msg` URL for the given slug. + */ +export const devMsg = (slug: string) => { + return `https://prismic.dev/msg/react/v${version}/${slug}`; +}; diff --git a/test/PrismicImage.test.tsx b/test/PrismicImage.test.tsx new file mode 100644 index 0000000..a0e6829 --- /dev/null +++ b/test/PrismicImage.test.tsx @@ -0,0 +1,324 @@ +import test from "ava"; +import * as React from "react"; +import * as prismicM from "@prismicio/mock"; +import * as prismicH from "@prismicio/helpers"; +import * as sinon from "sinon"; + +import { renderJSON } from "./__testutils__/renderJSON"; + +import { PrismicImage } from "../src"; + +test("renders null when passed an empty field", async (t) => { + const field = prismicM.value.image({ + seed: t.title, + state: "empty", + }); + + const actual = renderJSON(); + + t.deepEqual(actual, null); +}); + +test("renders an img element with a width-based srcset by default", async (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + + const { src, srcset } = prismicH.asImageWidthSrcSet(field); + + const actual = renderJSON(); + const expected = renderJSON( + {field.alt, + ); + + t.deepEqual(actual, expected); +}); + +test("renders a width-based srcset with given widths", async (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + + const widths = [100, 200, 300]; + const { src, srcset } = prismicH.asImageWidthSrcSet(field, { widths }); + + const actual = renderJSON(); + const expected = renderJSON( + {field.alt, + ); + + t.deepEqual(actual, expected); +}); + +test('renders a width-based srcset with default widths if widths is "defaults"', async (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + + const { src, srcset } = prismicH.asImageWidthSrcSet(field); + + const actual = renderJSON(); + const expected = renderJSON( + {field.alt, + ); + + t.deepEqual(actual, expected); +}); + +test('renders a width-based srcset with the field\'s responsive views if widths is "thumbnails"', async (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + + const { src, srcset } = prismicH.asImageWidthSrcSet(field, { + widths: "thumbnails", + }); + + const actual = renderJSON(); + const expected = renderJSON( + {field.alt, + ); + + t.deepEqual(actual, expected); +}); + +test('renders pixel-density srcset with default densities if pixelDensities is "defaults"', async (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + + const { src, srcset } = prismicH.asImagePixelDensitySrcSet(field); + + const actual = renderJSON( + , + ); + const expected = renderJSON( + {field.alt, + ); + + t.deepEqual(actual, expected); +}); + +test("renders pixel-density srcset with the given densities", async (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + + const pixelDensities = [9, 10]; + const { src, srcset } = prismicH.asImagePixelDensitySrcSet(field, { + pixelDensities, + }); + + const actual = renderJSON( + , + ); + const expected = renderJSON( + {field.alt, + ); + + t.deepEqual(actual, expected); +}); + +test.serial("prioritizes widths prop over pixelDensities", async (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + + const widths = [100, 200, 300]; + const { src, srcset } = prismicH.asImageWidthSrcSet(field, { widths }); + + const consoleWarnStub = sinon.stub(console, "warn"); + + const actual = renderJSON( + // @ts-expect-error - Purposely giving incompatible props. + , + ); + const expected = renderJSON( + {field.alt, + ); + + consoleWarnStub.restore(); + + t.deepEqual(actual, expected); +}); + +test.serial("warns if both widths and pixelDensites are given", async (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + + const consoleWarnStub = sinon.stub(console, "warn"); + + renderJSON( + // @ts-expect-error - Purposely giving incompatible props. + , + ); + + consoleWarnStub.restore(); + + t.true( + consoleWarnStub.calledWithMatch( + /only one of "widths" or "pixelDensities" props can be provided/i, + ), + ); +}); + +test("uses the field's alt if given", async (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + + const { src, srcset } = prismicH.asImageWidthSrcSet(field); + + const actual = renderJSON(); + const expected = renderJSON( + {field.alt, + ); + + t.deepEqual(actual, expected); +}); + +test("alt is undefined if the field does not have an alt value", async (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + field.alt = null; + + const { src, srcset } = prismicH.asImageWidthSrcSet(field); + + const actual = renderJSON(); + const expected = renderJSON( + {undefined}, + ); + + t.deepEqual(actual, expected); +}); + +test("supports an explicit decorative fallback alt value if given", (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + field.alt = null; + + const { src, srcset } = prismicH.asImageWidthSrcSet(field); + + const actual = renderJSON(); + const expected = renderJSON(); + + t.deepEqual(actual, expected); +}); + +test.serial("warns if a non-decorative fallback alt value is given", (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + + const consoleWarnStub = sinon.stub(console, "warn"); + + renderJSON( + // @ts-expect-error - Purposely giving incompatible props. + , + ); + + consoleWarnStub.restore(); + + t.true(consoleWarnStub.calledWithMatch(/alt-must-be-an-empty-string/i)); +}); + +test("supports an explicit decorative alt when field has an alt value", (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + field.alt = "provided alt"; + + const { src, srcset } = prismicH.asImageWidthSrcSet(field); + + const actual = renderJSON(); + const expected = renderJSON(); + + t.deepEqual(actual, expected); +}); + +test("supports an explicit decorative alt when field does not have an alt value", (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + field.alt = null; + + const { src, srcset } = prismicH.asImageWidthSrcSet(field); + + const actual = renderJSON(); + const expected = renderJSON(); + + t.deepEqual(actual, expected); +}); + +test.serial("warns if a non-decorative alt value is given", (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + + const consoleWarnStub = sinon.stub(console, "warn"); + + renderJSON( + // @ts-expect-error - Purposely giving incompatible props. + , + ); + + consoleWarnStub.restore(); + + t.true(consoleWarnStub.calledWithMatch(/alt-must-be-an-empty-string/i)); +}); + +test("forwards ref", (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + + let ref = null as HTMLImageElement | null; + + renderJSON( (ref = el)} field={field} />, { + createNodeMock: (element) => ({ tagName: element.type }), + }); + + t.is(ref?.tagName, "img"); +}); + +test("supports imgix parameters", (t) => { + const field = prismicM.value.image({ + seed: t.title, + model: prismicM.model.image({ seed: t.title }), + }); + + const imgixParams = { sat: -100 }; + const { src, srcset } = prismicH.asImageWidthSrcSet(field, imgixParams); + + const actual = renderJSON( + , + ); + const expected = renderJSON( + {field.alt, + ); + + t.deepEqual(actual, expected); +});