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
All checks were successful
Deploy production / deploy (push) Successful in 1m8s
This commit is contained in:
@@ -9,7 +9,13 @@
|
||||
import ProjectIcon from '#lib/components/ProjectIcon.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 {
|
||||
items,
|
||||
subtitleClass = 'mt-1 block text-sm text-muted-foreground',
|
||||
@@ -60,9 +66,32 @@
|
||||
<ul class="grid gap-3 sm:grid-cols-2" class:wide>
|
||||
{#each items as project (project.name)}
|
||||
<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">
|
||||
{#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
|
||||
href={project.href}
|
||||
rel="external"
|
||||
@@ -121,17 +150,57 @@
|
||||
ul :global(.catalog-card:hover .catalog-arrow) {
|
||||
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) {
|
||||
ul :global(.catalog-card),
|
||||
ul :global(.catalog-icon),
|
||||
ul :global(.catalog-arrow) {
|
||||
ul :global(.catalog-arrow),
|
||||
.chip,
|
||||
.chip :global(.chip-icon) {
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
ul :global(.catalog-card:hover),
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import * as Sheet from '#lib/components/ui/sheet/index.js';
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||
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 Menu from 'lucide-svelte/icons/menu';
|
||||
import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
|
||||
@@ -51,7 +52,13 @@
|
||||
{ label: 'blog', href: '/blog' },
|
||||
{ label: 'lists', href: '/lists' }
|
||||
] 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({
|
||||
'/projects': { label: 'projects', items: projects },
|
||||
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) }
|
||||
@@ -168,6 +175,36 @@
|
||||
function handleMenuPointerLeave(event: PointerEvent) {
|
||||
if (event.pointerType === 'touch') return;
|
||||
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>
|
||||
|
||||
@@ -246,12 +283,14 @@
|
||||
{#if openMenu === item.href}
|
||||
<div
|
||||
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'
|
||||
? '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}
|
||||
data-menu-panel
|
||||
>
|
||||
<a
|
||||
href={resolve(item.href)}
|
||||
@@ -261,6 +300,75 @@
|
||||
>
|
||||
<div class="col-span-2 my-1 h-px bg-border"></div>
|
||||
{#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. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a
|
||||
@@ -285,9 +393,13 @@
|
||||
>{project.subtitle}</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>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -399,6 +511,23 @@
|
||||
>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||
{/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>
|
||||
{:else}
|
||||
<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);
|
||||
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) {
|
||||
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
@@ -555,6 +697,9 @@
|
||||
background-image: linear-gradient(135deg, #10b981, #0d9488);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.flyout {
|
||||
animation: none;
|
||||
}
|
||||
.tile,
|
||||
.tile :global(.tile-icon) {
|
||||
transition:
|
||||
|
||||
@@ -32,11 +32,51 @@ export const projects = [
|
||||
}
|
||||
] 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
|
||||
// /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
|
||||
// in the sitemap; their routes 404 for everyone else.
|
||||
const allTrackers = [
|
||||
const allTrackers: readonly (Tracker & { private: boolean })[] = [
|
||||
{
|
||||
name: 'tasks',
|
||||
subtitle: 'private: to-dos, goals and my TickTick history',
|
||||
@@ -63,6 +103,7 @@ const allTrackers = [
|
||||
subtitle: "cars, trains, planes & wildlife I've spotted",
|
||||
href: '/trackers/spotting',
|
||||
external: false,
|
||||
children: spottingTrackers,
|
||||
private: false
|
||||
},
|
||||
{
|
||||
@@ -135,45 +176,10 @@ const allTrackers = [
|
||||
external: 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). */
|
||||
export const trackers: readonly Tracker[] = allTrackers.filter((t) => !t.private).map(strip);
|
||||
/** Owner-only trackers. */
|
||||
|
||||
Reference in New Issue
Block a user