diff --git a/frontend/package.json b/frontend/package.json index 6639a1ee..da84edb7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@inlang/paraglide-js-svelte": "^1.0.3", + "@jis3r/icons": "^2.9.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-trace-otlp-http": "^0.223.0", "@opentelemetry/resources": "^2.8.0", diff --git a/frontend/src/lib/components/shell/app-sidebar.svelte b/frontend/src/lib/components/shell/app-sidebar.svelte index d14d3315..6ce8e938 100644 --- a/frontend/src/lib/components/shell/app-sidebar.svelte +++ b/frontend/src/lib/components/shell/app-sidebar.svelte @@ -5,11 +5,15 @@ import { m } from '#lib/paraglide/messages.js'; import appConfigStore from '#lib/stores/application-configuration-store.ts'; import { commandPalette } from '#lib/stores/command-palette-store.svelte.ts'; + import { pageTransition } from '#lib/stores/page-transition-store.svelte.ts'; import type { AppVersionInformation } from '#lib/types/application-configuration.type.ts'; import { modKey } from '#lib/utils/keyboard-util.ts'; import { cn } from '#lib/utils/style.ts'; - import { CircleChevronUp, PanelLeftClose, PanelLeftOpen, Search } from '@lucide/svelte'; + import AnimatedPanelLeftClose from '@jis3r/icons/icons/panel-left-close'; + import AnimatedPanelLeftOpen from '@jis3r/icons/icons/panel-left-open'; + import { CircleChevronUp, Search } from '@lucide/svelte'; import AccountMenu from './account-menu.svelte'; + import NavIcon from './nav-icon.svelte'; import { activeHref, type NavGroup } from './nav-items.ts'; let { @@ -25,6 +29,36 @@ } = $props(); const current = $derived(activeHref(groups, page.url.pathname)); + + // The control whose icon plays: hovering or keyboard focus starts it, a click or tap never does + // Hover and keyboard focus are tracked apart, because a click moves focus to the new page and must not make a hovered icon play again + let hovered = $state(null); + let focused = $state(null); + const playing = $derived(hovered ?? focused); + const play = (key: string) => ({ + 'data-icon-play': key, + onmouseenter: () => (hovered = key), + // A click starts a page transition, which takes the pointer away and back, so a leave during it is not real + onmouseleave: () => { + if (!pageTransition.running) hovered = null; + }, + // The focusin pair leaves the tooltip's own onfocus and onblur in place + onfocusin: (e: FocusEvent) => { + if ((e.currentTarget as HTMLElement).matches(':focus-visible')) focused = key; + }, + onfocusout: () => (focused = null) + }); + + // A row the pointer left during a page transition stops playing on the next move, since the browser restores :hover only after the transition + $effect(() => { + if (pageTransition.running || !hovered) return; + const key = hovered; + const check = () => { + if (!document.querySelector(`[data-icon-play="${key}"]`)?.matches(':hover')) hovered = null; + }; + window.addEventListener('pointermove', check, { once: true }); + return () => window.removeEventListener('pointermove', check); + }); const versionLabel = $derived(`Pocket ID ${versionInformation.currentVersion}`); // One shared selected fill slides to the current row instead of every row painting its own @@ -155,6 +189,10 @@