diff --git a/frontend/src/routes/admin/EntityNav.svelte b/frontend/src/routes/admin/EntityNav.svelte
index f0c35b81..ae57556b 100644
--- a/frontend/src/routes/admin/EntityNav.svelte
+++ b/frontend/src/routes/admin/EntityNav.svelte
@@ -22,11 +22,21 @@ let {
const currentPath = $derived(page.url.pathname);
const isActive = (section: string) => currentPath.startsWith(`/admin/${section}`);
+
+let flyoutOpen = $state(false);
+
+function handleKeydown(event: KeyboardEvent) {
+ if (event.key === 'Escape' && flyoutOpen) {
+ flyoutOpen = false;
+ }
+}
+
+
+
+{#if flyoutOpen}
+
+
(flyoutOpen = false)}
+ >
+
+
+
+{/if}
diff --git a/frontend/src/routes/admin/entity-nav.svelte.spec.ts b/frontend/src/routes/admin/entity-nav.svelte.spec.ts
new file mode 100644
index 00000000..c9fea364
--- /dev/null
+++ b/frontend/src/routes/admin/entity-nav.svelte.spec.ts
@@ -0,0 +1,81 @@
+import { afterEach, describe, it, expect, vi } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import EntityNav from './EntityNav.svelte';
+
+vi.mock('$app/state', () => ({
+ page: { url: { pathname: '/admin/users' } }
+}));
+
+afterEach(cleanup);
+
+const props = {
+ userCount: 5,
+ groupCount: 3,
+ tagCount: 8,
+ canManageUsers: true,
+ canManageTags: true,
+ canManageGroups: true,
+ canRunMaintenance: true
+};
+
+describe('EntityNav — flyout', () => {
+ it('flyout dialog is not visible initially', async () => {
+ render(EntityNav, props);
+ await expect.element(page.getByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('clicking a flyout trigger opens the dialog', async () => {
+ render(EntityNav, props);
+ document.querySelector('[data-flyout-trigger]')!.click();
+ await expect.element(page.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('flyout dialog has aria-modal="true"', async () => {
+ render(EntityNav, props);
+ document.querySelector('[data-flyout-trigger]')!.click();
+ await expect.element(page.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
+ });
+
+ it('flyout dialog has an aria-label', async () => {
+ render(EntityNav, props);
+ document.querySelector('[data-flyout-trigger]')!.click();
+ await expect.element(page.getByRole('dialog')).toBeInTheDocument();
+ const dialog = document.querySelector('[role="dialog"]')!;
+ expect(dialog.getAttribute('aria-label')).toBeTruthy();
+ });
+
+ it('flyout contains navigation links to each entity', async () => {
+ render(EntityNav, props);
+ document.querySelector('[data-flyout-trigger]')!.click();
+ await expect.element(page.getByRole('dialog')).toBeInTheDocument();
+ const dialog = document.querySelector('[role="dialog"]')!;
+ const links = dialog.querySelectorAll('a[href^="/admin/"]');
+ expect(links.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it('pressing Escape closes the flyout', async () => {
+ render(EntityNav, props);
+ document.querySelector('[data-flyout-trigger]')!.click();
+ await expect.element(page.getByRole('dialog')).toBeInTheDocument();
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ await expect.element(page.getByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('clicking the backdrop closes the flyout', async () => {
+ render(EntityNav, props);
+ document.querySelector('[data-flyout-trigger]')!.click();
+ await expect.element(page.getByRole('dialog')).toBeInTheDocument();
+ document.querySelector('[data-flyout-backdrop]')!.click();
+ await expect.element(page.getByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('clicking a flyout link closes the flyout', async () => {
+ render(EntityNav, props);
+ document.querySelector('[data-flyout-trigger]')!.click();
+ await expect.element(page.getByRole('dialog')).toBeInTheDocument();
+ const dialog = document.querySelector('[role="dialog"]')!;
+ dialog.querySelector('a[href^="/admin/"]')!.click();
+ await expect.element(page.getByRole('dialog')).not.toBeInTheDocument();
+ });
+});