Compare commits
3 Commits
b13c10936b
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35c2c83996 | ||
|
|
c317c085aa | ||
|
|
bc805cb178 |
@@ -260,13 +260,6 @@ 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 {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"nav_conversations": "Briefwechsel",
|
"nav_conversations": "Briefwechsel",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Abmelden",
|
"nav_logout": "Abmelden",
|
||||||
|
"theme_toggle_to_light": "Zu hellem Design wechseln",
|
||||||
|
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
|
||||||
"btn_save": "Speichern",
|
"btn_save": "Speichern",
|
||||||
"btn_cancel": "Abbrechen",
|
"btn_cancel": "Abbrechen",
|
||||||
"btn_confirm": "Bestätigen",
|
"btn_confirm": "Bestätigen",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"nav_conversations": "Letters",
|
"nav_conversations": "Letters",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Sign out",
|
"nav_logout": "Sign out",
|
||||||
|
"theme_toggle_to_light": "Switch to light mode",
|
||||||
|
"theme_toggle_to_dark": "Switch to dark mode",
|
||||||
"btn_save": "Save",
|
"btn_save": "Save",
|
||||||
"btn_cancel": "Cancel",
|
"btn_cancel": "Cancel",
|
||||||
"btn_confirm": "Confirm",
|
"btn_confirm": "Confirm",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"nav_conversations": "Cartas",
|
"nav_conversations": "Cartas",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Cerrar sesión",
|
"nav_logout": "Cerrar sesión",
|
||||||
|
"theme_toggle_to_light": "Cambiar a modo claro",
|
||||||
|
"theme_toggle_to_dark": "Cambiar a modo oscuro",
|
||||||
"btn_save": "Guardar",
|
"btn_save": "Guardar",
|
||||||
"btn_cancel": "Cancelar",
|
"btn_cancel": "Cancelar",
|
||||||
"btn_confirm": "Confirmar",
|
"btn_confirm": "Confirmar",
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ let {
|
|||||||
dimmed = false,
|
dimmed = false,
|
||||||
flashAnnotationId = null,
|
flashAnnotationId = null,
|
||||||
onDraw,
|
onDraw,
|
||||||
onAnnotationClick,
|
onAnnotationClick
|
||||||
onDeleteRequest
|
|
||||||
}: {
|
}: {
|
||||||
annotations: Annotation[];
|
annotations: Annotation[];
|
||||||
canDraw: boolean;
|
canDraw: boolean;
|
||||||
@@ -30,7 +29,6 @@ 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);
|
||||||
@@ -114,8 +112,6 @@ 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 button when annotation is not hovered or active', async () => {
|
it('does not show delete buttons (annotations owned by blocks)', async () => {
|
||||||
render(AnnotationLayer, {
|
render(AnnotationLayer, {
|
||||||
annotations: [makeAnnotation('ann-1')],
|
annotations: [makeAnnotation('ann-1')],
|
||||||
canDraw: true,
|
canDraw: true,
|
||||||
@@ -107,19 +107,6 @@ describe('AnnotationLayer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||||
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
expect(page.getByRole('button', { name: /löschen/i }).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,8 +11,6 @@ let {
|
|||||||
blockNumber = undefined,
|
blockNumber = undefined,
|
||||||
isFlashing = false,
|
isFlashing = false,
|
||||||
isResizable = false,
|
isResizable = false,
|
||||||
showDelete = false,
|
|
||||||
onDeleteRequest,
|
|
||||||
onclick,
|
onclick,
|
||||||
onpointerenter,
|
onpointerenter,
|
||||||
onpointerleave
|
onpointerleave
|
||||||
@@ -25,15 +23,11 @@ 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);
|
||||||
@@ -89,7 +83,6 @@ 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}
|
||||||
@@ -119,51 +112,6 @@ 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: -8px;
|
|
||||||
right: -8px;
|
|
||||||
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}
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
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('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,7 +24,6 @@ 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 {
|
||||||
@@ -39,8 +38,7 @@ let {
|
|||||||
annotationsDimmed = false,
|
annotationsDimmed = false,
|
||||||
flashAnnotationId = null,
|
flashAnnotationId = null,
|
||||||
onAnnotationClick,
|
onAnnotationClick,
|
||||||
onTranscriptionDraw,
|
onTranscriptionDraw
|
||||||
onDeleteAnnotationRequest
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -100,7 +98,6 @@ 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}
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bellLabel = $derived(
|
||||||
|
stream.unreadCount > 0
|
||||||
|
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
||||||
|
: m.notification_bell_label()
|
||||||
|
);
|
||||||
|
|
||||||
function attachBellButton(node: HTMLButtonElement) {
|
function attachBellButton(node: HTMLButtonElement) {
|
||||||
bellButtonEl = node;
|
bellButtonEl = node;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -72,12 +78,11 @@ onDestroy(() => {
|
|||||||
{@attach attachBellButton}
|
{@attach attachBellButton}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggleDropdown}
|
onclick={toggleDropdown}
|
||||||
aria-label={stream.unreadCount > 0
|
aria-label={bellLabel}
|
||||||
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
title={bellLabel}
|
||||||
: m.notification_bell_label()}
|
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="relative cursor-pointer rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -55,6 +55,34 @@ async function openDropdownAndClickFirstNotification() {
|
|||||||
notifButton.click();
|
notifButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('NotificationBell — cursor and tooltip', () => {
|
||||||
|
it('bell button has cursor-pointer class', async () => {
|
||||||
|
render(NotificationBell);
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
expect(btn.classList.contains('cursor-pointer')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bell button title equals aria-label when unreadCount is 0', async () => {
|
||||||
|
mockNotificationList.value = [];
|
||||||
|
render(NotificationBell);
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bell button title equals aria-label when unreadCount is 3', async () => {
|
||||||
|
mockNotificationList.value = [
|
||||||
|
makeNotification({ id: 'n1' }),
|
||||||
|
makeNotification({ id: 'n2' }),
|
||||||
|
makeNotification({ id: 'n3' })
|
||||||
|
];
|
||||||
|
render(NotificationBell);
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('NotificationBell', () => {
|
describe('NotificationBell', () => {
|
||||||
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
||||||
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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
|
||||||
@@ -31,7 +30,6 @@ 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;
|
||||||
@@ -266,7 +264,6 @@ function handleAnnotationClick(id: string) {
|
|||||||
flashAnnotationId={flashAnnotationId}
|
flashAnnotationId={flashAnnotationId}
|
||||||
onDraw={handleDraw}
|
onDraw={handleDraw}
|
||||||
onAnnotationClick={handleAnnotationClick}
|
onAnnotationClick={handleAnnotationClick}
|
||||||
onDeleteRequest={onDeleteAnnotationRequest}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ onMount(() => {
|
|||||||
theme = resolveInitialTheme();
|
theme = resolveInitialTheme();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const themeLabel = $derived(
|
||||||
|
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
|
||||||
|
);
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
theme = theme === 'dark' ? 'light' : 'dark';
|
theme = theme === 'dark' ? 'light' : 'dark';
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('theme', theme);
|
||||||
@@ -29,8 +34,8 @@ function toggle() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggle}
|
onclick={toggle}
|
||||||
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
|
aria-label={themeLabel}
|
||||||
title={theme === 'dark' ? 'light mode' : 'dark mode'}
|
title={themeLabel}
|
||||||
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
{#if theme === 'dark'}
|
{#if theme === 'dark'}
|
||||||
|
|||||||
45
frontend/src/lib/components/ThemeToggle.svelte.spec.ts
Normal file
45
frontend/src/lib/components/ThemeToggle.svelte.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import ThemeToggle from './ThemeToggle.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.removeItem('theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeToggle — label derivation (light mode)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-label invites switching to dark mode when theme is light', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe('Zu dunklem Design wechseln');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title equals aria-label in light mode', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeToggle — label derivation (dark mode)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-label invites switching to light mode when theme is dark', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe('Zu hellem Design wechseln');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title equals aria-label in dark mode', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,12 +13,9 @@ 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);
|
||||||
@@ -108,23 +105,6 @@ 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
|
|
||||||
await fetch(`/api/documents/${doc.id}/annotations/${annotationId}`, { method: 'DELETE' });
|
|
||||||
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'
|
||||||
@@ -401,7 +381,6 @@ onMount(() => {
|
|||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
onAnnotationClick={handleAnnotationClick}
|
onAnnotationClick={handleAnnotationClick}
|
||||||
onTranscriptionDraw={createBlockFromDraw}
|
onTranscriptionDraw={createBlockFromDraw}
|
||||||
onDeleteAnnotationRequest={handleAnnotationDeleteRequest}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -365,6 +365,11 @@
|
|||||||
text-underline-offset: 4px;
|
text-underline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tailwind preflight resets cursor on *, overriding the browser default for buttons */
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* Fallback focus ring for any interactive element not styled with ring-focus-ring */
|
/* Fallback focus ring for any interactive element not styled with ring-focus-ring */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--c-focus-ring);
|
outline: 2px solid var(--c-focus-ring);
|
||||||
|
|||||||
Reference in New Issue
Block a user