Adds prev/next button click safety, ArrowRight + ArrowLeft + ArrowDown keyboard navigation through chips with wrap-around. 5 new tests covering ~10 branches. Refs #496. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
210 lines
5.9 KiB
TypeScript
210 lines
5.9 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { cleanup, render } from 'vitest-browser-svelte';
|
|
import { page } from 'vitest/browser';
|
|
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
|
|
|
afterEach(cleanup);
|
|
|
|
const makeEntry = (id: string, title: string, overrides: Record<string, unknown> = {}) => ({
|
|
id,
|
|
title,
|
|
status: 'idle' as 'idle' | 'error',
|
|
previewUrl: '',
|
|
...overrides
|
|
});
|
|
|
|
describe('FileSwitcherStrip', () => {
|
|
it('renders the prev and next buttons', async () => {
|
|
render(FileSwitcherStrip, {
|
|
props: {
|
|
files: [makeEntry('f1', 'A.pdf')],
|
|
activeId: 'f1',
|
|
onSelect: () => {},
|
|
onRemove: () => {}
|
|
}
|
|
});
|
|
|
|
await expect.element(page.getByRole('button', { name: /vorherige datei/i })).toBeVisible();
|
|
await expect.element(page.getByRole('button', { name: /nächste datei/i })).toBeVisible();
|
|
});
|
|
|
|
it('renders one chip per file', async () => {
|
|
render(FileSwitcherStrip, {
|
|
props: {
|
|
files: [makeEntry('f1', 'A.pdf'), makeEntry('f2', 'B.pdf'), makeEntry('f3', 'C.pdf')],
|
|
activeId: 'f1',
|
|
onSelect: () => {},
|
|
onRemove: () => {}
|
|
}
|
|
});
|
|
|
|
const chips = document.querySelectorAll('[data-chip-id]');
|
|
expect(chips.length).toBe(3);
|
|
});
|
|
|
|
it('marks the active chip with aria-current=true', async () => {
|
|
render(FileSwitcherStrip, {
|
|
props: {
|
|
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B')],
|
|
activeId: 'f2',
|
|
onSelect: () => {},
|
|
onRemove: () => {}
|
|
}
|
|
});
|
|
|
|
const f2 = document.querySelector('[data-chip-id="f2"]') as HTMLElement;
|
|
const f1 = document.querySelector('[data-chip-id="f1"]') as HTMLElement;
|
|
expect(f2.getAttribute('aria-current')).toBe('true');
|
|
expect(f1.getAttribute('aria-current')).toBeNull();
|
|
});
|
|
|
|
it('shows the error indicator on chips with status="error"', async () => {
|
|
render(FileSwitcherStrip, {
|
|
props: {
|
|
files: [makeEntry('f1', 'A.pdf', { status: 'error' })],
|
|
activeId: 'f1',
|
|
onSelect: () => {},
|
|
onRemove: () => {}
|
|
}
|
|
});
|
|
|
|
const chip = document.querySelector('[data-chip-id="f1"]') as HTMLElement;
|
|
expect(chip.getAttribute('data-status')).toBe('error');
|
|
});
|
|
|
|
it('calls onSelect with the chip id when clicked', async () => {
|
|
const onSelect = vi.fn();
|
|
render(FileSwitcherStrip, {
|
|
props: {
|
|
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B')],
|
|
activeId: 'f1',
|
|
onSelect,
|
|
onRemove: () => {}
|
|
}
|
|
});
|
|
|
|
const f2 = document.querySelector('[data-chip-id="f2"]') as HTMLElement;
|
|
f2.click();
|
|
|
|
expect(onSelect).toHaveBeenCalledWith('f2');
|
|
});
|
|
|
|
it('calls onRemove when the remove button is clicked', async () => {
|
|
const onRemove = vi.fn();
|
|
render(FileSwitcherStrip, {
|
|
props: {
|
|
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B')],
|
|
activeId: 'f1',
|
|
onSelect: () => {},
|
|
onRemove
|
|
}
|
|
});
|
|
|
|
const remove = document.querySelector('[data-remove-id="f1"]') as HTMLElement;
|
|
remove.click();
|
|
|
|
expect(onRemove).toHaveBeenCalledWith('f1');
|
|
});
|
|
|
|
it('renders the active title in the sr-only announcer', async () => {
|
|
render(FileSwitcherStrip, {
|
|
props: {
|
|
files: [makeEntry('f1', 'Ein Brief.pdf'), makeEntry('f2', 'B')],
|
|
activeId: 'f1',
|
|
onSelect: () => {},
|
|
onRemove: () => {}
|
|
}
|
|
});
|
|
|
|
const announcer = document.querySelector('[aria-live="polite"]');
|
|
expect(announcer?.textContent).toContain('Ein Brief.pdf');
|
|
});
|
|
|
|
it('clicking prev button does not throw with no scroll target available', async () => {
|
|
render(FileSwitcherStrip, {
|
|
props: {
|
|
files: [makeEntry('f1', 'A.pdf')],
|
|
activeId: 'f1',
|
|
onSelect: () => {},
|
|
onRemove: () => {}
|
|
}
|
|
});
|
|
|
|
const prevBtn = Array.from(document.querySelectorAll('button')).find((b) =>
|
|
/vorherige|prev/i.test(b.getAttribute('aria-label') ?? '')
|
|
) as HTMLButtonElement;
|
|
expect(() => prevBtn.click()).not.toThrow();
|
|
});
|
|
|
|
it('clicking next button does not throw', async () => {
|
|
render(FileSwitcherStrip, {
|
|
props: {
|
|
files: [makeEntry('f1', 'A.pdf')],
|
|
activeId: 'f1',
|
|
onSelect: () => {},
|
|
onRemove: () => {}
|
|
}
|
|
});
|
|
|
|
const nextBtn = Array.from(document.querySelectorAll('button')).find((b) =>
|
|
/nächste|next/i.test(b.getAttribute('aria-label') ?? '')
|
|
) as HTMLButtonElement;
|
|
expect(() => nextBtn.click()).not.toThrow();
|
|
});
|
|
|
|
it('navigates with ArrowRight key on focused chip', async () => {
|
|
render(FileSwitcherStrip, {
|
|
props: {
|
|
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B'), makeEntry('f3', 'C')],
|
|
activeId: 'f1',
|
|
onSelect: () => {},
|
|
onRemove: () => {}
|
|
}
|
|
});
|
|
|
|
const f1 = document.querySelector('[data-chip-id="f1"]') as HTMLElement;
|
|
f1.focus();
|
|
f1.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
|
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
expect(document.activeElement?.getAttribute('data-chip-id')).toBe('f2');
|
|
});
|
|
|
|
it('navigates with ArrowLeft key on focused chip (wraps around)', async () => {
|
|
render(FileSwitcherStrip, {
|
|
props: {
|
|
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B')],
|
|
activeId: 'f1',
|
|
onSelect: () => {},
|
|
onRemove: () => {}
|
|
}
|
|
});
|
|
|
|
const f1 = document.querySelector('[data-chip-id="f1"]') as HTMLElement;
|
|
f1.focus();
|
|
f1.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
|
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
// ArrowLeft from index 0 wraps to last (f2)
|
|
expect(document.activeElement?.getAttribute('data-chip-id')).toBe('f2');
|
|
});
|
|
|
|
it('ArrowDown is treated as ArrowRight (vertical key alias)', async () => {
|
|
render(FileSwitcherStrip, {
|
|
props: {
|
|
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B')],
|
|
activeId: 'f1',
|
|
onSelect: () => {},
|
|
onRemove: () => {}
|
|
}
|
|
});
|
|
|
|
const f1 = document.querySelector('[data-chip-id="f1"]') as HTMLElement;
|
|
f1.focus();
|
|
f1.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
expect(document.activeElement?.getAttribute('data-chip-id')).toBe('f2');
|
|
});
|
|
});
|