diff --git a/src/layouts/DocsLayout.astro b/src/layouts/DocsLayout.astro index 8a86815..9bf4403 100644 --- a/src/layouts/DocsLayout.astro +++ b/src/layouts/DocsLayout.astro @@ -44,3 +44,7 @@ if (sections.length) groups.push({ label: 'On this page', links: sections.map((h + + diff --git a/src/scripts/docs-toc.ts b/src/scripts/docs-toc.ts new file mode 100644 index 0000000..b27f4e7 --- /dev/null +++ b/src/scripts/docs-toc.ts @@ -0,0 +1,45 @@ +export {}; + +const toc = document.querySelector('.docs-layout .page-index'); +const sections = Array.from(toc?.querySelectorAll('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('[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(); +} diff --git a/tests/docs-toc.test.ts b/tests/docs-toc.test.ts new file mode 100644 index 0000000..105ee44 --- /dev/null +++ b/tests/docs-toc.test.ts @@ -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 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(); + const attributes = new Map([['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); +});