summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Swithinbank2024-08-16 11:34:23 +0200
committerGitHub2024-08-16 11:34:23 +0200
commit9368494210dbcd80ada5b410340814fe36c4eb6c (patch)
tree57b2d81c6f270bccc86414d7b806f88f86e7414e
parentec3b5794cac55a5755620fa5e205f0d54c9e343b (diff)
downloadIT.starlight-9368494210dbcd80ada5b410340814fe36c4eb6c.tar.gz
IT.starlight-9368494210dbcd80ada5b410340814fe36c4eb6c.tar.bz2
IT.starlight-9368494210dbcd80ada5b410340814fe36c4eb6c.zip
Sidebar state persistence (#2150)
-rw-r--r--.changeset/weak-insects-hope.md5
-rw-r--r--packages/starlight/components/Page.astro1
-rw-r--r--packages/starlight/components/Sidebar.astro36
-rw-r--r--packages/starlight/components/SidebarPersistState.ts70
-rw-r--r--packages/starlight/utils/navigation.ts22
5 files changed, 133 insertions, 1 deletions
diff --git a/.changeset/weak-insects-hope.md b/.changeset/weak-insects-hope.md
new file mode 100644
index 00000000..65b23a97
--- /dev/null
+++ b/.changeset/weak-insects-hope.md
@@ -0,0 +1,5 @@
+---
+'@astrojs/starlight': minor
+---
+
+Adds state persistence across page navigations to the main site sidebar
diff --git a/packages/starlight/components/Page.astro b/packages/starlight/components/Page.astro
index d040a5d6..0ab90d1b 100644
--- a/packages/starlight/components/Page.astro
+++ b/packages/starlight/components/Page.astro
@@ -80,6 +80,7 @@ const pagefindEnabled =
<PageFrame {...Astro.props}>
<Header slot="header" {...Astro.props} />
{Astro.props.hasSidebar && <Sidebar slot="sidebar" {...Astro.props} />}
+ <script src="./SidebarPersistState"></script>
<TwoColumnContent {...Astro.props}>
<PageSidebar slot="right-sidebar" {...Astro.props} />
<main
diff --git a/packages/starlight/components/Sidebar.astro b/packages/starlight/components/Sidebar.astro
index c591674f..c47d0315 100644
--- a/packages/starlight/components/Sidebar.astro
+++ b/packages/starlight/components/Sidebar.astro
@@ -2,12 +2,46 @@
import type { Props } from '../props';
import MobileMenuFooter from 'virtual:starlight/components/MobileMenuFooter';
+import { getSidebarHash } from '../utils/navigation';
import SidebarSublist from './SidebarSublist.astro';
const { sidebar } = Astro.props;
+const hash = getSidebarHash(sidebar);
---
-<SidebarSublist sublist={sidebar} />
+<sl-sidebar-state-persist data-hash={hash}>
+ <SidebarSublist sublist={sidebar} />
+</sl-sidebar-state-persist>
<div class="md:sl-hidden">
<MobileMenuFooter {...Astro.props} />
</div>
+
+{
+ /*
+ Inline script to restore sidebar state as soon as possible.
+ - On smaller viewports, restoring state is skipped as the sidebar is collapsed inside a menu.
+ - The state is parsed from session storage and restored.
+ - This is a progressive enhancement, so any errors are swallowed silently.
+ */
+}
+<script is:inline>
+ (() => {
+ try {
+ if (!matchMedia('(min-width: 50em)').matches) return;
+ const scroller = document.getElementById('starlight__sidebar');
+ /** @type {HTMLElement | null} */
+ const target = document.querySelector('sl-sidebar-state-persist');
+ const state = JSON.parse(sessionStorage.getItem('sl-sidebar-state') || '0');
+ if (!scroller || !target || !state || target.dataset.hash !== state.hash) return;
+ target
+ .querySelectorAll('details')
+ .forEach((el, idx) => typeof state.open[idx] === 'boolean' && (el.open = state.open[idx]));
+ scroller.scrollTop = state.scroll;
+ } catch {}
+ })();
+</script>
+<style>
+ sl-sidebar-state-persist {
+ display: contents;
+ }
+</style>
diff --git a/packages/starlight/components/SidebarPersistState.ts b/packages/starlight/components/SidebarPersistState.ts
new file mode 100644
index 00000000..4288fa41
--- /dev/null
+++ b/packages/starlight/components/SidebarPersistState.ts
@@ -0,0 +1,70 @@
+// Collect required elements from the DOM.
+const scroller = document.getElementById('starlight__sidebar');
+const target = scroller?.querySelector<HTMLElement>('sl-sidebar-state-persist');
+const details = [...(target?.querySelectorAll('details') || [])];
+
+/** Starlight uses this key to store sidebar state in `sessionStorage`. */
+const storageKey = 'sl-sidebar-state';
+
+/** The shape used to persist sidebar state across a user’s session. */
+interface SidebarState {
+ hash: string;
+ open: Array<boolean | null>;
+ scroll: number;
+}
+
+/**
+ * Get the current sidebar state.
+ *
+ * The `open` state is loaded from session storage, while `scroll` and `hash` are read from the current page.
+ */
+const getState = (): SidebarState => {
+ let open = [];
+ try {
+ const rawStoredState = sessionStorage.getItem(storageKey);
+ const storedState = JSON.parse(rawStoredState || '{}');
+ if (Array.isArray(storedState.open)) open = storedState.open;
+ } catch {}
+ return {
+ hash: target?.dataset.hash || '',
+ open,
+ scroll: scroller?.scrollTop || 0,
+ };
+};
+
+/** Store the passed sidebar state in session storage. */
+const storeState = (state: SidebarState): void => {
+ try {
+ sessionStorage.setItem(storageKey, JSON.stringify(state));
+ } catch {}
+};
+
+/** Updates sidebar state in session storage without modifying `open` state. */
+const updateState = (): void => storeState(getState());
+
+/** Updates sidebar state in session storage to include a new value for a specific `<details>` element. */
+const setToggleState = (open: boolean, detailsIndex: number): void => {
+ const state = getState();
+ state.open[detailsIndex] = open;
+ storeState(state);
+};
+
+// Store the current `open` state whenever a user interacts with one of the `<details>` groups.
+target?.addEventListener('click', (event) => {
+ if (!(event.target instanceof Element)) return;
+ // Query for the nearest `<summary>` and then its parent `<details>`.
+ // This excludes clicks outside of the `<summary>`, which don’t trigger toggles.
+ const toggledDetails = event.target.closest('summary')?.closest('details');
+ if (!toggledDetails) return;
+ const index = details.indexOf(toggledDetails);
+ if (index === -1) return;
+ setToggleState(!toggledDetails.open, index);
+});
+
+// Store sidebar state before navigating. These will also store it on tab blur etc.,
+// but avoid using the `beforeunload` event, which can cause issues with back/forward cache
+// on some browsers.
+addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'hidden') updateState();
+});
+addEventListener('pageHide', updateState);
diff --git a/packages/starlight/utils/navigation.ts b/packages/starlight/utils/navigation.ts
index 43369be0..d6a67a65 100644
--- a/packages/starlight/utils/navigation.ts
+++ b/packages/starlight/utils/navigation.ts
@@ -344,6 +344,28 @@ export function getSidebar(pathname: string, locale: string | undefined): Sideba
}
}
+/** Generates a deterministic string based on the content of the passed sidebar. */
+export function getSidebarHash(sidebar: SidebarEntry[]): string {
+ let hash = 0;
+ const sidebarIdentity = recursivelyBuildSidebarIdentity(sidebar);
+ for (let i = 0; i < sidebarIdentity.length; i++) {
+ const char = sidebarIdentity.charCodeAt(i);
+ hash = (hash << 5) - hash + char;
+ }
+ return (hash >>> 0).toString(36).padStart(7, '0');
+}
+
+/** Recurses through a sidebar tree to generate a string concatenating labels and link hrefs. */
+function recursivelyBuildSidebarIdentity(sidebar: SidebarEntry[]): string {
+ return sidebar
+ .flatMap((entry) =>
+ entry.type === 'group'
+ ? entry.label + recursivelyBuildSidebarIdentity(entry.entries)
+ : entry.label + entry.href
+ )
+ .join('');
+}
+
/** Turn the nested tree structure of a sidebar into a flat list of all the links. */
export function flattenSidebar(sidebar: SidebarEntry[]): Link[] {
return sidebar.flatMap((entry) =>