Skip to content

Commit a94f127

Browse files
committed
Add responsive image srcset support
Let browsers pick optimal image resolution based on viewport and device pixel ratio, improving quality on retina/4K displays while avoiding wasted bandwidth on mobile devices. Backgrounds get medium/large/ultra variants. Inline images use a tiered approach based on content element width with sizes hints matching the layout breakpoints. Small elements (<md) keep using medium only since it covers even 3x devices. Gated behind an image_srcset feature flag (enabled by default) for easy rollback per account. REDMINE-21238
1 parent bf1a784 commit a94f127

File tree

8 files changed

+329
-5
lines changed

8 files changed

+329
-5
lines changed

entry_types/scrolled/config/locales/de.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ de:
22
pageflow:
33
datawrapper_chart_embed_opt_in:
44
feature_name: Opt-in für Datawrapper Embeds
5+
image_srcset:
6+
feature_name: Responsive Bild-Srcset
57
defaultNavigation:
68
widget_type_name: Standard Navigation
79
editor:

entry_types/scrolled/config/locales/en.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ en:
22
pageflow:
33
datawrapper_chart_embed_opt_in:
44
feature_name: Opt-in for Datawrapper embeds
5+
image_srcset:
6+
feature_name: Responsive image srcset
57
defaultNavigation:
68
widget_type_name: Default navigation
79
editor:

entry_types/scrolled/lib/pageflow_scrolled/plugin.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ def configure(config)
196196
c.features.register('custom_palette_colors')
197197
c.features.register('decoration_effects')
198198
c.features.register('backdrop_size')
199+
c.features.register('image_srcset')
200+
c.features.enable_by_default('image_srcset')
199201

200202
c.features.register('faq_page_structured_data') do |feature_config|
201203
feature_config.entry_structured_data_types.register(

entry_types/scrolled/package/spec/contentElements/inlineImage/InlineImage-spec.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import React from 'react';
12
import 'contentElements/inlineImage/frontend';
23
import {renderContentElement, usePageObjects} from 'support/pageObjects';
4+
import {renderInContentElement} from 'pageflow-scrolled/testHelpers';
35
import '@testing-library/jest-dom/extend-expect'
46

7+
import {InlineImage} from 'contentElements/inlineImage/InlineImage';
8+
import {features} from 'pageflow/frontend';
59
import {usePortraitOrientation} from 'frontend/usePortraitOrientation';
610
jest.mock('frontend/usePortraitOrientation');
711

@@ -190,6 +194,105 @@ describe('InlineImage', () => {
190194
});
191195
});
192196

197+
describe('srcset', () => {
198+
beforeEach(() => features.enable('frontend', ['image_srcset']));
199+
afterEach(() => features.enabledFeatureNames = []);
200+
201+
function renderInlineImage({contentElementWidth = 0, ...seedOptions} = {}) {
202+
const result = renderInContentElement(
203+
<InlineImage contentElementId={42}
204+
contentElementWidth={contentElementWidth}
205+
configuration={{id: 100}} />,
206+
{seed: seedOptions}
207+
);
208+
result.simulateScrollPosition('near viewport');
209+
return result;
210+
}
211+
212+
it('uses medium and large srcset for default width', () => {
213+
const {getByRole} = renderInlineImage({
214+
imageFileUrlTemplates: {
215+
medium: ':id_partition/medium/image.jpg',
216+
large: ':id_partition/large/image.jpg'
217+
},
218+
imageFiles: [{permaId: 100, id: 1, width: 200, height: 100}]
219+
});
220+
221+
expect(getByRole('img')).toHaveAttribute('srcset',
222+
'000/000/001/medium/image.jpg 1024w, 000/000/001/large/image.jpg 1920w');
223+
expect(getByRole('img')).toHaveAttribute('sizes',
224+
'(min-width: 950px) 950px, 100vw');
225+
});
226+
227+
it('uses medium, large and ultra srcset for full width', () => {
228+
const {getByRole} = renderInlineImage({
229+
contentElementWidth: 3,
230+
imageFileUrlTemplates: {
231+
medium: ':id_partition/medium/image.jpg',
232+
large: ':id_partition/large/image.jpg',
233+
ultra: ':id_partition/ultra/image.jpg'
234+
},
235+
imageFiles: [{permaId: 100, id: 1, width: 200, height: 100}]
236+
});
237+
238+
expect(getByRole('img')).toHaveAttribute('srcset',
239+
'000/000/001/medium/image.jpg 1024w, ' +
240+
'000/000/001/large/image.jpg 1920w, ' +
241+
'000/000/001/ultra/image.jpg 3840w');
242+
expect(getByRole('img')).toHaveAttribute('sizes', '100vw');
243+
});
244+
245+
it('uses medium, large and ultra srcset with 1200px sizes for xl width', () => {
246+
const {getByRole} = renderInlineImage({
247+
contentElementWidth: 2,
248+
imageFileUrlTemplates: {
249+
medium: ':id_partition/medium/image.jpg',
250+
large: ':id_partition/large/image.jpg',
251+
ultra: ':id_partition/ultra/image.jpg'
252+
},
253+
imageFiles: [{permaId: 100, id: 1, width: 200, height: 100}]
254+
});
255+
256+
expect(getByRole('img')).toHaveAttribute('srcset',
257+
'000/000/001/medium/image.jpg 1024w, ' +
258+
'000/000/001/large/image.jpg 1920w, ' +
259+
'000/000/001/ultra/image.jpg 3840w');
260+
expect(getByRole('img')).toHaveAttribute('sizes',
261+
'(min-width: 950px) 1200px, 100vw');
262+
});
263+
264+
it('uses plain medium variant for small widths', () => {
265+
const {getByRole} = renderInlineImage({
266+
contentElementWidth: -1,
267+
imageFileUrlTemplates: {
268+
medium: ':id_partition/medium/image.jpg',
269+
large: ':id_partition/large/image.jpg'
270+
},
271+
imageFiles: [{permaId: 100, id: 1, width: 200, height: 100}]
272+
});
273+
274+
expect(getByRole('img')).not.toHaveAttribute('srcset');
275+
expect(getByRole('img')).toHaveAttribute('src',
276+
'000/000/001/medium/image.jpg');
277+
});
278+
279+
it('falls back to original behavior when feature is disabled', () => {
280+
features.enabledFeatureNames = [];
281+
282+
const {getByRole} = renderInlineImage({
283+
imageFileUrlTemplates: {
284+
medium: ':id_partition/medium/image.jpg',
285+
large: ':id_partition/large/image.jpg'
286+
},
287+
imageFiles: [{permaId: 100, id: 1, width: 200, height: 100}]
288+
});
289+
290+
expect(getByRole('img')).not.toHaveAttribute('srcset');
291+
expect(getByRole('img')).toHaveAttribute('src',
292+
'000/000/001/medium/image.jpg');
293+
});
294+
});
295+
193296
describe('basic functionality', () => {
194297
it('renders with FitViewport and ContentElementBox', () => {
195298
const {getContentElement} = renderContentElement({

entry_types/scrolled/package/spec/frontend/Image-spec.js

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,155 @@ describe('Image', () => {
359359
expect(getByRole('img').hasAttribute('alt')).toBe(true);
360360
});
361361

362+
it('renders srcset with width descriptors for array variant', () => {
363+
const {getByRole} = renderInEntry(
364+
() => <Image imageFile={useFile({collectionName: 'imageFiles', permaId: 100})}
365+
variant={['large', 'ultra']} />,
366+
{
367+
seed: {
368+
imageFileUrlTemplates: {
369+
large: ':id_partition/large/image.jpg',
370+
ultra: ':id_partition/ultra/image.jpg'
371+
},
372+
imageFiles: [
373+
{id: 1, permaId: 100}
374+
]
375+
}
376+
}
377+
);
378+
379+
expect(getByRole('img')).toHaveAttribute('src', '000/000/001/large/image.jpg');
380+
expect(getByRole('img')).toHaveAttribute('srcset',
381+
'000/000/001/large/image.jpg 1920w, 000/000/001/ultra/image.jpg 3840w');
382+
expect(getByRole('img')).toHaveAttribute('sizes', '100vw');
383+
});
384+
385+
it('passes custom sizes prop through', () => {
386+
const {getByRole} = renderInEntry(
387+
() => <Image imageFile={useFile({collectionName: 'imageFiles', permaId: 100})}
388+
variant={['large', 'ultra']}
389+
sizes="(min-width: 960px) 50vw, 100vw" />,
390+
{
391+
seed: {
392+
imageFileUrlTemplates: {
393+
large: ':id_partition/large/image.jpg',
394+
ultra: ':id_partition/ultra/image.jpg'
395+
},
396+
imageFiles: [
397+
{id: 1, permaId: 100}
398+
]
399+
}
400+
}
401+
);
402+
403+
expect(getByRole('img')).toHaveAttribute('sizes', '(min-width: 960px) 50vw, 100vw');
404+
});
405+
406+
it('filters unavailable variants from srcset', () => {
407+
const {getByRole} = renderInEntry(
408+
() => <Image imageFile={useFile({collectionName: 'imageFiles', permaId: 100})}
409+
variant={['large', 'ultra']} />,
410+
{
411+
seed: {
412+
imageFileUrlTemplates: {
413+
large: ':id_partition/large/image.jpg',
414+
ultra: ':id_partition/ultra/image.jpg'
415+
},
416+
imageFiles: [
417+
{id: 1, permaId: 100, variants: ['large']}
418+
]
419+
}
420+
}
421+
);
422+
423+
expect(getByRole('img')).toHaveAttribute('src', '000/000/001/large/image.jpg');
424+
expect(getByRole('img')).not.toHaveAttribute('srcset');
425+
expect(getByRole('img')).not.toHaveAttribute('sizes');
426+
});
427+
428+
it('treats single-element array variant like string variant', () => {
429+
const {getByRole} = renderInEntry(
430+
() => <Image imageFile={useFile({collectionName: 'imageFiles', permaId: 100})}
431+
variant={['large']} />,
432+
{
433+
seed: {
434+
imageFileUrlTemplates: {
435+
large: ':id_partition/large/image.jpg'
436+
},
437+
imageFiles: [
438+
{id: 1, permaId: 100}
439+
]
440+
}
441+
}
442+
);
443+
444+
expect(getByRole('img')).toHaveAttribute('src', '000/000/001/large/image.jpg');
445+
expect(getByRole('img')).not.toHaveAttribute('srcset');
446+
});
447+
448+
it('skips srcset for SVG when preferSvg is true', () => {
449+
const {getByRole} = renderInEntry(
450+
() => <Image imageFile={useFile({collectionName: 'imageFiles', permaId: 100})}
451+
variant={['large', 'ultra']}
452+
preferSvg={true} />,
453+
{
454+
seed: {
455+
imageFileUrlTemplates: {
456+
original: ':id_partition/original/:basename.:extension',
457+
large: ':id_partition/large/image.jpg',
458+
ultra: ':id_partition/ultra/image.jpg'
459+
},
460+
imageFiles: [
461+
{id: 1, permaId: 100, basename: 'image', extension: 'svg'}
462+
]
463+
}
464+
}
465+
);
466+
467+
expect(getByRole('img')).toHaveAttribute('src', '000/000/001/original/image.svg');
468+
expect(getByRole('img')).not.toHaveAttribute('srcset');
469+
});
470+
471+
it('does not render srcset for string variant', () => {
472+
const {getByRole} = renderInEntry(
473+
() => <Image imageFile={useFile({collectionName: 'imageFiles', permaId: 100})}
474+
variant="large" />,
475+
{
476+
seed: {
477+
imageFileUrlTemplates: {
478+
large: ':id_partition/large/image.jpg'
479+
},
480+
imageFiles: [
481+
{id: 1, permaId: 100}
482+
]
483+
}
484+
}
485+
);
486+
487+
expect(getByRole('img')).not.toHaveAttribute('srcset');
488+
});
489+
490+
it('uses correct width descriptors for medium and large array variant', () => {
491+
const {getByRole} = renderInEntry(
492+
() => <Image imageFile={useFile({collectionName: 'imageFiles', permaId: 100})}
493+
variant={['medium', 'large']} />,
494+
{
495+
seed: {
496+
imageFileUrlTemplates: {
497+
medium: ':id_partition/medium/image.jpg',
498+
large: ':id_partition/large/image.jpg'
499+
},
500+
imageFiles: [
501+
{id: 1, permaId: 100}
502+
]
503+
}
504+
}
505+
);
506+
507+
expect(getByRole('img')).toHaveAttribute('srcset',
508+
'000/000/001/medium/image.jpg 1024w, 000/000/001/large/image.jpg 1920w');
509+
});
510+
362511
it('supports width and height attributes', () => {
363512
const {getByRole} = renderInEntry(
364513
() => <Image imageFile={useFile({collectionName: 'imageFiles', permaId: 100})}

entry_types/scrolled/package/src/contentElements/inlineImage/InlineImage.js

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ExpandableImage,
1313
InlineFileRights
1414
} from 'pageflow-scrolled/frontend';
15+
import {features} from 'pageflow/frontend';
1516

1617
export function InlineImage({contentElementId, contentElementWidth, configuration}) {
1718

@@ -102,8 +103,7 @@ function ImageWithCaption({
102103
<Image imageFile={imageFile}
103104
load={shouldLoad}
104105
structuredData={true}
105-
variant={contentElementWidth === contentElementWidths.full ?
106-
'large' : 'medium'}
106+
{...imageVariantAndSizes(contentElementWidth)}
107107
preferSvg={true} />
108108
</ExpandableImage>
109109
</ContentElementBox>
@@ -120,6 +120,31 @@ function ImageWithCaption({
120120
);
121121
}
122122

123+
function imageVariantAndSizes(contentElementWidth) {
124+
if (!features.isEnabled('image_srcset')) {
125+
return {
126+
variant: contentElementWidth === contentElementWidths.full ? 'large' : 'medium'
127+
};
128+
}
129+
130+
if (contentElementWidth >= contentElementWidths.xl) {
131+
return {
132+
variant: ['medium', 'large', 'ultra'],
133+
sizes: contentElementWidth === contentElementWidths.full ?
134+
'100vw' : '(min-width: 950px) 1200px, 100vw'
135+
};
136+
}
137+
138+
if (contentElementWidth >= contentElementWidths.md) {
139+
return {
140+
variant: ['medium', 'large'],
141+
sizes: '(min-width: 950px) 950px, 100vw'
142+
};
143+
}
144+
145+
return {variant: 'medium'};
146+
}
147+
123148
function processImageModifiers(imageModifiers) {
124149
const cropValue = getModiferValue(imageModifiers, 'crop');
125150
const isCircleCrop = cropValue === 'circle';

0 commit comments

Comments
 (0)