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

@@ -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();
});
});