fix: stop icons remounting and shelves collapsing into nothing

This commit is contained in:
2026-08-11 20:08:53 -04:00
parent c09b8365fa
commit ef8e5e7fba
3 changed files with 99 additions and 75 deletions
@@ -3,58 +3,74 @@
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { Badge } from "$lib/components/ui/badge/index.js";
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
import { getLibraryState } from '$lib/state/library.svelte';
import { House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte';
const libraryState = getLibraryState();
const bookshelfState = getBookshelfState();
const sidebar = useSidebar();
let items = $derived(
[
{
title: 'Home',
url: `/library/${libraryState.activeLibrary?.id}`,
icon: House
},
{
title: 'Library',
url: `/library/${libraryState.activeLibrary?.id}/view`,
icon: LibraryBig
},
{
title: 'Shelves',
url: '#',
icon: Rows3,
shelves: []
}
].map((item) => ({
...item,
isActive: page.url.pathname === item.url
}))
);
// Owned here so the collapsed rail can force it open when it expands the
// sidebar. Previously open={isActive}, which was always false — the Shelves
// item has no route of its own, so pathname never matched.
let shelvesOpen = $state(false);
// Deliberately a plain const, not $derived. These objects hold component
// references, and `<item.icon />` is a dynamic component — if the array were
// rebuilt on every navigation the icons would remount, flashing and shifting
// layout. Only the url and active state need to be reactive, so they are
// computed per-item in the markup instead.
const items = [
{ title: 'Home', icon: House, path: (id?: number) => `/library/${id}` },
{ title: 'Library', icon: LibraryBig, path: (id?: number) => `/library/${id}/view` },
{ title: 'Shelves', icon: Rows3, path: () => '#', shelves: [] }
];
</script>
<Sidebar.Group>
<Sidebar.Menu>
{#each items as item (item.title)}
{#if 'shelves' in item}
<Collapsible.Root open={item.isActive} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton {...props} tooltipContent={item.title} class="h-10">
{#if item.icon}
{#snippet shelvesTrigger(item: { title: string; icon: typeof House })}
<item.icon class="scale-125" />
{/if}
<span class="text-md ml-2">{item.title}</span>
<ChevronRightIcon
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
{/snippet}
<Sidebar.Group>
<Sidebar.Menu>
{#each items as item (item.title)}
{@const url = item.path(libraryState.activeLibrary?.id)}
{@const isActive = page.url.pathname === url}
{#if 'shelves' in item}
<Collapsible.Root bind:open={shelvesOpen} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
{#if sidebar.state === 'collapsed'}
<!--
Sidebar.MenuSub is group-data-[collapsible=icon]:hidden, so
toggling in the rail opens a list nobody can see. Expand the
sidebar and open the section instead of toggling.
-->
<Sidebar.MenuButton
tooltipContent={item.title}
class="h-10"
onclick={() => {
sidebar.setOpen(true);
shelvesOpen = true;
}}
>
{@render shelvesTrigger(item)}
</Sidebar.MenuButton>
{:else}
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton {...props} tooltipContent={item.title} class="h-10">
{@render shelvesTrigger(item)}
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
{/if}
<Collapsible.Content>
<Sidebar.MenuSub class="w-full">
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)}
@@ -85,9 +101,9 @@
</Collapsible.Root>
{:else}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={item.isActive} tooltipContent={item.title} class="h-10">
<Sidebar.MenuButton isActive={isActive} tooltipContent={item.title} class="h-10">
{#snippet child({ props })}
<a href={item.url} {...props}>
<a href={url} {...props}>
{#if item.icon}
<item.icon class="scale-125" />
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
import { toggleMode, mode } from 'mode-watcher';
import { toggleMode } from 'mode-watcher';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
@@ -9,25 +9,26 @@
let { class: className = '' }: { class?: string } = $props();
</script>
<!--
Which icon shows is decided by CSS off the `dark` class, not by reading
`mode.current` in the markup. The server cannot know the mode, so a
`{#if mode.current === 'light'}` renders the wrong branch during SSR and
visibly swaps on hydration.
-->
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger
onclick={toggleMode}
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} scale-110 {className}"
>
{#if mode.current === 'light'}
<Moon class="scale-110" />
{:else}
<Sun class="scale-110" />
{/if}
<Moon class="scale-110 dark:hidden" />
<Sun class="hidden scale-110 dark:block" />
<span class="sr-only">Toggle light and dark mode</span>
</Tooltip.Trigger>
<Tooltip.Content>
{#if mode.current === 'light'}
<p>Dark Mode</p>
{:else}
<p>Light Mode</p>
{/if}
<p class="dark:hidden">Dark Mode</p>
<p class="hidden dark:block">Light Mode</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
@@ -23,8 +23,10 @@
// Start observing
resizeObserver.observe(scrollContainer);
// Initial check with delay
setTimeout(updateScrollState, 0);
// Measure synchronously. Reading scrollWidth/clientWidth forces layout, so
// the numbers are already accurate here — deferring to a macrotask just
// guaranteed one painted frame with the arrows in the wrong state.
updateScrollState();
// Cleanup
return () => {
@@ -33,12 +35,14 @@
});
const scrollLeft = () => {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: -scrollContainer.clientWidth, behavior: 'smooth' });
// Update state after scroll
setTimeout(updateScrollState, 100);
};
const scrollRight = () => {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: scrollContainer.clientWidth, behavior: 'smooth' });
// Update state after scroll
setTimeout(updateScrollState, 100);
@@ -61,9 +65,13 @@
{#if books.length > 0}
<div class="flex w-full flex-col">
<div class="flex items-center">
<h1 class="ml-4 text-xl font-semibold">{title}</h1>
{#if needsScroll}
<div class="ml-auto">
<h1 class="ml-4 font-serif text-xl font-semibold">{title}</h1>
<!--
Always mounted, only hidden. `needsScroll` is measured after mount,
so mounting on it made the arrows pop in a frame late and shift the
header; `invisible` keeps the space reserved from the first paint.
-->
<div class="ml-auto {needsScroll ? '' : 'invisible'}" aria-hidden={!needsScroll}>
<button
type="button"
disabled={!canScrollLeft}
@@ -83,7 +91,6 @@
<ChevronRight size="20" strokeWidth="3" />
</button>
</div>
{/if}
</div>
<div
bind:this={scrollContainer}