feat(admin): responsive entity nav and collapsible list panels (Phase 9)

EntityNav: hidden on mobile, 48px icon strip at tablet (md), full labels+counts at desktop (lg).
Each list panel collapses to a 32px handle via localStorage-persisted state; auto-collapses when
navigating to the "+New" route. Mobile routing hides the list panel when a detail route is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-30 07:19:41 +02:00
parent 06a489567a
commit 3c54401bb2
14 changed files with 540 additions and 178 deletions

View File

@@ -161,6 +161,8 @@
"admin_tag_edit_heading": "Schlagwort: {name}",
"admin_tag_updated": "Schlagwort umbenannt.",
"admin_unsaved_warning": "Du hast ungespeicherte Änderungen speichere oder verwerfe, bevor du wechselst.",
"admin_btn_collapse_list": "Liste einklappen",
"admin_btn_expand_list": "Liste ausklappen",
"admin_btn_edit_tag_label": "Schlagwort bearbeiten",
"admin_tag_delete_confirm": "Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.",
"admin_btn_delete_tag_label": "Schlagwort löschen",

View File

@@ -161,6 +161,8 @@
"admin_tag_edit_heading": "Tag: {name}",
"admin_tag_updated": "Tag renamed.",
"admin_unsaved_warning": "You have unsaved changes — save or discard before switching.",
"admin_btn_collapse_list": "Collapse list",
"admin_btn_expand_list": "Expand list",
"admin_btn_edit_tag_label": "Edit tag",
"admin_tag_delete_confirm": "Really delete? The tag will be removed from all documents.",
"admin_btn_delete_tag_label": "Delete tag",

View File

@@ -161,6 +161,8 @@
"admin_tag_edit_heading": "Etiqueta: {name}",
"admin_tag_updated": "Etiqueta renombrada.",
"admin_unsaved_warning": "Tienes cambios sin guardar — guarda o descarta antes de cambiar.",
"admin_btn_collapse_list": "Contraer lista",
"admin_btn_expand_list": "Expandir lista",
"admin_btn_edit_tag_label": "Editar etiqueta",
"admin_tag_delete_confirm": "¿Realmente eliminar? La etiqueta se eliminará de todos los documentos.",
"admin_btn_delete_tag_label": "Eliminar etiqueta",

View File

@@ -13,16 +13,18 @@ let { data, children } = $props();
Height fills from below the global header (64px) to bottom of viewport.
-->
<div class="-mt-6 flex overflow-hidden" style="height: calc(100vh - 65px)">
<!-- Entity Nav: always visible on desktop, icon strip on tablet (Phase 9) -->
<EntityNav
userCount={data.userCount}
groupCount={data.groupCount}
tagCount={data.tagCount}
canManageUsers={data.canManageUsers}
canManageTags={data.canManageTags}
canManageGroups={data.canManageGroups}
canRunMaintenance={data.canRunMaintenance}
/>
<!-- Entity Nav: hidden on mobile, icon strip on tablet, full labels on desktop -->
<div class="hidden md:flex">
<EntityNav
userCount={data.userCount}
groupCount={data.groupCount}
tagCount={data.tagCount}
canManageUsers={data.canManageUsers}
canManageTags={data.canManageTags}
canManageGroups={data.canManageGroups}
canRunMaintenance={data.canRunMaintenance}
/>
</div>
<!-- Right side: list panel + detail panel (or full-width for system) -->
<div class="flex min-w-0 flex-1 overflow-hidden">

View File

@@ -24,71 +24,154 @@ const currentPath = $derived(page.url.pathname);
const isActive = (section: string) => currentPath.startsWith(`/admin/${section}`);
</script>
<nav class="flex w-30 flex-shrink-0 flex-col bg-brand-navy" aria-label={m.admin_heading()}>
<div class="px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/30 uppercase">
<!--
Desktop (lg+): 120px with text labels
Tablet (mdlg): 48px icon-only strip
-->
<nav
class="flex flex-shrink-0 flex-col bg-brand-navy md:w-12 lg:w-30"
aria-label={m.admin_heading()}
>
<!-- Desktop-only heading -->
<div
class="hidden px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/30 uppercase lg:block"
>
{m.admin_heading()}
</div>
{#if canManageUsers}
<a
href="/admin/users"
class="flex flex-col gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors
class="flex flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors md:px-0 lg:items-start lg:px-3.5 lg:py-2.5
{isActive('users')
? 'border-brand-mint bg-white/10'
: 'border-transparent hover:bg-white/5'}"
aria-current={isActive('users') ? 'page' : undefined}
title={m.admin_tab_users()}
>
<span class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/20'}">
<!-- Icon: user group (always visible) -->
<svg
class="h-5 w-5 flex-shrink-0 {isActive('users') ? 'text-brand-mint' : 'text-white/40'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
<!-- Count: desktop only -->
<span
class="hidden text-[13px] font-black lg:block {isActive('users') ? 'text-white/65' : 'text-white/20'}"
>
{userCount}
</span>
<!-- Label: desktop only -->
<span
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
class="hidden text-[9px] font-extrabold tracking-[0.5px] uppercase lg:block
{isActive('users') ? 'text-white' : 'text-white/55'}"
>
{m.admin_tab_users()}
</span>
<!-- Count badge on tablet -->
<span
class="text-[9px] font-bold lg:hidden {isActive('users') ? 'text-white/80' : 'text-white/35'}"
>
{userCount}
</span>
</a>
{/if}
{#if canManageGroups}
<a
href="/admin/groups"
class="flex flex-col gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors
class="flex flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors md:px-0 lg:items-start lg:px-3.5 lg:py-2.5
{isActive('groups')
? 'border-brand-mint bg-white/10'
: 'border-transparent hover:bg-white/5'}"
aria-current={isActive('groups') ? 'page' : undefined}
title={m.admin_tab_groups()}
>
<span class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/20'}">
<!-- Icon: lock closed -->
<svg
class="h-5 w-5 flex-shrink-0 {isActive('groups') ? 'text-brand-mint' : 'text-white/40'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</svg>
<span
class="hidden text-[13px] font-black lg:block {isActive('groups') ? 'text-white/65' : 'text-white/20'}"
>
{groupCount}
</span>
<span
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
class="hidden text-[9px] font-extrabold tracking-[0.5px] uppercase lg:block
{isActive('groups') ? 'text-white' : 'text-white/55'}"
>
{m.admin_tab_groups()}
</span>
<span
class="text-[9px] font-bold lg:hidden {isActive('groups') ? 'text-white/80' : 'text-white/35'}"
>
{groupCount}
</span>
</a>
{/if}
{#if canManageTags}
<a
href="/admin/tags"
class="flex flex-col gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors
class="flex flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors md:px-0 lg:items-start lg:px-3.5 lg:py-2.5
{isActive('tags')
? 'border-brand-mint bg-white/10'
: 'border-transparent hover:bg-white/5'}"
aria-current={isActive('tags') ? 'page' : undefined}
title={m.admin_tab_tags()}
>
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/20'}">
<!-- Icon: tag -->
<svg
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/40'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
</svg>
<span
class="hidden text-[13px] font-black lg:block {isActive('tags') ? 'text-white/65' : 'text-white/20'}"
>
{tagCount}
</span>
<span
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
class="hidden text-[9px] font-extrabold tracking-[0.5px] uppercase lg:block
{isActive('tags') ? 'text-white' : 'text-white/55'}"
>
{m.admin_tab_tags()}
</span>
<span
class="text-[9px] font-bold lg:hidden {isActive('tags') ? 'text-white/80' : 'text-white/35'}"
>
{tagCount}
</span>
</a>
{/if}
@@ -97,14 +180,31 @@ const isActive = (section: string) => currentPath.startsWith(`/admin/${section}`
{#if canRunMaintenance}
<a
href="/admin/system"
class="flex flex-col gap-0.5 border-t border-l-[3px] border-white/10 px-3.5 py-2.5 transition-colors
class="flex flex-col items-center justify-center gap-0.5 border-t border-l-[3px] border-white/10 py-3 transition-colors md:px-0 lg:items-start lg:px-3.5 lg:py-2.5
{isActive('system')
? 'border-brand-mint bg-white/10'
: 'border-l-transparent hover:bg-white/5'}"
aria-current={isActive('system') ? 'page' : undefined}
title={m.admin_tab_system()}
>
<!-- Icon: cog -->
<svg
class="h-5 w-5 flex-shrink-0 {isActive('system') ? 'text-brand-mint' : 'text-white/40'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
class="hidden text-[9px] font-extrabold tracking-[0.5px] uppercase lg:block
{isActive('system') ? 'text-white' : 'text-white/55'}"
>
{m.admin_tab_system()}

View File

@@ -1,12 +1,17 @@
<script lang="ts">
import { page } from '$app/state';
import GroupsListPanel from './GroupsListPanel.svelte';
let { data, children } = $props();
const autoCollapse = $derived(page.url.pathname === '/admin/groups/new');
const isAtListRoot = $derived(page.url.pathname === '/admin/groups');
</script>
<GroupsListPanel groups={data.groups} />
<div class="{isAtListRoot ? 'flex' : 'hidden'} flex-shrink-0 md:flex">
<GroupsListPanel groups={data.groups} autocollapse={autoCollapse} />
</div>
<!-- Detail panel -->
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="{isAtListRoot ? 'hidden' : 'flex'} min-w-0 flex-1 flex-col overflow-hidden md:flex">
{@render children()}
</div>

View File

@@ -8,57 +8,104 @@ type Group = {
permissions: string[];
};
let { groups }: { groups: Group[] } = $props();
let {
groups,
autocollapse = false
}: {
groups: Group[];
autocollapse?: boolean;
} = $props();
let isCollapsed = $state(
typeof localStorage !== 'undefined' && localStorage.getItem('admin_list_collapsed') === 'true'
);
$effect(() => {
if (autocollapse) isCollapsed = true;
});
$effect(() => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('admin_list_collapsed', String(isCollapsed));
}
});
</script>
<div class="flex w-60 flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface">
<!-- Panel header -->
<div class="flex items-center justify-between border-b border-line px-3 py-2">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_groups_list_title()}
</span>
<a
href="/admin/groups/new"
class="inline-flex items-center gap-1 rounded-sm px-2 py-1 text-xs font-medium text-ink-2 transition-colors hover:bg-muted hover:text-ink"
title={m.admin_btn_new_group()}
aria-label={m.admin_btn_new_group()}
{#if isCollapsed}
<!-- Collapsed handle: 32px -->
<button
onclick={() => (isCollapsed = false)}
aria-label={m.admin_btn_expand_list()}
class="flex w-8 flex-shrink-0 flex-col items-center gap-2 border-r border-line bg-surface pt-2 hover:bg-muted"
>
<span class="text-sm font-bold text-ink-2"></span>
<span
class="text-[8px] font-extrabold tracking-widest text-ink-3 uppercase"
style="writing-mode: vertical-rl; transform: rotate(180deg);"
>
<svg
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
{m.admin_btn_new_group()}
</a>
</div>
<!-- Scrollable group list -->
<div class="flex-1 overflow-y-auto">
{#if groups.length === 0}
<p class="px-4 py-6 text-center text-xs text-ink-3">
{m.admin_groups_empty()}
</p>
{:else}
{#each groups as group (group.id)}
{@const isActive = page.url.pathname.startsWith('/admin/groups/' + group.id)}
{m.admin_tab_groups()}
</span>
</button>
{:else}
<div
class="flex w-[200px] flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface"
>
<!-- Panel header -->
<div class="flex items-center justify-between border-b border-line px-3 py-2">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_groups_list_title()}
</span>
<div class="flex items-center gap-1">
<a
href="/admin/groups/{group.id}"
aria-current={isActive ? 'page' : undefined}
class="block border-l-2 px-3 py-2.5 transition-colors {isActive
? 'border-primary bg-primary/10 dark:bg-primary/15'
: 'border-transparent hover:bg-muted'}"
href="/admin/groups/new"
class="inline-flex items-center gap-1 rounded-sm px-2 py-1 text-xs font-medium text-ink-2 transition-colors hover:bg-muted hover:text-ink"
title={m.admin_btn_new_group()}
aria-label={m.admin_btn_new_group()}
>
<div class="text-sm font-bold text-ink">{group.name}</div>
<div class="mt-0.5 text-xs text-ink-3">
{m.admin_groups_permission_count({ count: group.permissions.length })}
</div>
<svg
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
</a>
{/each}
{/if}
<button
onclick={() => (isCollapsed = true)}
aria-label={m.admin_btn_collapse_list()}
class="flex h-6 w-6 items-center justify-center rounded-sm text-xs font-bold text-ink-2 transition-colors hover:bg-muted"
>
</button>
</div>
</div>
<!-- Scrollable group list -->
<div class="flex-1 overflow-y-auto">
{#if groups.length === 0}
<p class="px-4 py-6 text-center text-xs text-ink-3">
{m.admin_groups_empty()}
</p>
{:else}
{#each groups as group (group.id)}
{@const isActive = page.url.pathname.startsWith('/admin/groups/' + group.id)}
<a
href="/admin/groups/{group.id}"
aria-current={isActive ? 'page' : undefined}
class="block border-l-2 px-3 py-2.5 transition-colors {isActive
? 'border-primary bg-primary/10 dark:bg-primary/15'
: 'border-transparent hover:bg-muted'}"
>
<div class="text-sm font-bold text-ink">{group.name}</div>
<div class="mt-0.5 text-xs text-ink-3">
{m.admin_groups_permission_count({ count: group.permissions.length })}
</div>
</a>
{/each}
{/if}
</div>
</div>
</div>
{/if}

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import GroupsListPanel from './GroupsListPanel.svelte';
@@ -77,3 +77,34 @@ describe('GroupsListPanel — empty state', () => {
await expect.element(page.getByText(/keine gruppen/i)).toBeInTheDocument();
});
});
// ─── Collapse toggle ──────────────────────────────────────────────────────────
describe('GroupsListPanel — collapse toggle', () => {
beforeEach(() => localStorage.removeItem('admin_list_collapsed'));
it('renders a collapse button with aria-label', async () => {
render(GroupsListPanel, { groups });
await expect
.element(page.getByRole('button', { name: /Liste einklappen/i }))
.toBeInTheDocument();
});
it('clicking collapse shows the expand handle', async () => {
render(GroupsListPanel, { groups });
await expect
.element(page.getByRole('button', { name: /Liste einklappen/i }))
.toBeInTheDocument();
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
await expect
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
.toBeInTheDocument();
});
it('autocollapse prop starts the panel in collapsed state', async () => {
render(GroupsListPanel, { groups, autocollapse: true });
await expect
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
.toBeInTheDocument();
});
});

View File

@@ -1,12 +1,16 @@
<script lang="ts">
import { page } from '$app/state';
import TagsListPanel from './TagsListPanel.svelte';
let { data, children } = $props();
const isAtListRoot = $derived(page.url.pathname === '/admin/tags');
</script>
<TagsListPanel tags={data.tags} />
<div class="{isAtListRoot ? 'flex' : 'hidden'} flex-shrink-0 md:flex">
<TagsListPanel tags={data.tags} />
</div>
<!-- Detail panel -->
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="{isAtListRoot ? 'hidden' : 'flex'} min-w-0 flex-1 flex-col overflow-hidden md:flex">
{@render children()}
</div>

View File

@@ -7,36 +7,82 @@ type Tag = {
name: string;
};
let { tags }: { tags: Tag[] } = $props();
let {
tags,
autocollapse = false
}: {
tags: Tag[];
autocollapse?: boolean;
} = $props();
let isCollapsed = $state(
typeof localStorage !== 'undefined' && localStorage.getItem('admin_list_collapsed') === 'true'
);
$effect(() => {
if (autocollapse) isCollapsed = true;
});
$effect(() => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('admin_list_collapsed', String(isCollapsed));
}
});
</script>
<div class="flex w-60 flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface">
<!-- Panel header -->
<div class="flex items-center border-b border-line px-3 py-2">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_tags_list_title()}
{#if isCollapsed}
<!-- Collapsed handle: 32px -->
<button
onclick={() => (isCollapsed = false)}
aria-label={m.admin_btn_expand_list()}
class="flex w-8 flex-shrink-0 flex-col items-center gap-2 border-r border-line bg-surface pt-2 hover:bg-muted"
>
<span class="text-sm font-bold text-ink-2"></span>
<span
class="text-[8px] font-extrabold tracking-widest text-ink-3 uppercase"
style="writing-mode: vertical-rl; transform: rotate(180deg);"
>
{m.admin_tab_tags()}
</span>
</div>
</button>
{:else}
<div
class="flex w-[200px] flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface"
>
<!-- Panel header -->
<div class="flex items-center justify-between border-b border-line px-3 py-2">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_tags_list_title()}
</span>
<button
onclick={() => (isCollapsed = true)}
aria-label={m.admin_btn_collapse_list()}
class="flex h-6 w-6 items-center justify-center rounded-sm text-xs font-bold text-ink-2 transition-colors hover:bg-muted"
>
</button>
</div>
<!-- Scrollable tag list -->
<div class="flex-1 overflow-y-auto">
{#if tags.length === 0}
<p class="px-4 py-6 text-center text-xs text-ink-3">
{m.admin_tags_empty()}
</p>
{:else}
{#each tags as tag (tag.id)}
{@const isActive = page.url.pathname.startsWith('/admin/tags/' + tag.id)}
<a
href="/admin/tags/{tag.id}"
aria-current={isActive ? 'page' : undefined}
class="block border-l-2 px-3 py-2.5 transition-colors {isActive
? 'border-primary bg-primary/10 dark:bg-primary/15'
: 'border-transparent hover:bg-muted'}"
>
<div class="text-sm font-bold text-ink">{tag.name}</div>
</a>
{/each}
{/if}
<!-- Scrollable tag list -->
<div class="flex-1 overflow-y-auto">
{#if tags.length === 0}
<p class="px-4 py-6 text-center text-xs text-ink-3">
{m.admin_tags_empty()}
</p>
{:else}
{#each tags as tag (tag.id)}
{@const isActive = page.url.pathname.startsWith('/admin/tags/' + tag.id)}
<a
href="/admin/tags/{tag.id}"
aria-current={isActive ? 'page' : undefined}
class="block border-l-2 px-3 py-2.5 transition-colors {isActive
? 'border-primary bg-primary/10 dark:bg-primary/15'
: 'border-transparent hover:bg-muted'}"
>
<div class="text-sm font-bold text-ink">{tag.name}</div>
</a>
{/each}
{/if}
</div>
</div>
</div>
{/if}

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TagsListPanel from './TagsListPanel.svelte';
@@ -59,3 +59,31 @@ describe('TagsListPanel — empty state', () => {
await expect.element(page.getByText(/keine schlagworte/i)).toBeInTheDocument();
});
});
// ─── Collapse toggle ──────────────────────────────────────────────────────────
describe('TagsListPanel — collapse toggle', () => {
beforeEach(() => localStorage.removeItem('admin_list_collapsed'));
it('renders a collapse button with aria-label', async () => {
render(TagsListPanel, { tags });
await expect
.element(page.getByRole('button', { name: /Liste einklappen/i }))
.toBeInTheDocument();
});
it('clicking collapse shows the expand handle', async () => {
render(TagsListPanel, { tags });
await page.getByRole('button', { name: /Liste einklappen/i }).click();
await expect
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
.toBeInTheDocument();
});
it('autocollapse prop starts the panel in collapsed state', async () => {
render(TagsListPanel, { tags, autocollapse: true });
await expect
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
.toBeInTheDocument();
});
});

View File

@@ -1,12 +1,22 @@
<script lang="ts">
import { page } from '$app/state';
import UsersListPanel from './UsersListPanel.svelte';
let { data, children } = $props();
// Auto-collapse list when user opens the create form (gives max form space on tablet)
const autoCollapse = $derived(page.url.pathname === '/admin/users/new');
// Mobile: show only the relevant panel at a time
const isAtListRoot = $derived(page.url.pathname === '/admin/users');
</script>
<UsersListPanel users={data.users} />
<!-- List panel: full-screen on mobile at list root; always visible at md+ -->
<div class="{isAtListRoot ? 'flex' : 'hidden'} flex-shrink-0 md:flex">
<UsersListPanel users={data.users} autocollapse={autoCollapse} />
</div>
<!-- Detail panel -->
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<!-- Detail panel: full-screen on mobile when not at list root; always visible at md+ -->
<div class="{isAtListRoot ? 'hidden' : 'flex'} min-w-0 flex-1 flex-col overflow-hidden md:flex">
{@render children()}
</div>

View File

@@ -16,9 +16,28 @@ type User = {
groups: Group[];
};
let { users }: { users: User[] } = $props();
let {
users,
autocollapse = false
}: {
users: User[];
autocollapse?: boolean;
} = $props();
let searchQuery = $state('');
let isCollapsed = $state(
typeof localStorage !== 'undefined' && localStorage.getItem('admin_list_collapsed') === 'true'
);
$effect(() => {
if (autocollapse) isCollapsed = true;
});
$effect(() => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('admin_list_collapsed', String(isCollapsed));
}
});
const filtered = $derived(
searchQuery.trim() === ''
@@ -31,75 +50,102 @@ const filtered = $derived(
);
</script>
<div class="flex w-60 flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface">
<!-- Panel header -->
<div class="flex items-center justify-between border-b border-line px-3 py-2">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_users_list_title()}
</span>
<a
href="/admin/users/new"
class="inline-flex items-center gap-1 rounded-sm px-2 py-1 text-xs font-medium text-ink-2 transition-colors hover:bg-muted hover:text-ink"
title={m.admin_btn_new_user()}
aria-label={m.admin_btn_new_user()}
{#if isCollapsed}
<!-- Collapsed handle: 32px -->
<button
onclick={() => (isCollapsed = false)}
aria-label={m.admin_btn_expand_list()}
class="flex w-8 flex-shrink-0 flex-col items-center gap-2 border-r border-line bg-surface pt-2 hover:bg-muted"
>
<span class="text-sm font-bold text-ink-2"></span>
<span
class="text-[8px] font-extrabold tracking-widest text-ink-3 uppercase"
style="writing-mode: vertical-rl; transform: rotate(180deg);"
>
<svg
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
{m.admin_btn_new_user()}
</a>
</div>
<!-- Search -->
<div class="border-b border-line px-3 py-2">
<input
type="search"
bind:value={searchQuery}
placeholder={m.admin_users_search_placeholder()}
class="w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink placeholder:text-ink-3 focus:ring-1 focus:ring-primary focus:outline-none"
/>
</div>
<!-- Scrollable user list -->
<div class="flex-1 overflow-y-auto">
{#if filtered.length === 0}
<p class="px-4 py-6 text-center text-xs text-ink-3">
{m.admin_users_empty()}
</p>
{:else}
{#each filtered as user (user.id)}
{@const isActive = page.url.pathname.startsWith('/admin/users/' + user.id)}
{@const fullName =
[user.firstName, user.lastName].filter(Boolean).join(' ') || null}
{m.admin_tab_users()}
</span>
</button>
{:else}
<div
class="flex w-[200px] flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface"
>
<!-- Panel header -->
<div class="flex items-center justify-between border-b border-line px-3 py-2">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_users_list_title()}
</span>
<div class="flex items-center gap-1">
<a
href="/admin/users/{user.id}"
aria-current={isActive ? 'page' : undefined}
class="block border-l-2 px-3 py-2.5 transition-colors {isActive
? 'border-primary bg-primary/10 dark:bg-primary/15'
: 'border-transparent hover:bg-muted'}"
href="/admin/users/new"
class="inline-flex items-center gap-1 rounded-sm px-2 py-1 text-xs font-medium text-ink-2 transition-colors hover:bg-muted hover:text-ink"
title={m.admin_btn_new_user()}
aria-label={m.admin_btn_new_user()}
>
<div class="text-sm font-bold text-ink">{user.username}</div>
{#if fullName}
<div class="mt-0.5 text-xs text-ink-3">{fullName}</div>
{/if}
{#if user.groups.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each user.groups as group (group.id)}
<span class="rounded-sm bg-muted px-1.5 py-0.5 text-[10px] text-ink-3">
{group.name}
</span>
{/each}
</div>
{/if}
<svg
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
</a>
{/each}
{/if}
<button
onclick={() => (isCollapsed = true)}
aria-label={m.admin_btn_collapse_list()}
class="flex h-6 w-6 items-center justify-center rounded-sm text-xs font-bold text-ink-2 transition-colors hover:bg-muted"
>
</button>
</div>
</div>
<!-- Search -->
<div class="border-b border-line px-3 py-2">
<input
type="search"
bind:value={searchQuery}
placeholder={m.admin_users_search_placeholder()}
class="w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink placeholder:text-ink-3 focus:ring-1 focus:ring-primary focus:outline-none"
/>
</div>
<!-- Scrollable user list -->
<div class="flex-1 overflow-y-auto">
{#if filtered.length === 0}
<p class="px-4 py-6 text-center text-xs text-ink-3">
{m.admin_users_empty()}
</p>
{:else}
{#each filtered as user (user.id)}
{@const isActive = page.url.pathname.startsWith('/admin/users/' + user.id)}
{@const fullName =
[user.firstName, user.lastName].filter(Boolean).join(' ') || null}
<a
href="/admin/users/{user.id}"
aria-current={isActive ? 'page' : undefined}
class="block border-l-2 px-3 py-2.5 transition-colors {isActive
? 'border-primary bg-primary/10 dark:bg-primary/15'
: 'border-transparent hover:bg-muted'}"
>
<div class="text-sm font-bold text-ink">{user.username}</div>
{#if fullName}
<div class="mt-0.5 text-xs text-ink-3">{fullName}</div>
{/if}
{#if user.groups.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each user.groups as group (group.id)}
<span class="rounded-sm bg-muted px-1.5 py-0.5 text-[10px] text-ink-3">
{group.name}
</span>
{/each}
</div>
{/if}
</a>
{/each}
{/if}
</div>
</div>
</div>
{/if}

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import UsersListPanel from './UsersListPanel.svelte';
@@ -93,3 +93,40 @@ describe('UsersListPanel — empty state', () => {
await expect.element(page.getByText(/keine benutzer/i)).toBeInTheDocument();
});
});
// ─── Collapse toggle ──────────────────────────────────────────────────────────
describe('UsersListPanel — collapse toggle', () => {
beforeEach(() => localStorage.removeItem('admin_list_collapsed'));
it('renders a collapse button with aria-label', async () => {
render(UsersListPanel, { users });
await expect
.element(page.getByRole('button', { name: /Liste einklappen/i }))
.toBeInTheDocument();
});
it('clicking collapse shows the expand handle', async () => {
render(UsersListPanel, { users });
await page.getByRole('button', { name: /Liste einklappen/i }).click();
await expect
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
.toBeInTheDocument();
});
it('clicking expand handle restores the full panel', async () => {
render(UsersListPanel, { users });
await page.getByRole('button', { name: /Liste einklappen/i }).click();
await page.getByRole('button', { name: /Liste ausklappen/i }).click();
await expect
.element(page.getByRole('button', { name: /Liste einklappen/i }))
.toBeInTheDocument();
});
it('autocollapse prop starts the panel in collapsed state', async () => {
render(UsersListPanel, { users, autocollapse: true });
await expect
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
.toBeInTheDocument();
});
});