feat(site): restore docs sidebar state on navigation (#160)

This commit is contained in:
Darius Cepulis
2025-11-04 10:40:30 -06:00
committed by GitHub
parent f28557359c
commit e644928df1
4 changed files with 107 additions and 3 deletions
@@ -4,7 +4,6 @@ import type { SupportedFramework, SupportedStyle } from '@/types/docs';
import { PreferenceUpdater } from '@/components/docs/PreferenceUpdater';
import SidebarItem from '@/components/docs/SidebarItem.astro';
import { filterSidebar } from '@/utils/docs/sidebar';
import FilmGrain from '../FilmGrain';
import clsx from 'clsx';
type Props<F extends SupportedFramework = SupportedFramework> = {
@@ -0,0 +1,90 @@
---
/**
* Sidebar state persistence using sessionStorage
* Progressive enhancement - only runs in browsers with view transitions
*/
interface Props {
docsSidebarId: string;
}
const { docsSidebarId } = Astro.props;
---
<script is:inline define:vars={{ docsSidebarId }}>
const STORAGE_KEY = 'vjs-sidebar-state';
window.addEventListener('pagereveal', () => {
const aside = document.getElementById(docsSidebarId);
if (!aside) return;
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const state = JSON.parse(stored);
if (state.sidebarScroll !== undefined) {
aside.scrollTop = state.sidebarScroll;
}
if (state.detailsState) {
document.querySelectorAll(`#${docsSidebarId} details[id]`).forEach((details) => {
const savedState = state.detailsState[details.id];
if (savedState !== undefined) {
details.open = savedState;
}
});
}
} catch (e) {
console.error('[Sidebar] Failed to restore state', e);
}
}
// Auto-expand sections containing active link (override saved state)
const activeLink = aside.querySelector('a[aria-current="page"]');
if (activeLink) {
let parent = activeLink.closest('details');
while (parent && aside.contains(parent)) {
parent.open = true;
parent = parent.parentElement?.closest('details') ?? null;
}
}
});
window.addEventListener('pageswap', () => {
const aside = document.getElementById(docsSidebarId);
if (!aside) return;
const detailsState = {};
document.querySelectorAll(`#${docsSidebarId} details[id]`).forEach((details) => {
detailsState[details.id] = details.open;
});
const state = {
sidebarScroll: aside.scrollTop,
detailsState,
};
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {
console.error('[Sidebar] Failed to save state', e);
}
});
</script>
{
/*
We need to enable view transitions to get access to pageswap and pagereveal
However, something in our app is causing Safari to get all flickery during transitions,
so that's where view-transition-name: none comes in.
(btw, I think it's related to FilmGrain. Stuff doesn't seem to flicker when that's not present)
*/
}
<style is:global>
@view-transition {
navigation: auto;
}
html,
body {
view-transition-name: none;
}
</style>
+9 -1
View File
@@ -2,6 +2,7 @@
import type { Guide, Section, SupportedFramework, SupportedStyle } from '@/types/docs';
import { isSection } from '@/types/docs';
import { ChevronDown } from 'lucide-react';
import GithubSlugger from 'github-slugger';
type Props<F extends SupportedFramework = SupportedFramework> = {
item: Guide | Section;
@@ -13,6 +14,7 @@ type Props<F extends SupportedFramework = SupportedFramework> = {
const { item, framework, style, docTitles, depth = 0 } = Astro.props;
const currentPath = Astro.url.pathname;
const slugger = new GithubSlugger();
// Helper to check if this item or any of its children contains the active path
function containsActivePath(item: Guide | Section): boolean {
@@ -29,7 +31,12 @@ const isActive = containsActivePath(item);
{
isSection(item) ? (
<details open class="group my-4 first:mt-0" style={`padding-left: calc(var(--spacing) * ${depth * 4})`}>
<details
id={slugger.slug(item.sidebarLabel)}
open
class="group my-4 first:mt-0"
style={depth ? `padding-left: calc(var(--spacing) * ${depth * 4})` : ``}
>
<summary
class:list={[
'py-2 flex items-center gap-4 ',
@@ -50,6 +57,7 @@ const isActive = containsActivePath(item);
</details>
) : (
<a
aria-current={isActive ? 'page' : undefined}
class:list={[
'py-2 flex items-center gap-4 ',
'intent:text-dark-100 dark:intent:text-light-100 cursor-pointer',
+8 -1
View File
@@ -12,6 +12,7 @@ import { Selectors } from '@/components/docs/Selectors';
import { getDocTitle } from '@/utils/docs/title';
import FooterEasterEgg from '@/components/FooterEasterEgg.astro';
import FilmGrain from '@/components/FilmGrain';
import DocsSidebarRestoration from '@/components/docs/DocsSidebarRestoration.astro';
type Props<F extends SupportedFramework = SupportedFramework> = {
doc: CollectionEntry<'docs'>;
@@ -25,10 +26,15 @@ const { doc, framework, style, slug } = Astro.props;
// Fetch all docs to get their framework-specific titles
const allDocs = await getCollection('docs');
const docTitles = new Map(allDocs.map((d) => [d.id, getDocTitle(d, framework)]));
const docsSidebarId = 'docs-sidebar';
---
<Base title={[getDocTitle(doc, framework), 'Docs']} description={doc.data.description}>
<slot name="head" slot="head" />
<Fragment slot="head">
<DocsSidebarRestoration docsSidebarId={docsSidebarId} />
<slot name="head" slot="head" />
</Fragment>
<div
class="grid grid-cols-1 lg:grid-cols-(--lg-grid-cols) min-h-screen"
style="--lg-grid-cols: calc(var(--spacing) * 75) 1fr;grid-template-rows:auto minmax(0,1fr);"
@@ -37,6 +43,7 @@ const docTitles = new Map(allDocs.map((d) => [d.id, getDocTitle(d, framework)]))
<DocsSidebar slot="mobile-nav" framework={framework} style={style} docTitles={docTitles} />
</NavBar>
<aside
id={docsSidebarId}
class="hidden lg:flex flex-col lg:border-r border-light-40 dark:border-dark-80 z-10 sticky overflow-y-scroll"
style="top: var(--nav-h); max-height: calc(100vh - var(--nav-h));"
>