trackers: one-click spotting links: header flyout (hover/focus, delayed close, side-aware), chips on the spotting tile, mobile sub-links
All checks were successful
Deploy production / deploy (push) Successful in 1m8s

This commit is contained in:
2026-10-25 12:18:33 +00:00
parent d8490ce94f
commit 45943543c2
3 changed files with 293 additions and 73 deletions

View File

@@ -9,7 +9,13 @@
import ProjectIcon from '#lib/components/ProjectIcon.svelte'; import ProjectIcon from '#lib/components/ProjectIcon.svelte';
import TrackerIcon from '#lib/components/TrackerIcon.svelte'; import TrackerIcon from '#lib/components/TrackerIcon.svelte';
type Item = { name: string; subtitle: string; href: string; external: boolean }; type Item = {
name: string;
subtitle: string;
href: string;
external: boolean;
children?: readonly Item[];
};
let { let {
items, items,
subtitleClass = 'mt-1 block text-sm text-muted-foreground', subtitleClass = 'mt-1 block text-sm text-muted-foreground',
@@ -60,9 +66,32 @@
<ul class="grid gap-3 sm:grid-cols-2" class:wide> <ul class="grid gap-3 sm:grid-cols-2" class:wide>
{#each items as project (project.name)} {#each items as project (project.name)}
<li> <li>
<Card.Root class="catalog-card h-full py-0 transition-colors hover:bg-muted/50" <Card.Root class="catalog-card relative h-full py-0 transition-colors hover:bg-muted/50"
><Card.Content class="h-full p-0"> ><Card.Content class="h-full p-0">
{#if project.external} {#if project.children}
<!-- Group tile: a stretched main link plus direct links to each sub-tracker layered
above it (siblings, so no nested <a>). -->
<div class="flex h-full min-h-28 flex-col gap-3 p-5">
<a
href={resolve(project.href as '/')}
class="stretched flex items-start justify-between gap-4 outline-none"
>
{@render body(project)}
</a>
<ul class="relative z-10 mt-auto flex flex-wrap gap-1.5 pl-13">
{#each project.children as child (child.name)}
<li>
<a
href={resolve(child.href as '/')}
class="chip flex items-center gap-1.5 py-1 pr-2.5 pl-1 text-xs font-medium"
title={child.subtitle}
><TrackerIcon name={child.name} class="chip-icon size-5" />{child.name}</a
>
</li>
{/each}
</ul>
</div>
{:else if project.external}
<a <a
href={project.href} href={project.href}
rel="external" rel="external"
@@ -121,17 +150,57 @@
ul :global(.catalog-card:hover .catalog-arrow) { ul :global(.catalog-card:hover .catalog-arrow) {
transform: translateX(2px); transform: translateX(2px);
} }
.stretched::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
}
.stretched:focus-visible::after {
outline: 2px solid var(--foreground);
outline-offset: -2px;
}
.chip {
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
border-radius: 999px;
background: color-mix(in oklab, var(--background) 70%, transparent);
outline: none;
transition:
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
background-color 160ms ease,
border-color 160ms ease;
}
.chip:hover {
background: color-mix(in oklab, var(--foreground) 10%, transparent);
border-color: color-mix(in oklab, var(--foreground) 35%, transparent);
transform: translateY(-1px);
}
.chip:focus-visible {
outline: 2px solid var(--foreground);
outline-offset: 2px;
}
.chip :global(.chip-icon) {
border-radius: 999px;
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.chip:hover :global(.chip-icon) {
transform: scale(1.12);
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
ul :global(.catalog-card), ul :global(.catalog-card),
ul :global(.catalog-icon), ul :global(.catalog-icon),
ul :global(.catalog-arrow) { ul :global(.catalog-arrow),
.chip,
.chip :global(.chip-icon) {
transition: transition:
background-color 160ms ease, background-color 160ms ease,
border-color 160ms ease; border-color 160ms ease;
} }
ul :global(.catalog-card:hover), ul :global(.catalog-card:hover),
ul :global(.catalog-card:hover .catalog-icon), ul :global(.catalog-card:hover .catalog-icon),
ul :global(.catalog-card:hover .catalog-arrow) { ul :global(.catalog-card:hover .catalog-arrow),
.chip:hover,
.chip:hover :global(.chip-icon) {
transform: none; transform: none;
} }
} }

View File

@@ -12,6 +12,7 @@
import * as Sheet from '#lib/components/ui/sheet/index.js'; import * as Sheet from '#lib/components/ui/sheet/index.js';
import ArrowRight from 'lucide-svelte/icons/arrow-right'; import ArrowRight from 'lucide-svelte/icons/arrow-right';
import ChevronDown from 'lucide-svelte/icons/chevron-down'; import ChevronDown from 'lucide-svelte/icons/chevron-down';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import ExternalLink from 'lucide-svelte/icons/external-link'; import ExternalLink from 'lucide-svelte/icons/external-link';
import Menu from 'lucide-svelte/icons/menu'; import Menu from 'lucide-svelte/icons/menu';
import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard'; import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
@@ -51,7 +52,13 @@
{ label: 'blog', href: '/blog' }, { label: 'blog', href: '/blog' },
{ label: 'lists', href: '/lists' } { label: 'lists', href: '/lists' }
] as const; ] as const;
type MenuItem = { name: string; subtitle: string; href: string; external: boolean }; type MenuItem = {
name: string;
subtitle: string;
href: string;
external: boolean;
children?: readonly MenuItem[];
};
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({ const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
'/projects': { label: 'projects', items: projects }, '/projects': { label: 'projects', items: projects },
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) } '/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) }
@@ -168,6 +175,36 @@
function handleMenuPointerLeave(event: PointerEvent) { function handleMenuPointerLeave(event: PointerEvent) {
if (event.pointerType === 'touch') return; if (event.pointerType === 'touch') return;
openMenu = null; openMenu = null;
flyout = null;
}
// Group trackers (spotting) open a flyout with their sub-trackers next to their tile in the
// Trackers dropdown. It opens on hover or keyboard focus, on the outer side of the dropdown
// when there is room, and closes after a short delay so a diagonal mouse move from the tile
// into the flyout (crossing a neighbouring tile) does not close it.
let flyout = $state<string | null>(null);
let flyoutSide = $state<'left' | 'right'>('right');
let flyoutTimer: ReturnType<typeof setTimeout> | undefined;
const FLYOUT_WIDTH = 240;
function openFlyout(name: string, el: HTMLElement) {
clearTimeout(flyoutTimer);
if (flyout === name) return;
const rect = el.getBoundingClientRect();
const panel = el.closest('[data-menu-panel]')?.getBoundingClientRect() ?? rect;
const leftColumn = rect.left + rect.width / 2 < panel.left + panel.width / 2;
const roomLeft = rect.left - FLYOUT_WIDTH >= 8;
const roomRight = rect.right + FLYOUT_WIDTH <= window.innerWidth - 8;
flyoutSide = leftColumn ? (roomLeft ? 'left' : 'right') : roomRight ? 'right' : 'left';
flyout = name;
}
function closeFlyoutOnEscape(event: KeyboardEvent) {
if (event.key !== 'Escape' || !flyout) return;
event.stopPropagation();
flyout = null;
}
function closeFlyoutSoon() {
clearTimeout(flyoutTimer);
flyoutTimer = setTimeout(() => (flyout = null), 300);
} }
</script> </script>
@@ -246,12 +283,14 @@
{#if openMenu === item.href} {#if openMenu === item.href}
<div <div
class={[ class={[
'absolute top-full z-50 max-h-[75vh] overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md', 'absolute top-full z-50 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
// No overflow clipping on the trackers panel: its spotting flyout sticks out.
item.href === '/trackers' item.href === '/trackers'
? 'left-1/2 grid w-[min(36rem,calc(100vw-2rem))] -translate-x-1/2 grid-cols-2 gap-1.5 p-2' ? 'left-1/2 grid w-[min(36rem,calc(100vw-2rem))] -translate-x-1/2 grid-cols-2 gap-1.5 p-2'
: 'left-0 w-72' : 'left-0 max-h-[75vh] w-72 overflow-y-auto'
]} ]}
aria-label={menu.label} aria-label={menu.label}
data-menu-panel
> >
<a <a
href={resolve(item.href)} href={resolve(item.href)}
@@ -261,6 +300,75 @@
> >
<div class="col-span-2 my-1 h-px bg-border"></div> <div class="col-span-2 my-1 h-px bg-border"></div>
{#each menu.items as project (project.name)} {#each menu.items as project (project.name)}
{#if project.children}
<div
class="relative"
class:z-10={flyout === project.name}
role="group"
aria-label={`${project.name} trackers`}
onpointerenter={(event) => {
if (event.pointerType !== 'touch')
openFlyout(project.name, event.currentTarget);
}}
onpointerleave={(event) => {
if (event.pointerType !== 'touch') closeFlyoutSoon();
}}
onfocusin={(event) => openFlyout(project.name, event.currentTarget)}
onfocusout={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
flyout = null;
}}
>
<a
href={resolve(project.href as '/')}
class="tile flex h-full items-center gap-2.5 p-2 text-sm"
aria-haspopup="true"
onkeydown={closeFlyoutOnEscape}
aria-expanded={flyout === project.name}
><TrackerIcon name={project.name} class="tile-icon size-9" /><span
class="min-w-0"
><span class="block font-medium">{project.name}</span><span
class="block truncate text-xs text-muted-foreground"
>{project.subtitle}</span
></span
><ChevronRight
size={14}
class="ml-auto shrink-0 text-muted-foreground transition-transform {flyout ===
project.name
? flyoutSide === 'left'
? 'rotate-180'
: 'translate-x-0.5'
: ''}"
/></a
>
{#if flyout === project.name}
<!-- The padding facing the tile is a hover bridge into the panel. -->
<div
class={[
'absolute -top-2 w-60',
flyoutSide === 'right' ? 'left-full pl-3' : 'right-full pr-3'
]}
>
<div
class="flyout grid gap-1 rounded-md border border-border bg-popover p-1.5 shadow-lg"
class:from-left={flyoutSide === 'left'}
>
{#each project.children as child (child.name)}
<a
href={resolve(child.href as '/')}
class="tile flex items-center gap-2.5 p-1.5 text-sm"
aria-current={current(child.href) ? 'page' : undefined}
onkeydown={closeFlyoutOnEscape}
><TrackerIcon name={child.name} class="tile-icon size-8" /><span
class="font-medium">{child.name}</span
></a
>
{/each}
</div>
</div>
{/if}
</div>
{:else}
<!-- Internal project links use resolve; external URLs are from the project catalog. --> <!-- Internal project links use resolve; external URLs are from the project catalog. -->
<!-- eslint-disable svelte/no-navigation-without-resolve --> <!-- eslint-disable svelte/no-navigation-without-resolve -->
<a <a
@@ -285,9 +393,13 @@
>{project.subtitle}</span >{project.subtitle}</span
></span ></span
> >
{#if project.external}<ExternalLink size={14} class="ml-auto shrink-0" />{/if} {#if project.external}<ExternalLink
size={14}
class="ml-auto shrink-0"
/>{/if}
</a> </a>
<!-- eslint-enable svelte/no-navigation-without-resolve --> <!-- eslint-enable svelte/no-navigation-without-resolve -->
{/if}
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -399,6 +511,23 @@
> >
<!-- eslint-enable svelte/no-navigation-without-resolve --> <!-- eslint-enable svelte/no-navigation-without-resolve -->
{/each} {/each}
{#each menus[item.href].items.filter((p) => p.children) as group (group.name)}
<div class="col-span-2 mt-1">
<span class="mb-1 block text-xs text-muted-foreground">{group.name}</span>
<div class="ml-1 grid grid-cols-2 gap-1.5 border-l border-border pl-2">
{#each group.children ?? [] as child (child.name)}
<a
href={resolve(child.href as '/')}
class="tile flex min-w-0 items-center gap-2 p-1.5 text-sm"
onclick={() => (menuOpen = false)}
><TrackerIcon name={child.name} class="tile-icon size-6" /><span
class="truncate">{child.name}</span
></a
>
{/each}
</div>
</div>
{/each}
</div> </div>
{:else} {:else}
<div class="ml-3 flex flex-col border-l border-border pl-3"> <div class="ml-3 flex flex-col border-l border-border pl-3">
@@ -533,6 +662,19 @@
background: color-mix(in oklab, var(--foreground) 9%, transparent); background: color-mix(in oklab, var(--foreground) 9%, transparent);
border-color: color-mix(in oklab, var(--foreground) 35%, transparent); border-color: color-mix(in oklab, var(--foreground) 35%, transparent);
} }
.flyout {
animation: flyout-in 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
--flyout-from: -4px;
}
.flyout.from-left {
--flyout-from: 4px;
}
@keyframes flyout-in {
from {
opacity: 0;
transform: translateX(var(--flyout-from));
}
}
.tile :global(.tile-icon) { .tile :global(.tile-icon) {
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1); transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
} }
@@ -555,6 +697,9 @@
background-image: linear-gradient(135deg, #10b981, #0d9488); background-image: linear-gradient(135deg, #10b981, #0d9488);
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.flyout {
animation: none;
}
.tile, .tile,
.tile :global(.tile-icon) { .tile :global(.tile-icon) {
transition: transition:

View File

@@ -32,11 +32,51 @@ export const projects = [
} }
] as const; ] as const;
type Tracker = {
name: string;
subtitle: string;
href: string;
external: boolean;
/** Sub-trackers linked directly from this item (header flyout, tile chips). */
children?: readonly Tracker[];
};
// The spotting trackers: pokedexes of things I can spot in the wild. Grouped under one
// "spotting" item in the header, home page and /trackers; each keeps its own URL.
export const spottingTrackers: readonly Tracker[] = [
{
name: 'cars',
subtitle: 'a car pokedex: every brand and model, with Dutch and US data',
href: '/trackers/cars',
external: false
},
{
name: 'trains',
subtitle:
'a train pokedex: steam, diesel and electric locos, high-speed trains, multiple units, metros and trams',
href: '/trackers/trains',
external: false
},
{
name: 'planes',
subtitle: 'a plane pokedex: airliners, helicopters, bizjets, light aircraft and warbirds',
href: '/trackers/planes',
external: false
},
{
name: 'wildlife',
subtitle:
'a wild animal pokedex: everything you can spot in the Netherlands, plus famous animals worldwide',
href: '/trackers/wildlife',
external: false
}
];
// Trackers, in display order. Names are short single nouns. Most keep their existing // Trackers, in display order. Names are short single nouns. Most keep their existing
// /projects/... URLs (no moves, so no redirects needed). `private` trackers are shown in the // /projects/... URLs (no moves, so no redirects needed). `private` trackers are shown in the
// header, home page and /trackers only to the logged-in owner (layout data `isOwner`), never // header, home page and /trackers only to the logged-in owner (layout data `isOwner`), never
// in the sitemap; their routes 404 for everyone else. // in the sitemap; their routes 404 for everyone else.
const allTrackers = [ const allTrackers: readonly (Tracker & { private: boolean })[] = [
{ {
name: 'tasks', name: 'tasks',
subtitle: 'private: to-dos, goals and my TickTick history', subtitle: 'private: to-dos, goals and my TickTick history',
@@ -63,6 +103,7 @@ const allTrackers = [
subtitle: "cars, trains, planes & wildlife I've spotted", subtitle: "cars, trains, planes & wildlife I've spotted",
href: '/trackers/spotting', href: '/trackers/spotting',
external: false, external: false,
children: spottingTrackers,
private: false private: false
}, },
{ {
@@ -135,45 +176,10 @@ const allTrackers = [
external: false, external: false,
private: false private: false
} }
] as const;
type Tracker = { name: string; subtitle: string; href: string; external: boolean };
const strip = ({ name, subtitle, href, external }: Tracker): Tracker => ({
name,
subtitle,
href,
external
});
// The spotting trackers: pokedexes of things I can spot in the wild. Grouped under one
// "spotting" item in the header, home page and /trackers; each keeps its own URL.
export const spottingTrackers: readonly Tracker[] = [
{
name: 'cars',
subtitle: 'a car pokedex: every brand and model, with Dutch and US data',
href: '/trackers/cars',
external: false
},
{
name: 'trains',
subtitle:
'a train pokedex: steam, diesel and electric locos, high-speed trains, multiple units, metros and trams',
href: '/trackers/trains',
external: false
},
{
name: 'planes',
subtitle: 'a plane pokedex: airliners, helicopters, bizjets, light aircraft and warbirds',
href: '/trackers/planes',
external: false
},
{
name: 'wildlife',
subtitle:
'a wild animal pokedex: everything you can spot in the Netherlands, plus famous animals worldwide',
href: '/trackers/wildlife',
external: false
}
]; ];
const strip = ({ name, subtitle, href, external, children }: Tracker): Tracker =>
children ? { name, subtitle, href, external, children } : { name, subtitle, href, external };
/** Public trackers (sitemap, logged-out navigation). */ /** Public trackers (sitemap, logged-out navigation). */
export const trackers: readonly Tracker[] = allTrackers.filter((t) => !t.private).map(strip); export const trackers: readonly Tracker[] = allTrackers.filter((t) => !t.private).map(strip);
/** Owner-only trackers. */ /** Owner-only trackers. */