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 Collapsible from '$lib/components/ui/collapsible/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { Badge } from "$lib/components/ui/badge/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 { getBookshelfState } from '$lib/state/bookshelf.svelte';
import { getLibraryState } from '$lib/state/library.svelte'; import { getLibraryState } from '$lib/state/library.svelte';
import { House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte'; import { House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte';
const libraryState = getLibraryState(); const libraryState = getLibraryState();
const bookshelfState = getBookshelfState(); const bookshelfState = getBookshelfState();
const sidebar = useSidebar();
let items = $derived( // 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.
title: 'Home', let shelvesOpen = $state(false);
url: `/library/${libraryState.activeLibrary?.id}`,
icon: House // 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
title: 'Library', // layout. Only the url and active state need to be reactive, so they are
url: `/library/${libraryState.activeLibrary?.id}/view`, // computed per-item in the markup instead.
icon: LibraryBig const items = [
}, { title: 'Home', icon: House, path: (id?: number) => `/library/${id}` },
{ { title: 'Library', icon: LibraryBig, path: (id?: number) => `/library/${id}/view` },
title: 'Shelves', { title: 'Shelves', icon: Rows3, path: () => '#', shelves: [] }
url: '#', ];
icon: Rows3,
shelves: []
}
].map((item) => ({
...item,
isActive: page.url.pathname === item.url
}))
);
</script> </script>
<Sidebar.Group> {#snippet shelvesTrigger(item: { title: string; icon: typeof House })}
<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}
<item.icon class="scale-125" /> <item.icon class="scale-125" />
{/if}
<span class="text-md ml-2">{item.title}</span> <span class="text-md ml-2">{item.title}</span>
<ChevronRightIcon <ChevronRightIcon
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" 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> </Sidebar.MenuButton>
{/snippet} {/snippet}
</Collapsible.Trigger> </Collapsible.Trigger>
{/if}
<Collapsible.Content> <Collapsible.Content>
<Sidebar.MenuSub class="w-full"> <Sidebar.MenuSub class="w-full">
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)} {#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)}
@@ -85,9 +101,9 @@
</Collapsible.Root> </Collapsible.Root>
{:else} {:else}
<Sidebar.MenuItem> <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 })} {#snippet child({ props })}
<a href={item.url} {...props}> <a href={url} {...props}>
{#if item.icon} {#if item.icon}
<item.icon class="scale-125" /> <item.icon class="scale-125" />
{/if} {/if}
@@ -1,5 +1,5 @@
<script lang="ts"> <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 * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { buttonVariants } from '$lib/components/ui/button/button.svelte'; import { buttonVariants } from '$lib/components/ui/button/button.svelte';
@@ -9,25 +9,26 @@
let { class: className = '' }: { class?: string } = $props(); let { class: className = '' }: { class?: string } = $props();
</script> </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.Provider>
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger <Tooltip.Trigger
onclick={toggleMode} onclick={toggleMode}
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} scale-110 {className}" class="{buttonVariants({ variant: 'ghost', size: 'icon' })} scale-110 {className}"
> >
{#if mode.current === 'light'} <Moon class="scale-110 dark:hidden" />
<Moon class="scale-110" /> <Sun class="hidden scale-110 dark:block" />
{:else} <span class="sr-only">Toggle light and dark mode</span>
<Sun class="scale-110" />
{/if}
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content> <Tooltip.Content>
{#if mode.current === 'light'} <p class="dark:hidden">Dark Mode</p>
<p>Dark Mode</p> <p class="hidden dark:block">Light Mode</p>
{:else}
<p>Light Mode</p>
{/if}
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider> </Tooltip.Provider>
@@ -23,8 +23,10 @@
// Start observing // Start observing
resizeObserver.observe(scrollContainer); resizeObserver.observe(scrollContainer);
// Initial check with delay // Measure synchronously. Reading scrollWidth/clientWidth forces layout, so
setTimeout(updateScrollState, 0); // the numbers are already accurate here — deferring to a macrotask just
// guaranteed one painted frame with the arrows in the wrong state.
updateScrollState();
// Cleanup // Cleanup
return () => { return () => {
@@ -33,12 +35,14 @@
}); });
const scrollLeft = () => { const scrollLeft = () => {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: -scrollContainer.clientWidth, behavior: 'smooth' }); scrollContainer.scrollBy({ left: -scrollContainer.clientWidth, behavior: 'smooth' });
// Update state after scroll // Update state after scroll
setTimeout(updateScrollState, 100); setTimeout(updateScrollState, 100);
}; };
const scrollRight = () => { const scrollRight = () => {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: scrollContainer.clientWidth, behavior: 'smooth' }); scrollContainer.scrollBy({ left: scrollContainer.clientWidth, behavior: 'smooth' });
// Update state after scroll // Update state after scroll
setTimeout(updateScrollState, 100); setTimeout(updateScrollState, 100);
@@ -61,9 +65,13 @@
{#if books.length > 0} {#if books.length > 0}
<div class="flex w-full flex-col"> <div class="flex w-full flex-col">
<div class="flex items-center"> <div class="flex items-center">
<h1 class="ml-4 text-xl font-semibold">{title}</h1> <h1 class="ml-4 font-serif text-xl font-semibold">{title}</h1>
{#if needsScroll} <!--
<div class="ml-auto"> 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 <button
type="button" type="button"
disabled={!canScrollLeft} disabled={!canScrollLeft}
@@ -83,7 +91,6 @@
<ChevronRight size="20" strokeWidth="3" /> <ChevronRight size="20" strokeWidth="3" />
</button> </button>
</div> </div>
{/if}
</div> </div>
<div <div
bind:this={scrollContainer} bind:this={scrollContainer}