Merge pull request #263 from reuters-graphics/mf-scroller

Updates Scroller
This commit is contained in:
MinamiFunakoshiTR 2025-04-15 12:23:24 -05:00 committed by GitHub
commit afc2496150
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 885 additions and 453 deletions

View file

@ -106,7 +106,6 @@
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@splidejs/svelte-splide": "^0.2.9",
"@sveltejs/kit": "^2.15.3",
"@sveltejs/svelte-scroller": "^2.0.7",
"dayjs": "^1.11.13",
"journalize": "^2.6.0",
"lodash-es": "^4.17.21",
@ -137,4 +136,4 @@
"plugin:storybook/recommended"
]
}
}
}

View file

@ -1,4 +1,4 @@
import type { ComponentType } from 'svelte';
import type { Component } from 'svelte';
/**
* Used for the list of <option> tags nested in a <select> input.
@ -28,38 +28,31 @@ export type HeadlineSize = 'small' | 'normal' | 'big' | 'bigger' | 'biggest';
* A step in the Scroller component.
*/
export interface ScrollerStep {
altText?: string;
/**
* A background component
* @required
*/
background: ComponentType;
background: Component;
/**
* Optional props for background component
*/
backgroundProps?: object;
/**
* Foreground can either be a component or markdown-formatted string.
* @required
* A component or markdown-formatted string
*/
foreground: ComponentType | string;
foreground: Component | string;
/**
* Optional props for foreground component
*/
foregroundProps?: object;
/**
* Optional alt text for the background, read aloud after the foreground text. You can add it to each step or just to the first step to describe the entire scroller graphic.
*/
altText?: string;
}
export interface PhotoCarouselImage {
/** Image source */
src: string;
/** Image alt text */
altText: string;
/** Optional caption */
caption?: string;
/** Optional credit */
credit?: string;
/** Optional object-fit rule */
objectFit?: string;
/** Optional object-position rule */
objectPosition?: string;
}
export type ForegroundPosition =
| 'middle'
| 'left'
| 'right'
| 'left opposite'
| 'right opposite';

View file

@ -3,17 +3,12 @@
interface Props {
index: number;
steps?: ScrollerStep[];
steps: ScrollerStep[];
preload?: number;
stackBackground?: boolean;
}
let {
index,
steps = [],
preload = 1,
stackBackground = true,
}: Props = $props();
let { index, steps, preload = 1, stackBackground = true }: Props = $props();
</script>
{#each steps as step, i}

View file

@ -6,13 +6,13 @@
import Background from './Background.svelte';
import Foreground from './Foreground.svelte';
interface Props {
steps?: ScrollerStep[];
steps: ScrollerStep[];
embeddedLayout?: EmbeddedLayout;
backgroundWidth?: ContainerWidth;
}
let {
steps = [],
steps,
embeddedLayout = 'fb',
backgroundWidth = 'fluid',
}: Props = $props();

View file

@ -3,10 +3,10 @@
import Markdown from '../Markdown/Markdown.svelte';
interface Props {
steps?: ScrollerStep[];
steps: ScrollerStep[];
}
let { steps = [] }: Props = $props();
let { steps }: Props = $props();
</script>
{#each steps as step, i}

View file

@ -0,0 +1,279 @@
import { Meta } from '@storybook/blocks';
import * as ScrollerStories from './Scroller.stories.svelte';
<Meta of={ScrollerStories} />
# Scroller
The `Scroller` component creates a basic scrollytelling graphic with layout options.
> This component is designed to handle most common layouts for scrollytelling. To make something more complex, customise [ScrollerBase](https://github.com/reuters-graphics/graphics-components/blob/main/src/components/Scroller/ScrollerBase/index.svelte), which is a Svelte 5 version of the [svelte-scroller](https://github.com/sveltejs/svelte-scroller).
[Demo](?path=/story/components-graphics-scroller--demo)
```svelte
<script>
import { Scroller } from '@reuters-graphics/graphics-components';
import MyBackground from './MyBackground.svelte'; // Your own background component
// Array of step objects that define the steps in your scroller.
const steps = [
{
background: MyBackground,
backgroundProps: { colour: 'red' }, // Optional props for your background component
foreground: '#### Step 1\n\nLorem ipsum red',
altText: 'Red background',
},
{
background: MyBackground,
backgroundProps: { colour: 'blue' },
foreground: '#### Step 2\n\nLorem ipsum blue',
altText: 'Blue background',
},
{
background: MyBackground,
backgroundProps: { colour: 'green' },
foreground: '#### Step 3\n\nLorem ipsum green',
altText: 'Green background',
},
],
</script>
<Scroller {steps} foregroundPosition="middle" backgroundWidth="fluid" />
```
## Using with ArchieML and ai2svelte
[Demo](?path=/story/components-graphics-scroller--archie-ml)
In your Graphics Kit project, import your ai2svelte graphics in `App.svelte` and add them to the `aiCharts` object:
```svelte
<!-- App.svelte -->
<script>
import AiMap1 from './ai2svelte/my-map-1.svelte';
import AiMap2 from './ai2svelte/my-map-2.svelte';
import AiMap3 from './ai2svelte/my-map-3.svelte';
import content from '$locales/en/content.json';
// Graphics Kit only
import { assets } from '$app/paths'; // 👈 If using in the Graphics Kit...
import { truthy } from '$utils/propValidators'; // 👈 If using in the Graphics Kit...
const aiCharts = {
AiMap1,
AiMap2,
AiMap3,
// Other charts...
};
</script>
```
Then add the following structure to your ArchieML Doc, making sure that the names of your charts in the `aiCharts` object match the names of each step's `background` in the ArchieML doc:
```yaml
# ArchieML doc
[blocks]
type: ai-scroller
id: my-map-scroller
width: fluid
foregroundPosition: right
stackBackground: true
# Array of step objects
[.steps]
background: AiMap1
foreground: #### Step 1
Here's where something happend.
:end
altText: A map showing the Upper West side in New York City.
Can add paragraphs of alt text if you want to break up sentences.
:end
background: AiMap2
foreground: #### Step 2
Something happened on some street...
:end
altText: The same map now highlights 98th Street.
:end
background: AiMap3
foreground: #### Step 3
... and now there are multiple protests.
:end
altText: The same map now highlights three locations near 98th Street where something particulary important happened.
:end
[]
[]
```
Then parse the relevant ArchieML block object before passing to the `Scroller` component.
```svelte
<!-- App.svelte -->
{#each content.blocks as block}
{#if block.type === 'ai-scroller'}
<Scroller
id={block.id}
backgroundWidth={block.width}
foregroundPosition={block.foregroundPosition}
stackBackground={truthy(block.stackBackground)}
steps={block.steps.map((step) => ({
background: aiCharts[step.background],
backgroundProps: { assetsPath: assets || '/' },
foreground: step.foreground,
altText: step.altText,
}))}
/>
{/if}
{/each}
```
> **Note:** Some props, like `stackBackground`, expect boolean values. If you're using the Graphics Kit, use the `truthy()` util function to convert a string value to a boolean.
> **Note:** In Graphics Kit, the image source paths in ai2svelte components have to be fixed by passing `assets` to each step object, like in the example above.
## Custom foreground
[Demo](?path=/story/components-graphics-scroller--custom-foreground)
Instead of just text, you can use components as foregrounds, and optionally pass props to it.
If you're customising your own foreground component, remember to add alt text that describes the background graphic.
```svelte
<script>
import MyBackground from './MyBackground.svelte'; // Your own background component
import MyInteractiveForeground from './MyInteractiveForeground.svelte'; // Your custom foreground component
const steps = [
{
background: MyBackground,
backgroundProps: { colour: 'red' }, // Props for your background component, if needed
foreground: MyInteractiveForeground, // Custom foreground component
},
{
background: MyBackground,
backgroundProps: { colour: 'blue' },
foreground: '#### Step 2\n\nLorem ipsum blue', // You can still add a markdown string as foreground; you can mix and match
},
{
background: MyBackground,
backgroundProps: { colour: 'green' },
foreground: MyInteractiveForeground,
foregroundProps: { count: 100 }, // Props for your custom foreground component, if needed
},
];
</script>
<Scroller {steps} />
```
## Custom foreground with ArchieML
[Demo](?path=/story/components-graphics-scroller--customforeground-archie-ml)
You can use custom foreground components with ArchieML with a few additional steps.
In your Graphics Kit project's `App.svelte`, import your custom foregroud components and add them to a `foregroundComponents` object, just as you import ai2svelte background graphics and add them to the `aiCharts` object:
```svelte
<!-- App.svelte -->
<script>
import content from '$locales/en/content.json';
// Background ai2svelte graphics
import AiMap1 from './ai2svelte/my-map-1.svelte';
import AiMap2 from './ai2svelte/my-map-2.svelte';
import AiMap3 from './ai2svelte/my-map-3.svelte';
// Foreground components, which can be ai2svelte or not.
import Foreground1 from './ai2svelte/my-foreground-1.svelte';
// Graphics Kit only
import { assets } from '$app/paths'; // 👈 If using in the Graphics Kit...
import { truthy } from '$utils/propValidators'; // 👈 If using in the Graphics Kit...
// Background ai2svelte graphics components
const aiCharts = {
AiMap1,
AiMap2,
AiMap3,
// Other charts...
};
// Foreground components
const foregroundComponents = {
Foreground1,
// Other components...
};
</script>
```
Then add the following structure to your ArchieML Doc, making sure that the names of your charts in the `aiCharts` and `foregroundComponents` objects match the names of each step's `background` and `foreground` in the ArchieML doc:
```yaml
# ArchieML doc
[blocks]
type: ai-scroller
id: my-map-scroller
foregroundPosition: left
stackBackground: true
# Array of step objects
[.steps]
background: AiMap1
# You can still use a markdown string even if other step/s use a custom foreground component
foreground: #### Step 1
Here's where something happend.
:end
altText: A map showing the Upper West side in New York City.
:end
background: AiMap2
foreground: Foreground1 # The name of your custom foreground component
altText: The same map now highlights 98th Street.
:end
background: AiMap3
foreground: #### Step 3
... and now there are multiple protests.
:end
altText: The same map now highlights three locations near 98th Street where something particulary important happened.
:end
[]
[]
```
Then parse the relevant ArchieML block object before passing to the `Scroller` component.
```svelte
<!-- App.svelte -->
{#each content.blocks as block}
{#if block.type === 'ai-scroller'}
<Scroller
id={block.id}
backgroundWidth={block.width}
foregroundPosition={block.foregroundPosition}
stackBackground={truthy(block.stackBackground)}
steps={block.steps.map((step) => ({
background: aiCharts[step.background],
backgroundProps: { assetsPath: assets || '/' },
foreground: foregroundComponents[step.foreground] || step.foreground,
foregroundProps: { assetsPath: assets || '/' },
altText: step.altText,
}))}
/>
{/if}
{/each}
```
> **Note:** You only need to pass `foregroundProps: { assetsPath: assets || '/' }` in Graphics Kit if your foreground components are ai2svelte graphicss.

View file

@ -1,24 +1,10 @@
<script module lang="ts">
// @ts-ignore raw
import componentDocs from './stories/docs/component.md?raw';
// @ts-ignore raw
import interactiveDocs from './stories/docs/interactive.md?raw';
// @ts-ignore raw
import ai2svelteDocs from './stories/docs/ai2svelte.md?raw';
// @ts-ignore raw
import archieMLDocs from './stories/docs/archieML.md?raw';
import { defineMeta } from '@storybook/addon-svelte-csf';
import Scroller from './Scroller.svelte';
import {
withComponentDocs,
withStoryDocs,
} from '$lib/docs/utils/withParams.js';
export const meta = {
const { Story } = defineMeta({
title: 'Components/Graphics/Scroller',
component: Scroller,
...withComponentDocs(componentDocs),
argTypes: {
steps: { control: false },
backgroundWidth: {
@ -34,17 +20,20 @@
options: ['fb', 'bf'],
},
},
};
});
</script>
<script>
import { Template, Story } from '@storybook/addon-svelte-csf';
import MyBackground from './demo/components/basic/Step.svelte';
import MyInteractiveForeground from './demo/components/basic/InteractiveForeground.svelte';
import BasicStep from './stories/components/basic/Step.svelte';
import InteractiveForeground from './stories/components/basic/InteractiveForeground.svelte';
import AiMap1 from './stories/components/ai2svelte/ai-scroller-1.svelte';
import AiMap2 from './stories/components/ai2svelte/ai-scroller-2.svelte';
import AiMap3 from './stories/components/ai2svelte/ai-scroller-3.svelte';
// ai2svelte backgrounds
import AiMap1 from './demo/components/ai2svelte/ai-scroller-1.svelte';
import AiMap2 from './demo/components/ai2svelte/ai-scroller-2.svelte';
import AiMap3 from './demo/components/ai2svelte/ai-scroller-3.svelte';
// ai2svelte foreground
import AiForeground from './demo/components/ai2svelte/ai-foreground.svelte';
const aiCharts = {
AiMap1,
@ -52,57 +41,77 @@
AiMap3,
};
const foregroundComponents = {
AiForeground,
};
const docBlock = {
type: 'scroller',
width: 'fluid',
foregroundPosition: 'middle',
foregroundPosition: 'right',
id: 'my-scroller',
stackBackground: true,
stackBackground: 'true',
steps: [
{
background: aiCharts.AiMap1,
text: '#### Step 1\n\nLorem ipsum',
foreground: "#### Step 1\n\nHere's where something happend.",
altText: 'A map showing the Upper West side in New York City.',
},
{
background: aiCharts.AiMap2,
text: '#### Step 2\n\nLorem ipsum',
foreground: '#### Step 2\n\nSomething happened on some street...',
altText: 'The same map now highlights 98th Street.',
},
{
background: aiCharts.AiMap3,
text: '#### Step 3\n\nLorem ipsum',
foreground: '#### Step 3\n\n... and now there are multiple protests.',
altText:
'The same map now highlights three locations near 98th Street where something particulary important happened.',
},
],
};
} as const;
const docBlockCustomForeground = {
foregroundPosition: 'left',
id: 'my-scroller',
stackBackground: 'true',
steps: [
{
background: aiCharts.AiMap1,
foreground: "#### Step 1\n\nHere's where something happend.",
altText: 'A map showing the Upper West side in New York City.',
},
{
background: aiCharts.AiMap2,
foreground: foregroundComponents.AiForeground,
altText: 'The same map now highlights 98th Street.',
},
{
background: aiCharts.AiMap3,
foreground: '#### Step 3\n\n... and now there are multiple protests.',
altText:
'The same map now highlights three locations near 98th Street where something particulary important happened.',
},
],
} as const;
</script>
<Template>
{#snippet children({ args })}
<Scroller {...args} />
{/snippet}
</Template>
<Story
name="Default"
name="Demo"
args={{
steps: [
{
background: BasicStep,
background: MyBackground,
backgroundProps: { colour: 'red' },
foreground: '#### Step 1\n\nLorem ipsum red',
altText: 'Red background',
},
{
background: BasicStep,
background: MyBackground,
backgroundProps: { colour: 'blue' },
foreground: '#### Step 2\n\nLorem ipsum blue',
altText: 'Blue background',
},
{
background: BasicStep,
background: MyBackground,
backgroundProps: { colour: 'green' },
foreground: '#### Step 3\n\nLorem ipsum green',
altText: 'Green background',
@ -110,71 +119,59 @@
],
foregroundPosition: 'middle',
backgroundWidth: 'fluid',
embeddedLayout: 'fb',
embedded: false,
}}
/>
<Story name="ArchieML" args={docBlock} {...withStoryDocs(archieMLDocs)} />
<Story name="ArchieML and ai2svelte" exportName="ArchieML">
<Scroller
id={docBlock.id}
foregroundPosition={docBlock.foregroundPosition}
stackBackground={docBlock.stackBackground === 'true'}
steps={docBlock.steps.map((step) => ({
background: step.background,
foreground: step.foreground,
altText: step.altText,
}))}
/>
</Story>
<Story
name="Foreground components"
name="Custom foreground"
exportName="CustomForeground"
args={{
steps: [
{
background: BasicStep,
background: MyBackground,
backgroundProps: { colour: 'red' },
foreground: InteractiveForeground,
foreground: MyInteractiveForeground,
},
{
background: BasicStep,
background: MyBackground,
backgroundProps: { colour: 'blue' },
foreground: '#### Step 2\n\nLorem ipsum blue',
},
{
background: BasicStep,
background: MyBackground,
backgroundProps: { colour: 'green' },
foreground: InteractiveForeground,
foreground: MyInteractiveForeground,
foregroundProps: { count: 100 },
},
],
foregroundPosition: 'middle',
backgroundWidth: 'fluid',
embeddedLayout: 'fb',
embedded: false,
}}
{...withStoryDocs(interactiveDocs)}
/>
<Story
name="Ai2svelte"
args={{
steps: [
{
background: AiMap1,
backgroundProps: { colour: 'red' },
foreground: '#### Step 1\n\nLorem ipsum',
altText: 'A map showing the Upper West side in New York City.',
},
{
background: AiMap2,
backgroundProps: { colour: 'blue' },
foreground: '#### Step 2\n\nLorem ipsum',
altText:
'The same map now highlights 98th Street where something interesting happened.',
},
{
background: AiMap3,
backgroundProps: { colour: 'green' },
foreground: '#### Step 3\n\nLorem ipsum',
altText:
'The same map now highlights three locations near 98th Street where something particulary important happened.',
},
],
foregroundPosition: 'middle',
backgroundWidth: 'fluid',
embeddedLayout: 'fb',
embedded: false,
}}
{...withStoryDocs(ai2svelteDocs)}
/>
name="Custom foreground with ArchiemL"
exportName="CustomforegroundArchieML"
>
<Scroller
id={docBlockCustomForeground.id}
foregroundPosition={docBlockCustomForeground.foregroundPosition}
stackBackground={docBlockCustomForeground.stackBackground === 'true'}
steps={docBlockCustomForeground.steps.map((step) => ({
background: step.background,
foreground: step.foreground,
altText: step.altText,
}))}
/>
</Story>

View file

@ -1,116 +1,106 @@
<!-- @migration-task Error while migrating Svelte code: Cannot set properties of undefined (setting 'next') -->
<!-- @component `Scroller` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-graphics-scroller--docs) -->
<script lang="ts">
import type { ContainerWidth, ScrollerStep } from '../@types/global';
/**
* ID of the scroller container
* @type {string}
*/
export let id: string = '';
/**
* An array of step objects that define the steps in your scroller.
*
* Each step object in the array can have:
*
* - `background` A background component. **REQUIRED**
* - `backgroundProps` An object of props given to the background component
* - `foreground` Either a markdown-formatted string or a foreground component **REQUIRED**
* - `altText` A string describing the background graphic, which is read aloud after the foreground blurb. You can add it to each step or, if you want to add just one alt text to describe all graphics in the scroll section, add it to just the first step. **RECOMMENDED**
* - `foregroundProps` An object of props given to the foreground component
*
* @required
*/
export let steps: ScrollerStep[] = [];
/**
* Width of the background
*/
export let backgroundWidth: ContainerWidth = 'fluid';
type ForegroundPosition =
| 'middle'
| 'left'
| 'right'
| 'left opposite'
| 'right opposite';
/**
* Position of the foreground. One of: middle, left, right, left opposite or right opposite.
*
* "opposite" options push the background to the other side on larger viewports.
*
* @type {string}
*/
export let foregroundPosition: ForegroundPosition = 'middle';
/**
* Whether previous background steps should stack below the current one.
*
* - `true` _default_ Background graphics from previous steps will remain visible below the active one, allowing you to stack graphics with transparent backgrounds.
* - `false` Only the background graphic from the current step will show and backgrounds from previous steps are hidden.
*/
export let stackBackground: boolean = true;
/**
* How many background steps to load before and after the currently active one, effectively lazy-loading them.
*
* Setting to `0` disables lazy-loading and loads all backgrounds at once.
*/
export let preload: number = 1;
/**
* Setting to `true` will unroll the scroll experience into a flat layout.
*/
export let embedded: boolean = false;
type EmbeddedLayout = 'fb' | 'bf';
/**
* Layout order when `embedded` is `true`.
*
* - `fb` _default_ Foreground then background
* - `bf` Background then foreground
*
* @type {string}
*/
export let embeddedLayout: EmbeddedLayout = 'fb';
/**
* Threshold prop passed to [svelte-scroller](https://github.com/sveltejs/svelte-scroller#parameters)
*/
export let threshold: number = 0.5;
/**
* Top prop passed to [svelte-scroller](https://github.com/sveltejs/svelte-scroller#parameters)
*/
export let top: number = 0;
/**
* Bottom prop passed to [svelte-scroller](https://github.com/sveltejs/svelte-scroller#parameters)
*/
export let bottom: number = 1;
/**
* Parallax prop passed to [svelte-scroller](https://github.com/sveltejs/svelte-scroller#parameters)
*/
export let parallax: boolean = false;
/**
* Set a class to target with SCSS.
* @type {string}
*/
let cls: string = '';
export { cls as class };
// @ts-ignore no types
import SvelteScroller from '@sveltejs/svelte-scroller';
import ScrollerBase from './ScrollerBase/index.svelte';
import Background from './Background.svelte';
import Foreground from './Foreground.svelte';
import Embedded from './Embedded/index.svelte';
import Block from '../Block/Block.svelte';
let index = 0;
let offset;
let progress;
// Types
import type {
ContainerWidth,
ForegroundPosition,
ScrollerStep,
} from '../@types/global';
interface Props {
/**
* An array of step objects that define the steps in your scroller.
*
* Each step object in the array can have:
*
* - `background` A background component. **REQUIRED**
* - `backgroundProps` Optional props for background component.
* - `foreground` A component or markdown-formatted string. **REQUIRED**
* - `foregroundProps` Optional props for foreground component.
* - `altText` Optional alt text for the background, read aloud after the foreground text. You can add it to each step or just to the first step to describe the entire scroller graphic. **RECOMMENDED**
*
*/
steps: ScrollerStep[];
/** Width of the background */
backgroundWidth?: ContainerWidth;
/** Position of the foreground */
foregroundPosition?: ForegroundPosition;
/**
* Whether previous background steps should stack below the current one.
*
* - `true` _default_ Background graphics from previous steps will remain visible below the active one, allowing you to stack graphics with transparent backgrounds.
* - `false` Only the background graphic from the current step will show and backgrounds from previous steps are hidden.
*/
stackBackground?: boolean;
/**
* How many background steps to load before and after the currently active one, effectively lazy-loading them.
*
* Setting to `0` disables lazy-loading and loads all backgrounds at once.
*/
preload?: number;
/** Setting to `true` will unroll the scroll experience into a flat layout */
embedded?: boolean;
/**
* Layout order when `embedded` is `true`.
*
* - `fb` _default_ Foreground then background
* - `bf` Background then foreground
*
*/
embeddedLayout?: 'fb' | 'bf';
/**
* Threshold prop passed to [svelte-scroller](https://github.com/sveltejs/svelte-scroller#parameters)
*/
threshold?: number;
/**
* Top prop passed to [svelte-scroller](https://github.com/sveltejs/svelte-scroller#parameters)
*/
top?: number;
/**
* Bottom prop passed to [svelte-scroller](https://github.com/sveltejs/svelte-scroller#parameters)
*/
bottom?: number;
/**
* Parallax prop passed to [svelte-scroller](https://github.com/sveltejs/svelte-scroller#parameters)
*/
parallax?: boolean;
/** ID of the scroller container */
id?: string;
/** Set a class to target with SCSS */
class?: string;
}
let {
id = '',
steps,
backgroundWidth = 'fluid',
foregroundPosition = 'middle',
stackBackground = true,
preload = 1,
embedded = false,
embeddedLayout = 'fb',
threshold = 0.5,
top = 0,
bottom = 1,
parallax = false,
class: cls = '',
}: Props = $props();
// Bindable variables passed to ScrollerBase
let index = $state(0);
let offset = $state(0);
let progress = $state(0);
</script>
{#if !embedded}
<Block width="fluid" class="scroller-container fmy-6 {cls}" {id}>
<SvelteScroller
<ScrollerBase
bind:index
bind:offset
bind:progress
@ -120,28 +110,30 @@
{parallax}
query="div.step-foreground-container"
>
<div
slot="background"
class="background min-h-screen relative p-0 flex justify-center"
class:right={foregroundPosition === 'left opposite'}
class:left={foregroundPosition === 'right opposite'}
aria-hidden="true"
>
<div class="scroller-graphic-well w-full">
<Block
width={backgroundWidth}
class="background-container step-{index +
1} my-0 min-h-screen flex justify-center items-center relative"
>
<Background {index} {steps} {preload} {stackBackground} />
</Block>
{#snippet backgroundSnippet()}
<div
class="background min-h-screen relative p-0 flex justify-center"
class:right={foregroundPosition === 'left opposite'}
class:left={foregroundPosition === 'right opposite'}
aria-hidden="true"
>
<div class="scroller-graphic-well w-full">
<Block
width={backgroundWidth}
class="background-container step-{index +
1} my-0 min-h-screen flex justify-center items-center relative"
>
<Background {index} {steps} {preload} {stackBackground} />
</Block>
</div>
</div>
</div>
<div slot="foreground" class="foreground {foregroundPosition} w-full">
<Foreground {steps} />
</div>
</SvelteScroller>
{/snippet}
{#snippet foregroundSnippet()}
<div class="foreground {foregroundPosition} w-full">
<Foreground {steps} />
</div>
{/snippet}
</ScrollerBase>
</Block>
{:else}
<Block width="widest" class="scroller-container embedded" {id}>

View file

@ -0,0 +1,269 @@
<!-- This is a Svelte 5 version of [svelte-scroller](https://github.com/sveltejs/svelte-scroller) -->
<script module lang="ts">
const handlers: Array<() => void> = [];
interface ManagerParams {
outer: Element;
update: () => void;
}
let manager: {
add: (params: ManagerParams) => void;
remove: (params: ManagerParams) => void;
};
if (typeof window !== 'undefined') {
const run_all = () => handlers.forEach((fn) => fn());
window.addEventListener('scroll', run_all);
window.addEventListener('resize', run_all);
}
if (typeof IntersectionObserver !== 'undefined') {
const map = new Map();
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const update = map.get(entry.target);
const index = handlers.indexOf(update);
if (entry.isIntersecting) {
if (index === -1) handlers.push(update);
} else {
update();
if (index !== -1) handlers.splice(index, 1);
}
});
},
{
rootMargin: '400px 0px', // TODO why 400?
}
);
manager = {
add: ({ outer, update }) => {
const { top, bottom } = outer.getBoundingClientRect();
if (top < window.innerHeight && bottom > 0) handlers.push(update);
map.set(outer, update);
observer.observe(outer);
},
remove: ({ outer, update }) => {
const index = handlers.indexOf(update);
if (index !== -1) handlers.splice(index, 1);
map.delete(outer);
observer.unobserve(outer);
},
};
} else {
manager = {
add: ({ update }) => {
handlers.push(update);
},
remove: ({ update }) => {
const index = handlers.indexOf(update);
if (index !== -1) handlers.splice(index, 1);
},
};
}
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { type Snippet } from 'svelte';
interface Props {
/** Config */
/** The vertical position that the top of the foreground must scroll past before the background becomes fixed, as a proportion of window height. **Value between 0 and 1.** */
top?: number;
/** The inverse of top — once the bottom of the foreground passes this point, the background becomes unfixed. **Value between 0 and 1.** */
bottom?: number;
/** Once a section crosses this point, it becomes 'active'. **Value between 0 and 1.** */
threshold?: number;
/** A CSS selector that describes the individual sections of your foreground. */
query?: string;
/** If `true`, the background will scroll such that the bottom edge reaches the bottom at the same time as the foreground. This effect can be unpleasant for people with high motion sensitivity, so use it advisedly. */
parallax?: boolean;
/** The background snippet. */
backgroundSnippet: Snippet;
/** The foreground snippet. */
foregroundSnippet: Snippet;
/** Bindings */
/** The currently active section. **Bindable** */
index?: number;
/** How far the section has scrolled past the threshold, as a value between 0 and 1. **Bindable**. */
offset?: number;
/** How far the foreground has travelled, where 0 is the top of the foreground crossing top, and 1 is the bottom crossing bottom. **Bindable**. */
progress?: number;
/** Number of sections */
count?: number;
/** Whether the foreground is visible */
visible?: boolean;
}
let {
// Bindings
index = $bindable(0),
count = $bindable(0),
offset = $bindable(0),
progress = $bindable(0),
visible = $bindable(false),
// Config
top = 0,
bottom = 1,
threshold = 0.5,
query = 'section',
parallax = false,
backgroundSnippet,
foregroundSnippet,
}: Props = $props();
let outer: HTMLElement;
let foreground: HTMLElement;
let background: HTMLElement;
let left;
// Target compiler option to es6 or higher for NodeListOf<T> to be iterable.
let sections: NodeListOf<HTMLElement>;
let wh = $state(0);
let fixed = $state(false);
let offset_top = 0;
let width = 1;
let top_px = Math.round(top * wh);
let bottom_px = Math.round(bottom * wh);
let threshold_px = Math.round(threshold * wh);
let style = $derived(`
position: ${fixed ? 'fixed' : 'absolute'};
transform: translate(0, ${offset_top}px);
`);
let widthStyle = $derived(fixed ? `width:${width}px;` : '');
onMount(() => {
sections = foreground.querySelectorAll(query);
count = sections.length;
update();
const scroller = { outer, update };
manager.add(scroller);
return () => manager.remove(scroller);
});
function update() {
if (!foreground) return;
// re-measure outer container
const bcr = outer.getBoundingClientRect();
left = bcr.left;
width = bcr.right - left;
// determine fix state
const fg = foreground.getBoundingClientRect();
const bg = background.getBoundingClientRect();
visible = fg.top < wh && fg.bottom > 0;
const foreground_height = fg.bottom - fg.top;
const background_height = bg.bottom - bg.top;
const available_space = bottom_px - top_px;
progress = (top_px - fg.top) / (foreground_height - available_space);
if (progress <= 0) {
offset_top = 0;
fixed = false;
} else if (progress >= 1) {
offset_top =
parallax ?
foreground_height - background_height
: foreground_height - available_space;
fixed = false;
} else {
offset_top =
parallax ?
Math.round(top_px - progress * (background_height - available_space))
: top_px;
fixed = true;
}
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
const { top } = section.getBoundingClientRect();
const next = sections[i + 1];
const bottom = next ? next.getBoundingClientRect().top : fg.bottom;
offset = (threshold_px - top) / (bottom - top);
if (bottom >= threshold_px) {
index = i;
break;
}
}
}
</script>
<svelte:window bind:innerHeight={wh} />
<svelte-scroller-outer bind:this={outer}>
<svelte-scroller-background-container
class="background-container"
style="{style}{widthStyle}"
>
<svelte-scroller-background bind:this={background}>
{@render backgroundSnippet()}
</svelte-scroller-background>
</svelte-scroller-background-container>
<svelte-scroller-foreground bind:this={foreground}>
{@render foregroundSnippet()}
</svelte-scroller-foreground>
</svelte-scroller-outer>
<style lang="scss">
svelte-scroller-outer {
display: block;
position: relative;
}
svelte-scroller-background {
display: block;
position: relative;
width: 100%;
}
svelte-scroller-foreground {
display: block;
position: relative;
z-index: 2;
}
svelte-scroller-foreground::after {
content: ' ';
display: block;
clear: both;
}
svelte-scroller-background-container {
display: block;
position: absolute;
width: 100%;
max-width: 100%;
pointer-events: none;
z-index: 1;
top: 0;
/* in theory this helps prevent jumping */
will-change: transform;
/* -webkit-transform: translate3d(0, 0, 0);
-moz-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0); */
}
</style>

View file

@ -0,0 +1,117 @@
<script>
// Hard-coding for demo purposes only...
import stepXs from './images/Body-issues-key-xs.png';
let width = $state();
</script>
<div id="g-Body-issues-key-box" bind:clientWidth={width}>
<!-- Artboard: xs -->
{#if width && width >= 0}
<div id="g-Body-issues-key-xs" class="g-artboard" style="">
<div style="padding: 0 0 48.4848% 0;"></div>
<div
id="g-Body-issues-key-xs-img"
class="g-aiImg"
alt=""
style="background-image: url({stepXs});"
></div>
<div
id="g-ai0-1"
class="g-Layer_1 g-aiAbs g-aiPointText"
style="top:19.4775%;margin-top:-10.2px;left:8.1818%;width:276px;"
>
<p class="g-pstyle0">Likelihood of something happening</p>
</div>
<div
id="g-ai0-3"
class="g-Layer_1 g-aiAbs g-aiPointText"
style="top:55.1025%;margin-top:-10.2px;left:27.2727%;width:68px;"
>
<p class="g-pstyle1">0-25%</p>
</div>
<div
id="g-ai0-4"
class="g-Layer_1 g-aiAbs g-aiPointText"
style="top:55.1025%;margin-top:-10.2px;left:74.2424%;width:75px;"
>
<p class="g-pstyle1">50-75%</p>
</div>
<div
id="g-ai0-5"
class="g-Layer_1 g-aiAbs g-aiPointText"
style="top:79.4775%;margin-top:-10.2px;left:74.2424%;width:82px;"
>
<p class="g-pstyle1">75-100%</p>
</div>
<div
id="g-ai0-6"
class="g-Layer_1 g-aiAbs g-aiPointText"
style="top:83.2275%;margin-top:-10.2px;left:27.2727%;width:77px;"
>
<p class="g-pstyle1">25-50%</p>
</div>
</div>
{/if}
</div>
<!-- End ai2html - 2025-03-17 09:52 -->
<!-- Generated by ai2html v0.100.0 - 2025-03-17 09:52 -->
<!-- ai file: Body-issues-key.ai -->
<style lang="scss">
#g-Body-issues-key-box,
#g-Body-issues-key-box .g-artboard {
margin: 0 auto;
}
#g-Body-issues-key-box p {
margin: 0;
}
#g-Body-issues-key-box .g-aiAbs {
position: absolute;
}
#g-Body-issues-key-box .g-aiImg {
position: absolute;
top: 0;
display: block;
width: 100% !important;
height: 100%;
background-size: contain;
background-repeat: no-repeat;
}
#g-Body-issues-key-box .g-aiPointText p {
white-space: nowrap;
}
#g-Body-issues-key-xs {
position: relative;
overflow: hidden;
}
#g-Body-issues-key-xs p {
font-family: 'Knowledge', 'Source Sans Pro', Arial, sans-serif;
font-weight: 500;
line-height: 19px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(64, 64, 64);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-Body-issues-key-xs .g-pstyle0 {
height: 19px;
}
#g-Body-issues-key-xs .g-pstyle1 {
font-weight: 400;
height: 19px;
}
/* Custom CSS */
.text {
}
</style>

View file

@ -1,14 +1,9 @@
<script>
// Hard-coding for demo purposes only...
// @ts-ignore img
import stepXl from './images/step-1-xl.png';
// @ts-ignore img
import stepLg from './images/step-1-lg.png';
// @ts-ignore img
import stepMd from './images/step-1-md.png';
// @ts-ignore img
import stepSm from './images/step-1-sm.png';
// @ts-ignore img
import stepXs from './images/step-1-xs.png';
let width = $state();
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -8,7 +8,7 @@
<h4>Interactive step</h4>
<p>The count is {count}</p>
<p class="font-sans">The count is <strong>{count}</strong></p>
<button
onclick={() => {

View file

@ -1,42 +0,0 @@
A more detailed example of using `Scroller` with graphics created by [ai2svelte](https://github.com/reuters-graphics/ai2svelte).
```svelte
<script>
import { Scroller } from '@reuters-graphics/graphics-components';
import MyAiMap1 from './ai2svelte/my-map-step-1.svelte';
import MyAiMap2 from './ai2svelte/my-map-step-2.svelte';
import MyAiMap3 from './ai2svelte/my-map-step-3.svelte';
import { assets } from '$app/paths'; // If using with the Graphics Kit
const steps = [
{
background: MyAiMap1,
backgroundProps: { assetsPath: assets },
foreground: '#### Step 1\n\nLorem ipsum',
altText: 'A map showing TKTK',
},
{
background: MyAiMap2,
backgroundProps: { assetsPath: assets },
foreground: '#### Step 2\n\nLorem ipsum',
altText: 'The same map now highlights something interesting.',
},
{
background: MyAiMap3,
backgroundProps: { assetsPath: assets },
foreground: '#### Step 3\n\nLorem ipsum',
altText: 'The same map now shows something else that is interesting.',
},
];
</script>
<Scroller
steps="{steps}"
backgroundWidth="fluid"
foregroundPosition="middle"
stackBackground="{true}"
embedded="{false}"
/>
```

View file

@ -1,87 +0,0 @@
First, import your ai2svelte graphics in `App.svelte` and add them to the `aiCharts` object;
```svelte
<!-- App.svelte -->
<script>
// Other stuff...
import AiMap1 from './ai2svelte/my-map-1.svelte';
import AiMap2 from './ai2svelte/my-map-2.svelte';
import AiMap3 from './ai2svelte/my-map-3.svelte';
const aiCharts = {
// Other charts...
AiMap1,
AiMap2,
AiMap3,
};
</script>
```
Then add the following structure to your ArchieML Doc, taking care that the names of your charts in the `aiCharts` object match the names of your step backgrounds:
```yaml
[blocks]
# ...
type: ai-scroller
id: my-map-scroller
width: normal
foregroundPosition: middle
stackBackground: true
[.steps]
background: AiMap1
text: #### Step 1
Lorem ipsum
:end
altText: A map showing the Upper West side in New York City.
Can add paragraphs of alt text if you want to break up sentences.
:end
background: AiMap2
text: #### Step 2
Lorem ipsum
:end
altText: The same map now highlights 98th Street.
:end
background: AiMap3
text: #### Step 3
Lorem ipsum
:end
altText: The same map now highlights three locations near 98th Street where something particulary important happened.
:end
[]
# ...
[]
```
```svelte
<!-- App.svelte -->
{#each content.blocks as block}
{#if block.type === 'text'}
<!-- ... -->
{:else if block.type === 'ai-scroller'}
<Scroller
id="{block.id}"
backgroundWidth="{block.width}"
foregroundPosition="{block.foregroundPosition}"
stackBackground="{block.stackBackground === 'true'}"
steps="{block.steps.map((step) => ({
background: aiCharts[step.background],
backgroundProps: { assetsPath: assets || '/' },
foreground: step.text,
altText: step.altText,
}))}"
/>
<!-- ... -->
{/if}
{/each}
```

View file

@ -1,39 +0,0 @@
The `Scroller` component helps you quickly create basic scrollytelling graphics with several layout options.
> This component is designed to handle most common layouts for scrollytelling. If you need something more complex, though, you should probably use [svelte-scroller](https://github.com/sveltejs/svelte-scroller), which is a lower level component you can more easily customize.
```svelte
<script>
import { Scroller } from '@reuters-graphics/graphics-components';
import MyColourBackground from './MyColourBackground.svelte';
const steps = [
{
background: MyColourBackground,
backgroundProps: { colour: 'red' },
foreground: '#### Step 1\n\nLorem ipsum red',
altText: 'Red background',
},
{
background: MyColourBackground,
backgroundProps: { colour: 'blue' },
foreground: '#### Step 2\n\nLorem ipsum blue',
altText: 'Blue background',
},
{
background: MyColourBackground,
backgroundProps: { colour: 'green' },
foreground: '#### Step 3\n\nLorem ipsum green',
altText: 'Green background',
},
];
</script>
<Scroller
steps="{steps}"
backgroundWidth="fluid"
foregroundPosition="middle"
embedded="{false}"
/>
```

View file

@ -1,36 +0,0 @@
Instead of just text, you can use any component in the foreground, too, and pass whatever props you need to it.
If you're making your own custom foreground component, remember to add alt text describing the background graphic.
```svelte
<script>
// ...
import MyCounter from './MyCounter.svelte';
const steps = [
{
background: MyColourBackground,
backgroundProps: { colour: 'red' },
foreground: MyCounter,
},
{
background: MyColourBackground,
backgroundProps: { colour: 'blue' },
foreground: '#### Step 2\n\nLorem ipsum blue',
},
{
background: MyColourBackground,
backgroundProps: { colour: 'green' },
foreground: MyCounter,
foregroundProps: { count: 100 },
},
];
</script>
<Scroller
steps="{steps}"
backgroundWidth="fluid"
foregroundPosition="middle"
embedded="{false}"
/>
```