Skip to content
Merged
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
81 changes: 74 additions & 7 deletions docs/src/components/Sidebar.astro
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,86 @@ const base = import.meta.env.BASE_URL;
<div id="sidebar-overlay" class="fixed inset-0 z-40 bg-black/50 hidden lg:hidden"></div>

<script>
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
const SIDEBAR_SCROLL_KEY = 'squad-docs-sidebar-scroll';

function getSidebarElements() {
return {
sidebar: document.getElementById('sidebar'),
overlay: document.getElementById('sidebar-overlay'),
};
}

function closeSidebar() {
const { sidebar, overlay } = getSidebarElements();
sidebar?.classList.add('-translate-x-full');
overlay?.classList.add('hidden');
}

overlay?.addEventListener('click', closeSidebar);
let lastScrollTop = 0;
let rafPending = false;

function trackSidebarScroll() {
const { sidebar } = getSidebarElements();
if (!sidebar) return;

if (!rafPending) {
rafPending = true;
requestAnimationFrame(() => {
lastScrollTop = sidebar.scrollTop;
rafPending = false;
});
}
}

function flushSidebarScroll() {
sessionStorage.setItem(SIDEBAR_SCROLL_KEY, String(lastScrollTop));
}

function restoreSidebarScroll() {
const { sidebar } = getSidebarElements();
if (!sidebar) return false;

const savedScrollTop = sessionStorage.getItem(SIDEBAR_SCROLL_KEY);
if (savedScrollTop === null) return false;

const scrollTop = Number(savedScrollTop);
if (Number.isNaN(scrollTop)) return false;

// Scroll the active TOC link into view on page load
const activeLink = sidebar?.querySelector('.sidebar-link.active');
if (activeLink && sidebar) {
activeLink.scrollIntoView({ block: 'center', behavior: 'instant' });
sidebar.scrollTop = scrollTop;
lastScrollTop = scrollTop;
return true;
}

function initSidebar() {
const { sidebar, overlay } = getSidebarElements();
if (!sidebar) return;

if (!sidebar.dataset.scrollPersistenceBound) {
sidebar.addEventListener('scroll', trackSidebarScroll, { passive: true });
sidebar.dataset.scrollPersistenceBound = 'true';
Comment thread
sturlath marked this conversation as resolved.
Resolved
}

if (overlay && !overlay.dataset.closeHandlerBound) {
overlay.addEventListener('click', closeSidebar);
overlay.dataset.closeHandlerBound = 'true';
}

requestAnimationFrame(() => {
const restoredScroll = restoreSidebarScroll();
if (restoredScroll) return;

const activeLink = sidebar.querySelector('.sidebar-link.active');
activeLink?.scrollIntoView({ block: 'center', behavior: 'instant' });
});
}

if (!window.__squadDocsSidebarStateSetup) {
document.addEventListener('astro:before-swap', flushSidebarScroll);
document.addEventListener('astro:after-swap', initSidebar);
document.addEventListener('astro:page-load', initSidebar);
window.addEventListener('pagehide', flushSidebarScroll);
window.__squadDocsSidebarStateSetup = true;
}

initSidebar();
</script>
70 changes: 70 additions & 0 deletions docs/tests/sidebar.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Playwright e2e tests for docs sidebar behavior.
*/
import { test, expect } from '@playwright/test';

const START = '/squad/docs/get-started/five-minute-start/';
const TARGET = '/squad/docs/scenarios/ci-cd-integration/';

test.describe('Docs sidebar navigation', () => {
test('keeps the sidebar scroll position when opening another article', async ({ page }) => {
await page.goto(START);

await page.evaluate(() => sessionStorage.removeItem('squad-docs-sidebar-scroll'));

const sidebar = page.locator('#sidebar');
await expect(sidebar).toBeVisible();

await page.evaluate(() => {
const sidebar = document.getElementById('sidebar');
if (!sidebar) {
throw new Error('Sidebar not found');
}

sidebar.scrollTop = 900;
sidebar.dispatchEvent(new Event('scroll'));
window.scrollTo(0, 1000);
});

const initialSidebarScroll = await page.evaluate(() => {
const sidebar = document.getElementById('sidebar');
if (!sidebar) {
throw new Error('Sidebar not found');
}

return sidebar.scrollTop;
});

expect(initialSidebarScroll).toBeGreaterThan(0);

await page.evaluate((targetHref) => {
const link = document.querySelector(`#sidebar a[href="${targetHref}"]`)
?? document.querySelector('#sidebar a[href*="docs/scenarios/ci-cd-integration/"]');

if (!(link instanceof HTMLAnchorElement)) {
throw new Error('Target sidebar link not found');
}

link.click();
}, TARGET);

await page.waitForURL(new RegExp(TARGET.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));

await expect.poll(async () => page.evaluate(() => {
const sidebar = document.getElementById('sidebar');
if (!sidebar) throw new Error('Sidebar not found');
return sidebar.scrollTop;
}), { timeout: 5000 }).toBeGreaterThan(initialSidebarScroll - 10);

const restoredSidebarScroll = await page.evaluate(() => {
const sidebar = document.getElementById('sidebar');
if (!sidebar) throw new Error('Sidebar not found');
return sidebar.scrollTop;
});

const pageScroll = await page.evaluate(() => window.scrollY);

expect(pageScroll).toBeLessThan(20);
expect(Math.abs(restoredSidebarScroll - initialSidebarScroll)).toBeLessThan(10);
});
});
Loading