feat(web): add HorizontalScrollRow component
Reusable horizontally-scrolling row with left/right arrow buttons, scroll-boundary disabling, snap scrolling, and hover-reveal arrows. Used by M6a home page (Task 17). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
|||||||
|
<script lang="ts" generics="T">
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
item,
|
||||||
|
ariaLabel
|
||||||
|
}: {
|
||||||
|
items: T[];
|
||||||
|
item?: Snippet<[T, number]>;
|
||||||
|
ariaLabel?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let scroller: HTMLElement | undefined = $state();
|
||||||
|
let scrollLeft = $state(0);
|
||||||
|
let scrollWidth = $state(0);
|
||||||
|
let clientWidth = $state(0);
|
||||||
|
|
||||||
|
const atStart = $derived(scrollLeft <= 1);
|
||||||
|
const atEnd = $derived(scrollLeft + clientWidth >= scrollWidth - 1);
|
||||||
|
|
||||||
|
function refreshMetrics() {
|
||||||
|
if (!scroller) return;
|
||||||
|
scrollLeft = scroller.scrollLeft;
|
||||||
|
scrollWidth = scroller.scrollWidth;
|
||||||
|
clientWidth = scroller.clientWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function page(direction: -1 | 1) {
|
||||||
|
if (!scroller) return;
|
||||||
|
const delta = direction * scroller.clientWidth * 0.85;
|
||||||
|
scroller.scrollBy({ left: delta, behavior: 'smooth' });
|
||||||
|
setTimeout(refreshMetrics, 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { refreshMetrics(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="row relative" aria-label={ariaLabel}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="arrow arrow-left"
|
||||||
|
aria-label="Scroll left"
|
||||||
|
onclick={() => page(-1)}
|
||||||
|
disabled={atStart}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} strokeWidth={1} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={scroller}
|
||||||
|
onscroll={refreshMetrics}
|
||||||
|
data-testid="scroll-container"
|
||||||
|
class="scroller flex gap-4 overflow-x-auto"
|
||||||
|
>
|
||||||
|
{#each items as it, i (i)}
|
||||||
|
{@render item?.(it, i)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="arrow arrow-right"
|
||||||
|
aria-label="Scroll right"
|
||||||
|
onclick={() => page(1)}
|
||||||
|
disabled={atEnd}
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} strokeWidth={1} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row { padding: 0 36px; }
|
||||||
|
.scroller {
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.scroller > :global(*) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
}
|
||||||
|
.arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--fs-iron);
|
||||||
|
color: var(--fs-vellum);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--fs-pewter);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.arrow:hover:not(:disabled) { color: var(--fs-parchment); }
|
||||||
|
.arrow:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
.arrow-left { left: 0; }
|
||||||
|
.arrow-right { right: 0; }
|
||||||
|
@media (hover: hover) {
|
||||||
|
.arrow { opacity: 0; transition: opacity 150ms ease; }
|
||||||
|
.row:hover .arrow { opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
|
import HorizontalScrollRow from './HorizontalScrollRow.svelte';
|
||||||
|
|
||||||
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
describe('HorizontalScrollRow', () => {
|
||||||
|
test('renders left and right scroll buttons', () => {
|
||||||
|
render(HorizontalScrollRow, { props: { items: ['a', 'b', 'c'] } });
|
||||||
|
expect(screen.getByRole('button', { name: /scroll left/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /scroll right/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('left button starts disabled (scrollLeft is 0)', () => {
|
||||||
|
render(HorizontalScrollRow, { props: { items: ['a'] } });
|
||||||
|
expect(screen.getByRole('button', { name: /scroll left/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking right button calls scrollBy on the container', async () => {
|
||||||
|
const { container } = render(HorizontalScrollRow, { props: { items: ['a', 'b'] } });
|
||||||
|
const scroller = container.querySelector('[data-testid="scroll-container"]') as HTMLElement;
|
||||||
|
const spy = vi.fn();
|
||||||
|
scroller.scrollBy = spy as unknown as HTMLElement['scrollBy'];
|
||||||
|
Object.defineProperty(scroller, 'clientWidth', { value: 800, configurable: true });
|
||||||
|
Object.defineProperty(scroller, 'scrollWidth', { value: 2000, configurable: true });
|
||||||
|
Object.defineProperty(scroller, 'scrollLeft', { value: 0, configurable: true, writable: true });
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /scroll right/i }));
|
||||||
|
expect(spy).toHaveBeenCalledWith({ left: expect.any(Number), behavior: 'smooth' });
|
||||||
|
expect(spy.mock.calls[0][0].left).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user