From 1f1b7aeab5a9af4383e472a6cb4b3f0a082dcc89 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 17:40:33 +0200 Subject: [PATCH] feat(bulk-upload): add FileSwitcherStrip component Horizontal chip strip for switching between files in a bulk upload session. Supports keyboard navigation (arrow keys cycle within the strip), error state chips, and onSelect/onRemove callbacks. Co-Authored-By: Claude Sonnet 4.6 --- .../document/FileSwitcherStrip.svelte | 91 +++++++++++++++++ .../document/FileSwitcherStrip.svelte.spec.ts | 97 +++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 frontend/src/lib/components/document/FileSwitcherStrip.svelte create mode 100644 frontend/src/lib/components/document/FileSwitcherStrip.svelte.spec.ts diff --git a/frontend/src/lib/components/document/FileSwitcherStrip.svelte b/frontend/src/lib/components/document/FileSwitcherStrip.svelte new file mode 100644 index 00000000..e7badb4d --- /dev/null +++ b/frontend/src/lib/components/document/FileSwitcherStrip.svelte @@ -0,0 +1,91 @@ + + + diff --git a/frontend/src/lib/components/document/FileSwitcherStrip.svelte.spec.ts b/frontend/src/lib/components/document/FileSwitcherStrip.svelte.spec.ts new file mode 100644 index 00000000..86968e24 --- /dev/null +++ b/frontend/src/lib/components/document/FileSwitcherStrip.svelte.spec.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import FileSwitcherStrip from './FileSwitcherStrip.svelte'; + +afterEach(cleanup); + +export interface FileEntry { + id: string; + file: File; + title: string; + status: 'idle' | 'error'; +} + +function makeFiles(n: number): FileEntry[] { + return Array.from({ length: n }, (_, i) => ({ + id: `id-${i}`, + file: new File([''], `file${i}.pdf`), + title: `File ${i}`, + status: 'idle' as const + })); +} + +describe('FileSwitcherStrip', () => { + it('renders N chips for N files', async () => { + const files = makeFiles(4); + render(FileSwitcherStrip, { + files, + activeId: files[0].id, + onSelect: vi.fn(), + onRemove: vi.fn() + }); + const chips = page.getByRole('listitem'); + await expect.element(chips.nth(0)).toBeInTheDocument(); + await expect.element(chips.nth(3)).toBeInTheDocument(); + }); + + it('active chip has aria-current="true"', async () => { + const files = makeFiles(3); + const { container } = render(FileSwitcherStrip, { + files, + activeId: files[1].id, + onSelect: vi.fn(), + onRemove: vi.fn() + }); + const activeBtn = container.querySelector('[aria-current="true"]'); + expect(activeBtn).not.toBeNull(); + expect(activeBtn?.textContent).toContain('File 1'); + }); + + it('clicking a chip fires onSelect with its id', async () => { + const files = makeFiles(3); + const onSelect = vi.fn(); + const { container } = render(FileSwitcherStrip, { + files, + activeId: files[0].id, + onSelect, + onRemove: vi.fn() + }); + const chip = container.querySelector('[data-chip-id="id-2"]') as HTMLElement; + expect(chip).not.toBeNull(); + chip.click(); + expect(onSelect).toHaveBeenCalledWith('id-2'); + }); + + it('error chip has aria-label containing warning indicator', async () => { + const files: FileEntry[] = [ + { id: 'e1', file: new File([''], 'bad.pdf'), title: 'Bad file', status: 'error' } + ]; + const { container } = render(FileSwitcherStrip, { + files, + activeId: 'e1', + onSelect: vi.fn(), + onRemove: vi.fn() + }); + const errBtn = container.querySelector('[data-status="error"]'); + expect(errBtn).not.toBeNull(); + }); + + it('ArrowRight moves focus to next chip without leaving strip', async () => { + const files = makeFiles(3); + const { container } = render(FileSwitcherStrip, { + files, + activeId: files[0].id, + onSelect: vi.fn(), + onRemove: vi.fn() + }); + const firstBtn = container.querySelectorAll('[role="button"]')[0] as HTMLElement; + firstBtn.focus(); + await userEvent.keyboard('{ArrowRight}'); + const focused = document.activeElement; + expect(focused).not.toBe(firstBtn); + // The new focused element should still be inside the strip + const strip = container.querySelector('[data-testid="file-switcher-strip"]'); + expect(strip?.contains(focused)).toBe(true); + }); +});