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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-30 09:06:03 +02:00
parent 3c54401bb2
commit 559b522507
2 changed files with 409 additions and 50 deletions

View File

@@ -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<HTMLButtonElement>('[data-flyout-trigger]')!.click();
await expect.element(page.getByRole('dialog')).toBeInTheDocument();
});
it('flyout dialog has aria-modal="true"', async () => {
render(EntityNav, props);
document.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-flyout-trigger]')!.click();
await expect.element(page.getByRole('dialog')).toBeInTheDocument();
document.querySelector<HTMLElement>('[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<HTMLButtonElement>('[data-flyout-trigger]')!.click();
await expect.element(page.getByRole('dialog')).toBeInTheDocument();
const dialog = document.querySelector('[role="dialog"]')!;
dialog.querySelector<HTMLAnchorElement>('a[href^="/admin/"]')!.click();
await expect.element(page.getByRole('dialog')).not.toBeInTheDocument();
});
});