ui: separate trackers from projects; keep header dropdown on top

- /trackers index, Trackers section on the homepage and in the header nav;
  /projects lists only projects (tracker URLs unchanged)
- header is its own z-50 layer and <main> is isolated, so maps, charts and
  sticky tables no longer paint over the nav dropdowns
This commit is contained in:
2026-10-23 22:03:42 +00:00
parent 2915135ed8
commit 7e69d1e888
10 changed files with 174 additions and 158 deletions

View File

@@ -7,7 +7,7 @@
import { getSeo } from '#lib/seo.js';
import CopyButton from '#lib/components/CopyButton.svelte';
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
import { projects } from '#lib/projects.js';
import { projects, trackers } from '#lib/projects.js';
import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
import * as Sheet from '#lib/components/ui/sheet/index.js';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
@@ -31,15 +31,24 @@
let { data, children } = $props();
const seo = $derived(getSeo(page.url.pathname, page.data, data.environment));
let menuOpen = $state(false);
let projectsMenuOpen = $state(false);
// Which header dropdown is open (desktop): '/projects', '/trackers' or none.
let openMenu = $state<string | null>(null);
type ThemePreference = 'dark' | 'system' | 'light';
let theme = $state<ThemePreference>('dark');
const navigation = [
{ label: 'home', href: '/' },
{ label: 'projects', href: '/projects' },
{ label: 'trackers', href: '/trackers' },
{ label: 'blog', href: '/blog' },
{ label: 'lists', href: '/lists' }
] as const;
const menus: Record<
string,
{ label: string; items: readonly (typeof projects | typeof trackers)[number][] }
> = {
'/projects': { label: 'projects', items: projects },
'/trackers': { label: 'trackers', items: trackers }
};
const socialLinks = [
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
{ icon: SiGithub, label: 'github', href: 'https://github.com/stanrunge' },
@@ -102,14 +111,14 @@
return page.url.pathname === href || (href !== '/' && page.url.pathname.startsWith(href + '/'));
}
function handleProjectsPointerEnter(event: PointerEvent) {
function handleMenuPointerEnter(event: PointerEvent, href: string) {
if (event.pointerType === 'touch') return;
projectsMenuOpen = true;
openMenu = href;
}
function handleProjectsPointerLeave(event: PointerEvent) {
function handleMenuPointerLeave(event: PointerEvent) {
if (event.pointerType === 'touch') return;
projectsMenuOpen = false;
openMenu = null;
}
</script>
@@ -170,7 +179,14 @@
class="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:bg-[var(--site-bg)] focus:px-4 focus:py-2"
>skip to content</a
>
<header class="border-b border-border bg-background/95 backdrop-blur">
<!--
Stacking: backdrop-blur makes the header its own stacking context, so the dropdown's z-50 only
counts inside it. Without a z-index of its own the header then sits at z-auto/0 and anything
positioned later in <main> (maps, chart tooltips, sticky headers) paints over the dropdown.
Fix: lift the header (relative z-50) and isolate <main> so page z-indexes stay inside it.
Dialogs/sheets/popovers are portalled to <body> and still render above both.
-->
<header class="relative z-50 border-b border-border bg-background/95 backdrop-blur">
<div class="mx-auto flex max-w-6xl items-center gap-3 px-4 py-3 sm:px-6">
<a
href={resolve('/')}
@@ -190,39 +206,40 @@
</a>
<nav class="ml-auto hidden items-center gap-1 md:flex" aria-label="Main navigation">
{#each navigation as item (item.href)}
{#if item.href === '/projects'}
{#if menus[item.href]}
{@const menu = menus[item.href]}
<div
class="relative"
role="group"
aria-label="Projects menu"
onpointerenter={handleProjectsPointerEnter}
onpointerleave={handleProjectsPointerLeave}
aria-label={`${menu.label} menu`}
onpointerenter={(event) => handleMenuPointerEnter(event, item.href)}
onpointerleave={handleMenuPointerLeave}
>
<Button
href={resolve('projects')}
href={resolve(item.href)}
variant={current(item.href) ? 'secondary' : 'ghost'}
size="sm"
class="cursor-pointer gap-1"
aria-label="Browse projects"
aria-label={`Browse ${menu.label}`}
aria-haspopup="true"
aria-expanded={projectsMenuOpen}
onclick={() => (projectsMenuOpen = false)}
aria-expanded={openMenu === item.href}
onclick={() => (openMenu = null)}
onkeydown={(event) => {
if (event.key === 'Escape') projectsMenuOpen = false;
if (event.key === 'Escape') openMenu = null;
}}>{item.label}<ChevronDown size={15} /></Button
>
{#if projectsMenuOpen}
{#if openMenu === item.href}
<div
class="absolute top-full left-0 z-50 max-h-[70vh] w-72 overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
aria-label="Projects"
aria-label={menu.label}
>
<a
href={resolve('projects')}
href={resolve(item.href)}
class="flex items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent"
>all projects <ArrowRight size={14} class="ml-auto" /></a
>all {menu.label} <ArrowRight size={14} class="ml-auto" /></a
>
<div class="my-1 h-px bg-border"></div>
{#each projects as project (project.name)}
{#each menu.items as project (project.name)}
<!-- Internal project links use resolve; external URLs are from the project catalog. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
@@ -297,9 +314,9 @@
class="justify-start"
onclick={() => (menuOpen = false)}>{item.label}</Button
>
{#if item.href === '/projects'}
{#if menus[item.href]}
<div class="ml-3 flex flex-col border-l border-border pl-3">
{#each projects as project (project.name)}
{#each menus[item.href].items as project (project.name)}
<Button
href={project.external ? project.href : resolve(project.href)}
variant="ghost"
@@ -343,7 +360,7 @@
</Sheet.Root>
</div>
</header>
<main id="main" class="min-w-0 flex-1">{@render children()}</main>
<main id="main" class="isolate min-w-0 flex-1">{@render children()}</main>
<footer
class="mt-16 border-t border-[var(--site-border)]"
class:tracker-footer={page.route.id === '/projects/game-tracker'}