closes #363 and adds default layouts based on number of images in pack and size of the container.

This commit is contained in:
hobbes7878 2025-10-23 16:43:17 +01:00
parent e6ff461899
commit dadce707b3
Failed to extract signature
4 changed files with 168 additions and 8 deletions

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,39 @@ 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,78 @@
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]);