| 1 | <script lang="ts"> |
| 2 | import { page } from '$app/stores'; |
| 3 | import { base } from '$app/paths'; |
| 4 | import type { NavSection } from '$lib/nav'; |
| 5 | |
| 6 | let { sections }: { sections: NavSection[] } = $props(); |
| 7 | |
| 8 | const currentPath = $derived($page.url.pathname); |
| 9 | |
| 10 | // Track user-toggled state per section. null means "not manually toggled" |
| 11 | let userToggles: Record<number, boolean | null> = $state({}); |
| 12 | |
| 13 | function isItemActive(href: string): boolean { |
| 14 | return ( |
| 15 | currentPath === `${base}${href}/` || |
| 16 | currentPath === `${base}${href}` || |
| 17 | currentPath.startsWith(`${base}${href}/`) |
| 18 | ); |
| 19 | } |
| 20 | |
| 21 | function sectionHasActiveChild(sectionIndex: number): boolean { |
| 22 | return sections[sectionIndex].items.some((item) => isItemActive(item.href)); |
| 23 | } |
| 24 | |
| 25 | function isSectionOpen(index: number): boolean { |
| 26 | const userToggle = userToggles[index]; |
| 27 | if (userToggle !== null && userToggle !== undefined) { |
| 28 | return userToggle; |
| 29 | } |
| 30 | // Auto-open if section has active child or defaultOpen |
| 31 | return sectionHasActiveChild(index) || (sections[index].defaultOpen ?? false); |
| 32 | } |
| 33 | |
| 34 | function toggleSection(index: number) { |
| 35 | const currentlyOpen = isSectionOpen(index); |
| 36 | userToggles[index] = !currentlyOpen; |
| 37 | } |
| 38 | |
| 39 | // When the route changes to a page within a section, force that section open |
| 40 | $effect(() => { |
| 41 | // Access currentPath to create the dependency |
| 42 | void currentPath; |
| 43 | for (let i = 0; i < sections.length; i++) { |
| 44 | if (sectionHasActiveChild(i)) { |
| 45 | userToggles[i] = true; |
| 46 | } |
| 47 | } |
| 48 | }); |
| 49 | </script> |
| 50 | |
| 51 | <nav class="space-y-1" aria-label="Documentation"> |
| 52 | {#each sections as section, index (section.title)} |
| 53 | {@const isOpen = isSectionOpen(index)} |
| 54 | <div> |
| 55 | <button |
| 56 | type="button" |
| 57 | class="flex w-full cursor-pointer items-center justify-between rounded-md px-1 py-2 font-display text-xs font-semibold tracking-wider text-text-muted uppercase transition-colors hover:text-foreground" |
| 58 | aria-expanded={isOpen} |
| 59 | onclick={() => toggleSection(index)} |
| 60 | > |
| 61 | <span class="flex items-center gap-2"> |
| 62 | {section.title} |
| 63 | {#if section.badge} |
| 64 | <span |
| 65 | class="rounded-full border border-amber-400/25 bg-amber-400/15 px-1.5 py-0.5 text-[10px] font-medium tracking-normal normal-case text-amber-600 dark:text-amber-400" |
| 66 | > |
| 67 | {section.badge} |
| 68 | </span> |
| 69 | {/if} |
| 70 | </span> |
| 71 | <svg |
| 72 | class="h-3.5 w-3.5 shrink-0 transition-transform duration-200 {isOpen |
| 73 | ? 'rotate-90' |
| 74 | : 'rotate-0'}" |
| 75 | viewBox="0 0 16 16" |
| 76 | fill="currentColor" |
| 77 | aria-hidden="true" |
| 78 | > |
| 79 | <path |
| 80 | d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z" |
| 81 | /> |
| 82 | </svg> |
| 83 | </button> |
| 84 | <div |
| 85 | class="grid transition-[grid-template-rows] duration-200 ease-in-out {isOpen |
| 86 | ? 'grid-rows-[1fr]' |
| 87 | : 'grid-rows-[0fr]'}" |
| 88 | > |
| 89 | <ul class="overflow-hidden"> |
| 90 | {#each section.items as item (item.href)} |
| 91 | {@const isActive = isItemActive(item.href)} |
| 92 | <li> |
| 93 | <a |
| 94 | href="{base}{item.href}" |
| 95 | class="block rounded-lg px-3 py-2 text-sm transition-colors {isActive |
| 96 | ? 'bg-primary/10 font-medium text-primary dark:bg-primary-light/15 dark:text-primary-light' |
| 97 | : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/6'}" |
| 98 | aria-current={isActive ? 'page' : undefined} |
| 99 | > |
| 100 | {item.title} |
| 101 | </a> |
| 102 | </li> |
| 103 | {/each} |
| 104 | </ul> |
| 105 | </div> |
| 106 | </div> |
| 107 | {/each} |
| 108 | </nav> |