ui : add unified ScrollCarousel component

Single carousel component with top/center variants, gap and scroll options, and hover-revealed chevrons. Rename the HorizontalScrollCarousel accessibility story accordingly.

Assisted-by: pi
This commit is contained in:
Aleksander Grygier
2026-08-21 08:46:30 +02:00
committed by GitHub
parent 2e875fa3d5
commit 0d574e77c0
3 changed files with 139 additions and 7 deletions
@@ -0,0 +1,3 @@
import ScrollCarousel from './scroll-carousel.svelte';
export { ScrollCarousel };
@@ -0,0 +1,129 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { cn } from '$lib/components/ui/utils';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
/** External carousel hook for callers that need to drive it (e.g. scrollToCenter). */
carousel?: ReturnType<typeof useScrollCarousel>;
/** Classes for the outer relative wrapper. */
class?: string;
/** Classes for the scrollable overflow container. */
containerClass?: string;
/** Classes for the min-w-max content wrapper. */
innerClass?: string;
/** Tailwind gap class applied to the content wrapper. */
gapSize?: string;
/** Arrow placement and styling. */
variant?: 'top' | 'center';
/** Called with whether the content overflows the container. */
onScrollableChange?: (isScrollable: boolean) => void;
/** Pixels scrolled per arrow click; defaults to ~2/3 of the container width. */
scrollBy?: number;
}
let {
carousel: externalCarousel,
children,
class: className = '',
containerClass = '',
gapSize = '3',
innerClass = '',
onScrollableChange,
scrollBy,
variant = 'top'
}: Props = $props();
const internalCarousel = $derived(useScrollCarousel(onScrollableChange));
const carousel = $derived(externalCarousel ?? internalCarousel);
const isCenter = $derived(variant === 'center');
function scrollLeft(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
const container = carousel.scrollContainer;
if (!container) return;
container.scrollBy({ behavior: 'smooth', left: -(scrollBy ?? container.clientWidth * 0.67) });
}
function scrollRight(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
const container = carousel.scrollContainer;
if (!container) return;
container.scrollBy({ behavior: 'smooth', left: scrollBy ?? container.clientWidth * 0.67 });
}
export function resetScroll() {
const container = carousel.scrollContainer;
if (!container) return;
container.scrollLeft = 0;
setTimeout(() => carousel.updateScrollButtons(), 0);
}
</script>
<div
class={cn('group relative', !isCenter && 'flex items-center', className)}
style={!isCenter ? 'scroll-padding: 1rem;' : undefined}
>
<button
class={cn(
'absolute z-10 flex h-6 w-6 items-center justify-center rounded-full shadow-md transition-opacity',
isCenter
? 'top-1/2 left-4 -translate-y-1/2 bg-background/25 backdrop-blur-xs hover:bg-background/45 disabled:pointer-events-none disabled:opacity-0'
: 'left-2 bg-muted backdrop-blur-sm hover:bg-accent',
!isCenter &&
(carousel.canScrollLeft
? 'opacity-0 group-hover:opacity-100'
: 'pointer-events-none opacity-0')
)}
{...isCenter ? { disabled: !carousel.canScrollLeft } : {}}
onclick={scrollLeft}
aria-label="Scroll left"
>
<ChevronLeft class={ICON_CLASS_DEFAULT} />
</button>
<div
class={cn('scrollbar-hide overflow-x-auto', containerClass)}
bind:this={carousel.scrollContainer}
onscroll={carousel.updateScrollButtons}
>
<div
class={cn('flex min-w-max', `gap-${gapSize}`, innerClass)}
bind:this={carousel.contentContainer}
>
{@render children?.()}
</div>
</div>
<button
class={cn(
'absolute z-10 flex h-6 w-6 items-center justify-center rounded-full shadow-md transition-opacity',
isCenter
? 'top-1/2 right-4 -translate-y-1/2 bg-background/25 backdrop-blur-xs hover:bg-background/45 disabled:pointer-events-none disabled:opacity-0'
: 'right-2 bg-muted backdrop-blur-sm hover:bg-accent',
!isCenter &&
(carousel.canScrollRight
? 'opacity-0 group-hover:opacity-100'
: 'pointer-events-none opacity-0')
)}
{...isCenter ? { disabled: !carousel.canScrollRight } : {}}
onclick={scrollRight}
aria-label="Scroll right"
>
<ChevronRight class={ICON_CLASS_DEFAULT} />
</button>
</div>
@@ -1,15 +1,15 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import HorizontalScrollCarousel from '$lib/components/app/misc/HorizontalScrollCarousel.svelte';
import { ScrollCarousel } from '$lib/components/ui/scroll-carousel';
import { expect, waitFor } from 'storybook/test';
const { Story } = defineMeta({
component: HorizontalScrollCarousel,
component: ScrollCarousel,
parameters: {
layout: 'centered'
},
tags: ['!dev'],
title: 'Components/HorizontalScrollCarousel/Accessibility'
title: 'Components/ScrollCarousel/Accessibility'
});
</script>
@@ -33,10 +33,10 @@
>
<div>
<button type="button">before</button>
<HorizontalScrollCarousel class="w-96">
<ScrollCarousel class="w-96" variant="center">
<div class="h-12 w-12 shrink-0 bg-muted"></div>
<div class="h-12 w-12 shrink-0 bg-muted"></div>
</HorizontalScrollCarousel>
</ScrollCarousel>
<button type="button">after</button>
</div>
</Story>
@@ -60,10 +60,10 @@
>
<div>
<button type="button">before</button>
<HorizontalScrollCarousel class="w-48">
<ScrollCarousel class="w-48" variant="center">
{#each [...Array(20).keys()] as i (i)}
<div class="h-12 w-24 shrink-0 bg-muted">{i}</div>
{/each}
</HorizontalScrollCarousel>
</ScrollCarousel>
</div>
</Story>