Merge pull request #366 from reuters-graphics/fix-photo-pack

Fix photo pack
This commit is contained in:
Jon McClure 2025-10-24 17:54:05 +01:00 committed by GitHub
commit 10ccecf676
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 193 additions and 8 deletions

View file

@ -0,0 +1,5 @@
---
'@reuters-graphics/graphics-components': patch
---
Fixes a bug in PhotoPack that on earlier iPhones would break. Also adds smarter default layouts based on the number of images in the pack and the max width of the PhotoPack.

View file

@ -0,0 +1,16 @@
name: Notify downstream repositories
on:
release:
types: [published]
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Dispatch to bluprint_graphics-kit
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.REPO_PAT_TOKEN }}
repository: reuters-graphics/bluprint_graphics-kit
event-type: dependency-updated

View file

@ -8,9 +8,22 @@ import * as PhotoPackStories from './PhotoPack.stories.svelte';
The `PhotoPack` component makes simple photo grids with custom layouts at various breakpoints.
`images` are defined with their src, alt text, captions and an optional `maxHeight`, which ensures that the images are no taller than that height in any layout.
`images` are defined with their src, alt text, captions and an optional `maxHeight`, which ensures that an image is no taller than that height in any layout.
`layouts` describe how images will be laid out at different breakpoints. The default layout is one photo per row, stacked vertically -- i.e. mobile layout. You can customise the layouts and group images into `rows` above a certain `breakpoint` by specifying the number of images that should go in that row. For example:
```javascript
const images = [
{
src: 'https://...',
altText: 'Alt text',
caption: 'Lorem ipsum. REUTERS/Photog',
// Optional max-height of images across all layouts
maxHeight: 800,
},
// ...
];
```
`layouts` optionally define how images are laid out at different breakpoints. You can customise the layouts and group images into `rows` above a certain `breakpoint` by specifying the number of images that should go in that row. For example:
```javascript
const layouts = [
@ -23,6 +36,8 @@ const layouts = [
... tells the component that when the `PhotoPack` container is 450 pixels or wider, it should group the 4 images in 3 rows: 1 in the first, 2 in the second and 1 in the last.
If you don't specify any layouts, the component will use a default responsive layout based on the number of images in your pack.
You can define as many layouts for as many images as you like.
```svelte
@ -123,3 +138,40 @@ gap: 10 # Optional; must be a number.
```
<Canvas of={PhotoPackStories.ArchieML} />
## Smart default layouts
If you don't specify the `layouts` prop, `PhotoPack` will automatically generate responsive layouts based on the number of images and the container width.
**How it works:**
- **Desktop** (1024px+): Number of images per row depends on container width:
- `normal`: max 2 per row
- `wide` / `wider`: max 3 per row
- `widest` / `fluid`: max 4 per row
- **Tablet** (768px+): Always max 2 per row
- **Mobile** (below 768px): 1 per row
The smart defaults use a **bottom-heavy distribution**, meaning earlier rows have fewer images (making them larger and more prominent), while later rows have more images.
**Examples:**
- 5 images, `wide` container, desktop: `[2, 3]` (2 in first row, 3 in second)
- 7 images, `widest` container, desktop: `[3, 4]` (3 in first row, 4 in second)
- 4 images, any container, desktop: `[2, 2]` (evenly distributed)
```svelte
<script>
import { PhotoPack } from '@reuters-graphics/graphics-components';
const images = [
{ src: `${assets}/image1.jpg`, altText: 'Photo 1', caption: 'Caption 1' },
{ src: `${assets}/image2.jpg`, altText: 'Photo 2', caption: 'Caption 2' },
{ src: `${assets}/image3.jpg`, altText: 'Photo 3', caption: 'Caption 3' },
{ src: `${assets}/image4.jpg`, altText: 'Photo 4', caption: 'Caption 4' },
];
</script>
<!-- No layouts prop = smart defaults! -->
<PhotoPack {images} width="wide" />
```

View file

@ -19,6 +19,12 @@
</script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
type SmartDefaultsArgs = Omit<ComponentProps<typeof PhotoPack>, 'images'> & {
imageCount: number;
};
const defaultImages = [
{
src: 'https://graphics.thomsonreuters.com/cdn/django-tools/media/graphics-gallery/galleries/world-cup-2022/spain-germany-11-27/2022-11-27T194630Z_544493697_UP1E.jpeg',
@ -51,7 +57,7 @@
{ breakpoint: 750, rows: [1, 3] },
];
const archieMLImages = [
const allImages = [
{
src: 'https://graphics.thomsonreuters.com/cdn/django-tools/media/graphics-gallery/galleries/world-cup-2022/spain-germany-11-27/2022-11-27T194630Z_544493697_UP1E.jpeg',
caption:
@ -88,7 +94,7 @@
width: 'wide' as const,
textWidth: 'normal' as const,
gap: Number('15'),
images: archieMLImages,
images: allImages.slice(0, 5),
layouts: [
{ breakpoint: 750, rows: [2, 3] },
{ breakpoint: 450, rows: [1, 2, 2] },
@ -106,3 +112,29 @@
}}
/>
<Story name="ArchieML" args={archieMLBlock} />
<Story
name="Smart layouts"
args={{
width: 'wide',
textWidth: 'normal',
// @ts-expect-error - imageCount is a custom arg for this story's template
imageCount: 4,
}}
argTypes={{
// @ts-expect-error - imageCount is a custom arg for this story's template
imageCount: {
control: { type: 'range', min: 2, max: 5, step: 1 },
description:
'Number of images to display (demonstrates smart default layouts)',
},
}}
>
{#snippet children(args)}
{@const { imageCount, ...photoPackProps } = args as SmartDefaultsArgs}
<PhotoPack
{...photoPackProps}
images={allImages.slice(0, imageCount || 4)}
/>
{/snippet}
</Story>

View file

@ -6,7 +6,7 @@
// Utils
import { random4 } from '../../utils';
import { groupRows } from './utils';
import { groupRows, generateDefaultLayouts } from './utils';
// Types
export interface Image {
@ -33,7 +33,7 @@
/** Add an ID to target with SCSS. Should be unique from all other elements. */
id?: string;
/** Add a class to target with SCSS. */
class: string;
class?: string;
/** Width of the component within the text well: 'normal' | 'wide' | 'wider' | 'widest' | 'fluid' */
width: ContainerWidth;
/** Set a different width for captions within the text well. For example, "normal" to keep captions inline with the rest of the text well.
@ -60,10 +60,14 @@
*
* @NOTE - We can't use `sort` directly on the array because it mutates the original array; we can't update a state inside a derived expression: https://svelte.dev/docs/svelte/runtime-errors#Client-errors-state_unsafe_mutation
*
* So, we need to use `toSorted` instead.
* We avoid `toSorted` because it's not supported on older iPhones. Instead, we create a shallow copy using the spread operator and then sort that copy.
*
* If no layouts are provided, we generate smart defaults based on the container width and number of images.
*/
let sortedLayouts = $derived(
layouts?.toSorted((a, b) => (a.breakpoint < b.breakpoint ? 1 : -1))
layouts ?
[...layouts].sort((a, b) => (a.breakpoint < b.breakpoint ? 1 : -1))
: generateDefaultLayouts(images.length, width)
);
let layout = $derived(

View file

@ -1,5 +1,81 @@
import type { Image, Layout } from './PhotoPack.svelte';
// Breakpoint constants for smart default layouts
export const DESKTOP_BREAKPOINT = 1024;
export const TABLET_BREAKPOINT = 768;
/**
* Generates a smart layout for a given number of images with bottom-heavy distribution.
* Avoids single-image rows by redistributing when necessary.
*
* @param imageCount - Total number of images
* @param maxPerRow - Maximum images per row
* @param breakpoint - Breakpoint threshold for this layout
* @returns Layout object with rows array
*/
export const generateSmartLayout = (
imageCount: number,
maxPerRow: number,
breakpoint: number
): Layout => {
// Handle edge cases
if (imageCount === 0) return { breakpoint, rows: [] };
if (imageCount === 1) return { breakpoint, rows: [1] };
const fullRows = Math.floor(imageCount / maxPerRow);
const remainder = imageCount % maxPerRow;
let rows: number[] = [];
if (remainder === 0) {
// Perfect division: all rows have maxPerRow
rows = Array(fullRows).fill(maxPerRow);
} else {
// Bottom-heavy: smaller row at top, larger rows below
// This makes early images larger (fewer per row = bigger display size)
rows = [remainder, ...Array(fullRows).fill(maxPerRow)];
}
return {
breakpoint,
rows,
};
};
type ContainerWidth = 'normal' | 'wide' | 'wider' | 'widest' | 'fluid';
/**
* Generates smart default layouts for desktop and tablet breakpoints.
* Mobile (below TABLET_BREAKPOINT) automatically shows 1 image per row.
*
* Max images per row by container width:
* - normal: 2
* - wide/wider: 3
* - widest/fluid: 4
*
* @param imageCount - Total number of images
* @param width - Container width setting
* @returns Array of 2 layouts [desktop, tablet]
*/
export const generateDefaultLayouts = (
imageCount: number,
width: ContainerWidth
): Layout[] => {
// Map container width to max images per row for desktop
const desktopMaxPerRow =
width === 'normal' ? 2
: width === 'widest' || width === 'fluid' ? 4
: 3;
// Tablet always uses max 2 per row
const tabletMaxPerRow = 2;
return [
generateSmartLayout(imageCount, desktopMaxPerRow, DESKTOP_BREAKPOINT),
generateSmartLayout(imageCount, tabletMaxPerRow, TABLET_BREAKPOINT),
];
};
export const groupRows = (images: Image[], layout?: Layout) => {
// Default layout, one img per row
if (!layout) return images.map((img) => [img]);