Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions src/layouts/DocsLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,7 @@ if (sections.length) groups.push({ label: 'On this page', links: sections.map((h
</div>
</main>
</SignalLayout>

<script>
import '../scripts/docs-toc';
</script>
45 changes: 45 additions & 0 deletions src/scripts/docs-toc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export {};

const toc = document.querySelector<HTMLElement>('.docs-layout .page-index');
const sections = Array.from(toc?.querySelectorAll<HTMLAnchorElement>('a[href^="#"]') ?? [])
.map((link) => {
const heading = document.getElementById(decodeURIComponent(link.hash.slice(1)));
return heading ? { link, heading } : null;
})
.filter((section) => section !== null);

if (sections.length) {
const select = toc?.querySelector<HTMLSelectElement>('[data-jump-select]');
let active: HTMLAnchorElement | undefined;
let scheduled = false;

function update() {
scheduled = false;
const offset = parseFloat(getComputedStyle(sections[0].heading).scrollMarginTop) || 0;
let current = sections[0];
for (const section of sections) {
if (section.heading.getBoundingClientRect().top <= offset + 1) current = section;
}
if (window.scrollY > 0 && window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 1) {
current = sections[sections.length - 1];
}
if (active === current.link) return;
active = current.link;
for (const { link } of sections) {
link.classList.toggle('is-active', link === active);
if (link === active) link.setAttribute('aria-current', 'location');
else link.removeAttribute('aria-current');
}
if (select) select.value = active.hash;
Comment thread
D3SOX marked this conversation as resolved.
Outdated
}

function scheduleUpdate() {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(update);
}

window.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate);
update();
}
109 changes: 109 additions & 0 deletions tests/docs-toc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';

const script = new Bun.Transpiler({ loader: 'ts', target: 'browser' }).transformSync(
readFileSync('src/scripts/docs-toc.ts', 'utf8').replace('export {};', ''),
);

function setup(initialScroll = 0, ids = ['first', 'second', 'last']) {
const listeners = new Map<string, () => void>();
const frames: (() => void)[] = [];
const window = {
scrollY: initialScroll,
innerHeight: 800,
addEventListener: (event: string, listener: () => void) => { listeners.set(event, listener); },
};
const links = ids.map((id) => {
const classes = new Set<string>();
const attributes = new Map<string, string>();
return {
hash: `#${id}`,
classList: { toggle: (name: string, on: boolean) => { on ? classes.add(name) : classes.delete(name); } },
setAttribute: (name: string, value: string) => { attributes.set(name, value); },
removeAttribute: (name: string) => { attributes.delete(name); },
get active() { return classes.has('is-active'); },
get current() { return attributes.get('aria-current'); },
};
});
const guide = { active: true, current: 'page' };
const select = { value: '/docs/installing/' };
const headings = ['first', 'second', 'last'].map((id, index) => ({
id,
getBoundingClientRect: () => ({ top: [300, 1300, 2800][index] - window.scrollY }),
}));
const toc = {
querySelectorAll: (selector: string) => selector === 'a[href^="#"]' ? links : [guide, ...links],
querySelector: () => select,
};
const document = {
documentElement: { scrollHeight: 3200 },
querySelector: () => toc,
getElementById: (id: string) => headings.find((heading) => heading.id === id) ?? null,
};
const style = { scrollMarginTop: '92px' };
runInNewContext(script, {
document,
window,
getComputedStyle: () => style,
requestAnimationFrame: (callback: () => void) => { frames.push(callback); },
});

function flush() { frames.splice(0).forEach((callback) => callback()); }
function scroll(top: number) {
window.scrollY = top;
listeners.get('scroll')?.();
flush();
}
flush();
return { links, guide, select, style, listeners, frames, flush, scroll };
}

test('the current section is highlighted on load without clearing the current guide', () => {
const { links, guide, select } = setup();
expect(links.map((link) => link.active)).toEqual([true, false, false]);
expect(links[0].current).toBe('location');
expect(guide).toEqual({ active: true, current: 'page' });
expect(select.value).toBe('#first');
});

test('scrolling updates the section and keeps it active between headings', () => {
const { links, select, scroll } = setup();
scroll(1250);
expect(links.map((link) => link.active)).toEqual([false, true, false]);
expect(links[0].current).toBeUndefined();
expect(links[1].current).toBe('location');
expect(select.value).toBe('#second');
scroll(2000);
expect(links[1].active).toBe(true);
scroll(500);
expect(links.map((link) => link.active)).toEqual([true, false, false]);
});

test('a restored scroll position highlights the section on initialization', () => {
expect(setup(1500).links[1].active).toBe(true);
});

test('the last section becomes active at the bottom even if its heading cannot reach the top', () => {
const { links, scroll } = setup();
scroll(2400);
expect(links.map((link) => link.active)).toEqual([false, false, true]);
});

test('resizing reevaluates the heading offset and scroll events share one animation frame', () => {
const { links, style, listeners, frames, flush } = setup(1150);
expect(links[0].active).toBe(true);
style.scrollMarginTop = '160px';
listeners.get('resize')?.();
listeners.get('scroll')?.();
listeners.get('scroll')?.();
expect(frames).toHaveLength(1);
flush();
expect(links[1].active).toBe(true);
});

test('pages with no section targets keep their guide selection and add no listeners', () => {
const { select, listeners } = setup(0, ['missing']);
expect(select.value).toBe('/docs/installing/');
expect(listeners.size).toBe(0);
});
Loading