Files
familienarchiv/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts
Marcel 4961c74a01 feat(document-picker): optional inputId prop for external label wiring (#795)
The input always carries an id (generated doc-picker-input-{uid} default,
mirroring the listbox id scheme). When a caller passes inputId it wires a
visible <label for> — the aria-label fallback is dropped then so the
visible label wins. JourneyAddBar stays untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:23:45 +02:00

262 lines
8.6 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import DocumentPickerDropdown from './DocumentPickerDropdown.svelte';
import { m } from '$lib/paraglide/messages.js';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const docFactory = (id: string, title: string) => ({
id,
title,
documentDate: '1880-01-01',
metaDatePrecision: 'DAY' as const,
metaDateEnd: undefined
});
function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ items })
})
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('DocumentPickerDropdown — empty query guard', () => {
it('does not call fetch on empty query', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), '');
await waitForDebounce();
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('DocumentPickerDropdown — already-added indicator', () => {
it('shows already-added document as aria-disabled with sr-only hint', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, {
alreadyAddedIds: new Set(['d1']),
onSelect: vi.fn()
});
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
const disabledItem = page
.getByText(/Brief von Eugenie/i)
.element()
.closest('li')!;
expect(disabledItem.getAttribute('aria-disabled')).toBe('true');
// Screen-reader text "bereits enthalten" must be present in the item
await expect.element(page.getByText(/bereits enthalten/i)).toBeInTheDocument();
});
});
describe('DocumentPickerDropdown — selection', () => {
it('calls onSelect with the item when a non-disabled option is clicked', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, { onSelect });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.click(page.getByText(/Brief von Eugenie/i));
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
});
it('does not call onSelect when an aria-disabled option is clicked', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, {
alreadyAddedIds: new Set(['d1']),
onSelect
});
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await page.getByText(/Brief von Eugenie/i).click({ force: true });
expect(onSelect).not.toHaveBeenCalled();
});
});
describe('DocumentPickerDropdown — keyboard navigation', () => {
it('selects the first option via ArrowDown then Enter', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, { onSelect });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('{Enter}');
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
});
it('does not select an aria-disabled option on Enter', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, {
alreadyAddedIds: new Set(['d1']),
onSelect
});
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('{Enter}');
expect(onSelect).not.toHaveBeenCalled();
});
it('closes the dropdown on Escape', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
it('points aria-activedescendant at the active option', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
const input = page.getByRole('combobox');
await userEvent.fill(input, 'Brief');
await waitForDebounce();
expect(input.element().getAttribute('aria-activedescendant')).toBeNull();
await userEvent.keyboard('{ArrowDown}');
const activeId = input.element().getAttribute('aria-activedescendant');
expect(activeId).toMatch(/-option-0$/);
const firstOption = page
.getByText(/Brief von Eugenie/i)
.element()
.closest('li')!;
expect(firstOption.id).toBe(activeId);
expect(firstOption.getAttribute('aria-selected')).toBe('true');
});
});
describe('DocumentPickerDropdown — no results', () => {
it('shows a non-interactive no-results row when the search returns zero hits', async () => {
mockSearchResponse([]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'xyz');
await waitForDebounce();
await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument();
});
});
describe('DocumentPickerDropdown — search failure', () => {
it('shows an error message when the search request fails instead of vanishing', async () => {
// 500 from /api/documents/search — must surface, not render as "no results"
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' })
})
);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await expect.element(page.getByText(m.comp_typeahead_error())).toBeInTheDocument();
});
});
describe('DocumentPickerDropdown — ARIA listbox integrity', () => {
it('does not render a listbox when results are empty (no aria-required-children violation)', async () => {
mockSearchResponse([]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'xyz');
await waitForDebounce();
// no-results message must be visible, but NOT inside a listbox
await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument();
expect(document.querySelector('[role="listbox"]')).toBeNull();
});
it('does not render a listbox when loading (no aria-required-children violation)', async () => {
let resolveSearch!: (v: unknown) => void;
vi.stubGlobal(
'fetch',
vi.fn().mockReturnValue(new Promise((resolve) => (resolveSearch = resolve)))
);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
// While in-flight, no listbox should exist
expect(document.querySelector('[role="listbox"]')).toBeNull();
resolveSearch({ ok: true, json: () => Promise.resolve({ items: [] }) });
});
it('option elements do not have tabindex (combobox pattern: focus stays on input)', async () => {
mockSearchResponse([docFactory('d1', 'Brief A'), docFactory('d2', 'Brief B')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
const options = document.querySelectorAll('[role="listbox"] [role="option"]');
expect(options.length).toBeGreaterThan(0);
options.forEach((opt) => {
expect(opt).not.toHaveAttribute('tabindex');
});
});
});
describe('DocumentPickerDropdown — external label wiring (#795)', () => {
it('renders a generated default id on the input and keeps the aria-label fallback', async () => {
render(DocumentPickerDropdown, { onSelect: vi.fn() });
const input = page.getByRole('combobox').element() as HTMLInputElement;
expect(input.id).toMatch(/^doc-picker-input-/);
expect(input.getAttribute('aria-label')).not.toBeNull();
});
it('uses the provided inputId and drops the aria-label so an external label wins', async () => {
render(DocumentPickerDropdown, { onSelect: vi.fn(), inputId: 'story-doc-picker' });
const input = page.getByRole('combobox').element() as HTMLInputElement;
expect(input.id).toBe('story-doc-picker');
expect(input.getAttribute('aria-label')).toBeNull();
});
});