hypnagaga/src/components/HorizontalScroller/HorizontalScroller.svelte
2026-01-12 10:08:09 -05:00

253 lines
7 KiB
Svelte

<script lang="ts">
import { onMount, type Snippet } from 'svelte';
import { Tween } from 'svelte/motion';
import { clamp, map } from './utils/index';
import type { Action } from 'svelte/action';
import Debug from './Debug.svelte';
interface Props {
/** Optional id for the scroller container */
id?: string;
/** Optional additional classes for the scroller container */
class?: string;
/** Height of the scroller container in CSS `vh` units. Set it to `100lvh` when using inside ScrollerBase. */
height?: string;
/** Bindable progress value. Ideal range: `[0-1]`. Bind ScrollerBase's progress to this prop. */
progress?: number;
/** Direction of movement*/
direction?: 'left' | 'right';
/** Content to scroll*/
children?: Snippet;
/** Array of numbers desired as stops for the scroller */
stops?: number[];
/** Should the component handle scroll events? Set it to `false` when using inside ScrollerBase. */
handleScroll?: boolean;
/** Whether the stops should be scrubbed */
scrubbed?: boolean;
/** Easing function for the progress/stops */
easing?: (t: number) => number;
/** Duration of the easing animation in milliseconds. Effective only when scrubbed is false. */
duration?: number;
/** Whether to show debug info */
showDebugInfo?: boolean;
/** Modified starting scale. Default is 0 */
mappedStart?: number;
/** Modified ending scale. Default is 1 */
mappedEnd?: number;
}
let {
id = '',
class: cls = '',
height = '200lvh',
direction = 'right',
progress = $bindable(0),
mappedStart = 0,
mappedEnd = 1,
children,
stops = [],
handleScroll = true,
scrubbed = true,
easing: ease = (t) => t,
duration = 400,
showDebugInfo = false,
}: Props = $props();
let componentState = $derived.by(() => ({
progress,
mappedProgress,
easedProgress: easedProgress.current,
direction,
mappedStart,
mappedEnd,
triggerStops: scrubbed ? stops : divisions,
stops: stops,
handleScroll,
scrubbed,
easing: ease,
duration,
}));
let easedProgress: Tween<number> = $state(
new Tween(mappedStart, { duration, easing: ease })
);
let container: HTMLDivElement | undefined = $state(undefined);
let containerHeight: number = $state(0);
let containerWidth: number = $state(0);
let content: HTMLDivElement | undefined = $state(undefined);
let contentWidth: number = $state(0);
let screenHeight: number = $state(0);
let divisions: number[] = $derived(
[...stops, mappedStart, mappedEnd].sort((a, b) => a - b)
);
let divisionsCount: number = $derived.by(() => divisions.length - 1);
let mappedProgress: number = $state(0);
// handles horizontal translation of the content
let translateX: number = $derived.by(() => {
let processedProgress = clamp(
easedProgress.current,
mappedStart,
mappedEnd
);
let normalisedProgress = processedProgress;
normalisedProgress =
direction === 'right' ? processedProgress : mappedEnd - processedProgress;
const translate = -(contentWidth - containerWidth) * normalisedProgress;
return translate;
});
onMount(() => {
// Initialize mappedProgress to mappedStart on mount
mappedProgress = mappedStart;
});
const scrollListener: Action = () => {
if (handleScroll) {
window.addEventListener('scroll', handleScrollFunction, {
passive: true,
});
} else {
window.addEventListener('scroll', () => handleStops(progress), {
passive: true,
});
}
};
// calculates distance scrolled inside the container
function handleScrollFunction() {
if (!container) return;
progress =
(-container?.offsetTop + window?.scrollY) /
(containerHeight - screenHeight);
handleStops(progress);
}
// updates easedProgress based on stops and scrubbed settings
function handleStops(rawProgress: number) {
mappedProgress = map(rawProgress, 0, 1, mappedStart, mappedEnd);
if (!stops || stops.length === 0) {
easedProgress.set(ease(map(rawProgress, 0, 1, mappedStart, mappedEnd)), {
duration: 0,
});
return;
}
if (!scrubbed) {
for (let i = 0; i < divisions.length; i++) {
if (
mappedProgress > divisions[i] &&
mappedProgress <=
(divisions[i + 1] ?? divisions[divisions.length - 1])
) {
const midPoint =
divisions[i] +
((divisions[i + 1] ?? divisions[divisions.length - 1]) -
divisions[i]) *
0.5;
if (
mappedProgress >= midPoint &&
easedProgress.target !==
(divisions[i + 1] ?? divisions[divisions.length - 1])
) {
easedProgress.set(
divisions[i + 1] ?? divisions[divisions.length - 1]
);
return;
} else if (
mappedProgress < midPoint &&
easedProgress.target !== divisions[i]
) {
easedProgress.set(divisions[i]);
return;
}
} else if (
mappedProgress <
divisions[0] + (divisions[1] ?? mappedStart) * 0.5
) {
if (easedProgress.target !== divisions[0]) {
easedProgress.set(divisions[0]);
return;
}
} else {
continue;
}
}
} else {
for (let i = 0; i < divisions.length; i++) {
let oneByDivCount = 1 / divisionsCount;
let normalStart = i == 0 ? mappedStart : oneByDivCount * i;
let normalEnd =
i == divisionsCount - 1 ? mappedEnd : oneByDivCount * (i + 1);
if (mappedProgress >= normalStart && mappedProgress < normalEnd) {
let stopStart = divisions[i];
let stopEnd = divisions[i + 1] ?? mappedEnd;
let newProgressVal =
stopStart +
ease(map(mappedProgress, normalStart, normalEnd, 0, 1)) *
(stopEnd - stopStart);
easedProgress.set(newProgressVal, { duration: 0 });
return;
} else {
continue;
}
}
}
}
</script>
<svelte:window bind:innerHeight={screenHeight} />
<div
{id}
class={`horizontal-scroller-container ${cls}`}
style="height: {height};"
bind:this={container}
bind:clientHeight={containerHeight}
bind:clientWidth={containerWidth}
>
<div
class="horizontal-scroller-content"
bind:this={content}
bind:clientWidth={contentWidth}
style="transform: translateX({translateX}px);"
use:scrollListener
>
{#if children}
{@render children()}
{/if}
{#if showDebugInfo}
<div
class="debug-info"
style={`position: absolute; left: ${-translateX}px; top: 0px;`}
>
<Debug {componentState} />
</div>
{/if}
</div>
</div>
<style lang="scss">
.horizontal-scroller-container {
width: 100%;
contain: paint;
}
.horizontal-scroller-content {
display: inline-block;
height: 100%;
max-height: 100lvh;
position: sticky;
top: 0;
}
</style>