diff --git a/__tests__/client/blocks/InlineMedia.client.test.tsx b/__tests__/client/blocks/InlineMedia.client.test.tsx new file mode 100644 index 00000000..3b14017a --- /dev/null +++ b/__tests__/client/blocks/InlineMedia.client.test.tsx @@ -0,0 +1,52 @@ +import { InlineMediaComponent } from '@/blocks/InlineMedia/Component' +import type { Media } from '@/payload-types' +import '@testing-library/jest-dom' +import { render } from '@testing-library/react' + +// Stand in for the next/image-backed Media component and record the props it receives. +const mockMedia = jest.fn((_props: { sizes?: string }) => null) + +jest.mock('../../../src/components/Media', () => ({ + Media: (props: { sizes?: string }) => mockMedia(props), +})) + +// InlineMedia only renders when `media` is a resolved object. +const media: Media = { id: 1, tenant: 1, alt: 'test', updatedAt: '', createdAt: '' } + +beforeEach(() => mockMedia.mockClear()) + +describe('InlineMediaComponent', () => { + it.each([ + ['25', 'w-1/4'], + ['50', 'w-1/2'], + ['75', 'w-3/4'], + ['100', 'w-full'], + ] as const)('sizes the %s%% block to a container fraction (%s)', (size, expectedClass) => { + const { container } = render() + expect(container.firstElementChild).toHaveClass(expectedClass) + }) + + it('renders original at natural size', () => { + const { container } = render() + expect(container.firstElementChild).toHaveClass('max-w-fit') + }) + + it.each(['25', '100', 'original'] as const)( + 'requests resolution from the rendered width via sizes="auto" (%s)', + (size) => { + render() + expect(mockMedia).toHaveBeenCalledWith(expect.objectContaining({ sizes: 'auto' })) + }, + ) + + it('renders a fixed-height image with sizes="auto"', () => { + render() + expect(mockMedia).toHaveBeenCalledWith(expect.objectContaining({ sizes: 'auto' })) + }) + + it('renders nothing when the media relationship is unresolved', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + expect(mockMedia).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/client/blocks/MediaBlock.client.test.tsx b/__tests__/client/blocks/MediaBlock.client.test.tsx new file mode 100644 index 00000000..f1d083ae --- /dev/null +++ b/__tests__/client/blocks/MediaBlock.client.test.tsx @@ -0,0 +1,67 @@ +import { MediaBlockComponent } from '@/blocks/Media/Component' +import '@testing-library/jest-dom' +import { render } from '@testing-library/react' +import { ComponentProps } from 'react' + +// Stand in for the next/image-backed Media component and record the props it receives. +const mockMedia = jest.fn((_props: { sizes?: string }) => null) + +jest.mock('../../../src/components/Media', () => ({ + Media: (props: { sizes?: string }) => mockMedia(props), +})) + +jest.mock('../../../src/components/RichText', () => ({ + __esModule: true, + default: () => null, +})) + +type Props = ComponentProps + +beforeEach(() => mockMedia.mockClear()) + +function renderBlock(imageSize?: Props['imageSize']) { + const { container } = render( + , + ) + // The size class lives on the div that wraps the image (the only `.gap-2` element). + return container.querySelector('.gap-2') +} + +describe('MediaBlockComponent', () => { + it.each([ + ['xsmall', 'max-w-[max(12rem,33cqw)]'], + ['small', 'max-w-[max(16rem,50cqw)]'], + ['medium', 'max-w-[max(20rem,75cqw)]'], + ['large', 'max-w-[max(24rem,90cqw)]'], + ['full', 'max-w-full'], + ] as const)('sizes the %s block relative to its container (%s)', (imageSize, expectedClass) => { + const sizeWrapper = renderBlock(imageSize) + expect(sizeWrapper).toHaveClass(expectedClass) + // non-original sizes fill the container up to their cap + expect(sizeWrapper).toHaveClass('w-full') + }) + + it('renders original at natural size without filling the container', () => { + const sizeWrapper = renderBlock('original') + expect(sizeWrapper).toHaveClass('max-w-fit') + expect(sizeWrapper).not.toHaveClass('w-full') + }) + + it('defaults to original when no size is set', () => { + expect(renderBlock(undefined)).toHaveClass('max-w-fit') + }) + + it.each(['xsmall', 'small', 'medium', 'large', 'full', 'original'] as const)( + 'requests resolution from the rendered width via sizes="auto" (%s)', + (imageSize) => { + renderBlock(imageSize) + expect(mockMedia).toHaveBeenCalledWith(expect.objectContaining({ sizes: 'auto' })) + }, + ) +}) diff --git a/src/blocks/InlineMedia/Component.tsx b/src/blocks/InlineMedia/Component.tsx index 88d32bcb..0897c690 100644 --- a/src/blocks/InlineMedia/Component.tsx +++ b/src/blocks/InlineMedia/Component.tsx @@ -42,7 +42,9 @@ export const InlineMediaComponent = ({ let sizeClass = '' let imgSizeClass = 'w-auto h-auto' - let sizes = '100vw' + // Images are lazy-loaded, so `sizes="auto"` lets the browser pick the resolution + // from the actual rendered width set by the width/height classes below. + const sizes = 'auto' const isFixedHeight = resolvedSize === 'fixed-height' && fixedHeight if (resolvedSize === 'original') { @@ -50,11 +52,8 @@ export const InlineMediaComponent = ({ } else if (isWidthSize(resolvedSize)) { sizeClass = widthClasses[resolvedSize] imgSizeClass = 'w-full h-auto' - // Approximate sizes hint for responsive images - sizes = `${resolvedSize}vw` } else if (isFixedHeight) { imgSizeClass = 'h-full w-auto' - sizes = '96px' } const positionClasses = isFloat diff --git a/src/blocks/Media/Component.tsx b/src/blocks/Media/Component.tsx index fe16d260..87335664 100644 --- a/src/blocks/Media/Component.tsx +++ b/src/blocks/Media/Component.tsx @@ -6,11 +6,8 @@ import { cn } from '@/utilities/ui' import type { MediaBlock as MediaBlockProps } from '@/payload-types' import { Media } from '@/components/Media' -import { cssVariables } from '@/cssVariables' import getTextColorFromBgColor from '@/utilities/getTextColorFromBgColor' -const { breakpoints } = cssVariables - type Props = MediaBlockProps & { isLayoutBlock: boolean captionClassName?: string @@ -36,14 +33,18 @@ export const MediaBlockComponent = (props: Props) => { const bgColorClass = `bg-${backgroundColor}` const textColor = getTextColorFromBgColor(backgroundColor) + // `cqw` sizes the image relative to the block's `@container`; the browser reads the + // rendered width via `sizes="auto"` (images are lazy-loaded) to pick the resolution. const getImageSizeClasses = () => { switch (imageSize) { + case 'xsmall': + return 'max-w-[max(12rem,33cqw)]' case 'small': - return 'max-w-xs md:max-w-sm lg:max-w-md' + return 'max-w-[max(16rem,50cqw)]' case 'medium': - return 'max-w-sm md:max-w-lg lg:max-w-2xl' + return 'max-w-[max(20rem,75cqw)]' case 'large': - return 'max-w-md md:max-w-2xl lg:max-w-4xl' + return 'max-w-[max(24rem,90cqw)]' case 'full': return 'max-w-full' case 'original': @@ -52,29 +53,12 @@ export const MediaBlockComponent = (props: Props) => { } } - // sizes prop hints to browser what image width to request based on imageSize setting - // Uses breakpoints from cssVariables for consistency with other image components - const getSizesForImageSize = () => { - switch (imageSize) { - case 'small': - return `(max-width: ${breakpoints.md}px) 100vw, 384px` // max-w-sm = 24rem = 384px - case 'medium': - return `(max-width: ${breakpoints.md}px) 100vw, 672px` // max-w-2xl = 42rem = 672px - case 'large': - return `(max-width: ${breakpoints.md}px) 100vw, 896px` // max-w-4xl = 56rem = 896px - case 'full': - return '100vw' - case 'original': - default: - return '100vw' - } - } - return (
{ )} resource={media} src={staticImage} - sizes={getSizesForImageSize()} + sizes="auto" /> {caption && (
diff --git a/src/endpoints/seed/blocks/media-blocks.ts b/src/endpoints/seed/blocks/media-blocks.ts index 9e97e595..0d0131c9 100644 --- a/src/endpoints/seed/blocks/media-blocks.ts +++ b/src/endpoints/seed/blocks/media-blocks.ts @@ -2,6 +2,44 @@ import type { Media } from '@/payload-types' import { RequiredDataFromCollectionSlug } from 'payload' export const mediaBlocks = (image: Media): RequiredDataFromCollectionSlug<'pages'>['layout'] => [ + { + media: image.id, + caption: { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'An extra-small media block with left alignment', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: 'start', + indent: 0, + type: 'paragraph', + version: 1, + textFormat: 0, + textStyle: '', + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }, + backgroundColor: 'brand-100', + alignContent: 'left', + imageSize: 'xsmall', + blockType: 'mediaBlock', + }, { media: image.id, caption: { diff --git a/src/fields/imageSize.ts b/src/fields/imageSize.ts index ea75dd4e..f12d8c15 100644 --- a/src/fields/imageSize.ts +++ b/src/fields/imageSize.ts @@ -8,6 +8,7 @@ export const imageSizeField: (label: string) => Field = (label) => ({ defaultValue: 'original', options: [ { label: 'Original (Natural size)', value: 'original' }, + { label: 'Extra small', value: 'xsmall' }, { label: 'Small', value: 'small' }, { label: 'Medium', value: 'medium' }, { label: 'Large', value: 'large' }, diff --git a/src/payload-types.ts b/src/payload-types.ts index f2d0a2ea..ce769d49 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -1427,7 +1427,7 @@ export interface MediaBlock { /** * Controls the maximum width of the image with responsive behavior. Original uses the image's natural size. Sizes automatically adapt for different screen sizes. */ - imageSize?: ('original' | 'small' | 'medium' | 'large' | 'full') | null; + imageSize?: ('original' | 'xsmall' | 'small' | 'medium' | 'large' | 'full') | null; id?: string | null; blockName?: string | null; blockType: 'mediaBlock';