Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Add sidebar state persistence #1121

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gentle-wolves-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/starlight': minor
---

Sidebar now persists its scroll position and expanded / contracted section state across page navigations. It also scrolls the active sidebar item into view if necessary.
87 changes: 87 additions & 0 deletions packages/starlight/components/Sidebar.astro
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,90 @@ const { sidebar } = Astro.props;
<div class="md:sl-hidden">
<MobileMenuFooter {...Astro.props} />
</div>

{
/* Persist sidebar state across page navigations,
and scroll the active item into view if necessary.
This is intentionally inlined to avoid FOUSC
(Flash Of Un-Scrolled Content). */
}
<script is:inline>
(() => {
const sidebarContent = document.getElementById('starlight__sidebar')?.firstElementChild;

if (sidebarContent) {
// consider simpler approach when :has proliferates:
// sidebarContent.querySelectorAll('details:has(a[aria-current="page"])')
function findAllDetailsAncestors(activeLink, boundaryElement) {
const detailsElements = new Set();
let parent = activeLink.parentElement;
while (parent && parent !== boundaryElement) {
if (parent.tagName === 'DETAILS') {
detailsElements.add(parent);
}
parent = parent.parentElement;
}
return [...detailsElements];
}

// open ancestor details of the active item
const activeLink = sidebarContent.querySelector('a[aria-current="page"]');
const activeLinkDetailAncestors = findAllDetailsAncestors(activeLink, sidebarContent);

activeLinkDetailAncestors.forEach((detail) => {
detail.setAttribute('open', '');
});

const detailElements = [...sidebarContent.querySelectorAll('details')];

// Restore state from previous navigation
// (but keep the active item's ancestors open regardless)
const key = 'sl-sidebar-state';
const savedStateJson = sessionStorage.getItem(key);

if (savedStateJson) {
let savedState;

try {
savedState = JSON.parse(savedStateJson);
} catch (e) {
console.error('Error parsing saved position:', e);
}

if (savedState) {
savedState.details.forEach((isOpen, i) => {
if (isOpen) {
detailElements[i]?.setAttribute('open', '');
} else if (!activeLinkDetailAncestors.includes(detailElements[i])) {
detailElements[i]?.removeAttribute('open');
}
});

sidebarContent.scrollTop = savedState.scrollTop;
}
}

// Scroll the active list item into view if necessary
const activeLi = sidebarContent.querySelector('a[aria-current="page"]')?.parentElement;
if (activeLi) {
// intersection observer might be more performant?
const sidebarRect = sidebarContent.getBoundingClientRect();
const liRect = activeLi.getBoundingClientRect();
if (liRect.top < sidebarRect.top || liRect.bottom > sidebarRect.bottom) {
activeLi.scrollIntoView({ behavior: 'instant', block: 'nearest' });
}
}

// Save user state before navigating
window.addEventListener('beforeunload', () => {
sessionStorage.setItem(
key,
JSON.stringify({
details: detailElements.map((detail) => detail.hasAttribute('open')),
scrollTop: sidebarContent.scrollTop,
})
);
});
}
})();
</script>
Loading