Skip to content
Draft
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
123 changes: 102 additions & 21 deletions dotcom-rendering/src/components/SlideshowCarousel.island.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,44 @@ import { useEffect, useRef, useState } from 'react';
import { getZIndex } from '../lib/getZIndex';
import { takeFirst } from '../lib/tuple';
import { palette } from '../palette';
import type { DCRSlideshowImage } from '../types/front';
import type { DCRSlideshowMedia } from '../types/front';
import type { MediaSizeType } from './Card/components/MediaWrapper';
import { CardPicture } from './CardPicture';
import { SlideshowCarouselScrollingDots } from './SlideshowCarouselScrollingDots';

/**
* TEMPORARY SPIKE HACK — remove before merging.
*
* A hardcoded video slide so we can see what mixed media feels like on a real
* front locally. It gets injected as the third slide in every slideshow (see
* where `SPIKE_VIDEO_SLIDE` is spliced into the slides array below).
*
* This is the same asset used in the Storybook stories.
*/
const SPIKE_VIDEO_SLIDE: DCRSlideshowMedia = {
type: 'video',
videoSrc:
'https://uploads.guim.co.uk/2025%2F06%2F20%2Ftesting+only%2C+please+ignore--3cb22b60-2c3f-48d6-8bce-38c956907cce-3.mp4',
posterSrc:
'https://media.guim.co.uk/6537e163c9164d25ec6102641f6a04fa5ba76560/0_210_5472_3283/master/5472.jpg',
caption: 'A self-hosted looping video slide (spike)',
};

/**
* Spike helpers for supporting mixed media (images and videos) in a slideshow.
* A slide is treated as an image unless it is explicitly typed as a video.
*/
const isVideoSlide = (
slide: DCRSlideshowMedia,
): slide is Extract<DCRSlideshowMedia, { type: 'video' }> =>
slide.type === 'video';

const getSlideKey = (slide: DCRSlideshowMedia): string =>
isVideoSlide(slide) ? slide.videoSrc : slide.imageSrc;

const getSlideCaption = (slide: DCRSlideshowMedia): string | undefined =>
isVideoSlide(slide) ? slide.caption : slide.imageCaption;

const themeButton: Partial<ThemeButton> = {
borderTertiary: palette('--carousel-chevron-border'),
textTertiary: palette('--carousel-chevron'),
Expand Down Expand Up @@ -118,8 +151,19 @@ const mediaOverlayStyles = css`
width: 100%;
`;

const videoStyles = css`
display: block;
width: 100%;
aspect-ratio: 5 / 4;
object-fit: cover;
`;

type Props = {
images: readonly DCRSlideshowImage[];
/**
* Slides may be images or videos. The prop retains the `images` name for
* backwards compatibility while we spike mixed-media slideshows.
*/
images: readonly DCRSlideshowMedia[];
imageSize: MediaSizeType;
hasNavigationBackgroundColour: boolean;
linkTo: string;
Expand Down Expand Up @@ -212,10 +256,23 @@ export const SlideshowCarousel = ({
}, []);

/**
* Restrict slideshow to a maximum of 10 images
* Restrict slideshow to a maximum of 10 slides
*/
const slideshowImages = takeFirst(images, 10);
const slideshowImageCount = slideshowImages.length;
const passedSlides = takeFirst(images, 10);

/**
* TEMPORARY SPIKE HACK — remove before merging.
*
* Inject the hardcoded video slide as the third slide (index 2) so we can
* preview mixed media on a real front. Falls back to appending if there are
* fewer than two passed slides.
*/
const slides: readonly DCRSlideshowMedia[] = [
...passedSlides.slice(0, 2),
SPIKE_VIDEO_SLIDE,
...passedSlides.slice(2),
];
const slideCount = slides.length;

return (
<div
Expand All @@ -235,28 +292,52 @@ export const SlideshowCarousel = ({
css={carouselStyles}
data-heatphan-type="carousel"
>
{slideshowImages.map((image, index) => {
{slides.map((slide, index) => {
const loading = index > 0 ? 'lazy' : 'eager';
const caption = getSlideCaption(slide);
return (
<li
css={carouselItemStyles}
key={image.imageSrc}
key={getSlideKey(slide)}
role="group"
aria-roledescription="slide"
aria-label={image.imageCaption}
aria-label={caption}
aria-hidden={index !== currentPage}
>
<figure>
<CardPicture
mainImage={image.imageSrc}
imageSize={imageSize}
aspectRatio="5:4"
alt={image.imageCaption}
loading={loading}
/>
{!!image.imageCaption && (
{isVideoSlide(slide) ? (
<video
css={videoStyles}
poster={slide.posterSrc}
autoPlay={true}
muted={true}
loop={true}
playsInline={true}
preload={
index > 0 ? 'none' : 'metadata'
}
aria-label={caption}
>
<source
src={slide.videoSrc}
type={
slide.mimeType ??
'video/mp4'
}
/>
</video>
) : (
<CardPicture
mainImage={slide.imageSrc}
imageSize={imageSize}
aspectRatio="5:4"
alt={caption}
loading={loading}
/>
)}
{!!caption && (
<figcaption css={captionStyles}>
{image.imageCaption}
{caption}
</figcaption>
)}
</figure>
Expand All @@ -270,7 +351,7 @@ export const SlideshowCarousel = ({
</ul>
</a>

{slideshowImageCount > 1 && (
{slideCount > 1 && (
<div
className="slideshow-carousel-footer"
css={navigationStyles(hasNavigationBackgroundColour)}
Expand All @@ -279,7 +360,7 @@ export const SlideshowCarousel = ({
>
<div css={scrollingDotStyles}>
<SlideshowCarouselScrollingDots
total={slideshowImageCount}
total={slideCount}
current={currentPage}
/>
</div>
Expand All @@ -297,7 +378,7 @@ export const SlideshowCarousel = ({
}
size="small"
disabled={!previousButtonEnabled}
aria-label="Previous image"
aria-label="Previous slide"
// TODO: data-link-name="slideshow carousel left chevron"
/>

Expand All @@ -314,7 +395,7 @@ export const SlideshowCarousel = ({
}
size="small"
disabled={!nextButtonEnabled}
aria-label="Next image"
aria-label="Next slide"
// TODO: data-link-name="slideshow carousel right chevron"
/>
</div>
Expand Down
38 changes: 37 additions & 1 deletion dotcom-rendering/src/components/SlideshowCarousel.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { breakpoints, space, textSans17 } from '@guardian/source/foundations';
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import type { ReactNode } from 'react';
import { palette } from '../palette';
import type { DCRContainerPalette, DCRSlideshowImage } from '../types/front';
import type {
DCRContainerPalette,
DCRSlideshowImage,
DCRSlideshowMedia,
} from '../types/front';
import { ContainerOverrides } from './ContainerOverrides';
import { SlideshowCarousel } from './SlideshowCarousel.island';

Expand Down Expand Up @@ -46,6 +50,26 @@ const images = [
},
] as const satisfies readonly DCRSlideshowImage[];

/**
* Spike: video slides that can be mixed into a slideshow. Reuses the self-hosted
* loop video assets already used elsewhere on fronts.
*/
const videoSlide = {
type: 'video',
videoSrc:
'https://uploads.guim.co.uk/2025%2F06%2F20%2Ftesting+only%2C+please+ignore--3cb22b60-2c3f-48d6-8bce-38c956907cce-3.mp4',
posterSrc:
'https://media.guim.co.uk/6537e163c9164d25ec6102641f6a04fa5ba76560/0_210_5472_3283/master/5472.jpg',
caption: 'A self-hosted looping video slide',
} as const satisfies DCRSlideshowMedia;

const mixedMedia = [
images[0],
videoSlide,
images[1],
images[2],
] as const satisfies readonly DCRSlideshowMedia[];

const Wrapper = ({
children,
heading,
Expand Down Expand Up @@ -120,6 +144,18 @@ export const WithOneImage = {
},
} satisfies Story;

export const WithVideoAndImages = {
args: {
images: mixedMedia,
},
} satisfies Story;

export const WithOneVideo = {
args: {
images: [videoSlide],
},
} satisfies Story;

const containerPalettes = [
'InvestigationPalette',
'LongRunningPalette',
Expand Down
27 changes: 27 additions & 0 deletions dotcom-rendering/src/types/front.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,37 @@ export type DCRFrontCard = {
};

export type DCRSlideshowImage = {
/**
* Discriminant for slideshow media. Optional for backwards compatibility:
* a slide without a `type` is treated as an image.
*/
type?: 'image';
imageSrc: string;
imageCaption?: string;
};

/**
* Spike: a video slide within a media slideshow.
*
* Kept intentionally lightweight while we explore supporting non-image media in
* slideshows. Videos autoplay muted and loop, matching the "loop video" style
* used elsewhere on fronts.
*/
export type DCRSlideshowVideo = {
type: 'video';
videoSrc: string;
/** MIME type of the video source, defaults to `video/mp4` when rendered. */
mimeType?: string;
/** Poster image shown before the video loads. */
posterSrc?: string;
caption?: string;
};

/**
* A single slide in a media slideshow, which may be an image or a video.
*/
export type DCRSlideshowMedia = DCRSlideshowImage | DCRSlideshowVideo;

export type DCRSnapType = {
embedHtml?: string;
embedCss?: string;
Expand Down
Loading