From 95cfae33e6159f6a5772e760d5583aa6b87e4228 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 19 Oct 2026 18:32:09 +0200 Subject: [PATCH] feat(frontend): animate sidebar icons on hover --- frontend/package.json | 1 + .../lib/components/shell/app-sidebar.svelte | 67 +++++++++-- .../src/lib/components/shell/nav-icon.svelte | 30 +++++ .../src/lib/components/shell/nav-items.ts | 108 +++++++++++++----- .../src/lib/icons/id-card-animated.svelte | 42 +++++++ .../src/lib/icons/layout-grid-animated.svelte | 39 +++++++ .../stores/page-transition-store.svelte.ts | 2 + .../lib/stores/show-advanced-store.svelte.ts | 4 - frontend/src/routes/settings/+layout.svelte | 56 ++++----- frontend/src/routes/settings/+layout.ts | 14 +-- pnpm-lock.yaml | 12 ++ 11 files changed, 290 insertions(+), 85 deletions(-) create mode 100644 frontend/src/lib/components/shell/nav-icon.svelte create mode 100644 frontend/src/lib/icons/id-card-animated.svelte create mode 100644 frontend/src/lib/icons/layout-grid-animated.svelte create mode 100644 frontend/src/lib/stores/page-transition-store.svelte.ts delete mode 100644 frontend/src/lib/stores/show-advanced-store.svelte.ts 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 @@
    {#each group.items as item (item.href)} {@const active = item.href === current} + {@const iconClass = cn( + 'size-[17px] shrink-0 transition-colors', + active ? 'text-fg' : 'text-fg-muted group-hover:text-fg' + )}
  • @@ -163,8 +201,9 @@ {...props} href={item.href} aria-current={active ? 'page' : undefined} + {...play(item.href)} class={cn( - 'group text-fg focus-visible:outline-ring relative flex h-9 items-center gap-3 rounded-sm text-sm font-medium outline-offset-[-2px] focus-visible:outline-2', + 'group text-fg focus-visible:outline-ring relative flex h-9 scroll-my-6 items-center gap-3 rounded-sm text-sm font-medium outline-offset-[-2px] focus-visible:outline-2', 'before:bg-fill-hover before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:opacity-0 before:transition-opacity before:duration-(--dur-fast) hover:before:opacity-100', active && 'hover:before:opacity-0', collapsed @@ -172,12 +211,15 @@ : 'w-9 justify-center lg:w-full lg:justify-start lg:pr-3 lg:pl-4' )} > - + {#if item.animated} + + {:else} + + {/if} (collapsed = !collapsed)} + {...play('collapse')} > - {#if collapsed}{:else}{/if} + {/snippet} diff --git a/frontend/src/lib/components/shell/nav-icon.svelte b/frontend/src/lib/components/shell/nav-icon.svelte new file mode 100644 index 00000000..2324b4e5 --- /dev/null +++ b/frontend/src/lib/components/shell/nav-icon.svelte @@ -0,0 +1,30 @@ + + + + diff --git a/frontend/src/lib/components/shell/nav-items.ts b/frontend/src/lib/components/shell/nav-items.ts index bff723d0..7f23fbe3 100644 --- a/frontend/src/lib/components/shell/nav-items.ts +++ b/frontend/src/lib/components/shell/nav-items.ts @@ -1,4 +1,15 @@ import { m } from '#lib/paraglide/messages.js'; +import type { Features } from '#lib/types/feature.type.ts'; +import AnimatedIdCard from '#lib/icons/id-card-animated.svelte'; +import AnimatedLayoutGrid from '#lib/icons/layout-grid-animated.svelte'; +import AnimatedHistory from '@jis3r/icons/icons/history'; +import AnimatedUser from '@jis3r/icons/icons/user'; +import AnimatedUsersRound from '@jis3r/icons/icons/users-round'; +import AnimatedBlocks from '@jis3r/icons/icons/blocks'; +import AnimatedKey from '@jis3r/icons/icons/key'; +import AnimatedFileText from '@jis3r/icons/icons/file-text'; +import AnimatedSlidersHorizontal from '@jis3r/icons/icons/sliders-horizontal'; +import type { IconProps } from '@jis3r/icons'; import { Blocks, FileText, @@ -13,27 +24,38 @@ import { } from '@lucide/svelte'; import type { Component } from 'svelte'; -export type NavItem = { href: string; label: string; icon: Component }; +// animated is the sidebar's moving version of the icon; My account and My apps use #lib/icons because the library has no id-card and its layout-grid motion was too busy +export type NavItem = { + href: string; + label: string; + icon: Component; + animated?: Component; +}; export type NavGroup = { label: string; items: NavItem[] }; // The sidebar, the rail and the phone bar share these destinations export function navGroups({ isAdmin, verificationPending, - showApis, - showApiKeys + features }: { isAdmin: boolean; verificationPending: boolean; - showApis: boolean; - showApiKeys: boolean; + features: Features | null; }): NavGroup[] { // A user who still has to verify their email can only reach their account until they do if (verificationPending) { return [ { label: m.account(), - items: [{ href: '/settings/account', label: m.my_account(), icon: IdCard }] + items: [ + { + href: '/settings/account', + label: m.my_account(), + icon: IdCard, + animated: AnimatedIdCard + } + ] } ]; } @@ -42,9 +64,24 @@ export function navGroups({ { label: m.account(), items: [ - { href: '/settings/account', label: m.my_account(), icon: IdCard }, - { href: '/settings/apps', label: m.my_apps(), icon: LayoutGrid }, - { href: '/settings/audit-log', label: m.activity(), icon: History } + { + href: '/settings/account', + label: m.my_account(), + icon: IdCard, + animated: AnimatedIdCard + }, + { + href: '/settings/apps', + label: m.my_apps(), + icon: LayoutGrid, + animated: AnimatedLayoutGrid + }, + { + href: '/settings/audit-log', + label: m.activity(), + icon: History, + animated: AnimatedHistory + } ] } ]; @@ -53,16 +90,43 @@ export function navGroups({ groups.push({ label: m.administration(), items: [ - { href: '/settings/admin/users', label: m.users(), icon: User }, - { href: '/settings/admin/user-groups', label: m.groups(), icon: UsersRound }, - { href: '/settings/admin/oidc-clients', label: m.oidc_clients(), icon: Blocks }, - { href: '/settings/admin/apis', label: m.apis(), icon: Server }, - { href: '/settings/admin/api-keys', label: m.api_keys(), icon: Key }, - { href: '/settings/audit-log/global', label: m.audit_log(), icon: FileText }, + { href: '/settings/admin/users', label: m.users(), icon: User, animated: AnimatedUser }, + { + href: '/settings/admin/user-groups', + label: m.groups(), + icon: UsersRound, + animated: AnimatedUsersRound + }, + { + href: '/settings/admin/oidc-clients', + label: m.oidc_clients(), + icon: Blocks, + animated: AnimatedBlocks + }, + ...(features?.apis.enabled + ? [{ href: '/settings/admin/apis', label: m.apis(), icon: Server }] + : []), + ...(features?.apiKeys.enabled + ? [ + { + href: '/settings/admin/api-keys', + label: m.api_keys(), + icon: Key, + animated: AnimatedKey + } + ] + : []), + { + href: '/settings/audit-log/global', + label: m.audit_log(), + icon: FileText, + animated: AnimatedFileText + }, { href: '/settings/admin/application-configuration', label: m.configuration(), - icon: SlidersHorizontal + icon: SlidersHorizontal, + animated: AnimatedSlidersHorizontal } ] }); @@ -71,18 +135,6 @@ export function navGroups({ return groups; } -// APIs and API keys join the sidebar once one exists (docs/design-system.md, PO11), and a failed check keeps the row -export async function checkApiRows() { - const first = { pagination: { page: 1, limit: 1 } }; - const exists = (list: Promise>) => - list.then((r) => r.data.length > 0).catch(() => true); - const [apis, apiKeys] = await Promise.all([ - exists(new ApisService().list(first)), - exists(new ApiKeyService().list(first)) - ]); - return { apis, apiKeys }; -} - // The most specific destination wins, so the global audit log is not also marked as Activity export function activeHref(groups: NavGroup[], pathname: string) { return groups diff --git a/frontend/src/lib/icons/id-card-animated.svelte b/frontend/src/lib/icons/id-card-animated.svelte new file mode 100644 index 00000000..b2e09cd3 --- /dev/null +++ b/frontend/src/lib/icons/id-card-animated.svelte @@ -0,0 +1,42 @@ + + + + + + + + + + + + + diff --git a/frontend/src/lib/icons/layout-grid-animated.svelte b/frontend/src/lib/icons/layout-grid-animated.svelte new file mode 100644 index 00000000..b5ef7959 --- /dev/null +++ b/frontend/src/lib/icons/layout-grid-animated.svelte @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/frontend/src/lib/stores/page-transition-store.svelte.ts b/frontend/src/lib/stores/page-transition-store.svelte.ts new file mode 100644 index 00000000..c074085e --- /dev/null +++ b/frontend/src/lib/stores/page-transition-store.svelte.ts @@ -0,0 +1,2 @@ +// True while a settings page crossfade runs; the browser takes the pointer off the page for it, so hover events in that window are not real moves +export const pageTransition = $state({ running: false }); diff --git a/frontend/src/lib/stores/show-advanced-store.svelte.ts b/frontend/src/lib/stores/show-advanced-store.svelte.ts deleted file mode 100644 index 230eb2ce..00000000 --- a/frontend/src/lib/stores/show-advanced-store.svelte.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PersistedState } from 'runed'; - -// Per-admin preference that opens every Advanced disclosure; it lives in this browser until it moves to the user's settings -export const showAdvanced = new PersistedState('show-advanced-settings', false); diff --git a/frontend/src/routes/settings/+layout.svelte b/frontend/src/routes/settings/+layout.svelte index 208212a2..8ddbd689 100644 --- a/frontend/src/routes/settings/+layout.svelte +++ b/frontend/src/routes/settings/+layout.svelte @@ -1,14 +1,16 @@ @@ -118,7 +101,10 @@ - + {m.sqlite_network_storage_warning()} + {/if} @@ -129,5 +115,5 @@ - + diff --git a/frontend/src/routes/settings/+layout.ts b/frontend/src/routes/settings/+layout.ts index 062ca237..9c83fe9b 100644 --- a/frontend/src/routes/settings/+layout.ts +++ b/frontend/src/routes/settings/+layout.ts @@ -1,4 +1,4 @@ -import { checkApiRows } from '#lib/components/shell/nav-items.ts'; +import FeatureService from '#lib/services/feature-service.ts'; import StorageService from '#lib/services/storage-service.ts'; import VersionService from '#lib/services/version-service.ts'; import WebAuthnService from '#lib/services/webauthn-service.ts'; @@ -6,21 +6,21 @@ import type { AppVersionInformation } from '#lib/types/application-configuration import { redirect } from '@sveltejs/kit'; import type { LayoutLoad } from './$types'; -export const load: LayoutLoad = async ({ url, parent, untrack }) => { +export const load: LayoutLoad = async ({ url, parent, untrack, depends }) => { + depends('app:features'); + const versionService = new VersionService(); const storageService = new StorageService(); const webauthnService = new WebAuthnService(); const currentVersion = versionService.getCurrentVersion(); - const [newestVersion, sqliteStorageWarning, passkeys, apiRows] = await Promise.all([ + const [newestVersion, sqliteStorageWarning, passkeys, features] = await Promise.all([ versionService.getNewestVersion().catch(() => null), storageService.getSqliteStorageWarning().catch(() => false), webauthnService.listCredentials(), // A tracked parent() would rerun this load on every navigation, because the root load reads the pathname - untrack(() => parent()).then(({ user }) => - user?.isAdmin ? checkApiRows() : { apis: false, apiKeys: false } - ) + untrack(() => parent()).then(({ user }) => (user?.isAdmin ? new FeatureService().list() : null)) ]); // If newestVersion is empty, it means the check is disabled or failed. @@ -45,6 +45,6 @@ export const load: LayoutLoad = async ({ url, parent, untrack }) => { versionInformation, sqliteStorageWarning, passkeys, - apiRows + features }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00e1e1d4..20a5f14a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@inlang/paraglide-js-svelte': specifier: ^1.0.3 version: 1.0.4(@inlang/paraglide-js@2.29.0(@voidzero-dev/vite-plus-core@1.3.0(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3))(svelte@5.57.2(@typescript-eslint/types@8.70.0)) + '@jis3r/icons': + specifier: ^2.9.0 + version: 2.9.0(svelte@5.57.2(@typescript-eslint/types@8.70.0)) '@opentelemetry/api': specifier: ^1.9.1 version: 1.9.1 @@ -717,6 +720,11 @@ packages: '@internationalized/date@3.12.4': resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==} + '@jis3r/icons@2.9.0': + resolution: {integrity: sha512-jXpUPNomX142/nga4ElkhKIzSD+o6PM3LqQI4CgjPiEPLaGouLpNmJPF+RNBdBvLKGWTIthNWXzmcFx39QPC2A==} + peerDependencies: + svelte: ^5.0.0 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -3778,6 +3786,10 @@ snapshots: dependencies: '@swc/helpers': 0.5.23 + '@jis3r/icons@2.9.0(svelte@5.57.2(@typescript-eslint/types@8.70.0))': + dependencies: + svelte: 5.57.2(@typescript-eslint/types@8.70.0) + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0