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
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.getAttribute('href') ?? '';
}

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

window.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate);
update();
}
117 changes: 117 additions & 0 deletions tests/docs-toc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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'], headingIds = ids) {
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>([['href', `#${id}`]]);
return {
hash: new URL(`#${id}`, 'https://opentubex.org').hash,
getAttribute: (name: string) => attributes.get(name) ?? null,
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 = headingIds.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('non-ASCII fragments keep the picker on the matching rendered option value', () => {
const { links, select } = setup(1500, ['first', 'café', 'last']);
expect(links[1].hash).toBe('#caf%C3%A9');
expect(links[1].active).toBe(true);
expect(select.value).toBe('#café');
});

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