<script lang="ts">
import type { Snippet } from "svelte";
import ChevronDown from "$icon/chevron-down";
import Menu from "$icon/menu";
import Monitor from "$icon/monitor";
import Moon from "$icon/moon";
import Search from "$icon/search";
import Sun from "$icon/sun";
import X from "$icon/x";
import Button from "#lib/components/ui/Button.svelte";
import Input from "#lib/components/ui/Input.svelte";
import Logo from "#lib/components/ui/Logo.svelte";
import { plural } from "#lib/format.ts";
interface Entry {
name: string;
slug: string;
}
interface Props {
title: string;
entries: Entry[];
total: number;
query?: string;
children: Snippet;
}
let { title, entries, total, query = $bindable(""), children }: Props = $props();
type Theme = "system" | "light" | "dark";
const themes: Record<Theme, { next: Theme; label: string; icon: typeof Sun }> = {
system: { next: "light", label: "System theme", icon: Monitor },
light: { next: "dark", label: "Light theme", icon: Sun },
dark: { next: "system", label: "Dark theme", icon: Moon }
};
let theme = $state<Theme>("system");
let sidebarOpen = $state(true);
let drawerOpen = $state(false);
let componentsOpen = $state(true);
$effect(() => {
const root = document.documentElement;
if (theme === "system") root.removeAttribute("data-theme");
else root.dataset.theme = theme;
return () => root.removeAttribute("data-theme");
});
const sorted = $derived(entries.toSorted((a, b) => a.name.localeCompare(b.name)));
</script>
{#snippet themeButton()}
<Button
variant="ghost"
icon={themes[theme].icon}
aria-label={themes[theme].label}
title={themes[theme].label}
onclick={() => (theme = themes[theme].next)}
/>
{/snippet}
{#snippet nav()}
<div class="mb-sm">
<Input size="small" iconLeft={Search} placeholder="Search..." bind:value={query} />
</div>
<ul class="flex flex-col gap-px">
<li>
<a
href="#top"
class="block rounded-md p-xs text-foreground-default no-underline transition-colors hover:bg-background-subtle hover:no-underline"
>
Overview
</a>
</li>
</ul>
<div class="my-md border-b border-border-default"></div>
<div class="mb-md">
<button
type="button"
class="flex w-full cursor-pointer items-center justify-between rounded-md p-xs font-medium text-foreground-default"
aria-expanded={componentsOpen}
onclick={() => (componentsOpen = !componentsOpen)}
>
<span>Components</span>
<ChevronDown
class={[
"size-4 transition-transform duration-300",
!componentsOpen && "-rotate-90"
]}
/>
</button>
{#if componentsOpen}
<ul class="mt-2xs flex flex-col gap-px">
{#each sorted as entry (entry.slug)}
<li>
<a
href="#{entry.slug}"
class="block rounded-md p-xs text-foreground-subtle no-underline transition-colors hover:bg-background-subtle hover:text-foreground-default hover:no-underline"
onclick={() => (drawerOpen = false)}
>
{entry.name}
</a>
</li>
{:else}
<li class="p-xs text-foreground-placeholder">No matches</li>
{/each}
</ul>
{/if}
</div>
{/snippet}
<div id="top" class="isolate min-h-screen bg-background-default text-foreground-default">
<div
class="fixed inset-x-0 top-0 z-50 flex h-12 items-center justify-between border-b border-border-default bg-background-default px-xs md:hidden"
>
<Button
variant="ghost"
icon={Menu}
aria-label="Open navigation"
onclick={() => (drawerOpen = true)}
/>
<h1 class="typography-paragraph-regular font-medium">{title}</h1>
{@render themeButton()}
</div>
{#if drawerOpen}
<button
type="button"
class="fixed inset-0 z-40 bg-background-emphasis/40 md:hidden"
aria-label="Close navigation"
onclick={() => (drawerOpen = false)}
></button>
{/if}
<aside
class={[
"fixed inset-y-0 left-0 z-50 flex w-72 flex-col border-r border-border-default bg-background-default transition-transform duration-300 ease-in-out md:hidden",
!drawerOpen && "-translate-x-full"
]}
>
<div
class="flex h-12 flex-none items-center justify-between border-b border-border-default px-sm"
>
<h1 class="typography-paragraph-regular font-medium">{title}</h1>
<Button
variant="ghost"
icon={X}
aria-label="Close navigation"
onclick={() => (drawerOpen = false)}
/>
</div>
<div
class="min-h-0 grow overflow-y-auto overscroll-contain px-sm py-md typography-paragraph-small text-foreground-subtle"
>
{@render nav()}
</div>
</aside>
<div
class="fixed inset-y-0 left-0 z-50 hidden w-12 border-r border-border-default bg-background-default md:block"
>
<div class="relative h-12 border-b border-border-default">
<button
type="button"
class="absolute inset-0 grid cursor-pointer place-items-center text-foreground-default"
aria-label={sidebarOpen ? "Hide navigation" : "Show navigation"}
aria-expanded={sidebarOpen}
onclick={() => (sidebarOpen = !sidebarOpen)}
>
<Logo class="size-5" />
</button>
</div>
</div>
<div
class="pointer-events-none fixed top-0 left-12 z-50 hidden h-12 items-center px-sm font-medium select-none md:flex"
>
<h1 class="typography-paragraph-regular font-medium">{title}</h1>
</div>
<aside
class={[
"fixed inset-y-0 left-12 z-40 hidden w-64 flex-col border-r border-border-default bg-background-default transition-transform duration-300 ease-in-out md:flex",
!sidebarOpen && "-translate-x-full"
]}
>
<div class="h-12 flex-none border-b border-border-default"></div>
<div
class="min-h-0 grow overflow-y-auto overscroll-contain px-sm py-md typography-paragraph-small text-foreground-subtle"
>
{@render nav()}
</div>
</aside>
<div
class={[
"mt-12 transition-[margin] duration-300 md:mt-0",
sidebarOpen ? "md:ml-76" : "md:ml-12"
]}
>
<div class="flex flex-col">
<header
class="sticky top-0 z-10 hidden h-12 border-b border-border-default bg-background-default md:flex"
>
<div
class="flex min-w-0 flex-1 items-center justify-end border-r border-border-default px-md"
>
<span
class="rounded-xs bg-background-inset px-1.5 py-0.5 typography-monospace-small text-foreground-subtle"
>
{total}
{plural(total, "component", "components")}
</span>
</div>
<div class="flex w-12 shrink-0 items-center justify-center">
{@render themeButton()}
</div>
</header>
<main class="flex grow flex-col md:pr-12">
<div class="mx-auto w-full grow md:border-r md:border-border-default">
{@render children()}
</div>
</main>
</div>
</div>
</div>