feat(frontend): animate sidebar icons on hover

This commit is contained in:
Elias Schneider
2026-10-27 23:32:53 +01:00
parent dac121cf56
commit 95cfae33e6
11 changed files with 290 additions and 85 deletions
+1
View File
@@ -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",
@@ -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<string | null>(null);
let focused = $state<string | null>(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 @@
<ul class={cn('flex flex-col gap-0.5', collapsed ? 'items-center' : 'max-lg:items-center')}>
{#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'
)}
<li>
<Tooltip.Root>
<Tooltip.Trigger>
@@ -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'
)}
>
<item.icon
class={cn(
'size-[17px] shrink-0 transition-colors',
active ? 'text-fg' : 'text-fg-muted group-hover:text-fg'
)}
/>
{#if item.animated}
<NavIcon
icon={item.animated}
active={playing === item.href}
class={iconClass}
/>
{:else}
<item.icon class={iconClass} />
{/if}
<span
class={cn(
'min-w-0 flex-1',
@@ -268,10 +310,13 @@
class="text-fg-muted hover:bg-fill-hover hover:text-fg focus-visible:outline-ring hidden size-9 shrink-0 items-center justify-center rounded-sm outline-offset-[-2px] focus-visible:outline-2 lg:flex"
aria-label={collapsed ? m.expand_sidebar() : m.collapse_sidebar()}
onclick={() => (collapsed = !collapsed)}
{...play('collapse')}
>
{#if collapsed}<PanelLeftOpen class="size-[17px]" />{:else}<PanelLeftClose
class="size-[17px]"
/>{/if}
<NavIcon
icon={collapsed ? AnimatedPanelLeftOpen : AnimatedPanelLeftClose}
active={playing === 'collapse'}
class="size-[17px]"
/>
</button>
{/snippet}
</Tooltip.Trigger>
@@ -0,0 +1,30 @@
<script lang="ts">
import { cn } from '#lib/utils/style.ts';
import type { IconProps } from '@jis3r/icons';
import type { Component } from 'svelte';
import { prefersReducedMotion } from 'svelte/motion';
let {
icon: Icon,
active,
class: className
}: { icon: Component<IconProps>; active: boolean; class?: string } = $props();
// Reduced motion and Disable animations keep the icon still; the class is read on every hover because the setting can change at runtime
const animate = $derived(
active &&
!prefersReducedMotion.current &&
!document.documentElement.classList.contains('no-motion')
);
</script>
<!-- The library names each icon with role="img" in English, which would leak into the row's accessible name, and plays on its own hover, which ignores reduced motion -->
<span
aria-hidden="true"
class={cn(
'pointer-events-none inline-grid [&_svg_*]:[vector-effect:non-scaling-stroke]',
className
)}
>
<Icon size={17} strokeWidth={1.5} {animate} />
</span>
+80 -28
View File
@@ -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<IconProps>;
};
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<Paginated<unknown>>) =>
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
@@ -0,0 +1,42 @@
<script lang="ts">
import type { IconProps } from '@jis3r/icons';
let { size = 24, strokeWidth = 2, animate = false }: IconProps = $props();
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class:animate
>
<path d="M16 10h2" />
<path d="M16 14h2" />
<g class="avatar">
<path d="M6.17 15a3 3 0 0 1 5.66 0" />
<circle cx="9" cy="11" r="2" />
</g>
<rect x="2" y="5" width="20" height="14" rx="2" />
</svg>
<style>
svg {
overflow: visible;
}
.animate .avatar {
animation: nod 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes nod {
45% {
transform: translateY(-1.5px);
}
}
</style>
@@ -0,0 +1,39 @@
<script lang="ts">
import type { IconProps } from '@jis3r/icons';
let { size = 24, strokeWidth = 2, animate = false }: IconProps = $props();
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class:animate
>
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" class="lifted" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
<style>
svg {
overflow: visible;
}
.animate .lifted {
animation: lift 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes lift {
45% {
transform: translate(2px, -2px);
}
}
</style>
@@ -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 });
@@ -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);
+21 -35
View File
@@ -1,14 +1,16 @@
<script lang="ts">
import { onNavigate } from '$app/navigation';
import FormattedMessage from '#lib/components/formatted-message.svelte';
import { page } from '$app/state';
import DocsLink from '#lib/components/docs-link.svelte';
import AppSidebar from '#lib/components/shell/app-sidebar.svelte';
import CommandPalette from '#lib/components/shell/command-palette.svelte';
import { checkApiRows, navGroups } from '#lib/components/shell/nav-items.ts';
import { navGroups } from '#lib/components/shell/nav-items.ts';
import PhoneNav from '#lib/components/shell/phone-nav.svelte';
import * as Alert from '#lib/components/ui/alert/index.ts';
import * as Tooltip from '#lib/components/ui/tooltip/index.ts';
import { m } from '#lib/paraglide/messages.js';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import { pageTransition } from '#lib/stores/page-transition-store.svelte.ts';
import userStore from '#lib/stores/user-store.ts';
import { isEmailVerificationPending } from '#lib/utils/email-verification-util.ts';
import { cn } from '#lib/utils/style.ts';
@@ -30,27 +32,7 @@
const isAdmin = $derived(!!($userStore?.isAdmin || user?.isAdmin));
const verificationPending = $derived(isEmailVerificationPending(user, $appConfigStore));
const showUpdate = $derived(isAdmin && versionInformation.isUpToDate === false);
// A writable $derived(data.apiRows) would lose a re-check on the next navigation, because the root load re-runs every time and replaces data
let recheckedApiRows = $state<typeof data.apiRows>();
const apiRows = $derived(recheckedApiRows ?? data.apiRows);
// The page you are on keeps its row, and the palette always lists both (docs/design-system.md, PO11)
const groups = $derived(
navGroups({
isAdmin,
verificationPending,
showApis:
apiRows.apis ||
showAdvanced.current ||
page.url.pathname.startsWith('/settings/admin/apis'),
showApiKeys:
apiRows.apiKeys ||
showAdvanced.current ||
page.url.pathname.startsWith('/settings/admin/api-keys')
})
);
const paletteGroups = $derived(
navGroups({ isAdmin, verificationPending, showApis: true, showApiKeys: true })
);
const groups = $derived(navGroups({ isAdmin, verificationPending, features: data.features }));
const collapsed = new PersistedState('sidebar-collapsed', false);
// List pages run gutter to gutter, the other pages keep the centered column
@@ -68,14 +50,7 @@
let mainEl: HTMLElement;
// Navigating from the APIs or API keys pages, their tables' page updates included, re-checks their rows, so a first API or key has its row before you leave
// SvelteKit passes a from without a url when the app starts
afterNavigate(async ({ from }) => {
if (from?.url?.pathname.startsWith('/settings/admin/api'))
recheckedApiRows = await checkApiRows();
});
// Only the content region crossfades between pages; the shell never moves (docs/design-system.md, Page transitions)
// Only the content region crossfades between pages, so the shell never moves
onNavigate((navigation) => {
if (
navigation.shallow ||
@@ -83,17 +58,25 @@
document.documentElement.classList.contains('no-motion') ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches ||
!navigation.to?.url.pathname.startsWith('/settings/') ||
navigation.from?.url.pathname === navigation.to.url.pathname
navigation.from?.url.pathname === navigation.to.url.pathname ||
// Configuration tabs are routes, but switching them swaps only the tab content
(navigation.to.route.id === '/settings/admin/application-configuration/[category]' &&
navigation.from?.route.id === navigation.to.route.id)
) {
return;
}
// onNavigate runs after the load functions resolved, so the transition never delays the new page
return new Promise((resolve) => {
document.startViewTransition(async () => {
pageTransition.running = true;
// The outgoing column fades out where it was instead of at the new page's scroll position
mainEl.style.viewTransitionName = 'settings-content-old';
const transition = document.startViewTransition(async () => {
mainEl.style.viewTransitionName = '';
resolve();
await navigation.complete;
});
transition.finished.finally(() => (pageTransition.running = false));
});
});
</script>
@@ -118,7 +101,10 @@
<Alert.Root variant="warning">
<LucideTriangleAlert />
<Alert.Description>
<FormattedMessage message={m.sqlite_storage_warning} />
{m.sqlite_network_storage_warning()}
<DocsLink
href="https://pocket-id.org/docs/configuration/environment-variables#sqlite"
/>
</Alert.Description>
</Alert.Root>
{/if}
@@ -129,5 +115,5 @@
</main>
</div>
</div>
<CommandPalette groups={paletteGroups} isAdmin={isAdmin && !verificationPending} />
<CommandPalette {groups} features={data.features} isAdmin={isAdmin && !verificationPending} />
</Tooltip.Provider>
+7 -7
View File
@@ -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
};
};