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 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>
|
||||||
|
|
||||||
|
{#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.Group>
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
{#each items as item (item.title)}
|
{#each items as item (item.title)}
|
||||||
|
{@const url = item.path(libraryState.activeLibrary?.id)}
|
||||||
|
{@const isActive = page.url.pathname === url}
|
||||||
{#if 'shelves' in item}
|
{#if 'shelves' in item}
|
||||||
<Collapsible.Root open={item.isActive} class="group/collapsible">
|
<Collapsible.Root bind:open={shelvesOpen} class="group/collapsible">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<Sidebar.MenuItem {...props}>
|
<Sidebar.MenuItem {...props}>
|
||||||
<Collapsible.Trigger>
|
{#if sidebar.state === 'collapsed'}
|
||||||
{#snippet child({ props })}
|
<!--
|
||||||
<Sidebar.MenuButton {...props} tooltipContent={item.title} class="h-10">
|
Sidebar.MenuSub is group-data-[collapsible=icon]:hidden, so
|
||||||
{#if item.icon}
|
toggling in the rail opens a list nobody can see. Expand the
|
||||||
<item.icon class="scale-125" />
|
sidebar and open the section instead of toggling.
|
||||||
{/if}
|
-->
|
||||||
<span class="text-md ml-2">{item.title}</span>
|
<Sidebar.MenuButton
|
||||||
<ChevronRightIcon
|
tooltipContent={item.title}
|
||||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
class="h-10"
|
||||||
/>
|
onclick={() => {
|
||||||
</Sidebar.MenuButton>
|
sidebar.setOpen(true);
|
||||||
{/snippet}
|
shelvesOpen = true;
|
||||||
</Collapsible.Trigger>
|
}}
|
||||||
|
>
|
||||||
|
{@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>
|
<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,29 +65,32 @@
|
|||||||
{#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,
|
||||||
<button
|
so mounting on it made the arrows pop in a frame late and shift the
|
||||||
type="button"
|
header; `invisible` keeps the space reserved from the first paint.
|
||||||
disabled={!canScrollLeft}
|
-->
|
||||||
onclick={scrollLeft}
|
<div class="ml-auto {needsScroll ? '' : 'invisible'}" aria-hidden={!needsScroll}>
|
||||||
class={`${canScrollLeft ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
|
<button
|
||||||
aria-label="Scroll Left"
|
type="button"
|
||||||
>
|
disabled={!canScrollLeft}
|
||||||
<ChevronLeft size="20" strokeWidth="3" />
|
onclick={scrollLeft}
|
||||||
</button>
|
class={`${canScrollLeft ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
|
||||||
<button
|
aria-label="Scroll Left"
|
||||||
type="button"
|
>
|
||||||
disabled={!canScrollRight}
|
<ChevronLeft size="20" strokeWidth="3" />
|
||||||
onclick={scrollRight}
|
</button>
|
||||||
class={`${canScrollRight ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
|
<button
|
||||||
aria-label="Scroll Right"
|
type="button"
|
||||||
>
|
disabled={!canScrollRight}
|
||||||
<ChevronRight size="20" strokeWidth="3" />
|
onclick={scrollRight}
|
||||||
</button>
|
class={`${canScrollRight ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
|
||||||
</div>
|
aria-label="Scroll Right"
|
||||||
{/if}
|
>
|
||||||
|
<ChevronRight size="20" strokeWidth="3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
bind:this={scrollContainer}
|
bind:this={scrollContainer}
|
||||||
|
|||||||
Reference in New Issue
Block a user