Round-4 polish from Leonie (S-2), Felix (#3), Sara (#4): - Add aria-hidden="true" to the visible empty-state <p> so VoiceOver does not double-announce — the persistent sr-only live region is now the sole AT source of truth (NVDA already de-duped, VoiceOver did not). - Extract `searchQuery.trim() === ''` into an `isQueryEmpty` $derived; both the announcer branch and the visible empty-state branch now read from the single intent-named alias. - Cover the singular branch of the persistent live region (1 item -> "1 Person gefunden" / "1 person found" / "1 persona encontrada"). Plural was already covered; this closes the missing-branch gap. - Extend the existing "no aria-live on visible <p>" test to also assert aria-hidden="true" so a regression on the AT-source-of-truth contract goes red immediately. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
453 lines
16 KiB
TypeScript
453 lines
16 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { cleanup, render } from 'vitest-browser-svelte';
|
|
import { page, userEvent } from 'vitest/browser';
|
|
import { flushSync, mount, tick, unmount } from 'svelte';
|
|
import MentionDropdown from './MentionDropdown.svelte';
|
|
import MentionDropdownFixture from './MentionDropdown.test-fixture.svelte';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
import type { components } from '$lib/generated/api';
|
|
|
|
type Person = components['schemas']['Person'];
|
|
|
|
afterEach(cleanup);
|
|
|
|
const makePerson = (id: string, name: string, overrides: Partial<Person> = {}): Person => {
|
|
const parts = name.split(' ');
|
|
return {
|
|
id,
|
|
firstName: parts[0],
|
|
lastName: parts.slice(1).join(' ') || name,
|
|
displayName: name,
|
|
personType: 'PERSON',
|
|
familyMember: false,
|
|
...overrides
|
|
};
|
|
};
|
|
|
|
type DropdownState = {
|
|
items: Person[];
|
|
command: (item: Person) => void;
|
|
clientRect: (() => DOMRect | null) | null;
|
|
};
|
|
|
|
const baseModel = (overrides: Partial<DropdownState> = {}): DropdownState => ({
|
|
items: [],
|
|
command: vi.fn(),
|
|
clientRect: () => new DOMRect(100, 100, 0, 24),
|
|
...overrides
|
|
});
|
|
|
|
describe('MentionDropdown', () => {
|
|
it('renders the listbox with the mention label', async () => {
|
|
render(MentionDropdown, { props: { model: baseModel() } });
|
|
|
|
await expect.element(page.getByRole('listbox', { name: /person verlinken/i })).toBeVisible();
|
|
});
|
|
|
|
it('shows the "enter a name" prompt when the search field is empty', async () => {
|
|
render(MentionDropdown, { props: { model: baseModel() } });
|
|
|
|
// Scope to the visible empty-state <p> (text-ink-3) — the persistent
|
|
// sr-only aria-live region above also contains the same prompt copy.
|
|
const visibleEmptyP = document.querySelector(
|
|
'[role="listbox"] p.text-ink-3'
|
|
) as HTMLElement | null;
|
|
expect(visibleEmptyP).not.toBeNull();
|
|
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_search_prompt());
|
|
expect(visibleEmptyP!.textContent ?? '').not.toContain(m.person_mention_popup_empty());
|
|
});
|
|
|
|
it('shows "no persons found" when the search has a query but the list is empty', async () => {
|
|
render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } });
|
|
|
|
const visibleEmptyP = document.querySelector(
|
|
'[role="listbox"] p.text-ink-3'
|
|
) as HTMLElement | null;
|
|
expect(visibleEmptyP).not.toBeNull();
|
|
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty());
|
|
expect(visibleEmptyP!.textContent ?? '').not.toContain(m.person_mention_search_prompt());
|
|
});
|
|
|
|
it('shows the create-new escape hatch link in the empty state', async () => {
|
|
render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'unknown' } });
|
|
|
|
const link = (await page
|
|
.getByRole('link', { name: /neue person anlegen/i })
|
|
.element()) as HTMLAnchorElement;
|
|
expect(link.href).toContain('/persons/new');
|
|
expect(link.target).toBe('_blank');
|
|
expect(link.rel).toContain('noopener');
|
|
expect(link.rel).toContain('noreferrer');
|
|
});
|
|
|
|
it('renders one option per item when populated', async () => {
|
|
render(MentionDropdown, {
|
|
props: {
|
|
model: baseModel({
|
|
items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')]
|
|
})
|
|
}
|
|
});
|
|
|
|
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
|
|
await expect.element(page.getByText('Bert Meier')).toBeVisible();
|
|
});
|
|
|
|
it('marks the first item as aria-selected by default', async () => {
|
|
render(MentionDropdown, {
|
|
props: { model: baseModel({ items: [makePerson('p1', 'Anna Schmidt')] }) }
|
|
});
|
|
|
|
const option = document.querySelector('[role="option"]');
|
|
expect(option?.getAttribute('aria-selected')).toBe('true');
|
|
});
|
|
|
|
it('renders the life-date range when birthYear or deathYear is present', async () => {
|
|
render(MentionDropdown, {
|
|
props: {
|
|
model: baseModel({
|
|
items: [makePerson('p1', 'Anna', { birthYear: 1899, deathYear: 1972 })]
|
|
})
|
|
}
|
|
});
|
|
|
|
await expect.element(page.getByText(/1899/)).toBeVisible();
|
|
});
|
|
|
|
it('falls back to a default position when clientRect returns null', async () => {
|
|
render(MentionDropdown, {
|
|
props: {
|
|
model: baseModel({ clientRect: () => null })
|
|
}
|
|
});
|
|
|
|
const dropdown = document.querySelector('[role="listbox"]') as HTMLElement;
|
|
expect(dropdown.style.left).toBe('0px');
|
|
});
|
|
|
|
it('positions itself based on the clientRect callback', async () => {
|
|
render(MentionDropdown, {
|
|
props: {
|
|
model: baseModel({
|
|
clientRect: () => new DOMRect(123, 200, 50, 24)
|
|
})
|
|
}
|
|
});
|
|
|
|
const dropdown = document.querySelector('[role="listbox"]') as HTMLElement;
|
|
expect(dropdown.style.left).toBe('123px');
|
|
});
|
|
});
|
|
|
|
// ─── Search input — Issue #380 ────────────────────────────────────────────────
|
|
|
|
describe('MentionDropdown — search input', () => {
|
|
it('renders a search input pre-filled with the editorQuery prop', async () => {
|
|
render(MentionDropdown, {
|
|
props: { model: baseModel(), editorQuery: 'WdG' }
|
|
});
|
|
|
|
await expect.element(page.getByRole('searchbox')).toHaveValue('WdG');
|
|
});
|
|
|
|
it('exposes a data-test-search-input attribute for E2E selectors', async () => {
|
|
render(MentionDropdown, { props: { model: baseModel() } });
|
|
|
|
const input = document.querySelector('[data-test-search-input]');
|
|
expect(input).not.toBeNull();
|
|
expect((input as HTMLInputElement).type).toBe('search');
|
|
});
|
|
|
|
it('search input wrapper meets the 44px touch target (WCAG 2.2 AA)', async () => {
|
|
render(MentionDropdown, { props: { model: baseModel() } });
|
|
|
|
const input = document.querySelector('[data-test-search-input]') as HTMLElement;
|
|
expect(input).not.toBeNull();
|
|
expect(input.className).toContain('min-h-[44px]');
|
|
});
|
|
|
|
it('renders a persistent aria-live="polite" region (does not remount on items transition; Leonie #3 on PR #629)', async () => {
|
|
render(MentionDropdown, { props: { model: baseModel() } });
|
|
|
|
const listbox = document.querySelector('[role="listbox"]');
|
|
expect(listbox).not.toBeNull();
|
|
const live = listbox!.querySelector('p[aria-live="polite"]');
|
|
expect(live).not.toBeNull();
|
|
// Empty + empty-query → "Namen eingeben…" prompt
|
|
expect(live!.textContent ?? '').toContain(m.person_mention_search_prompt());
|
|
});
|
|
|
|
it('announces the result count in the persistent live region when items populate (Leonie #3 on PR #629)', async () => {
|
|
render(MentionDropdown, {
|
|
props: {
|
|
model: baseModel({
|
|
items: [
|
|
makePerson('p1', 'Anna Schmidt'),
|
|
makePerson('p2', 'Bert Meier'),
|
|
makePerson('p3', 'Carl Vogel')
|
|
]
|
|
})
|
|
}
|
|
});
|
|
|
|
const listbox = document.querySelector('[role="listbox"]');
|
|
expect(listbox).not.toBeNull();
|
|
const live = listbox!.querySelector('p[aria-live="polite"]');
|
|
expect(live).not.toBeNull();
|
|
// Populated → "3 Personen gefunden" (plural)
|
|
expect(live!.textContent ?? '').toContain('3');
|
|
});
|
|
|
|
it('announces the singular form when exactly one item is present (Sara #4 on PR #629)', async () => {
|
|
render(MentionDropdown, {
|
|
props: {
|
|
model: baseModel({
|
|
items: [makePerson('p1', 'Anna Schmidt')]
|
|
})
|
|
}
|
|
});
|
|
|
|
const listbox = document.querySelector('[role="listbox"]');
|
|
expect(listbox).not.toBeNull();
|
|
const live = listbox!.querySelector('p[aria-live="polite"]');
|
|
expect(live).not.toBeNull();
|
|
// Singular branch — "1 Person gefunden" / "1 person found" / "1 persona encontrada"
|
|
// (locale-dependent; resolved via the Paraglide message helper).
|
|
expect(live!.textContent ?? '').toContain(m.person_mention_results_count_singular());
|
|
});
|
|
|
|
it('keeps the visible empty-state copy without its own aria-live and hides it from AT (Leonie #3 on PR #629 round 3; Leonie S-2 round 4)', async () => {
|
|
render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } });
|
|
|
|
// Visible empty-state <p> exists with the empty-result copy ...
|
|
const empty = document.querySelector('p.text-ink-3') as HTMLElement | null;
|
|
expect(empty).not.toBeNull();
|
|
expect(empty!.textContent ?? '').toContain(m.person_mention_popup_empty());
|
|
// ... but it must NOT carry its own aria-live (the persistent sr-only
|
|
// region above the conditional is the announcer now).
|
|
expect(empty!.hasAttribute('aria-live')).toBe(false);
|
|
// ... and it MUST be hidden from screen readers via aria-hidden="true"
|
|
// so VoiceOver does not double-announce (the persistent sr-only region
|
|
// is the sole AT source of truth). Leonie S-2 on PR #629 round 4.
|
|
expect(empty!.getAttribute('aria-hidden')).toBe('true');
|
|
});
|
|
|
|
it('renders the magnifier icon at h-5 w-5 with text-ink-2 (Leonie BLOCKER on PR #629)', async () => {
|
|
render(MentionDropdown, { props: { model: baseModel() } });
|
|
|
|
const icon = document.querySelector('[data-test-search-input]')
|
|
?.previousElementSibling as SVGElement | null;
|
|
expect(icon).not.toBeNull();
|
|
expect(icon!.tagName.toLowerCase()).toBe('svg');
|
|
expect(icon!.getAttribute('class') ?? '').toContain('h-5');
|
|
expect(icon!.getAttribute('class') ?? '').toContain('w-5');
|
|
expect(icon!.getAttribute('class') ?? '').toContain('text-ink-2');
|
|
});
|
|
|
|
it('caps the search input at maxlength=100 (CWE-400 amplification — Nora on PR #629)', async () => {
|
|
render(MentionDropdown, { props: { model: baseModel() } });
|
|
|
|
const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
|
|
expect(input).not.toBeNull();
|
|
expect(input.maxLength).toBe(100);
|
|
});
|
|
|
|
it('clips a long editorQuery mirror to 100 chars (CWE-400 layered — Nora #1 on PR #629)', async () => {
|
|
const longQuery = 'A'.repeat(200);
|
|
render(MentionDropdown, { props: { model: baseModel(), editorQuery: longQuery } });
|
|
|
|
const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
|
|
expect(input).not.toBeNull();
|
|
expect(input.value.length).toBe(100);
|
|
expect(input.value).toBe('A'.repeat(100));
|
|
});
|
|
|
|
it('caps the listbox width to the viewport (320 px reflow guard — Leonie FINDING-MENTION-005)', async () => {
|
|
render(MentionDropdown, { props: { model: baseModel() } });
|
|
|
|
const listbox = document.querySelector('[role="listbox"]') as HTMLElement;
|
|
expect(listbox).not.toBeNull();
|
|
expect(listbox.className).toContain('max-w-[calc(100vw-1rem)]');
|
|
});
|
|
|
|
it('renders the @mention search input at text-base (16 px senior-audience floor — Leonie FINDING-MENTION-006)', async () => {
|
|
render(MentionDropdown, { props: { model: baseModel() } });
|
|
|
|
const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
|
|
expect(input).not.toBeNull();
|
|
expect(input.className).toContain('text-base');
|
|
expect(input.className).not.toContain('text-sm');
|
|
});
|
|
|
|
it('invokes onSearch with the current value whenever the user types', async () => {
|
|
const onSearch = vi.fn();
|
|
render(MentionDropdown, { props: { model: baseModel(), onSearch } });
|
|
|
|
await userEvent.type(page.getByRole('searchbox'), 'Walter');
|
|
|
|
await vi.waitFor(() => {
|
|
expect(onSearch).toHaveBeenCalled();
|
|
expect(onSearch).toHaveBeenLastCalledWith('Walter');
|
|
});
|
|
});
|
|
|
|
it('keeps the user-edited search value when editorQuery changes after the takeover (Felix on PR #629)', async () => {
|
|
let setEditorQuery!: (q: string) => void;
|
|
render(MentionDropdownFixture, {
|
|
model: baseModel(),
|
|
initialEditorQuery: 'WdG',
|
|
onReady: (s: (q: string) => void) => {
|
|
setEditorQuery = s;
|
|
}
|
|
});
|
|
|
|
await expect.element(page.getByRole('searchbox')).toHaveValue('WdG');
|
|
|
|
await page.getByRole('searchbox').fill('Walter');
|
|
await expect.element(page.getByRole('searchbox')).toHaveValue('Walter');
|
|
|
|
setEditorQuery('WdGruyter');
|
|
// Flush pending Svelte reactivity so any (non-)update from the mirror
|
|
// $effect has landed before we assert. expect.element already polls, so
|
|
// no fixed-timeout fallback is needed. Sara on PR #629 round 3.
|
|
await tick();
|
|
|
|
await expect.element(page.getByRole('searchbox')).toHaveValue('Walter');
|
|
});
|
|
});
|
|
|
|
// ─── ArrowDown via exported onKeyDown (Sara #3 on PR #629) ──────────────────
|
|
//
|
|
// In production, Tiptap intercepts ArrowDown/ArrowUp/Enter at the editor level
|
|
// and forwards them to the dropdown via its exported onKeyDown(event) function
|
|
// — the dropdown itself has no DOM keydown listener. This test exercises the
|
|
// same export so a regression in highlightedIndex/selection logic is caught
|
|
// at the unit level. The full E2E focus-chain test is deferred to a separate
|
|
// issue (Playwright).
|
|
//
|
|
// These unit tests directly invoke the exported `onKeyDown` to pin its
|
|
// behaviour in isolation. They do NOT exercise the Tiptap forwarding
|
|
// chain (PersonMentionEditor.suggestion.render() returning { onKeyDown })
|
|
// — that integration is covered by the 'ArrowDown moves the highlight'
|
|
// test in PersonMentionEditor.svelte.spec.ts. Sara on PR #629 round 3.
|
|
|
|
describe('MentionDropdown — onKeyDown forwarding', () => {
|
|
// flushSync ensures Svelte reactivity propagation completes before
|
|
// asserting (uniform across all four key tests so the next reader
|
|
// doesn't have to figure out why some are wrapped and others aren't).
|
|
// Felix #1 suggestion on PR #629 round 3.
|
|
|
|
it('ArrowDown advances aria-selected to the next option in the listbox', async () => {
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const instance = mount(MentionDropdown, {
|
|
target: container,
|
|
props: {
|
|
model: baseModel({
|
|
items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')]
|
|
})
|
|
}
|
|
});
|
|
try {
|
|
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
|
|
|
|
// First option starts highlighted.
|
|
const first = container.querySelector('[data-test-person-id="p1"]') as HTMLElement;
|
|
const second = container.querySelector('[data-test-person-id="p2"]') as HTMLElement;
|
|
expect(first.getAttribute('aria-selected')).toBe('true');
|
|
expect(second.getAttribute('aria-selected')).toBe('false');
|
|
|
|
let consumed = false;
|
|
flushSync(() => {
|
|
consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
|
});
|
|
expect(consumed).toBe(true);
|
|
|
|
expect(first.getAttribute('aria-selected')).toBe('false');
|
|
expect(second.getAttribute('aria-selected')).toBe('true');
|
|
} finally {
|
|
unmount(instance);
|
|
container.remove();
|
|
}
|
|
});
|
|
|
|
it('ArrowUp wraps from the first option to the last', async () => {
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const instance = mount(MentionDropdown, {
|
|
target: container,
|
|
props: {
|
|
model: baseModel({
|
|
items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')]
|
|
})
|
|
}
|
|
});
|
|
try {
|
|
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
|
|
|
|
let consumed = false;
|
|
flushSync(() => {
|
|
consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
|
|
});
|
|
expect(consumed).toBe(true);
|
|
|
|
const second = container.querySelector('[data-test-person-id="p2"]') as HTMLElement;
|
|
expect(second.getAttribute('aria-selected')).toBe('true');
|
|
} finally {
|
|
unmount(instance);
|
|
container.remove();
|
|
}
|
|
});
|
|
|
|
it('Enter invokes model.command with the currently highlighted item', async () => {
|
|
const command = vi.fn();
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const instance = mount(MentionDropdown, {
|
|
target: container,
|
|
props: {
|
|
model: baseModel({
|
|
items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')],
|
|
command
|
|
})
|
|
}
|
|
});
|
|
try {
|
|
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
|
|
|
|
let consumed = false;
|
|
flushSync(() => {
|
|
consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
});
|
|
expect(consumed).toBe(true);
|
|
expect(command).toHaveBeenCalledTimes(1);
|
|
expect(command.mock.calls[0][0].id).toBe('p1');
|
|
} finally {
|
|
unmount(instance);
|
|
container.remove();
|
|
}
|
|
});
|
|
|
|
it('Escape returns false so the suggestion plugin can handle it', async () => {
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const instance = mount(MentionDropdown, {
|
|
target: container,
|
|
props: {
|
|
model: baseModel({ items: [makePerson('p1', 'Anna Schmidt')] })
|
|
}
|
|
});
|
|
try {
|
|
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
|
|
let consumed = true;
|
|
flushSync(() => {
|
|
consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }));
|
|
});
|
|
expect(consumed).toBe(false);
|
|
} finally {
|
|
unmount(instance);
|
|
container.remove();
|
|
}
|
|
});
|
|
});
|