From 33c29fbff311716d0ebf474bf1ad5f7d5023202e Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 30 Mar 2026 09:06:03 +0200 Subject: [PATCH] feat(admin): entity flyout for tablet icon strip (Phase 9 complete) Tapping any icon in the 48px tablet nav strip now opens a 160px overlay flyout with full entity labels and navigation links. Flyout closes on Escape, backdrop click, or link click. Includes role="dialog", aria-modal, aria-label for WCAG. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/admin/EntityNav.svelte | 378 +++++++++++++++--- .../routes/admin/entity-nav.svelte.spec.ts | 81 ++++ 2 files changed, 409 insertions(+), 50 deletions(-) create mode 100644 frontend/src/routes/admin/entity-nav.svelte.spec.ts 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(); + }); +});