fix: stop icons remounting and shelves collapsing into nothing
This commit is contained in:
@@ -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>
|
||||
|
||||
{#snippet shelvesTrigger(item: { title: string; icon: typeof House })}
|
||||
<item.icon class="scale-125" />
|
||||
<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 open={item.isActive} class="group/collapsible">
|
||||
<Collapsible.Root bind:open={shelvesOpen} 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" />
|
||||
{/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"
|
||||
/>
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</Collapsible.Trigger>
|
||||
{#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,29 +65,32 @@
|
||||
{#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">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canScrollLeft}
|
||||
onclick={scrollLeft}
|
||||
class={`${canScrollLeft ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
|
||||
aria-label="Scroll Left"
|
||||
>
|
||||
<ChevronLeft size="20" strokeWidth="3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canScrollRight}
|
||||
onclick={scrollRight}
|
||||
class={`${canScrollRight ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
|
||||
aria-label="Scroll Right"
|
||||
>
|
||||
<ChevronRight size="20" strokeWidth="3" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<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}
|
||||
onclick={scrollLeft}
|
||||
class={`${canScrollLeft ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
|
||||
aria-label="Scroll Left"
|
||||
>
|
||||
<ChevronLeft size="20" strokeWidth="3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canScrollRight}
|
||||
onclick={scrollRight}
|
||||
class={`${canScrollRight ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
|
||||
aria-label="Scroll Right"
|
||||
>
|
||||
<ChevronRight size="20" strokeWidth="3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
bind:this={scrollContainer}
|
||||
|
||||
Reference in New Issue
Block a user