Compare commits
17 Commits
feat/issue
...
fe13df574a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe13df574a | ||
|
|
a9080e9dab | ||
|
|
e8a1cc82ff | ||
|
|
5b18b87450 | ||
|
|
bfa8b9c147 | ||
|
|
3a94d62c74 | ||
|
|
163e99016a | ||
|
|
d6f3ca5c43 | ||
|
|
108edff8d2 | ||
|
|
3d3fe8d626 | ||
|
|
31e5573eab | ||
|
|
934a00feb3 | ||
|
|
be27489618 | ||
|
|
4e486a31cf | ||
|
|
2c5877ea9e | ||
|
|
cfbe33140c | ||
| e8d1835ae1 |
@@ -154,6 +154,13 @@ class AnnotationControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
||||||
|
|||||||
@@ -260,6 +260,13 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(delete(URL_BLOCK))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
||||||
|
|||||||
202
frontend/e2e/person-typeahead.spec.ts
Normal file
202
frontend/e2e/person-typeahead.spec.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* E2E regression tests for PersonTypeahead dropdown visibility.
|
||||||
|
*
|
||||||
|
* These tests verify that the dropdown list is never clipped by a parent
|
||||||
|
* container's stacking context — the root cause of issue #343.
|
||||||
|
*
|
||||||
|
* The tests run at both desktop (1280×720) and tablet (768×1024) viewports
|
||||||
|
* as required by the acceptance criteria.
|
||||||
|
*/
|
||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a document edit URL to use as the test page.
|
||||||
|
* Falls back to /documents/new if no existing document is found.
|
||||||
|
*/
|
||||||
|
async function getDocumentEditUrl(page: Page): Promise<string> {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
const firstDocLink = page.locator('a[href^="/documents/"]').first();
|
||||||
|
const href = await firstDocLink.getAttribute('href').catch(() => null);
|
||||||
|
if (href) {
|
||||||
|
return `${href}/edit`;
|
||||||
|
}
|
||||||
|
return '/documents/new';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for the listbox to become visible after triggering a search. */
|
||||||
|
async function waitForListbox(page: Page): Promise<void> {
|
||||||
|
await page.waitForSelector('[role="listbox"]', { state: 'visible', timeout: 2000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('PersonTypeahead — dropdown visibility (desktop)', () => {
|
||||||
|
test.use({ viewport: { width: 1280, height: 720 } });
|
||||||
|
|
||||||
|
test('sender dropdown items are visible and not clipped in document edit', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Find the sender typeahead input (the visible text input, not the hidden one)
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await expect(senderInput).toBeVisible();
|
||||||
|
|
||||||
|
// Type to trigger the dropdown
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
|
||||||
|
// Wait for the dropdown to appear (handles debounce automatically)
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const dropdown = page.locator('[role="listbox"]').first();
|
||||||
|
await expect(dropdown).toBeVisible();
|
||||||
|
|
||||||
|
const firstOption = dropdown.locator('[role="option"]').first();
|
||||||
|
await expect(firstOption).toBeVisible();
|
||||||
|
|
||||||
|
// Verify the bounding box is within the viewport (not clipped)
|
||||||
|
const box = await firstOption.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box!.y).toBeGreaterThan(0);
|
||||||
|
expect(box!.y + box!.height).toBeLessThan(720);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-desktop.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dropdown is positioned below the input field (not hidden behind parent)', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await expect(senderInput).toBeVisible();
|
||||||
|
|
||||||
|
const inputBox = await senderInput.boundingBox();
|
||||||
|
expect(inputBox).not.toBeNull();
|
||||||
|
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const dropdown = page.locator('[role="listbox"]').first();
|
||||||
|
await expect(dropdown).toBeVisible();
|
||||||
|
|
||||||
|
const dropdownBox = await dropdown.boundingBox();
|
||||||
|
expect(dropdownBox).not.toBeNull();
|
||||||
|
|
||||||
|
// Dropdown must appear below the input, not on top or clipped behind it
|
||||||
|
expect(dropdownBox!.y).toBeGreaterThanOrEqual(inputBox!.y + inputBox!.height - 5);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-position.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('PersonTypeahead — dropdown visibility (tablet)', () => {
|
||||||
|
test.use({ viewport: { width: 768, height: 1024 } });
|
||||||
|
|
||||||
|
test('sender dropdown items are visible and not clipped on tablet viewport', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await expect(senderInput).toBeVisible();
|
||||||
|
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const dropdown = page.locator('[role="listbox"]').first();
|
||||||
|
await expect(dropdown).toBeVisible();
|
||||||
|
|
||||||
|
const firstOption = dropdown.locator('[role="option"]').first();
|
||||||
|
await expect(firstOption).toBeVisible();
|
||||||
|
|
||||||
|
const box = await firstOption.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box!.y).toBeGreaterThan(0);
|
||||||
|
expect(box!.y + box!.height).toBeLessThan(1024);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-tablet.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('PersonTypeahead — keyboard navigation', () => {
|
||||||
|
test.use({ viewport: { width: 1280, height: 720 } });
|
||||||
|
|
||||||
|
test('ArrowDown moves focus to the first option', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
await senderInput.press('ArrowDown');
|
||||||
|
// First option should now be the active descendant
|
||||||
|
const activeDescendant = await senderInput.getAttribute('aria-activedescendant');
|
||||||
|
expect(activeDescendant).toBeTruthy();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/person-typeahead-keyboard-nav.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape key closes the dropdown', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const dropdown = page.locator('[role="listbox"]').first();
|
||||||
|
await expect(dropdown).toBeVisible();
|
||||||
|
await senderInput.press('Escape');
|
||||||
|
await expect(dropdown).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aria-expanded is true when dropdown is open', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
|
||||||
|
// Initially closed
|
||||||
|
const initialExpanded = await senderInput.getAttribute('aria-expanded');
|
||||||
|
expect(initialExpanded).toBe('false');
|
||||||
|
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const expanded = await senderInput.getAttribute('aria-expanded');
|
||||||
|
expect(expanded).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('PersonTypeahead — click-outside dismiss (fixed position)', () => {
|
||||||
|
test.use({ viewport: { width: 1280, height: 720 } });
|
||||||
|
|
||||||
|
test('clicking outside a fixed-position dropdown closes it', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const dropdown = page.locator('[role="listbox"]').first();
|
||||||
|
await expect(dropdown).toBeVisible();
|
||||||
|
// Click somewhere else on the page
|
||||||
|
await page.click('body', { position: { x: 10, y: 10 } });
|
||||||
|
await expect(dropdown).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,6 +40,26 @@ export default defineConfig(
|
|||||||
parser: ts.parser,
|
parser: ts.parser,
|
||||||
svelteConfig
|
svelteConfig
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// text-accent resolves to #a1dcd8 in light mode (1.52:1 on white — WCAG fail).
|
||||||
|
// layout.css documents it as decorative-only (borders, icon tints, bg fills).
|
||||||
|
// For any text label use text-primary or text-ink instead. This rule catches
|
||||||
|
// the pattern where text-accent appears inside a JavaScript string literal
|
||||||
|
// (e.g. conditional ternary class expressions in Svelte templates).
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
selector: 'Literal[value=/\\btext-accent\\b/]',
|
||||||
|
message:
|
||||||
|
'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'TemplateLiteral > TemplateElement[value.raw=/\\btext-accent\\b/]',
|
||||||
|
message:
|
||||||
|
'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -818,6 +818,7 @@
|
|||||||
"pagination_next": "Weiter",
|
"pagination_next": "Weiter",
|
||||||
"pagination_page_of": "Seite {page} von {total}",
|
"pagination_page_of": "Seite {page} von {total}",
|
||||||
"pagination_nav_label": "Seitennavigation",
|
"pagination_nav_label": "Seitennavigation",
|
||||||
|
"pagination_page_button": "Seite {page}",
|
||||||
|
|
||||||
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
||||||
|
|
||||||
|
|||||||
@@ -818,6 +818,7 @@
|
|||||||
"pagination_next": "Next",
|
"pagination_next": "Next",
|
||||||
"pagination_page_of": "Page {page} of {total}",
|
"pagination_page_of": "Page {page} of {total}",
|
||||||
"pagination_nav_label": "Pagination",
|
"pagination_nav_label": "Pagination",
|
||||||
|
"pagination_page_button": "Page {page}",
|
||||||
|
|
||||||
"common_opens_new_tab": "(opens in new tab)",
|
"common_opens_new_tab": "(opens in new tab)",
|
||||||
|
|
||||||
|
|||||||
@@ -818,6 +818,7 @@
|
|||||||
"pagination_next": "Siguiente",
|
"pagination_next": "Siguiente",
|
||||||
"pagination_page_of": "Página {page} de {total}",
|
"pagination_page_of": "Página {page} de {total}",
|
||||||
"pagination_nav_label": "Paginación",
|
"pagination_nav_label": "Paginación",
|
||||||
|
"pagination_page_button": "Página {page}",
|
||||||
|
|
||||||
"common_opens_new_tab": "(abre en pestaña nueva)",
|
"common_opens_new_tab": "(abre en pestaña nueva)",
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ let {
|
|||||||
dimmed = false,
|
dimmed = false,
|
||||||
flashAnnotationId = null,
|
flashAnnotationId = null,
|
||||||
onDraw,
|
onDraw,
|
||||||
onAnnotationClick
|
onAnnotationClick,
|
||||||
|
onDeleteRequest
|
||||||
}: {
|
}: {
|
||||||
annotations: Annotation[];
|
annotations: Annotation[];
|
||||||
canDraw: boolean;
|
canDraw: boolean;
|
||||||
@@ -29,6 +30,7 @@ let {
|
|||||||
flashAnnotationId?: string | null;
|
flashAnnotationId?: string | null;
|
||||||
onDraw: (rect: DrawRect) => void;
|
onDraw: (rect: DrawRect) => void;
|
||||||
onAnnotationClick?: (id: string) => void;
|
onAnnotationClick?: (id: string) => void;
|
||||||
|
onDeleteRequest?: (annotationId: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let drawStart = $state<{ x: number; y: number } | null>(null);
|
let drawStart = $state<{ x: number; y: number } | null>(null);
|
||||||
@@ -112,6 +114,8 @@ const containerStyle = $derived(
|
|||||||
dimmed={dimmed}
|
dimmed={dimmed}
|
||||||
blockNumber={blockNumbers[annotation.id]}
|
blockNumber={blockNumbers[annotation.id]}
|
||||||
isFlashing={flashAnnotationId === annotation.id}
|
isFlashing={flashAnnotationId === annotation.id}
|
||||||
|
showDelete={canDraw}
|
||||||
|
onDeleteRequest={() => onDeleteRequest?.(annotation.id)}
|
||||||
onclick={() => onAnnotationClick?.(annotation.id)}
|
onclick={() => onAnnotationClick?.(annotation.id)}
|
||||||
onpointerenter={() => (hoveredId = annotation.id)}
|
onpointerenter={() => (hoveredId = annotation.id)}
|
||||||
onpointerleave={() => (hoveredId = null)}
|
onpointerleave={() => (hoveredId = null)}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ describe('AnnotationLayer', () => {
|
|||||||
expect(el2.style.opacity).toBe('1');
|
expect(el2.style.opacity).toBe('1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show delete buttons (annotations owned by blocks)', async () => {
|
it('does not show delete button when annotation is not hovered or active', async () => {
|
||||||
render(AnnotationLayer, {
|
render(AnnotationLayer, {
|
||||||
annotations: [makeAnnotation('ann-1')],
|
annotations: [makeAnnotation('ann-1')],
|
||||||
canDraw: true,
|
canDraw: true,
|
||||||
@@ -107,6 +107,19 @@ describe('AnnotationLayer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||||
expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull();
|
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show delete button when canDraw is false even if annotation is active', async () => {
|
||||||
|
render(AnnotationLayer, {
|
||||||
|
annotations: [makeAnnotation('ann-1')],
|
||||||
|
canDraw: false,
|
||||||
|
color: '#00C7B1',
|
||||||
|
activeAnnotationId: 'ann-1',
|
||||||
|
onDraw: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||||
|
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ let {
|
|||||||
blockNumber = undefined,
|
blockNumber = undefined,
|
||||||
isFlashing = false,
|
isFlashing = false,
|
||||||
isResizable = false,
|
isResizable = false,
|
||||||
|
showDelete = false,
|
||||||
|
onDeleteRequest,
|
||||||
onclick,
|
onclick,
|
||||||
onpointerenter,
|
onpointerenter,
|
||||||
onpointerleave
|
onpointerleave
|
||||||
@@ -23,11 +25,15 @@ let {
|
|||||||
blockNumber?: number | undefined;
|
blockNumber?: number | undefined;
|
||||||
isFlashing?: boolean;
|
isFlashing?: boolean;
|
||||||
isResizable?: boolean;
|
isResizable?: boolean;
|
||||||
|
showDelete?: boolean;
|
||||||
|
onDeleteRequest?: () => void;
|
||||||
onclick: () => void;
|
onclick: () => void;
|
||||||
onpointerenter: () => void;
|
onpointerenter: () => void;
|
||||||
onpointerleave: () => void;
|
onpointerleave: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const deleteVisible = $derived(showDelete && (isHovered || isActive));
|
||||||
|
|
||||||
function hexToRgba(hex: string, alpha: number): string {
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
const r = parseInt(hex.slice(1, 3), 16);
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
const g = parseInt(hex.slice(3, 5), 16);
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
@@ -83,6 +89,7 @@ let shapeStyle = $derived(
|
|||||||
onclick={onclick}
|
onclick={onclick}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') onclick();
|
if (e.key === 'Enter' || e.key === ' ') onclick();
|
||||||
|
if (e.key === 'Delete' && showDelete) onDeleteRequest?.();
|
||||||
}}
|
}}
|
||||||
onpointerenter={onpointerenter}
|
onpointerenter={onpointerenter}
|
||||||
onpointerleave={onpointerleave}
|
onpointerleave={onpointerleave}
|
||||||
@@ -112,6 +119,51 @@ let shapeStyle = $derived(
|
|||||||
{blockNumber}
|
{blockNumber}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if deleteVisible}
|
||||||
|
<button
|
||||||
|
data-testid="annotation-delete-{annotation.id}"
|
||||||
|
type="button"
|
||||||
|
aria-label="Löschen"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteRequest?.();
|
||||||
|
}}
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid var(--color-error, #e53e3e);
|
||||||
|
color: var(--color-error, #e53e3e);
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
|
||||||
|
z-index: 10;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if isResizable}
|
{#if isResizable}
|
||||||
<AnnotationEditOverlay annotation={annotation} />
|
<AnnotationEditOverlay annotation={annotation} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
177
frontend/src/lib/components/AnnotationShape.svelte.spec.ts
Normal file
177
frontend/src/lib/components/AnnotationShape.svelte.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import AnnotationShape from './AnnotationShape.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function makeAnnotation(id = 'ann-1') {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
documentId: 'doc-1',
|
||||||
|
pageNumber: 1,
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.1,
|
||||||
|
width: 0.3,
|
||||||
|
height: 0.2,
|
||||||
|
color: '#00C7B1',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AnnotationShape', () => {
|
||||||
|
it('renders the annotation element', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: false,
|
||||||
|
isActive: false,
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show delete button when showDelete is false', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: true,
|
||||||
|
isActive: false,
|
||||||
|
showDelete: false,
|
||||||
|
onDeleteRequest: vi.fn(),
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show delete button when showDelete is true but neither hovered nor active', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: false,
|
||||||
|
isActive: false,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest: vi.fn(),
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows delete button when showDelete is true and isHovered is true', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: true,
|
||||||
|
isActive: false,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest: vi.fn(),
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows delete button when showDelete is true and isActive is true', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: false,
|
||||||
|
isActive: true,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest: vi.fn(),
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onDeleteRequest when delete button is clicked', async () => {
|
||||||
|
const onDeleteRequest = vi.fn();
|
||||||
|
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: true,
|
||||||
|
isActive: false,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest,
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
|
||||||
|
await deleteBtn.click();
|
||||||
|
|
||||||
|
expect(onDeleteRequest).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onclick when delete button is clicked', async () => {
|
||||||
|
const onclick = vi.fn();
|
||||||
|
const onDeleteRequest = vi.fn();
|
||||||
|
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: true,
|
||||||
|
isActive: false,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest,
|
||||||
|
onclick,
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
|
||||||
|
await deleteBtn.click();
|
||||||
|
|
||||||
|
expect(onclick).not.toHaveBeenCalled();
|
||||||
|
expect(onDeleteRequest).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onDeleteRequest when Delete key is pressed on the annotation', async () => {
|
||||||
|
const onDeleteRequest = vi.fn();
|
||||||
|
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: false,
|
||||||
|
isActive: true,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest,
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
|
||||||
|
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
|
||||||
|
|
||||||
|
expect(onDeleteRequest).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onDeleteRequest on Delete key when showDelete is false', async () => {
|
||||||
|
const onDeleteRequest = vi.fn();
|
||||||
|
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: false,
|
||||||
|
isActive: true,
|
||||||
|
showDelete: false,
|
||||||
|
onDeleteRequest,
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
|
||||||
|
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
|
||||||
|
|
||||||
|
expect(onDeleteRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ type Props = {
|
|||||||
flashAnnotationId?: string | null;
|
flashAnnotationId?: string | null;
|
||||||
onAnnotationClick: (id: string) => void;
|
onAnnotationClick: (id: string) => void;
|
||||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||||
|
onDeleteAnnotationRequest?: (annotationId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -38,7 +39,8 @@ let {
|
|||||||
annotationsDimmed = false,
|
annotationsDimmed = false,
|
||||||
flashAnnotationId = null,
|
flashAnnotationId = null,
|
||||||
onAnnotationClick,
|
onAnnotationClick,
|
||||||
onTranscriptionDraw
|
onTranscriptionDraw,
|
||||||
|
onDeleteAnnotationRequest
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -98,6 +100,7 @@ let {
|
|||||||
flashAnnotationId={flashAnnotationId}
|
flashAnnotationId={flashAnnotationId}
|
||||||
onAnnotationClick={onAnnotationClick}
|
onAnnotationClick={onAnnotationClick}
|
||||||
onTranscriptionDraw={onTranscriptionDraw}
|
onTranscriptionDraw={onTranscriptionDraw}
|
||||||
|
onDeleteAnnotationRequest={onDeleteAnnotationRequest}
|
||||||
documentFileHash={doc.fileHash ?? null}
|
documentFileHash={doc.fileHash ?? null}
|
||||||
/>
|
/>
|
||||||
{:else if fileUrl}
|
{:else if fileUrl}
|
||||||
|
|||||||
@@ -20,6 +20,48 @@ const controlBase =
|
|||||||
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink';
|
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink';
|
||||||
const linkBase = `${controlBase} transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none`;
|
const linkBase = `${controlBase} transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none`;
|
||||||
const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
|
const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
|
||||||
|
const activePageBase =
|
||||||
|
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm border border-brand-navy bg-brand-navy px-4 py-2 font-sans text-sm font-bold text-white';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the sliding window of 1-indexed page numbers to render as buttons.
|
||||||
|
* Always shows: first, last, current, one neighbor each side.
|
||||||
|
* null entries represent ellipsis gaps.
|
||||||
|
*/
|
||||||
|
const pageWindow = $derived.by(() => {
|
||||||
|
const first = 1;
|
||||||
|
const last = totalPages;
|
||||||
|
const current = page + 1; // convert to 1-indexed
|
||||||
|
|
||||||
|
const windowStart = Math.max(first, current - 1);
|
||||||
|
const windowEnd = Math.min(last, current + 1);
|
||||||
|
|
||||||
|
const result: (number | null)[] = [];
|
||||||
|
|
||||||
|
result.push(first);
|
||||||
|
|
||||||
|
if (windowStart > first + 2) {
|
||||||
|
result.push(null); // left ellipsis
|
||||||
|
} else if (windowStart === first + 2) {
|
||||||
|
result.push(first + 1); // bridge: one page gap, show directly instead of ellipsis
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let p = Math.max(windowStart, first + 1); p <= Math.min(windowEnd, last - 1); p++) {
|
||||||
|
result.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windowEnd < last - 2) {
|
||||||
|
result.push(null); // right ellipsis
|
||||||
|
} else if (windowEnd === last - 2) {
|
||||||
|
result.push(last - 1); // bridge: one page gap, show directly instead of ellipsis
|
||||||
|
}
|
||||||
|
|
||||||
|
if (last > first) {
|
||||||
|
result.push(last);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if totalPages > 1}
|
{#if totalPages > 1}
|
||||||
@@ -52,13 +94,60 @@ const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Mobile: "Seite X von Y" label (hidden on sm: and above) -->
|
||||||
|
<!-- aria-hidden: decorative visual label; AT uses the sr-only span below for aria-current -->
|
||||||
<span
|
<span
|
||||||
data-testid="pagination-page-label"
|
data-testid="pagination-page-label"
|
||||||
aria-current="page"
|
aria-hidden="true"
|
||||||
class="font-sans text-sm text-ink-2"
|
class="font-sans text-sm text-ink-2 sm:hidden"
|
||||||
>
|
>
|
||||||
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- Always in the AT tree: announces current page regardless of breakpoint.
|
||||||
|
On mobile, the desktop button container is display:none so this is the only AT anchor.
|
||||||
|
On desktop, the active page button also carries aria-current — both announce the same info. -->
|
||||||
|
<span data-testid="pagination-current-page-sr" aria-current="page" class="sr-only">
|
||||||
|
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Desktop: numbered page buttons (hidden below sm:) -->
|
||||||
|
<div data-testid="pagination-pages" class="hidden items-center gap-1 sm:flex">
|
||||||
|
{#each pageWindow as entry, i (entry === null ? 'ellipsis-' + i : entry)}
|
||||||
|
{#if entry === null}
|
||||||
|
{#if i === 1}
|
||||||
|
<span
|
||||||
|
data-testid="pagination-ellipsis-left"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="px-2 text-sm text-ink-2">…</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
data-testid="pagination-ellipsis-right"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="px-2 text-sm text-ink-2">…</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{:else if entry === page + 1}
|
||||||
|
<span
|
||||||
|
data-testid="pagination-page-{entry}"
|
||||||
|
aria-current="page"
|
||||||
|
aria-label={m.pagination_page_button({ page: entry })}
|
||||||
|
class={activePageBase}
|
||||||
|
>
|
||||||
|
{entry}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
data-testid="pagination-page-{entry}"
|
||||||
|
aria-label={m.pagination_page_button({ page: entry })}
|
||||||
|
href={makeHref(entry - 1)}
|
||||||
|
class={linkBase}
|
||||||
|
>
|
||||||
|
{entry}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if hasNext}
|
{#if hasNext}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -19,11 +19,145 @@ describe('Pagination', () => {
|
|||||||
await expect.element(label).toHaveTextContent(/10/);
|
await expect.element(label).toHaveTextContent(/10/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks the current page label with aria-current="page"', async () => {
|
it('mobile page label is aria-hidden (desktop buttons carry the aria-current role)', async () => {
|
||||||
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||||
|
|
||||||
const label = page.getByTestId('pagination-page-label');
|
const label = page.getByTestId('pagination-page-label');
|
||||||
await expect.element(label).toHaveAttribute('aria-current', 'page');
|
await expect.element(label).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('page number buttons', () => {
|
||||||
|
it('renders page number buttons when totalPages > 1', async () => {
|
||||||
|
render(Pagination, { page: 4, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
// active page button — the current page (5, 1-indexed)
|
||||||
|
const activeBtn = nav.getByTestId('pagination-page-5');
|
||||||
|
await expect.element(activeBtn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render page number buttons when totalPages <= 1', async () => {
|
||||||
|
render(Pagination, { page: 0, totalPages: 1, makeHref });
|
||||||
|
|
||||||
|
// entire nav is hidden
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
await expect.element(nav).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the active page button with aria-current="page"', async () => {
|
||||||
|
render(Pagination, { page: 4, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const activeBtn = nav.getByTestId('pagination-page-5');
|
||||||
|
await expect.element(activeBtn).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('active page button has brand-navy background', async () => {
|
||||||
|
render(Pagination, { page: 4, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const activeBtn = nav.getByTestId('pagination-page-5');
|
||||||
|
await expect.element(activeBtn).toHaveClass(/bg-brand-navy/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('active page button has 44px touch target', async () => {
|
||||||
|
render(Pagination, { page: 4, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const activeBtn = nav.getByTestId('pagination-page-5');
|
||||||
|
await expect.element(activeBtn).toHaveClass(/min-h-\[44px\]/);
|
||||||
|
await expect.element(activeBtn).toHaveClass(/min-w-\[44px\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inactive page buttons link to their target page via makeHref', async () => {
|
||||||
|
const spy = vi.fn(makeHref);
|
||||||
|
render(Pagination, { page: 4, totalPages: 12, makeHref: spy });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
// page button for page 1 (0-indexed: 0) should link to /documents?page=0
|
||||||
|
const firstPageBtn = nav.getByTestId('pagination-page-1');
|
||||||
|
await expect.element(firstPageBtn).toHaveAttribute('href', '/documents?page=0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders first and last page buttons always visible', async () => {
|
||||||
|
render(Pagination, { page: 5, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument();
|
||||||
|
await expect.element(nav.getByTestId('pagination-page-12')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ellipsis span between first page and window when gap exists', async () => {
|
||||||
|
// page 6 (0-indexed: 5) — window is 5,6,7 — gap between 1 and 5
|
||||||
|
render(Pagination, { page: 5, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const ellipses = nav.getByTestId('pagination-ellipsis-left');
|
||||||
|
await expect.element(ellipses).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ellipsis span between window and last page when gap exists', async () => {
|
||||||
|
// page 1 (0-indexed: 0) — window is 1,2 — gap between 2 and 12
|
||||||
|
render(Pagination, { page: 0, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const ellipsis = nav.getByTestId('pagination-ellipsis-right');
|
||||||
|
await expect.element(ellipsis).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render left ellipsis when window is adjacent to first page', async () => {
|
||||||
|
// page 1 (0-indexed: 0) — window starts at 1, adjacent to first page
|
||||||
|
render(Pagination, { page: 0, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const leftEllipsis = nav.getByTestId('pagination-ellipsis-left');
|
||||||
|
await expect.element(leftEllipsis).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render right ellipsis when window is adjacent to last page', async () => {
|
||||||
|
// last page (0-indexed: 11) — window ends at 12, adjacent to last page
|
||||||
|
render(Pagination, { page: 11, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const rightEllipsis = nav.getByTestId('pagination-ellipsis-right');
|
||||||
|
await expect.element(rightEllipsis).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('page buttons container has hidden class on mobile (sm: prefix)', async () => {
|
||||||
|
// The page buttons container must be hidden below sm: breakpoint
|
||||||
|
render(Pagination, { page: 4, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const pageButtons = nav.getByTestId('pagination-pages');
|
||||||
|
await expect.element(pageButtons).toHaveClass(/hidden/);
|
||||||
|
await expect.element(pageButtons).toHaveClass(/sm:flex/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders both pages without ellipsis when totalPages is 2', async () => {
|
||||||
|
render(Pagination, { page: 0, totalPages: 2, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument();
|
||||||
|
await expect.element(nav.getByTestId('pagination-page-2')).toBeInTheDocument();
|
||||||
|
await expect.element(nav.getByTestId('pagination-ellipsis-left')).not.toBeInTheDocument();
|
||||||
|
await expect.element(nav.getByTestId('pagination-ellipsis-right')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mobile page label is aria-hidden so screen readers skip it on wide screens', async () => {
|
||||||
|
render(Pagination, { page: 2, totalPages: 10, makeHref });
|
||||||
|
|
||||||
|
const label = page.getByTestId('pagination-page-label');
|
||||||
|
await expect.element(label).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sr-only span always provides aria-current="page" for screen readers at all breakpoints', async () => {
|
||||||
|
render(Pagination, { page: 2, totalPages: 10, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const srLabel = nav.getByTestId('pagination-current-page-sr');
|
||||||
|
await expect.element(srLabel).toBeInTheDocument();
|
||||||
|
await expect.element(srLabel).toHaveAttribute('aria-current', 'page');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders prev as a link pointing at page - 1 when not on first page', async () => {
|
it('renders prev as a link pointing at page - 1 when not on first page', async () => {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ let {
|
|||||||
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||||
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
||||||
? 'text-ink-2 hover:bg-surface/10'
|
? 'text-ink-2 hover:bg-surface/10'
|
||||||
: 'bg-surface/10 text-accent'}"
|
: 'bg-surface/10 text-primary'}"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-3.5 w-3.5 shrink-0"
|
class="h-3.5 w-3.5 shrink-0"
|
||||||
|
|||||||
67
frontend/src/lib/components/PdfControls.svelte.spec.ts
Normal file
67
frontend/src/lib/components/PdfControls.svelte.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { vi, describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
import PdfControls from './PdfControls.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 3,
|
||||||
|
isLoaded: true,
|
||||||
|
showAnnotations: false,
|
||||||
|
annotationCount: 0,
|
||||||
|
onPrev: vi.fn(),
|
||||||
|
onNext: vi.fn(),
|
||||||
|
onZoomIn: vi.fn(),
|
||||||
|
onZoomOut: vi.fn(),
|
||||||
|
onToggleAnnotations: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PdfControls — annotation toggle visibility', () => {
|
||||||
|
it('renders annotation toggle when annotationCount is greater than zero', async () => {
|
||||||
|
render(PdfControls, { ...defaultProps, annotationCount: 3 });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /annotierungen anzeigen/i }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render annotation toggle when annotationCount is zero', async () => {
|
||||||
|
render(PdfControls, { ...defaultProps, annotationCount: 0 });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /annotierungen/i }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PdfControls — annotation toggle label', () => {
|
||||||
|
it('shows "Annotierungen anzeigen" label when annotations are hidden', async () => {
|
||||||
|
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: false });
|
||||||
|
const btn = page.getByRole('button', { name: /annotierungen anzeigen/i });
|
||||||
|
await expect.element(btn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Annotierungen verbergen" label when annotations are visible', async () => {
|
||||||
|
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: true });
|
||||||
|
const btn = page.getByRole('button', { name: /annotierungen verbergen/i });
|
||||||
|
await expect.element(btn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PdfControls — annotation toggle contrast (WCAG 2.1 AA)', () => {
|
||||||
|
it('uses text-primary class on annotation toggle button when annotations are hidden', async () => {
|
||||||
|
const { container } = render(PdfControls, {
|
||||||
|
...defaultProps,
|
||||||
|
annotationCount: 2,
|
||||||
|
showAnnotations: false
|
||||||
|
});
|
||||||
|
const allButtons = container.querySelectorAll('button');
|
||||||
|
const annotationBtn = Array.from(allButtons).find((b) =>
|
||||||
|
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
|
||||||
|
);
|
||||||
|
expect(annotationBtn).not.toBeNull();
|
||||||
|
expect(annotationBtn!.className).toContain('text-primary');
|
||||||
|
expect(annotationBtn!.className).not.toContain('text-accent');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ let {
|
|||||||
activeAnnotationId = $bindable<string | null>(null),
|
activeAnnotationId = $bindable<string | null>(null),
|
||||||
onAnnotationClick,
|
onAnnotationClick,
|
||||||
onTranscriptionDraw,
|
onTranscriptionDraw,
|
||||||
|
onDeleteAnnotationRequest,
|
||||||
documentFileHash,
|
documentFileHash,
|
||||||
annotationsDimmed = false,
|
annotationsDimmed = false,
|
||||||
flashAnnotationId = null
|
flashAnnotationId = null
|
||||||
@@ -30,6 +31,7 @@ let {
|
|||||||
activeAnnotationId?: string | null;
|
activeAnnotationId?: string | null;
|
||||||
onAnnotationClick?: (id: string) => void;
|
onAnnotationClick?: (id: string) => void;
|
||||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||||
|
onDeleteAnnotationRequest?: (annotationId: string) => void;
|
||||||
documentFileHash?: string | null;
|
documentFileHash?: string | null;
|
||||||
annotationsDimmed?: boolean;
|
annotationsDimmed?: boolean;
|
||||||
flashAnnotationId?: string | null;
|
flashAnnotationId?: string | null;
|
||||||
@@ -264,6 +266,7 @@ function handleAnnotationClick(id: string) {
|
|||||||
flashAnnotationId={flashAnnotationId}
|
flashAnnotationId={flashAnnotationId}
|
||||||
onDraw={handleDraw}
|
onDraw={handleDraw}
|
||||||
onAnnotationClick={handleAnnotationClick}
|
onAnnotationClick={handleAnnotationClick}
|
||||||
|
onDeleteRequest={onDeleteAnnotationRequest}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,6 +76,22 @@ const typeahead = createTypeahead<Person>({
|
|||||||
debounceMs: 300
|
debounceMs: 300
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fixed-position dropdown state — escapes any CSS stacking context that would clip it.
|
||||||
|
let inputEl: HTMLInputElement;
|
||||||
|
let dropdownStyle = $state('');
|
||||||
|
let activeIndex = $state(-1);
|
||||||
|
|
||||||
|
// Stable id linking the input's aria-controls to the listbox element.
|
||||||
|
const listboxId = `${name}-listbox`;
|
||||||
|
|
||||||
|
const isOpen = $derived(typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading));
|
||||||
|
|
||||||
|
function updateDropdownPosition() {
|
||||||
|
if (!inputEl) return;
|
||||||
|
const rect = inputEl.getBoundingClientRect();
|
||||||
|
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
||||||
|
}
|
||||||
|
|
||||||
function handleInput() {
|
function handleInput() {
|
||||||
if (value && searchTerm !== initialName) {
|
if (value && searchTerm !== initialName) {
|
||||||
value = '';
|
value = '';
|
||||||
@@ -88,6 +104,7 @@ function handleInput() {
|
|||||||
|
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
onfocused?.();
|
onfocused?.();
|
||||||
|
updateDropdownPosition();
|
||||||
if (restrictToCorrespondentsOf) {
|
if (restrictToCorrespondentsOf) {
|
||||||
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -109,13 +126,47 @@ function selectPerson(person: Person) {
|
|||||||
value = person.id!;
|
value = person.id!;
|
||||||
searchTerm = person.displayName;
|
searchTerm = person.displayName;
|
||||||
typeahead.close();
|
typeahead.close();
|
||||||
|
activeIndex = -1;
|
||||||
onchange?.(person.id!);
|
onchange?.(person.id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
typeahead.close();
|
||||||
|
activeIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const results = typeahead.results;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex = (activeIndex + 1) % results.length;
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex = (activeIndex - 1 + results.length) % results.length;
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeIndex >= 0 && results[activeIndex]) {
|
||||||
|
selectPerson(results[activeIndex]);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep dropdown position current when user scrolls or resizes.
|
||||||
|
// fixed positioning is intentional — it escapes any CSS stacking context (overflow, transform,
|
||||||
|
// shadow-sm + z-index combinations) that would clip an absolute-positioned dropdown.
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
|
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||||
|
|
||||||
|
<div class="relative" use:clickOutside onclickoutside={closeDropdown}>
|
||||||
<label
|
<label
|
||||||
for={name}
|
for="{name}-search"
|
||||||
class={compact
|
class={compact
|
||||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||||
: 'block text-sm font-medium text-ink-2'}
|
: 'block text-sm font-medium text-ink-2'}
|
||||||
@@ -125,13 +176,22 @@ function selectPerson(person: Person) {
|
|||||||
<input type="hidden" name={name} bind:value={value} />
|
<input type="hidden" name={name} bind:value={value} />
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
type="text"
|
type="text"
|
||||||
id="{name}-search"
|
id="{name}-search"
|
||||||
|
role="combobox"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autofocus={autofocus}
|
autofocus={autofocus}
|
||||||
bind:value={searchTerm}
|
bind:value={searchTerm}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-controls={listboxId}
|
||||||
|
aria-activedescendant={activeIndex >= 0 && typeahead.results[activeIndex]
|
||||||
|
? `${listboxId}-option-${typeahead.results[activeIndex].id}`
|
||||||
|
: undefined}
|
||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onfocus={handleFocus}
|
onfocus={handleFocus}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
||||||
class={large
|
class={large
|
||||||
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||||
@@ -140,29 +200,34 @@ function selectPerson(person: Person) {
|
|||||||
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
{#if isOpen}
|
||||||
<div
|
<ul
|
||||||
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
id={listboxId}
|
||||||
|
role="listbox"
|
||||||
|
style={dropdownStyle}
|
||||||
|
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
||||||
>
|
>
|
||||||
{#if typeahead.loading}
|
{#if typeahead.loading}
|
||||||
<div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
|
<li class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</li>
|
||||||
{:else}
|
{:else}
|
||||||
{#each typeahead.results as person (person.id)}
|
{#each typeahead.results as person, i (person.id)}
|
||||||
<div
|
<li
|
||||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
|
id="{listboxId}-option-{person.id}"
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === activeIndex}
|
||||||
|
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg {i === activeIndex ? 'bg-accent-bg' : ''}"
|
||||||
onclick={() => selectPerson(person)}
|
onclick={() => selectPerson(person)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||||
role="button"
|
tabindex="-1"
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="block truncate font-medium">
|
<span class="block truncate font-medium">
|
||||||
{person.displayName}
|
{person.displayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page, userEvent } from 'vitest/browser';
|
||||||
import PersonTypeahead from './PersonTypeahead.svelte';
|
import PersonTypeahead from './PersonTypeahead.svelte';
|
||||||
|
|
||||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||||
@@ -130,11 +130,11 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
await expect.element(input).toHaveValue('Max Mustermann');
|
await expect.element(input).toHaveValue('Max Mustermann');
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: 'Max Mustermann' }))
|
.element(page.getByRole('option', { name: 'Max Mustermann' }))
|
||||||
.not.toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
||||||
});
|
});
|
||||||
@@ -145,7 +145,7 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
await tick();
|
await tick();
|
||||||
expect(hiddenInput('senderId')?.value).toBe('1');
|
expect(hiddenInput('senderId')?.value).toBe('1');
|
||||||
@@ -158,7 +158,7 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
expect(onchange).toHaveBeenCalledWith('1');
|
expect(onchange).toHaveBeenCalledWith('1');
|
||||||
});
|
});
|
||||||
@@ -177,7 +177,7 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Ma');
|
await input.fill('Ma');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
await expect.element(input).toHaveValue('Max Mustermann');
|
await expect.element(input).toHaveValue('Max Mustermann');
|
||||||
});
|
});
|
||||||
@@ -194,7 +194,7 @@ describe('PersonTypeahead – clearing a selection', () => {
|
|||||||
|
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
expect(onchange).toHaveBeenCalledWith('1');
|
expect(onchange).toHaveBeenCalledWith('1');
|
||||||
onchange.mockClear();
|
onchange.mockClear();
|
||||||
@@ -285,3 +285,194 @@ describe('PersonTypeahead – click outside', () => {
|
|||||||
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── ARIA roles ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonTypeahead – ARIA roles', () => {
|
||||||
|
it('dropdown uses role="listbox" container and role="option" items', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
// Container must be a listbox
|
||||||
|
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Items must be options, not buttons
|
||||||
|
const options = page.getByRole('option');
|
||||||
|
await expect.element(options.first()).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('option', { name: 'Max Mustermann' })).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('option', { name: 'Anna Musterfrau' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('input has aria-expanded="false" when dropdown is closed', async () => {
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await expect.element(input).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('input has aria-expanded="true" when dropdown is open', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
await expect.element(input).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('input has aria-controls pointing to the listbox id', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
const ariaControls = await input.element().getAttribute('aria-controls');
|
||||||
|
expect(ariaControls).toBeTruthy();
|
||||||
|
|
||||||
|
// The listbox with that id must exist
|
||||||
|
const listbox = document.getElementById(ariaControls!);
|
||||||
|
expect(listbox).not.toBeNull();
|
||||||
|
expect(listbox!.getAttribute('role')).toBe('listbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('input has aria-haspopup="listbox"', async () => {
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await expect.element(input).toHaveAttribute('aria-haspopup', 'listbox');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Keyboard navigation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonTypeahead – keyboard navigation', () => {
|
||||||
|
it('ArrowDown moves highlight to the first option', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
await input.click();
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// First option should be highlighted (aria-selected="true")
|
||||||
|
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
|
||||||
|
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowDown then ArrowDown moves highlight to the second option', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
await input.click();
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
await tick();
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const secondOption = page.getByRole('option', { name: 'Anna Musterfrau' });
|
||||||
|
await expect.element(secondOption).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowUp from first wraps to last option', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
await input.click();
|
||||||
|
await userEvent.keyboard('{ArrowDown}'); // highlight first
|
||||||
|
await tick();
|
||||||
|
await userEvent.keyboard('{ArrowUp}'); // wrap to last
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const lastOption = page.getByRole('option', { name: 'Anna Musterfrau' });
|
||||||
|
await expect.element(lastOption).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowDown from last wraps to first option', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
await input.click();
|
||||||
|
await userEvent.keyboard('{ArrowDown}'); // highlight first (index 0)
|
||||||
|
await tick();
|
||||||
|
await userEvent.keyboard('{ArrowDown}'); // highlight second (index 1 = last)
|
||||||
|
await tick();
|
||||||
|
await userEvent.keyboard('{ArrowDown}'); // wrap to first (index 0)
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
|
||||||
|
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Enter selects the highlighted option', async () => {
|
||||||
|
mockFetchWithPersons([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Ma');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
await input.click();
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
await tick();
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
await expect.element(input).toHaveValue('Max Mustermann');
|
||||||
|
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape closes the dropdown without selecting', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await input.click();
|
||||||
|
await userEvent.keyboard('{Escape}');
|
||||||
|
await tick();
|
||||||
|
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||||
|
// Value unchanged — nothing was selected
|
||||||
|
await expect.element(input).toHaveValue('Mu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('active option id is set as aria-activedescendant on the input', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
// No active option before pressing ArrowDown
|
||||||
|
const beforeNav = await input.element().getAttribute('aria-activedescendant');
|
||||||
|
expect(beforeNav).toBeFalsy();
|
||||||
|
|
||||||
|
await input.click();
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const afterNav = await input.element().getAttribute('aria-activedescendant');
|
||||||
|
expect(afterNav).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ let { percentage }: { percentage: number } = $props();
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span
|
||||||
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-accent' : 'text-gray-400'}"
|
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-primary' : 'text-gray-400'}"
|
||||||
>
|
>
|
||||||
{percentage}%
|
{percentage}%
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ describe('ProgressRing', () => {
|
|||||||
expect(el.className).toContain('text-gray-400');
|
expect(el.className).toContain('text-gray-400');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a mint-colored label when percentage is > 0', async () => {
|
it('renders a primary-colored label when percentage is > 0', async () => {
|
||||||
render(ProgressRing, { percentage: 75 });
|
render(ProgressRing, { percentage: 75 });
|
||||||
const label = page.getByText('75%');
|
const label = page.getByText('75%');
|
||||||
await expect.element(label).toBeInTheDocument();
|
await expect.element(label).toBeInTheDocument();
|
||||||
const el = (await label.element()) as HTMLElement;
|
const el = (await label.element()) as HTMLElement;
|
||||||
expect(el.className).toContain('text-accent');
|
expect(el.className).toContain('text-primary');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a fully filled arc for 100%', async () => {
|
it('renders a fully filled arc for 100%', async () => {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ let {
|
|||||||
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
|
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
|
||||||
<!-- Sender -->
|
<!-- Sender -->
|
||||||
<div
|
<div
|
||||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||||
>
|
>
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="senderId"
|
name="senderId"
|
||||||
@@ -73,7 +73,7 @@ let {
|
|||||||
|
|
||||||
<!-- Receiver -->
|
<!-- Receiver -->
|
||||||
<div
|
<div
|
||||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||||
>
|
>
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="receiverId"
|
name="receiverId"
|
||||||
@@ -86,7 +86,7 @@ let {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
|
<div class="grid grid-cols-1 items-end gap-6 md:grid-cols-3">
|
||||||
<!-- Date From -->
|
<!-- Date From -->
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ import { getErrorMessage } from '$lib/errors';
|
|||||||
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
|
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
|
||||||
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
|
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
|
||||||
import { scrollToCommentFromQuery } from '$lib/utils/deepLinkScroll';
|
import { scrollToCommentFromQuery } from '$lib/utils/deepLinkScroll';
|
||||||
|
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
|
const { confirm } = getConfirmService();
|
||||||
|
|
||||||
const doc = $derived(data.document);
|
const doc = $derived(data.document);
|
||||||
const canWrite = $derived(data.canWrite ?? false);
|
const canWrite = $derived(data.canWrite ?? false);
|
||||||
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
||||||
@@ -105,6 +108,26 @@ async function deleteBlock(blockId: string) {
|
|||||||
annotationReloadKey++;
|
annotationReloadKey++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAnnotationDeleteRequest(annotationId: string) {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: m.transcription_block_delete_confirm(),
|
||||||
|
destructive: true
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
const block = transcriptionBlocks.find((b) => b.annotationId === annotationId);
|
||||||
|
if (block) {
|
||||||
|
await deleteBlock(block.id);
|
||||||
|
} else {
|
||||||
|
// Annotation has no linked block — delete the annotation directly
|
||||||
|
const res = await fetch(`/api/documents/${doc.id}/annotations/${annotationId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Delete annotation failed');
|
||||||
|
annotationReloadKey++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function reviewToggle(blockId: string) {
|
async function reviewToggle(blockId: string) {
|
||||||
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}/review`, {
|
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}/review`, {
|
||||||
method: 'PUT'
|
method: 'PUT'
|
||||||
@@ -381,6 +404,7 @@ onMount(() => {
|
|||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
onAnnotationClick={handleAnnotationClick}
|
onAnnotationClick={handleAnnotationClick}
|
||||||
onTranscriptionDraw={createBlockFromDraw}
|
onTranscriptionDraw={createBlockFromDraw}
|
||||||
|
onDeleteAnnotationRequest={handleAnnotationDeleteRequest}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ let mergeTargetId = $state('');
|
|||||||
let showMergeConfirm = $state(false);
|
let showMergeConfirm = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
<div class="mb-10 overflow-hidden rounded-sm border border-red-200 bg-surface shadow-sm">
|
||||||
<div class="p-6 md:p-8">
|
<div class="p-6 md:p-8">
|
||||||
<h2 class="mb-1 font-serif text-lg text-ink">{m.person_merge_heading()}</h2>
|
<h2 class="mb-1 font-serif text-lg text-ink">{m.person_merge_heading()}</h2>
|
||||||
<p class="mb-5 font-sans text-sm text-ink-2">
|
<p class="mb-5 font-sans text-sm text-ink-2">
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import PersonMergePanel from './PersonMergePanel.svelte';
|
||||||
|
|
||||||
|
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||||
|
vi.mock('$lib/components/PersonTypeahead.svelte', () => ({
|
||||||
|
default: () => null
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const makePerson = (overrides = {}) => ({
|
||||||
|
displayName: 'Hans Müller',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Danger indicator ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMergePanel — danger indicator', () => {
|
||||||
|
it('renders outer container with red border class', () => {
|
||||||
|
const { container } = render(PersonMergePanel, {
|
||||||
|
props: { person: makePerson(), form: null }
|
||||||
|
});
|
||||||
|
const panel = container.firstElementChild as HTMLElement;
|
||||||
|
expect(panel?.classList.contains('border-red-200')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Initial state ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMergePanel — initial state', () => {
|
||||||
|
it('renders merge heading', async () => {
|
||||||
|
render(PersonMergePanel, { props: { person: makePerson(), form: null } });
|
||||||
|
const heading = page.getByRole('heading', { level: 2 });
|
||||||
|
await expect.element(heading).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merge button is disabled when no target selected', async () => {
|
||||||
|
render(PersonMergePanel, { props: { person: makePerson(), form: null } });
|
||||||
|
const mergeBtn = page.getByRole('button', { name: /zusammenführen/i });
|
||||||
|
await expect.element(mergeBtn).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Error state ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMergePanel — error state', () => {
|
||||||
|
it('renders mergeError when form contains error', async () => {
|
||||||
|
render(PersonMergePanel, {
|
||||||
|
props: { person: makePerson(), form: { mergeError: 'Zielperson nicht gefunden.' } }
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Zielperson nicht gefunden.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import BackButton from '$lib/components/BackButton.svelte';
|
|||||||
import PersonEditForm from './PersonEditForm.svelte';
|
import PersonEditForm from './PersonEditForm.svelte';
|
||||||
import PersonEditSaveBar from './PersonEditSaveBar.svelte';
|
import PersonEditSaveBar from './PersonEditSaveBar.svelte';
|
||||||
import NameHistoryEditCard from './NameHistoryEditCard.svelte';
|
import NameHistoryEditCard from './NameHistoryEditCard.svelte';
|
||||||
import PersonDangerZone from './PersonDangerZone.svelte';
|
import PersonMergePanel from '../PersonMergePanel.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
const person = $derived(data.person);
|
const person = $derived(data.person);
|
||||||
@@ -35,7 +35,9 @@ const person = $derived(data.person);
|
|||||||
|
|
||||||
<NameHistoryEditCard aliases={data.aliases} canWrite={true} aliasError={form?.aliasError} />
|
<NameHistoryEditCard aliases={data.aliases} canWrite={true} aliasError={form?.aliasError} />
|
||||||
|
|
||||||
<PersonDangerZone person={person} form={form} />
|
{#key person.id}
|
||||||
|
<PersonMergePanel person={person} form={form} />
|
||||||
|
{/key}
|
||||||
|
|
||||||
<PersonEditSaveBar discardHref="/persons/{person.id}" formId="person-edit-form" />
|
<PersonEditSaveBar discardHref="/persons/{person.id}" formId="person-edit-form" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import PersonMergePanel from '../PersonMergePanel.svelte';
|
|
||||||
|
|
||||||
let {
|
|
||||||
person,
|
|
||||||
form
|
|
||||||
}: {
|
|
||||||
person: { id: string; firstName?: string | null; lastName: string; displayName: string };
|
|
||||||
form?: { mergeError?: string } | null;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let open = $state(false);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mt-8 overflow-hidden rounded-sm border border-red-200 bg-surface shadow-sm">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (open = !open)}
|
|
||||||
class="flex w-full items-center justify-between px-6 py-4 text-left"
|
|
||||||
aria-expanded={open}
|
|
||||||
>
|
|
||||||
<span class="text-sm font-bold tracking-widest text-red-600 uppercase">
|
|
||||||
{m.person_danger_zone_heading()}
|
|
||||||
</span>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4 text-red-400 transition-transform {open ? 'rotate-180' : ''}"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if open}
|
|
||||||
<div class="border-t border-red-100 px-6 py-4">
|
|
||||||
{#key person.id}
|
|
||||||
<PersonMergePanel person={person} form={form} />
|
|
||||||
{/key}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
Reference in New Issue
Block a user