set up photo carousel
This commit is contained in:
parent
4a674123df
commit
45eb8178e8
7 changed files with 195 additions and 210 deletions
|
|
@ -48,3 +48,22 @@ export interface ScrollerStep {
|
|||
*/
|
||||
foregroundProps?: object;
|
||||
}
|
||||
|
||||
export interface PhotoCarouselImage {
|
||||
/**
|
||||
* Image src
|
||||
*/
|
||||
src: string;
|
||||
/**
|
||||
* Image alt text
|
||||
*/
|
||||
altText: string;
|
||||
/** Optional caption as a string */
|
||||
caption?: string;
|
||||
/** Optional credit as string */
|
||||
credit?: string;
|
||||
/** Optional object-fit rule */
|
||||
objectFit?: string;
|
||||
/** Optional object-position rule */
|
||||
objectPosition?: string;
|
||||
}
|
||||
|
|
|
|||
63
src/components/PhotoCarousel/PhotoCarousel.mdx
Normal file
63
src/components/PhotoCarousel/PhotoCarousel.mdx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { Meta, Canvas } from '@storybook/blocks';
|
||||
|
||||
import * as PhotoCarouselStories from './PhotoCarousel.stories.svelte';
|
||||
|
||||
<Meta of={PhotoCarouselStories} />
|
||||
|
||||
# PhotoCarousel
|
||||
|
||||
The `PhotoCarousel` component creates a simple, accessible photo carousel with lazy-loading and mobile swipe built in.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { PhotoCarousel } from '@reuters-graphics/graphics-components';
|
||||
|
||||
const photos = [
|
||||
{
|
||||
src: 'https://.../myImage.jpg',
|
||||
altText: 'A picture of...',
|
||||
caption: 'My caption...', // Optional
|
||||
credit: 'REUTERS/Jane Doe', // Optional
|
||||
objectFit: 'contain', // Optional
|
||||
objectPosition: '50% 50%', // Optional
|
||||
},
|
||||
// ...
|
||||
];
|
||||
</script>
|
||||
|
||||
<PhotoCarousel {photos} />
|
||||
```
|
||||
|
||||
<Canvas of={PhotoCarouselStories.Demo} />
|
||||
|
||||
## Custom text
|
||||
|
||||
Use named slots to style your own custom credits and/or captions.
|
||||
|
||||
```svelte
|
||||
<PhotoCarousel {photos}>
|
||||
<p slot="credit" class="custom-credit" let:credit>{credit}</p>
|
||||
<p slot="caption" class="custom-caption" let:caption>{caption}</p>
|
||||
</PhotoCarousel>
|
||||
|
||||
<style lang="scss">
|
||||
p {
|
||||
position: absolute;
|
||||
color: white;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
font-family: sans-serif;
|
||||
font-size: 0.8rem;
|
||||
padding: 0 5px;
|
||||
&.custom-credit {
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
&.custom-caption {
|
||||
bottom: 5px;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
<Canvas of={PhotoCarouselStories.CustomText} />
|
||||
|
|
@ -1,68 +1,41 @@
|
|||
<script module lang="ts">
|
||||
// @ts-ignore raw
|
||||
import componentDocs from './stories/docs/component.md?raw';
|
||||
// @ts-ignore raw
|
||||
import customDocs from './stories/docs/withCustom.md?raw';
|
||||
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import PhotoCarousel from './PhotoCarousel.svelte';
|
||||
|
||||
import { withComponentDocs, withStoryDocs } from '$docs/utils/withParams.js';
|
||||
|
||||
export const meta = {
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/Multimedia/PhotoCarousel',
|
||||
component: PhotoCarousel,
|
||||
...withComponentDocs(componentDocs),
|
||||
argTypes: {
|
||||
width: {
|
||||
control: 'select',
|
||||
options: ['normal', 'wide', 'wider', 'widest', 'fluid'],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { Template, Story } from '@storybook/addon-svelte-csf';
|
||||
|
||||
import photosJson from './stories/photos.json';
|
||||
import photosJson from './demo/photos.json';
|
||||
// import { PhotoCarouselImage } from '../@types/global';
|
||||
|
||||
const photos = photosJson.map((p) => ({ ...p, altText: p.caption }));
|
||||
</script>
|
||||
|
||||
<Template >
|
||||
{#snippet children({ args })}
|
||||
<PhotoCarousel {...args} />
|
||||
{/snippet}
|
||||
</Template>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
args="{{
|
||||
width: 'wider',
|
||||
name="Demo"
|
||||
args={{
|
||||
photos,
|
||||
}}"
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Custom credits and captions"
|
||||
args="{{
|
||||
width: 'wider',
|
||||
photos,
|
||||
}}"
|
||||
{...withStoryDocs(customDocs)}
|
||||
>
|
||||
<PhotoCarousel
|
||||
{...{
|
||||
width: 'wider',
|
||||
photos,
|
||||
}}
|
||||
>
|
||||
{#snippet credit({ credit })}
|
||||
<p class="custom-credit" >{credit}</p>
|
||||
{/snippet}
|
||||
{#snippet caption({ caption })}
|
||||
<p class="custom-caption" >{caption}</p>
|
||||
{/snippet}
|
||||
<Story name="Custom text" exportName="CustomText">
|
||||
<PhotoCarousel {photos}>
|
||||
{#snippet credit(photo)}
|
||||
<p class="custom-credit">{photo.credit}</p>
|
||||
{/snippet}
|
||||
{#snippet caption(photo)}
|
||||
<p class="custom-caption">{photo.caption}</p>
|
||||
{/snippet}
|
||||
</PhotoCarousel>
|
||||
</Story>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,108 +1,86 @@
|
|||
<!-- @migration-task Error while migrating Svelte code: Cannot set properties of undefined (setting 'next') -->
|
||||
<!-- @component `PhotoCarousel` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-multimedia-photocarousel--docs) -->
|
||||
<script lang="ts">
|
||||
type ContainerWidth = 'normal' | 'wide' | 'wider' | 'widest' | 'fluid';
|
||||
|
||||
/** Width of the component within the text well. */
|
||||
export let width: ContainerWidth = 'wider';
|
||||
|
||||
/**
|
||||
* Set a different width for captions within the text well, for example,
|
||||
* "normal" to keep captions inline with the rest of the text well.
|
||||
* Can't ever be wider than `width`.
|
||||
* @type {string}
|
||||
*/
|
||||
export let textWidth: ContainerWidth = 'normal';
|
||||
|
||||
/** Add an ID to target with SCSS. */
|
||||
export let id: string = '';
|
||||
|
||||
/** Add a class to target with SCSS. */
|
||||
let cls: string = '';
|
||||
export { cls as class };
|
||||
|
||||
interface Image {
|
||||
/**
|
||||
* Image src
|
||||
* @required
|
||||
*/
|
||||
src: string;
|
||||
/**
|
||||
* Image alt-text
|
||||
* @required
|
||||
*/
|
||||
altText: string;
|
||||
/** Optional caption */
|
||||
caption?: string;
|
||||
/** Optional credit */
|
||||
credit?: string;
|
||||
/** Optional object-fit rule */
|
||||
objectFit?: string;
|
||||
/** Optional object-position rule */
|
||||
objectPosition?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Array of photos.
|
||||
* @required
|
||||
*/
|
||||
export let photos: Image[] = [];
|
||||
|
||||
/**
|
||||
* Max height of the carousel
|
||||
*/
|
||||
export let maxHeight: number = 660;
|
||||
|
||||
type ObjectFit = 'cover' | 'contain';
|
||||
|
||||
/**
|
||||
* Default Image object-fit style, either `cover` or `contain`.
|
||||
*/
|
||||
export let defaultImageObjectFit: ObjectFit = 'cover';
|
||||
|
||||
/**
|
||||
* Default image object-position style, e.g., `center center` or `50% 50%`.
|
||||
*/
|
||||
export let defaultImageObjectPosition: string = 'center center';
|
||||
|
||||
/**
|
||||
* ARIA label for the carousel.
|
||||
* @required
|
||||
*/
|
||||
export let carouselAriaLabel: string = 'Photo gallery';
|
||||
|
||||
/**
|
||||
* Set height of the carousel as a ratio of its width
|
||||
* as long as its below whatever you set in `maxHeight`.
|
||||
*/
|
||||
export let heightRatio: number = 0.68;
|
||||
|
||||
/**
|
||||
* Number of images to preload ahead of the active image.
|
||||
*/
|
||||
export let preloadImages: number = 1;
|
||||
|
||||
import Block from '../Block/Block.svelte';
|
||||
import { Splide, SplideSlide, SplideTrack } from '@splidejs/svelte-splide';
|
||||
// Utils
|
||||
import '@splidejs/svelte-splide/css/core';
|
||||
// @ts-ignore no types
|
||||
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';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
// 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';
|
||||
|
||||
let containerWidth: number;
|
||||
type ContainerWidth = 'normal' | 'wide' | 'wider' | 'widest' | 'fluid';
|
||||
|
||||
let activeImageIndex = 0;
|
||||
type ObjectFit = 'cover' | 'contain';
|
||||
|
||||
$: carouselHeight =
|
||||
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;
|
||||
: maxHeight
|
||||
);
|
||||
|
||||
const handleActiveChange = (e?: CustomEvent<MoveEventDetail>) => {
|
||||
if (!e) return;
|
||||
|
|
@ -111,17 +89,17 @@
|
|||
</script>
|
||||
|
||||
<Block {width} {id} class="photo-carousel fmy-6 {cls}">
|
||||
<div class="carousel-container" bind:clientWidth="{containerWidth}">
|
||||
<div class="carousel-container" bind:clientWidth={containerWidth}>
|
||||
<Splide
|
||||
hasTrack="{false}"
|
||||
options="{{
|
||||
hasTrack={false}
|
||||
options={{
|
||||
height: carouselHeight,
|
||||
fixedHeight: carouselHeight,
|
||||
lazyLoad: 'nearby',
|
||||
preloadPages: preloadImages,
|
||||
}}"
|
||||
aria-label="{carouselAriaLabel}"
|
||||
on:move="{handleActiveChange}"
|
||||
}}
|
||||
aria-label={carouselAriaLabel}
|
||||
on:move={handleActiveChange}
|
||||
>
|
||||
<div class="image-container">
|
||||
<SplideTrack>
|
||||
|
|
@ -134,20 +112,20 @@
|
|||
>
|
||||
<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}"
|
||||
data-splide-lazy={photo.src}
|
||||
alt={photo.altText}
|
||||
style:object-fit={photo.objectFit || defaultImageObjectFit}
|
||||
style:object-position={photo.objectPosition ||
|
||||
defaultImageObjectPosition}
|
||||
/>
|
||||
{#if $$slots.credit}
|
||||
<slot name="credit" credit="{photo.credit}" />
|
||||
{:else}
|
||||
{#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'}"
|
||||
class:contain-fit={photo.objectFit === 'contain' ||
|
||||
defaultImageObjectFit === 'contain'}
|
||||
>{photo.credit}</span
|
||||
>
|
||||
{/if}
|
||||
|
|
@ -158,20 +136,20 @@
|
|||
</SplideTrack>
|
||||
|
||||
{#if photos[activeImageIndex].caption}
|
||||
<PaddingReset containerIsFluid="{width === 'fluid'}">
|
||||
<Block width="{textWidth}">
|
||||
{#if $$slots.caption}
|
||||
<slot
|
||||
name="caption"
|
||||
caption="{photos[activeImageIndex].caption}"
|
||||
/>
|
||||
{:else}
|
||||
{@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 }}"
|
||||
in:fly|local={{ x: 20, duration: 175 }}
|
||||
>
|
||||
{photos[activeImageIndex].caption}
|
||||
{activePhoto.caption}
|
||||
</p>
|
||||
{/key}
|
||||
{/if}
|
||||
|
|
@ -185,10 +163,10 @@
|
|||
|
||||
<div class="splide__arrows fp-1">
|
||||
<button class="splide__arrow splide__arrow--prev">
|
||||
<Fa icon="{faChevronLeft}" fw />
|
||||
<Fa icon={faChevronLeft} fw />
|
||||
</button>
|
||||
<button class="splide__arrow splide__arrow--next">
|
||||
<Fa icon="{faChevronRight}" fw />
|
||||
<Fa icon={faChevronRight} fw />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
A simple, accessible photo carousel with lazy-loading and mobile swipe built in.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { PhotoCarousel } from '@reuters-graphics/graphics-components';
|
||||
|
||||
const photos = [
|
||||
{
|
||||
src: 'https://.../myImage.jpg',
|
||||
altText: 'A picture of...',
|
||||
caption: 'My caption...', // Optional
|
||||
credit: 'REUTERS/Jane Doe', // Optional
|
||||
objectFit: 'contain', // Optional
|
||||
objectPosition: '50% 50%', // Optional
|
||||
},
|
||||
// ...
|
||||
];
|
||||
</script>
|
||||
|
||||
<PhotoCarousel photos="{photos}" />
|
||||
```
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
Use named slots to style your own custom credits and/or captions.
|
||||
|
||||
```svelte
|
||||
<PhotoCarousel photos="{photos}">
|
||||
<p slot="credit" class="custom-credit" let:credit>{credit}</p>
|
||||
<p slot="caption" class="custom-caption" let:caption>{caption}</p>
|
||||
</PhotoCarousel>
|
||||
|
||||
<style lang="scss">
|
||||
p {
|
||||
position: absolute;
|
||||
color: white;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
font-family: sans-serif;
|
||||
font-size: 0.8rem;
|
||||
padding: 0 5px;
|
||||
&.custom-credit {
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
&.custom-caption {
|
||||
bottom: 5px;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
Loading…
Reference in a new issue