feat(web): MobileNavDrawer off-canvas nav for <md viewport
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { X } from 'lucide-svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, logout } from '$lib/auth/store.svelte';
|
||||
import { mobileNav, closeMobileNav } from '$lib/stores/mobileNav.svelte';
|
||||
|
||||
let previouslyFocused: HTMLElement | null = null;
|
||||
let firstNavLink: HTMLAnchorElement | undefined = $state();
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/library/artists', label: 'Artists' },
|
||||
{ href: '/library/albums', label: 'Albums' },
|
||||
{ href: '/library/liked', label: 'Liked' },
|
||||
{ href: '/library/history', label: 'History' },
|
||||
{ href: '/discover', label: 'Discover' },
|
||||
{ href: '/playlists', label: 'Playlists' }
|
||||
];
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === '/') return page.url.pathname === '/';
|
||||
return page.url.pathname.startsWith(href);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
closeMobileNav();
|
||||
await logout();
|
||||
goto('/login', { replaceState: true });
|
||||
}
|
||||
|
||||
function onLinkClick() {
|
||||
closeMobileNav();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (mobileNav.value) {
|
||||
previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
Promise.resolve().then(() => firstNavLink?.focus());
|
||||
} else if (previouslyFocused) {
|
||||
previouslyFocused.focus();
|
||||
previouslyFocused = null;
|
||||
}
|
||||
});
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (mobileNav.value && e.key === 'Escape') {
|
||||
closeMobileNav();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#if mobileNav.value}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close navigation backdrop"
|
||||
class="fixed inset-0 bg-obsidian/40 z-40 md:hidden"
|
||||
onclick={() => closeMobileNav()}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<aside
|
||||
aria-label="Main navigation"
|
||||
aria-hidden={!mobileNav.value}
|
||||
inert={!mobileNav.value}
|
||||
class="fixed top-0 left-0 h-full w-[min(280px,80vw)] bg-surface z-50 md:hidden
|
||||
transition-transform duration-200 flex flex-col
|
||||
motion-reduce:transition-none
|
||||
{mobileNav.value ? 'translate-x-0' : '-translate-x-full'}"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 class="text-lg font-semibold">Navigate</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close navigation"
|
||||
onclick={() => closeMobileNav()}
|
||||
class="text-text-secondary hover:text-text-primary min-h-[44px] min-w-[44px]
|
||||
flex items-center justify-center"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 overflow-y-auto p-2">
|
||||
<ul>
|
||||
{#each navItems as item, i (item.href)}
|
||||
<li>
|
||||
<a
|
||||
bind:this={i === 0 ? firstNavLink : undefined}
|
||||
href={item.href}
|
||||
onclick={onLinkClick}
|
||||
class="flex items-center min-h-[44px] rounded px-3 py-2 hover:bg-surface-hover"
|
||||
aria-current={isActive(item.href) ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<hr class="my-2 border-border" />
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
href="/settings"
|
||||
onclick={onLinkClick}
|
||||
class="flex items-center min-h-[44px] rounded px-3 py-2 hover:bg-surface-hover"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
{#if user.value?.is_admin}
|
||||
<li>
|
||||
<a
|
||||
href="/admin"
|
||||
onclick={onLinkClick}
|
||||
class="flex items-center min-h-[44px] rounded px-3 py-2 hover:bg-surface-hover"
|
||||
>
|
||||
Admin
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleLogout}
|
||||
class="flex w-full items-center min-h-[44px] rounded px-3 py-2 text-left
|
||||
hover:bg-surface-hover"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import MobileNavDrawer from './MobileNavDrawer.svelte';
|
||||
import { mobileNav, openMobileNav, closeMobileNav } from '$lib/stores/mobileNav.svelte';
|
||||
|
||||
describe('MobileNavDrawer', () => {
|
||||
beforeEach(() => {
|
||||
closeMobileNav();
|
||||
});
|
||||
|
||||
it('renders inert + aria-hidden when closed', () => {
|
||||
render(MobileNavDrawer);
|
||||
const aside = screen.getByLabelText('Main navigation');
|
||||
expect(aside.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(aside.hasAttribute('inert')).toBe(true);
|
||||
});
|
||||
|
||||
it('flips to visible when mobileNav opens', async () => {
|
||||
render(MobileNavDrawer);
|
||||
openMobileNav();
|
||||
await Promise.resolve();
|
||||
const aside = screen.getByLabelText('Main navigation');
|
||||
expect(aside.getAttribute('aria-hidden')).toBe('false');
|
||||
expect(aside.hasAttribute('inert')).toBe(false);
|
||||
});
|
||||
|
||||
it('renders all nav items + Settings + Log out', () => {
|
||||
openMobileNav();
|
||||
render(MobileNavDrawer);
|
||||
expect(screen.getByText('Home')).toBeInTheDocument();
|
||||
expect(screen.getByText('Artists')).toBeInTheDocument();
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
expect(screen.getByText('Log out')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Escape key closes the drawer', () => {
|
||||
openMobileNav();
|
||||
render(MobileNavDrawer);
|
||||
expect(mobileNav.value).toBe(true);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(mobileNav.value).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user