From 8f5eb210946c14175f91e1bcc24a3499169419ea Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 20:15:10 -0400 Subject: [PATCH] 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 --- .../lib/components/HorizontalScrollRow.svelte | 106 ++++++++++++++++++ .../components/HorizontalScrollRow.test.ts | 32 ++++++ 2 files changed, 138 insertions(+) create mode 100644 web/src/lib/components/HorizontalScrollRow.svelte create mode 100644 web/src/lib/components/HorizontalScrollRow.test.ts diff --git a/web/src/lib/components/HorizontalScrollRow.svelte b/web/src/lib/components/HorizontalScrollRow.svelte new file mode 100644 index 00000000..fe953700 --- /dev/null +++ b/web/src/lib/components/HorizontalScrollRow.svelte @@ -0,0 +1,106 @@ + + +
+ + +
+ {#each items as it, i (i)} + {@render item?.(it, i)} + {/each} +
+ + +
+ + diff --git a/web/src/lib/components/HorizontalScrollRow.test.ts b/web/src/lib/components/HorizontalScrollRow.test.ts new file mode 100644 index 00000000..55ee2e9d --- /dev/null +++ b/web/src/lib/components/HorizontalScrollRow.test.ts @@ -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); + }); +});