Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions __tests__/client/blocks/InlineMedia.client.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<InlineMediaComponent media={media} size={size} />)
expect(container.firstElementChild).toHaveClass(expectedClass)
})

it('renders original at natural size', () => {
const { container } = render(<InlineMediaComponent media={media} size="original" />)
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(<InlineMediaComponent media={media} size={size} />)
expect(mockMedia).toHaveBeenCalledWith(expect.objectContaining({ sizes: 'auto' }))
},
)

it('renders a fixed-height image with sizes="auto"', () => {
render(<InlineMediaComponent media={media} size="fixed-height" fixedHeight={120} />)
expect(mockMedia).toHaveBeenCalledWith(expect.objectContaining({ sizes: 'auto' }))
})

it('renders nothing when the media relationship is unresolved', () => {
const { container } = render(<InlineMediaComponent media={1} size="50" />)
expect(container).toBeEmptyDOMElement()
expect(mockMedia).not.toHaveBeenCalled()
})
})
67 changes: 67 additions & 0 deletions __tests__/client/blocks/MediaBlock.client.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof MediaBlockComponent>

beforeEach(() => mockMedia.mockClear())

function renderBlock(imageSize?: Props['imageSize']) {
const { container } = render(
<MediaBlockComponent
blockType="mediaBlock"
media={1}
isLayoutBlock={false}
backgroundColor="brand-100"
imageSize={imageSize}
/>,
)
// 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' }))
},
)
})
7 changes: 3 additions & 4 deletions src/blocks/InlineMedia/Component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,18 @@ 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') {
sizeClass = 'max-w-fit'
} 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
Expand Down
34 changes: 9 additions & 25 deletions src/blocks/Media/Component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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':
Expand All @@ -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 (
<div className={cn(bgColorClass, textColor)}>
<div
className={cn(
isLayoutBlock && 'container py-10',
'@container',
'flex flex-col',
alignContent === 'left' && 'items-start',
alignContent === 'center' && 'items-center',
Expand All @@ -99,7 +83,7 @@ export const MediaBlockComponent = (props: Props) => {
)}
resource={media}
src={staticImage}
sizes={getSizesForImageSize()}
sizes="auto"
/>
{caption && (
<div className={captionClassName}>
Expand Down
38 changes: 38 additions & 0 deletions src/endpoints/seed/blocks/media-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions src/fields/imageSize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
2 changes: 1 addition & 1 deletion src/payload-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading