Compare commits
2 Commits
108edff8d2
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
379bc84e11 | ||
|
|
110da9b8b0 |
@@ -23,8 +23,6 @@
|
|||||||
"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",
|
||||||
@@ -818,7 +816,6 @@
|
|||||||
"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)",
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
"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",
|
||||||
@@ -818,7 +816,6 @@
|
|||||||
"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)",
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
"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",
|
||||||
@@ -818,7 +816,6 @@
|
|||||||
"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)",
|
||||||
|
|
||||||
|
|||||||
@@ -48,12 +48,6 @@ 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 () => {
|
||||||
@@ -78,11 +72,12 @@ onDestroy(() => {
|
|||||||
{@attach attachBellButton}
|
{@attach attachBellButton}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggleDropdown}
|
onclick={toggleDropdown}
|
||||||
aria-label={bellLabel}
|
aria-label={stream.unreadCount > 0
|
||||||
title={bellLabel}
|
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
||||||
|
: m.notification_bell_label()}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -55,34 +55,6 @@ 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' })];
|
||||||
|
|||||||
@@ -20,48 +20,6 @@ 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}
|
||||||
@@ -94,60 +52,13 @@ const pageWindow = $derived.by(() => {
|
|||||||
</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-hidden="true"
|
aria-current="page"
|
||||||
class="font-sans text-sm text-ink-2 sm:hidden"
|
class="font-sans text-sm text-ink-2"
|
||||||
>
|
>
|
||||||
{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,145 +19,11 @@ describe('Pagination', () => {
|
|||||||
await expect.element(label).toHaveTextContent(/10/);
|
await expect.element(label).toHaveTextContent(/10/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mobile page label is aria-hidden (desktop buttons carry the aria-current role)', async () => {
|
it('marks the current page label with aria-current="page"', 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-hidden', 'true');
|
await expect.element(label).toHaveAttribute('aria-current', 'page');
|
||||||
});
|
|
||||||
|
|
||||||
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 () => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<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';
|
||||||
|
|
||||||
@@ -20,10 +19,6 @@ 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);
|
||||||
@@ -34,8 +29,8 @@ function toggle() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggle}
|
onclick={toggle}
|
||||||
aria-label={themeLabel}
|
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||||
title={themeLabel}
|
title={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||||
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'}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
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'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -365,11 +365,6 @@
|
|||||||
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);
|
||||||
|
|||||||
@@ -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-red-200 bg-surface shadow-sm">
|
<div class="mb-10 overflow-hidden rounded-sm border border-line 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">
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
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 PersonMergePanel from '../PersonMergePanel.svelte';
|
import PersonDangerZone from './PersonDangerZone.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
const person = $derived(data.person);
|
const person = $derived(data.person);
|
||||||
@@ -35,9 +35,7 @@ const person = $derived(data.person);
|
|||||||
|
|
||||||
<NameHistoryEditCard aliases={data.aliases} canWrite={true} aliasError={form?.aliasError} />
|
<NameHistoryEditCard aliases={data.aliases} canWrite={true} aliasError={form?.aliasError} />
|
||||||
|
|
||||||
{#key person.id}
|
<PersonDangerZone person={person} form={form} />
|
||||||
<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>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<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