hypnagaga/src/components/PhotoCarousel/PhotoCarousel.svelte
2025-03-20 14:49:53 -07:00

267 lines
8.1 KiB
Svelte

<!-- @component `PhotoCarousel` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-multimedia-photocarousel--docs) -->
<script lang="ts">
// Utils
import '@splidejs/svelte-splide/css/core';
import { fly } from 'svelte/transition';
import { Splide, SplideSlide, SplideTrack } from '@splidejs/svelte-splide';
// Icons
import Fa from 'svelte-fa/src/fa.svelte';
import {
faChevronLeft,
faChevronRight,
} from '@fortawesome/free-solid-svg-icons';
// Components
import Block from '../Block/Block.svelte';
import PaddingReset from '../PaddingReset/PaddingReset.svelte';
// Types
import type { MoveEventDetail } from '@splidejs/svelte-splide/types';
import type { Snippet } from 'svelte';
import type { PhotoCarouselImage } from '../@types/global';
type ContainerWidth = 'normal' | 'wide' | 'wider' | 'widest' | 'fluid';
type ObjectFit = 'cover' | 'contain';
interface Props {
/** Array of photos. */
photos: PhotoCarouselImage[];
/** 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, e.g. "normal," to keep captions inline with the rest of the text well. Can't ever be wider than `width`.
*/
textWidth?: ContainerWidth;
/** Add an ID to target with SCSS. */
id?: string;
/** Add a class to target with SCSS. */
cls?: string;
/** Max height of the carousel */
maxHeight?: number;
/** Default Image object-fit style: cover, contain */
defaultImageObjectFit?: ObjectFit;
/** Default image object-position style, e.g., `center center` or `50% 50%`. */
defaultImageObjectPosition?: string;
/** ARIA label for the carousel. */
carouselAriaLabel?: string;
/** Set height of the carousel as a ratio of its width as long as its below whatever you set in `maxHeight`. */
heightRatio?: number;
/** Number of images to preload ahead of the active image. */
preloadImages?: number;
/** Optional custom credit format as a snippet, which takes the argument `photo`. */
credit?: Snippet<[PhotoCarouselImage]>;
/** Optional custom caption format as a snippet, which takes the argument `photo`. */
caption?: Snippet<[PhotoCarouselImage]>;
}
let {
width = 'wider',
textWidth = 'normal',
id = '',
cls = '',
photos,
maxHeight = 660,
defaultImageObjectFit = 'cover',
defaultImageObjectPosition = 'center center',
carouselAriaLabel = 'Photo gallery',
heightRatio = 0.68,
preloadImages = 1,
credit,
caption,
}: Props = $props();
let containerWidth: number | undefined = $state(undefined);
let activeImageIndex = $state(0);
// Derive carousel height based on container width
let carouselHeight = $derived(
containerWidth ?
Math.min(containerWidth * heightRatio, maxHeight)
: maxHeight
);
const handleActiveChange = (e?: CustomEvent<MoveEventDetail>) => {
if (!e) return;
activeImageIndex = e.detail.dest;
};
</script>
<Block {width} {id} class="photo-carousel fmy-6 {cls}">
<div class="carousel-container" bind:clientWidth={containerWidth}>
<Splide
hasTrack={false}
options={{
height: carouselHeight,
fixedHeight: carouselHeight,
lazyLoad: 'nearby',
preloadPages: preloadImages,
}}
aria-label={carouselAriaLabel}
on:move={handleActiveChange}
>
<div class="image-container">
<SplideTrack>
{#each photos as photo}
<SplideSlide>
<div class="photo-slide w-full h-full relative">
<figure
class="fm-0 w-full relative"
style="height: {carouselHeight}px;"
>
<img
class="w-full h-full fmy-0"
data-splide-lazy={photo.src}
alt={photo.altText}
style:object-fit={photo.objectFit || defaultImageObjectFit}
style:object-position={photo.objectPosition ||
defaultImageObjectPosition}
/>
{#if credit}
<!-- Render custom credit snippet -->
{@render credit(photo)}
{:else if photo.credit}
<span
class="credit absolute fmb-1 fml-1 leading-tighter font-note text-xxs"
class:contain-fit={photo.objectFit === 'contain' ||
defaultImageObjectFit === 'contain'}
>{photo.credit}</span
>
{/if}
</figure>
</div>
</SplideSlide>
{/each}
</SplideTrack>
{#if photos[activeImageIndex].caption}
{@const activePhoto = photos[activeImageIndex]}
<PaddingReset containerIsFluid={width === 'fluid'}>
<Block width={textWidth}>
{#if caption && activePhoto.caption}
{#key activeImageIndex}
{@render caption(activePhoto)}
{/key}
{:else if typeof activePhoto.caption}
{#key activeImageIndex}
<p
class="caption body-caption text-center"
in:fly|local={{ x: 20, duration: 175 }}
>
{activePhoto.caption}
</p>
{/key}
{/if}
</Block>
</PaddingReset>
{/if}
<div class="splide__progress">
<div class="splide__progress__bar"></div>
</div>
<div class="splide__arrows fp-1">
<button class="splide__arrow splide__arrow--prev">
<Fa icon={faChevronLeft} fw />
</button>
<button class="splide__arrow splide__arrow--next">
<Fa icon={faChevronRight} fw />
</button>
</div>
</div>
</Splide>
</div>
</Block>
<style lang="scss">
@use '../../scss/mixins' as mixins;
.carousel-container {
.photo-slide {
figure {
span.credit {
bottom: 0;
left: 0;
color: white;
text-shadow:
-1px -1px 0 #333,
1px -1px 0 #333,
-1px 1px 0 #333,
1px 1px 0 #333;
&.contain-fit {
left: 50%;
transform: translate(-50%, 0%);
}
}
}
}
:global(.splide) {
max-height: 100%;
}
:global(li) {
padding: 0;
}
:global(.splide__arrows) {
width: 275px;
margin: 0 auto;
display: flex;
justify-content: space-between;
}
:global(.splide__arrows button) {
display: flex;
font-size: 14px;
height: 30px;
width: 30px;
justify-content: center;
align-items: center;
border: 1px solid transparent;
border-radius: 50%;
background-color: transparent;
cursor: pointer;
@include mixins.text-secondary;
opacity: 0.4;
}
:global(.splide__arrows button.splide__arrow--prev) {
padding-right: 7px;
}
:global(.splide__arrows button.splide__arrow--next) {
padding-left: 7px;
}
:global(.splide__arrows button:hover) {
opacity: 1;
border-color: mixins.$theme-colour-text-secondary;
@include mixins.text-secondary;
}
:global(.splide__arrows button:disabled) {
opacity: 0.4;
}
:global(.splide__arrows button:disabled:hover) {
border-color: transparent;
}
:global(ul.splide__pagination) {
width: 200px;
padding: 0;
text-align: center;
margin: -26px auto 0;
display: flex;
flex-wrap: nowrap;
}
:global(ul.splide__pagination li) {
flex-grow: 1;
margin-top: -9px;
}
:global(ul.splide__pagination li button) {
width: 100%;
height: 7px;
border-radius: 0;
padding: 0;
border: 1px solid mixins.$theme-colour-background;
background: mixins.$theme-colour-text-secondary;
opacity: 0.4;
}
:global(ul.splide__pagination li button.is-active) {
opacity: 1;
}
}
</style>