diff options
author | Chris Swithinbank | 2024-08-16 11:34:23 +0200 |
---|---|---|
committer | GitHub | 2024-08-16 11:34:23 +0200 |
commit | 9368494210dbcd80ada5b410340814fe36c4eb6c (patch) | |
tree | 57b2d81c6f270bccc86414d7b806f88f86e7414e | |
parent | ec3b5794cac55a5755620fa5e205f0d54c9e343b (diff) | |
download | IT.starlight-9368494210dbcd80ada5b410340814fe36c4eb6c.tar.gz IT.starlight-9368494210dbcd80ada5b410340814fe36c4eb6c.tar.bz2 IT.starlight-9368494210dbcd80ada5b410340814fe36c4eb6c.zip |
Sidebar state persistence (#2150)
-rw-r--r-- | .changeset/weak-insects-hope.md | 5 | ||||
-rw-r--r-- | packages/starlight/components/Page.astro | 1 | ||||
-rw-r--r-- | packages/starlight/components/Sidebar.astro | 36 | ||||
-rw-r--r-- | packages/starlight/components/SidebarPersistState.ts | 70 | ||||
-rw-r--r-- | packages/starlight/utils/navigation.ts | 22 |
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) => |