Merge pull request #235 from reuters-graphics/mf-before-after
Updates BeforeAfter
This commit is contained in:
commit
8ad8024987
10 changed files with 319 additions and 308 deletions
111
src/components/BeforeAfter/BeforeAfter.mdx
Normal file
111
src/components/BeforeAfter/BeforeAfter.mdx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { Meta, Canvas } from '@storybook/blocks';
|
||||||
|
|
||||||
|
import * as BeforeAfterStories from './BeforeAfter.stories.svelte';
|
||||||
|
|
||||||
|
<Meta of={BeforeAfterStories} />
|
||||||
|
|
||||||
|
# BeforeAfter
|
||||||
|
|
||||||
|
The `BeforeAfter` component shows a before-and-after comparison of an image.
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script>
|
||||||
|
import { BeforeAfter } from '@reuters-graphics/graphics-components';
|
||||||
|
import { assets } from '$app/paths'; // If using in the Graphics Kit
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BeforeAfter
|
||||||
|
beforeSrc={`${assets}/images/before-after/myrne-before.jpg`}
|
||||||
|
beforeAlt="Satellite image of Russian base at Myrne taken on July 7, 2020."
|
||||||
|
afterSrc={`${assets}/images/before-after/myrne-after.jpg`}
|
||||||
|
afterAlt="Satellite image of Russian base at Myrne taken on Oct. 20, 2020."
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
<Canvas of={BeforeAfterStories.Demo} />
|
||||||
|
|
||||||
|
## Using with ArchieML docs
|
||||||
|
|
||||||
|
With the Graphics Kit, you'll likely get your text value from an ArchieML doc...
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ArchieML doc
|
||||||
|
[blocks]
|
||||||
|
|
||||||
|
type: before-after
|
||||||
|
beforeSrc: images/before-after/myrne-before.jpg
|
||||||
|
beforeAlt: Satellite image of Russian base at Myrne taken on July 7, 2020.
|
||||||
|
afterSrc: images/before-after/myrne-after.jpg
|
||||||
|
afterAlt: Satellite image of Russian base at Myrne taken on Oct. 20, 2020.
|
||||||
|
|
||||||
|
[]
|
||||||
|
```
|
||||||
|
|
||||||
|
... which you'll parse out of a ArchieML block object before passing to the `BeforeAfter` component.
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- App.svelte -->
|
||||||
|
{#each content.blocks as block}
|
||||||
|
{#if block.type === 'before-after'}
|
||||||
|
<BeforeAfter
|
||||||
|
beforeSrc={`${assets}/${block.beforeSrc}`}
|
||||||
|
beforeAlt={block.beforeAlt}
|
||||||
|
afterSrc={`${assets}/${block.afterSrc}`}
|
||||||
|
afterAlt={block.afterAlt}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
```
|
||||||
|
|
||||||
|
<Canvas of={BeforeAfterStories.Demo} />
|
||||||
|
|
||||||
|
## Adding text
|
||||||
|
|
||||||
|
To add text overlays and captions, use [snippets](https://svelte.dev/docs/svelte/snippet) for `beforeOverlay`, `afterOverlay` and `caption`. You can style the snippets to match your page design, like in [this demo](./?path=/story/components-multimedia-beforeafter--with-overlays).
|
||||||
|
|
||||||
|
> 💡**NOTE:** The text in the overlays are used as [ARIA descriptions](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-describedby) for your before and after images. You must always use the `beforeAlt` / `afterAlt` props to label your image for visually impaired readers, but these ARIA descriptions provide additional information or context that the reader might need.
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<BeforeAfter
|
||||||
|
beforeSrc={beforeImg}
|
||||||
|
beforeAlt="Satellite image of Russian base at Myrne taken on July 7, 2020."
|
||||||
|
afterSrc={afterImg}
|
||||||
|
afterAlt="Satellite image of Russian base at Myrne taken on Oct. 20, 2020."
|
||||||
|
>
|
||||||
|
<!-- Optional custom text overlay for the before image -->
|
||||||
|
{#snippet beforeOverlay()}
|
||||||
|
<div class="overlay p-3 before text-left">
|
||||||
|
<p class="h4 font-bold">July 7, 2020</p>
|
||||||
|
<p class="body-note">Initially, this site was far smaller.</p>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- Optional custom text overlay for the after image -->
|
||||||
|
{#snippet afterOverlay()}
|
||||||
|
<div class="overlay p-3 after text-right">
|
||||||
|
<p class="h4 font-bold">Oct. 20, 2020</p>
|
||||||
|
<p class="body-note">But then forces built up.</p>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- Optional custom caption for both images -->
|
||||||
|
{#snippet caption()}
|
||||||
|
<p class="body-note">Photos by MAXAR Technologies, 2021.</p>
|
||||||
|
{/snippet}
|
||||||
|
</BeforeAfter>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
max-width: 350px;
|
||||||
|
&.after {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
<Canvas of={BeforeAfterStories.WithText} />
|
||||||
|
|
@ -1,19 +1,10 @@
|
||||||
<!-- @migration-task Error while migrating Svelte code: end is out of bounds -->
|
<script module lang="ts">
|
||||||
<script context="module" lang="ts">
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
import BeforeAfter from './BeforeAfter.svelte';
|
import BeforeAfter from './BeforeAfter.svelte';
|
||||||
// @ts-ignore raw
|
|
||||||
import componentDocs from './stories/docs/component.md?raw';
|
|
||||||
// @ts-ignore raw
|
|
||||||
import withOverlaysDocs from './stories/docs/withOverlays.md?raw';
|
|
||||||
// @ts-ignore raw
|
|
||||||
import ariaDescriptionsDocs from './stories/docs/ariaDescriptions.md?raw';
|
|
||||||
|
|
||||||
import { withComponentDocs, withStoryDocs } from '$docs/utils/withParams.js';
|
const { Story } = defineMeta({
|
||||||
|
title: 'Components/Multimedia/BeforeAfter',
|
||||||
export const meta = {
|
|
||||||
title: 'Components/Graphics/BeforeAfter',
|
|
||||||
component: BeforeAfter,
|
component: BeforeAfter,
|
||||||
...withComponentDocs(componentDocs),
|
|
||||||
argTypes: {
|
argTypes: {
|
||||||
handleColour: { control: 'color' },
|
handleColour: { control: 'color' },
|
||||||
width: {
|
width: {
|
||||||
|
|
@ -21,96 +12,54 @@
|
||||||
options: ['normal', 'wide', 'wider', 'widest', 'fluid'],
|
options: ['normal', 'wide', 'wider', 'widest', 'fluid'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { Template, Story } from '@storybook/addon-svelte-csf';
|
import beforeImg from './images/myrne-before.jpg';
|
||||||
|
import afterImg from './images/myrne-after.jpg';
|
||||||
// @ts-ignore raw
|
|
||||||
import beforeImg from './stories/myrne-before.jpg';
|
|
||||||
// @ts-ignore raw
|
|
||||||
import afterImg from './stories/myrne-after.jpg';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Template let:args>
|
|
||||||
<BeforeAfter {...args} />
|
|
||||||
</Template>
|
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
name="Default"
|
name="Demo"
|
||||||
args="{{
|
args={{
|
||||||
beforeSrc: beforeImg,
|
beforeSrc: beforeImg,
|
||||||
beforeAlt:
|
beforeAlt:
|
||||||
'Satellite image of Russian base at Myrne taken on July 7, 2020.',
|
'Satellite image of Russian base at Myrne taken on July 7, 2020.',
|
||||||
afterSrc: afterImg,
|
afterSrc: afterImg,
|
||||||
afterAlt:
|
afterAlt:
|
||||||
'Satellite image of Russian base at Myrne taken on Oct. 20, 2020.',
|
'Satellite image of Russian base at Myrne taken on Oct. 20, 2020.',
|
||||||
}}"
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Story name="With overlays" {...withStoryDocs(withOverlaysDocs)}>
|
<Story name="With text" exportName="WithText">
|
||||||
<BeforeAfter
|
<BeforeAfter
|
||||||
beforeSrc="{beforeImg}"
|
beforeSrc={beforeImg}
|
||||||
beforeAlt="Satellite image of Russian base at Myrne taken on July 7, 2020."
|
beforeAlt="Satellite image of Russian base at Myrne taken on July 7, 2020."
|
||||||
afterSrc="{afterImg}"
|
afterSrc={afterImg}
|
||||||
afterAlt="Satellite image of Russian base at Myrne taken on Oct. 20, 2020."
|
afterAlt="Satellite image of Russian base at Myrne taken on Oct. 20, 2020."
|
||||||
>
|
>
|
||||||
<div slot="beforeOverlay" class="overlay p-3 before">
|
{#snippet beforeOverlay()}
|
||||||
<p class="h4 font-bold">July 7, 2020</p>
|
<div class="overlay p-3 before text-left">
|
||||||
<p class="body-note">Initially, this site was far smaller.</p>
|
<p class="h4 font-bold">July 7, 2020</p>
|
||||||
</div>
|
<p class="body-note">Initially, this site was far smaller.</p>
|
||||||
<div slot="afterOverlay" class="overlay p-3 after">
|
</div>
|
||||||
<p class="h4 font-bold">Oct. 20, 2020</p>
|
{/snippet}
|
||||||
<p class="body-note">But then forces built up.</p>
|
{#snippet afterOverlay()}
|
||||||
</div>
|
<div class="overlay p-3 after text-right">
|
||||||
<p slot="caption">Photos by MAXAR Technologies, 2021.</p>
|
<p class="h4 font-bold">Oct. 20, 2020</p>
|
||||||
|
<p class="body-note">But then forces built up.</p>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet caption()}
|
||||||
|
<p class="body-note">Photos by MAXAR Technologies, 2021.</p>
|
||||||
|
{/snippet}
|
||||||
</BeforeAfter>
|
</BeforeAfter>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.overlay {
|
.overlay {
|
||||||
background: rgba(0, 0, 0, 0.45);
|
background: rgba(0, 0, 0, 0.45);
|
||||||
max-width: 350px;
|
max-width: 350px;
|
||||||
&.after {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</Story>
|
|
||||||
|
|
||||||
<Story name="ARIA descriptions" {...withStoryDocs(ariaDescriptionsDocs)}>
|
|
||||||
<BeforeAfter
|
|
||||||
beforeSrc="{beforeImg}"
|
|
||||||
beforeAlt="Satellite image of Russian base at Myrne taken on July 7, 2020."
|
|
||||||
afterSrc="{afterImg}"
|
|
||||||
afterAlt="Satellite image of Russian base at Myrne taken on Oct. 20, 2020."
|
|
||||||
>
|
|
||||||
<div let:description="{id}" slot="beforeOverlay" class="overlay p-3">
|
|
||||||
<p class="body-caption" {id}>
|
|
||||||
On July 7, 2020, the base contained only a few transport vehicles.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div let:description="{id}" slot="afterOverlay" class="overlay p-3">
|
|
||||||
<!-- 👇 id can also be used on an element containing multiple text elements -->
|
|
||||||
<div {id}>
|
|
||||||
<p class="body-caption">
|
|
||||||
But by October, tanks and artillery could be seen.
|
|
||||||
</p>
|
|
||||||
<p class="body-caption">
|
|
||||||
In total, over 50 pieces of heavy machinery and 200 personnel are now
|
|
||||||
based here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p slot="caption">Photos by MAXAR Technologies, 2021.</p>
|
|
||||||
</BeforeAfter>
|
|
||||||
<style lang="scss">
|
|
||||||
div.overlay {
|
|
||||||
max-width: 250px;
|
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
p {
|
p {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,119 +1,149 @@
|
||||||
<!-- @migration-task Error while migrating Svelte code: Cannot set properties of undefined (setting 'next') -->
|
|
||||||
<!-- @component `BeforeAfter` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-graphics-beforeafter--docs) -->
|
<!-- @component `BeforeAfter` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-graphics-beforeafter--docs) -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { type Snippet } from 'svelte';
|
||||||
import { throttle } from 'lodash-es';
|
import { throttle } from 'lodash-es';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import Block from '../Block/Block.svelte';
|
import Block from '../Block/Block.svelte';
|
||||||
import type { ContainerWidth } from '../@types/global';
|
|
||||||
import PaddingReset from '../PaddingReset/PaddingReset.svelte';
|
import PaddingReset from '../PaddingReset/PaddingReset.svelte';
|
||||||
|
import type { ContainerWidth } from '../@types/global';
|
||||||
|
import { random4 } from '../../utils/';
|
||||||
|
|
||||||
/** Width of the chart within the text well. */
|
interface Props {
|
||||||
export let width: ContainerWidth = 'normal'; // options: wide, wider, widest, fluid
|
/** Width of the chart within the text well. Options: wide, wider, widest, fluid */
|
||||||
/** Height of the component */
|
width?: ContainerWidth;
|
||||||
export let height = 600;
|
/** Height of the component */
|
||||||
|
height?: number;
|
||||||
|
/**
|
||||||
|
* If set, makes the height a ratio of the component's width.
|
||||||
|
*/
|
||||||
|
heightRatio?: number;
|
||||||
|
/**
|
||||||
|
* Before image source
|
||||||
|
*/
|
||||||
|
beforeSrc: string;
|
||||||
|
/**
|
||||||
|
* Before image altText
|
||||||
|
*/
|
||||||
|
beforeAlt: string;
|
||||||
|
/**
|
||||||
|
* After image source
|
||||||
|
*/
|
||||||
|
afterSrc: string;
|
||||||
|
/**
|
||||||
|
* After image altText
|
||||||
|
*/
|
||||||
|
afterAlt: string;
|
||||||
|
/**
|
||||||
|
* Class to target with SCSS.
|
||||||
|
*/
|
||||||
|
class?: string;
|
||||||
|
/** Drag handle colour */
|
||||||
|
handleColour?: string;
|
||||||
|
/** Drag handle opacity */
|
||||||
|
handleInactiveOpacity?: number;
|
||||||
|
/** Margin at the edge of the image to stop dragging */
|
||||||
|
handleMargin?: number;
|
||||||
|
/** Percentage of the component width the handle will travel ona key press */
|
||||||
|
keyPressStep?: number;
|
||||||
|
/** Initial offset of the handle, between 0 and 1. */
|
||||||
|
offset?: number;
|
||||||
|
/** ID to target with SCSS. */
|
||||||
|
id?: string;
|
||||||
|
/**
|
||||||
|
* Optional snippet for a custom overlay for the before image.
|
||||||
|
*/
|
||||||
|
beforeOverlay?: Snippet;
|
||||||
|
/**
|
||||||
|
* Optional snippet for a custom overlay for the after image.
|
||||||
|
*/
|
||||||
|
afterOverlay?: Snippet;
|
||||||
|
/**
|
||||||
|
* Optional snippet for a custom caption.
|
||||||
|
*/
|
||||||
|
caption?: Snippet;
|
||||||
|
/** Custom ARIA label language to label the component. */
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
let {
|
||||||
* If set, makes the height a ratio of the component's width.
|
width = 'normal',
|
||||||
* @type {number}
|
height = 600,
|
||||||
|
heightRatio,
|
||||||
|
beforeSrc,
|
||||||
|
beforeAlt,
|
||||||
|
afterSrc,
|
||||||
|
afterAlt,
|
||||||
|
class: cls = '',
|
||||||
|
handleColour = 'white',
|
||||||
|
handleInactiveOpacity = 0.9,
|
||||||
|
handleMargin = 20,
|
||||||
|
keyPressStep = 0.05,
|
||||||
|
offset = 0.5,
|
||||||
|
id = 'before-after-' + random4() + random4(),
|
||||||
|
beforeOverlay,
|
||||||
|
afterOverlay,
|
||||||
|
caption,
|
||||||
|
ariaLabel = 'Stacked before and after images with an adjustable slider',
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
/** DOM nodes are undefined until the component is mounted — in other words, you should read it inside an effect or an event handler, but not during component initialisation.
|
||||||
*/
|
*/
|
||||||
export let heightRatio: number | null = null;
|
let img: HTMLImageElement | undefined = $state(undefined);
|
||||||
|
|
||||||
/**
|
/** Defaults with an empty DOMRect with all values set to 0 */
|
||||||
* Before image src
|
let imgOffset: DOMRect = $state(new DOMRect());
|
||||||
* @required
|
|
||||||
*/
|
|
||||||
export let beforeSrc: string | null = null;
|
|
||||||
/**
|
|
||||||
* Before image altText
|
|
||||||
* @required
|
|
||||||
*/
|
|
||||||
export let beforeAlt: string | null = null;
|
|
||||||
/**
|
|
||||||
* After image src
|
|
||||||
* @required
|
|
||||||
*/
|
|
||||||
export let afterSrc: string | null = null;
|
|
||||||
/**
|
|
||||||
* After image altText
|
|
||||||
* @required
|
|
||||||
*/
|
|
||||||
export let afterAlt: string | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a class to target with SCSS.
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
let cls: string = '';
|
|
||||||
export { cls as class };
|
|
||||||
|
|
||||||
/** Drag handle colour */
|
|
||||||
export let handleColour = 'white';
|
|
||||||
/** Drag handle opacity */
|
|
||||||
export let handleInactiveOpacity = 0.9;
|
|
||||||
/** Margin at the edge of the image to stop dragging */
|
|
||||||
export let handleMargin = 20;
|
|
||||||
/** Percentage of the component width the handle will travel ona key press */
|
|
||||||
export let keyPressStep = 0.05;
|
|
||||||
|
|
||||||
/** Initial offset of the handle, between 0 and 1. */
|
|
||||||
export let offset = 0.5;
|
|
||||||
|
|
||||||
const random4 = () =>
|
|
||||||
Math.floor((1 + Math.random()) * 0x10000)
|
|
||||||
.toString(16)
|
|
||||||
.substring(1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add an ID to target with SCSS.
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
export let id: string = 'before-after-' + random4() + random4();
|
|
||||||
|
|
||||||
let img: HTMLImageElement;
|
|
||||||
let imgOffset: DOMRect | null = null;
|
|
||||||
let sliding = false;
|
let sliding = false;
|
||||||
let figure: HTMLElement;
|
let figure: HTMLElement | undefined = $state(undefined);
|
||||||
let beforeOverlayWidth = 0;
|
let beforeOverlayWidth = $state(0);
|
||||||
let isFocused = false;
|
let isFocused = false;
|
||||||
let containerWidth: number;
|
let containerWidth: number = $state(0); // Defaults to 0
|
||||||
|
|
||||||
$: containerHeight =
|
let containerHeight = $derived(
|
||||||
containerWidth && heightRatio ? containerWidth * heightRatio : height;
|
containerWidth && heightRatio ? containerWidth * heightRatio : height
|
||||||
|
);
|
||||||
|
|
||||||
$: w = (imgOffset && imgOffset.width) || 0;
|
let w = $derived(imgOffset.width);
|
||||||
$: x = w * offset;
|
let x = $derived(w * offset);
|
||||||
$: figStyle = `width:100%;height:${containerHeight}px;`;
|
let figStyle = $derived(`width:100%;height:${containerHeight}px;`);
|
||||||
$: imgStyle = 'width:100%;height:100%;';
|
const imgStyle = 'width:100%;height:100%;';
|
||||||
$: beforeOverlayClip =
|
let beforeOverlayClip = $derived(
|
||||||
x < beforeOverlayWidth ? Math.abs(x - beforeOverlayWidth) : 0;
|
x < beforeOverlayWidth ? Math.abs(x - beforeOverlayWidth) : 0
|
||||||
|
);
|
||||||
|
|
||||||
const onFocus = () => (isFocused = true);
|
/** Toggle `isFocused` */
|
||||||
const onBlur = () => (isFocused = false);
|
const onfocus = () => (isFocused = true);
|
||||||
|
const onblur = () => (isFocused = false);
|
||||||
|
|
||||||
|
/** Handle left or right arrows being pressed */
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!isFocused) return;
|
if (!isFocused) return;
|
||||||
const { keyCode } = e;
|
const { code, key } = e;
|
||||||
const margin = handleMargin / w;
|
const margin = handleMargin / w;
|
||||||
if (keyCode === 39) {
|
if (code === 'ArrowRight' || key === 'ArrowRight') {
|
||||||
offset = Math.min(1 - margin, offset + keyPressStep);
|
offset = Math.min(1 - margin, offset + keyPressStep);
|
||||||
} else if (keyCode === 37) {
|
} else if (code === 'ArrowLeft' || key === 'ArrowLeft') {
|
||||||
offset = Math.max(0 + margin, offset - keyPressStep);
|
offset = Math.max(0 + margin, offset - keyPressStep);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Measure image and set image offset */
|
||||||
const measureImage = () => {
|
const measureImage = () => {
|
||||||
if (img && img.complete) imgOffset = img.getBoundingClientRect();
|
if (img && img.complete) imgOffset = img.getBoundingClientRect();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Reset image offset on resize */
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
measureImage();
|
measureImage();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Measure image and set image offset on load */
|
||||||
const measureLoadedImage = (e: Event) => {
|
const measureLoadedImage = (e: Event) => {
|
||||||
if (e.type === 'load') {
|
if (e.type === 'load') {
|
||||||
imgOffset = (e.target as HTMLImageElement).getBoundingClientRect();
|
imgOffset = (e.target as HTMLImageElement).getBoundingClientRect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Move the slider */
|
||||||
const move = (e: MouseEvent | TouchEvent) => {
|
const move = (e: MouseEvent | TouchEvent) => {
|
||||||
if (sliding && imgOffset) {
|
if (sliding && imgOffset) {
|
||||||
const el =
|
const el =
|
||||||
|
|
@ -130,115 +160,115 @@
|
||||||
offset = x / w;
|
offset = x / w;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Starts the slider */
|
||||||
const start = (e: MouseEvent | TouchEvent) => {
|
const start = (e: MouseEvent | TouchEvent) => {
|
||||||
sliding = true;
|
sliding = true;
|
||||||
move(e);
|
move(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Sets `sliding` to `false`*/
|
||||||
const end = () => {
|
const end = () => {
|
||||||
sliding = false;
|
sliding = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Keep this warning since these values are often read from an ArchieML doc, which will not trigger typescript errors even if required values don't exist */
|
||||||
if (!(beforeSrc && beforeAlt && afterSrc && afterAlt)) {
|
if (!(beforeSrc && beforeAlt && afterSrc && afterAlt)) {
|
||||||
console.warn('Missing required src or alt props for BeforeAfter component');
|
console.warn('Missing required src or alt props for BeforeAfter component');
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
/** @TODO - Double check if this onMount is still necessary */
|
||||||
// This is necessary b/c on:load doesn't reliably fire on the image...
|
// onMount(() => {
|
||||||
const interval = setInterval(() => {
|
// // This is necessary b/c on:load doesn't reliably fire on the image...
|
||||||
if (imgOffset) clearInterval(interval);
|
// const interval = setInterval(() => {
|
||||||
if (img && img.complete && !imgOffset) measureImage();
|
// if (imgOffset) clearInterval(interval);
|
||||||
}, 50);
|
// if (img && img.complete && !imgOffset) measureImage();
|
||||||
});
|
// }, 50);
|
||||||
|
// });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
on:touchmove="{move}"
|
ontouchmove={move}
|
||||||
on:touchend="{end}"
|
ontouchend={end}
|
||||||
on:mousemove="{move}"
|
onmousemove={move}
|
||||||
on:mouseup="{end}"
|
onmouseup={end}
|
||||||
on:resize="{throttle(resize, 100)}"
|
onresize={throttle(resize, 100)}
|
||||||
on:keydown="{handleKeyDown}"
|
onkeydown={handleKeyDown}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Since we usually read these values from ArchieML, check that they exist -->
|
||||||
{#if beforeSrc && beforeAlt && afterSrc && afterAlt}
|
{#if beforeSrc && beforeAlt && afterSrc && afterAlt}
|
||||||
<Block {width} {id} class="photo before-after fmy-6 {cls}">
|
<Block {width} {id} class="photo before-after fmy-6 {cls}">
|
||||||
<div
|
<div style="height: {containerHeight}px;" bind:clientWidth={containerWidth}>
|
||||||
style="height: {containerHeight}px;"
|
<button
|
||||||
bind:clientWidth="{containerWidth}"
|
style={figStyle}
|
||||||
>
|
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
|
||||||
<figure
|
|
||||||
style="{figStyle}"
|
|
||||||
class="before-after-container relative overflow-hidden my-0 mx-auto"
|
class="before-after-container relative overflow-hidden my-0 mx-auto"
|
||||||
on:touchstart="{start}"
|
ontouchstart={start}
|
||||||
on:mousedown="{start}"
|
onmousedown={start}
|
||||||
bind:this="{figure}"
|
bind:this={figure}
|
||||||
aria-labelledby="{($$slots.caption && `${id}-caption`) || undefined}"
|
aria-label={ariaLabel}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
bind:this="{img}"
|
bind:this={img}
|
||||||
src="{afterSrc}"
|
src={afterSrc}
|
||||||
alt="{afterAlt}"
|
alt={afterAlt}
|
||||||
on:load="{measureLoadedImage}"
|
onload={measureLoadedImage}
|
||||||
on:mousedown|preventDefault
|
style={imgStyle}
|
||||||
style="{imgStyle}"
|
|
||||||
class="after absolute block m-0 max-w-full object-cover"
|
class="after absolute block m-0 max-w-full object-cover"
|
||||||
aria-describedby="{($$slots.beforeOverlay && `${id}-before`) ||
|
aria-describedby={beforeOverlay ?
|
||||||
undefined}"
|
`${id}-before-description`
|
||||||
|
: undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src="{beforeSrc}"
|
src={beforeSrc}
|
||||||
alt="{beforeAlt}"
|
alt={beforeAlt}
|
||||||
on:mousedown|preventDefault
|
|
||||||
style="clip: rect(0 {x}px {containerHeight}px 0);{imgStyle}"
|
style="clip: rect(0 {x}px {containerHeight}px 0);{imgStyle}"
|
||||||
class="before absolute block m-0 max-w-full object-cover"
|
class="before absolute block m-0 max-w-full object-cover"
|
||||||
aria-describedby="{($$slots.afterOverlay && `${id}-after`) ||
|
aria-describedby={afterOverlay ?
|
||||||
undefined}"
|
`${id}-after-description`
|
||||||
|
: undefined}
|
||||||
/>
|
/>
|
||||||
{#if $$slots.beforeOverlay}
|
{#if beforeOverlay}
|
||||||
<div
|
<div
|
||||||
id="image-before-label"
|
id="{id}-before-description"
|
||||||
class="overlay-container before absolute"
|
class="overlay-container before absolute"
|
||||||
bind:clientWidth="{beforeOverlayWidth}"
|
bind:clientWidth={beforeOverlayWidth}
|
||||||
style="clip-path: inset(0 {beforeOverlayClip}px 0 0);"
|
style="clip-path: inset(0 {beforeOverlayClip}px 0 0);"
|
||||||
>
|
>
|
||||||
<!-- Overlay for before image -->
|
<!-- Overlay for before image -->
|
||||||
<slot
|
{@render beforeOverlay()}
|
||||||
name="beforeOverlay"
|
|
||||||
description="{`${id}-before-description`}"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $$slots.afterOverlay}
|
{#if afterOverlay}
|
||||||
<div id="image-after-label" class="overlay-container after absolute">
|
<div
|
||||||
|
id="{id}-after-description"
|
||||||
|
class="overlay-container after absolute"
|
||||||
|
>
|
||||||
<!-- Overlay for after image -->
|
<!-- Overlay for after image -->
|
||||||
<slot
|
{@render afterOverlay()}
|
||||||
name="afterOverlay"
|
|
||||||
description="{`${id}-after-description`}"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="slider"
|
role="slider"
|
||||||
aria-valuenow="{Math.round(offset * 100)}"
|
aria-valuenow={Math.round(offset * 100)}
|
||||||
class="handle"
|
class="handle"
|
||||||
style="left: calc({offset *
|
style="left: calc({offset *
|
||||||
100}% - 20px); --before-after-handle-colour: {handleColour}; --before-after-handle-inactive-opacity: {handleInactiveOpacity};"
|
100}% - 20px); --before-after-handle-colour: {handleColour}; --before-after-handle-inactive-opacity: {handleInactiveOpacity};"
|
||||||
on:focus="{onFocus}"
|
{onfocus}
|
||||||
on:blur="{onBlur}"
|
{onblur}
|
||||||
>
|
>
|
||||||
<div class="arrow-left"></div>
|
<div class="arrow-left"></div>
|
||||||
<div class="arrow-right"></div>
|
<div class="arrow-right"></div>
|
||||||
</div>
|
</div>
|
||||||
</figure>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if $$slots.caption}
|
{#if caption}
|
||||||
<PaddingReset containerIsFluid="{width === 'fluid'}">
|
<PaddingReset containerIsFluid={width === 'fluid'}>
|
||||||
<aside class="before-after-caption mx-auto" id="{`${id}-caption`}">
|
<aside class="before-after-caption mx-auto" id={`${id}-caption`}>
|
||||||
<!-- Caption for image credits -->
|
<!-- Caption for image credits -->
|
||||||
<slot name="caption" />
|
{@render caption()}
|
||||||
</aside>
|
</aside>
|
||||||
</PaddingReset>
|
</PaddingReset>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -246,9 +276,9 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@use '../../scss/mixins' as *;
|
@use '../../scss/mixins' as mixins;
|
||||||
|
|
||||||
figure.before-after-container {
|
button.before-after-container {
|
||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
|
|
@ -264,7 +294,7 @@
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.overlay-container {
|
.overlay-container {
|
||||||
position: absolute;
|
top: 0;
|
||||||
:global(:first-child) {
|
:global(:first-child) {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -337,9 +367,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
aside.before-after-caption {
|
.before-after-caption {
|
||||||
:global(p) {
|
:global(p) {
|
||||||
@include body-caption;
|
@include mixins.body-caption;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 715 KiB After Width: | Height: | Size: 715 KiB |
|
Before Width: | Height: | Size: 472 KiB After Width: | Height: | Size: 472 KiB |
|
|
@ -1,35 +0,0 @@
|
||||||
Use text elements in your overlays as [ARIA descriptions](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-describedby) for your images by setting an ID on text elements within each overlay with the `description` [slot prop](https://svelte.dev/tutorial/slot-props).
|
|
||||||
|
|
||||||
> **💡 TIP:** You must always use the `beforeAlt` / `afterAlt` props to label your image for visually impaired readers, but you can use these descriptions to provide more information or context that the reader might also need.
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<BeforeAfter
|
|
||||||
beforeSrc="{beforeImg}"
|
|
||||||
beforeAlt="Satellite image of Russian base at Myrne taken on July 7, 2020."
|
|
||||||
afterSrc="{afterImg}"
|
|
||||||
afterAlt="Satellite image of Russian base at Myrne taken on Oct. 20, 2020."
|
|
||||||
>
|
|
||||||
<div slot="beforeOverlay" class="overlay p-3 before">
|
|
||||||
<p class="h4 font-bold">July 7, 2020</p>
|
|
||||||
<p class="body-note">Initially, this site was far smaller.</p>
|
|
||||||
</div>
|
|
||||||
<div slot="afterOverlay" class="overlay p-3 after">
|
|
||||||
<p class="h4 font-bold">Oct. 20, 2020</p>
|
|
||||||
<p class="body-note">But then forces built up.</p>
|
|
||||||
</div>
|
|
||||||
<p slot="caption">Photos by MAXAR Technologies, 2021.</p>
|
|
||||||
</BeforeAfter>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.overlay {
|
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
max-width: 350px;
|
|
||||||
&.after {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
```
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
A before and after image comparison component.
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script>
|
|
||||||
import { BeforeAfter } from '@reuters-graphics/graphics-components';
|
|
||||||
import { assets } from '$app/paths'; // If using in the Graphics Kit
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<BeforeAfter
|
|
||||||
beforeSrc="{`${assets}/images/before-after/myrne-before.jpg`}"
|
|
||||||
beforeAlt="Satellite image of Russian base at Myrne taken on July 7, 2020."
|
|
||||||
afterSrc="{`${assets}/images/before-after/myrne-after.jpg`}"
|
|
||||||
afterAlt="Satellite image of Russian base at Myrne taken on Oct. 20, 2020."
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
Add overlays with the `beforeOverlay` and `afterOverlay` slots and a caption to the `caption` slot, then style these elements to match your page design as below.
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<BeforeAfter
|
|
||||||
beforeSrc="{beforeImg}"
|
|
||||||
beforeAlt="Satellite image of Russian base at Myrne taken on July 7, 2020."
|
|
||||||
afterSrc="{afterImg}"
|
|
||||||
afterAlt="Satellite image of Russian base at Myrne taken on Oct. 20, 2020."
|
|
||||||
>
|
|
||||||
<div slot="beforeOverlay" class="overlay p-3 before">
|
|
||||||
<p class="h4 font-bold">July 7, 2020</p>
|
|
||||||
<p class="body-note">Initially, this site was far smaller.</p>
|
|
||||||
</div>
|
|
||||||
<div slot="afterOverlay" class="overlay p-3 after">
|
|
||||||
<p class="h4 font-bold">Oct. 20, 2020</p>
|
|
||||||
<p class="body-note">But then forces built up.</p>
|
|
||||||
</div>
|
|
||||||
<p slot="caption">Photos by MAXAR Technologies, 2021.</p>
|
|
||||||
</BeforeAfter>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.overlay {
|
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
max-width: 350px;
|
|
||||||
&.after {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
```
|
|
||||||
|
|
@ -17,12 +17,10 @@
|
||||||
publishTime: string;
|
publishTime: string;
|
||||||
/**
|
/**
|
||||||
* Update time as a datetime string.
|
* Update time as a datetime string.
|
||||||
* @type {string}
|
|
||||||
*/
|
*/
|
||||||
updateTime: string;
|
updateTime: string;
|
||||||
/**
|
/**
|
||||||
* Alignment of the byline.
|
* Alignment of the byline.
|
||||||
* @type {string}
|
|
||||||
*/
|
*/
|
||||||
align?: 'left' | 'center';
|
align?: 'left' | 'center';
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import slugify from 'slugify';
|
import slugify from 'slugify';
|
||||||
|
|
||||||
|
/** Helper function to generate a random 4-character string */
|
||||||
|
export const random4 = () =>
|
||||||
|
Math.floor((1 + Math.random()) * 0x10000)
|
||||||
|
.toString(16)
|
||||||
|
.substring(1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom function that returns an author page URL.
|
* Custom function that returns an author page URL.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue