Skip to content

Commit 9a80646

Browse files
committed
release: 0.1.4
1 parent 7489ac8 commit 9a80646

12 files changed

Lines changed: 344 additions & 28 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 0.1.4
4+
5+
- Added `progressMode: "content"` for article-height reading progress instead of outline-item progress.
6+
- Added `activeBoundary: "viewport-end"` so active heading and progress can follow the lower viewport edge.
7+
- Added `edge.afterBoundary` and `edge.afterOffset` for smoother after-content fade timing.
8+
- Moved the live demo link to the top of the README.
9+
310
## 0.1.3
411

512
- Fixed rail sizing at shorter viewport heights so the panel and list stay inside the viewport.

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
Sticky reading rail for long articles — TOC links, scroll progress, and active heading state. Vanilla TypeScript, ESM-only, zero dependencies.
44

5+
[Live demo](https://hanityx.github.io/toc-rail/) · [npm](https://www.npmjs.com/package/toc-rail)
6+
57
![toc-rail demo](https://raw.githubusercontent.com/hanityx/toc-rail/main/demo/demo.png)
68

79
```sh
@@ -36,9 +38,13 @@ change after a re-render.
3638
| `ariaLabel` | `string` | title text or `"Table of contents"` | Accessible label on the nav element. |
3739
| `minWidth` | `number` | `1140` | Hide below this viewport width (px). |
3840
| `topOffset` | `number` | `52` | Fixed header height for scroll math. |
39-
| `activeOffset` | `number` | `32` | Extra offset for deciding the active heading. |
41+
| `activeBoundary` | `"viewport-start" \| "viewport-end"` | `"viewport-start"` | Boundary used for deciding the active heading. |
42+
| `activeOffset` | `number` | `32` | Extra offset for deciding the active heading. With `viewport-end`, this is distance above the bottom edge. |
43+
| `progressMode` | `"outline" \| "content"` | `"outline"` | `outline` follows the active TOC item. `content` fills by article reading progress. |
4044
| `edge.hideBefore` | `boolean` | `true` | Hide before the article enters the viewport. |
4145
| `edge.hideAfter` | `boolean` | `true` | Hide after the article leaves the viewport. |
46+
| `edge.afterBoundary` | `"viewport-start" \| "viewport-end"` | `"viewport-start"` | Boundary used for after-content fade. Use `viewport-end` to fade when the article end reaches the bottom of the viewport. |
47+
| `edge.afterOffset` | `number` | `0` | Extra distance after the after-boundary before fade starts. |
4248
| `edge.beforeOffset` | `number` | `120` | Before-content threshold (px). |
4349
| `edge.afterFadeDistance` | `number` | `160` | Fade distance (px) near the article end. |
4450
| `classes` | `object` || Extra class hooks: `root`, `link`, `activeItem`. |
@@ -49,8 +55,6 @@ change after a re-render.
4955

5056
Progress-only mode ignores navigation-only options like link classes and active item classes.
5157

52-
Live example: [demo](https://hanityx.github.io/toc-rail/)
53-
5458
## Styling
5559

5660
Override CSS tokens:

demo/index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1" />
66
<title>toc-rail demo</title>
77
<link rel="icon" href="data:," />
8-
<link rel="stylesheet" href="../style.css?v=demo-0.1.3" />
8+
<link rel="stylesheet" href="../style.css?v=demo-0.1.4" />
99
<style>
1010
:root {
1111
color-scheme: light;
@@ -474,8 +474,8 @@ <h2 id="final-check">Final check</h2>
474474
</main>
475475
<script type="module">
476476
const moduleUrl = location.hostname.endsWith("github.io")
477-
? "https://cdn.jsdelivr.net/npm/toc-rail@0.1.3/+esm"
478-
: "../dist/index.js?v=demo-0.1.3";
477+
? "https://cdn.jsdelivr.net/npm/toc-rail@0.1.4/+esm"
478+
: "../dist/index.js?v=demo-0.1.4";
479479
const { mountTocRail } = await import(moduleUrl);
480480

481481
mountTocRail({

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "toc-rail",
3-
"version": "0.1.3",
3+
"version": "0.1.4",
44
"description": "Sticky reading rail with table-of-contents, scroll progress, and active heading state. Vanilla, ESM, zero dependencies.",
55
"type": "module",
66
"license": "MIT",

src/geometry.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,11 @@ export function findActiveHeadingIndex(
7777
win: Window,
7878
options: TocRailOptions
7979
): number {
80+
const activeOffset = options.activeOffset ?? DEFAULT_ACTIVE_OFFSET;
8081
const activePoint =
81-
win.scrollY + (options.topOffset ?? DEFAULT_TOP_OFFSET) + (options.activeOffset ?? DEFAULT_ACTIVE_OFFSET);
82+
options.activeBoundary === "viewport-end"
83+
? win.scrollY + win.innerHeight - activeOffset
84+
: win.scrollY + (options.topOffset ?? DEFAULT_TOP_OFFSET) + activeOffset;
8285
let activeIndex = -1;
8386

8487
for (let index = 0; index < headings.length; index += 1) {
@@ -109,7 +112,10 @@ function calculateAfterFade(
109112
}
110113

111114
const fadeDistance = Math.max(options.edge?.afterFadeDistance ?? DEFAULT_AFTER_FADE_DISTANCE, 1);
112-
const afterDistance = topOffset - getRectBottom(metrics.contentRect);
115+
const afterBoundary =
116+
options.edge?.afterBoundary === "viewport-end" ? metrics.innerHeight : topOffset;
117+
const afterOffset = options.edge?.afterOffset ?? 0;
118+
const afterDistance = afterBoundary - getRectBottom(metrics.contentRect) - afterOffset;
113119
const opacity = 1 - clamp(afterDistance / fadeDistance, 0, 1);
114120
return {
115121
opacity,

src/mount.ts

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -134,19 +134,16 @@ export function mountTocRail(options: TocRailOptions): TocRailInstance {
134134
const visualState = computeVisualState(metrics, options);
135135
const activeIndex = findActiveHeadingIndex(headingData, win, options);
136136
syncActiveItemVisibility(view, itemData, activeIndex);
137-
const progress =
138-
itemData.length > 0
139-
? getOutlineProgress(
140-
view,
141-
itemData,
142-
headingData,
143-
activeIndex,
144-
metrics,
145-
win,
146-
options,
147-
visualState.progress
148-
)
149-
: visualState.progress;
137+
const progress = getProgress({
138+
activeIndex,
139+
headings: headingData,
140+
items: itemData,
141+
metrics,
142+
options,
143+
fallbackProgress: visualState.progress,
144+
view,
145+
win
146+
});
150147

151148
currentProgress = progress;
152149
currentActiveId = applyActiveItem(itemData, activeIndex, options);
@@ -189,6 +186,61 @@ function resolveElement(target: string | Element, doc: Document): Element | null
189186
return typeof target === "string" ? doc.querySelector(target) : target;
190187
}
191188

189+
function getViewportContentProgress(
190+
metrics: ReturnType<typeof measureRailMetrics>,
191+
options: TocRailOptions
192+
): number {
193+
const topOffset = options.topOffset ?? DEFAULT_TOP_OFFSET;
194+
const activeOffset = options.activeOffset ?? DEFAULT_ACTIVE_OFFSET;
195+
const contentTop = metrics.contentRect.top + metrics.scrollY;
196+
const contentHeight = Math.max(metrics.scrollHeight || metrics.contentRect.height, 1);
197+
const contentEnd = contentTop + contentHeight;
198+
const progressPoint =
199+
options.activeBoundary === "viewport-end"
200+
? metrics.scrollY + metrics.innerHeight - activeOffset
201+
: metrics.scrollY + topOffset;
202+
203+
if (contentEnd <= contentTop) return progressPoint >= contentTop ? 1 : 0;
204+
return clamp((progressPoint - contentTop) / (contentEnd - contentTop), 0, 1);
205+
}
206+
207+
function getProgress({
208+
activeIndex,
209+
headings,
210+
items,
211+
metrics,
212+
options,
213+
fallbackProgress,
214+
view,
215+
win
216+
}: {
217+
activeIndex: number;
218+
headings: readonly InternalTocRailHeading[];
219+
items: readonly TocRailItem[];
220+
metrics: ReturnType<typeof measureRailMetrics>;
221+
options: TocRailOptions;
222+
fallbackProgress: number;
223+
view: ReturnType<typeof createTocRailView>;
224+
win: Window;
225+
}): number {
226+
if (options.progressMode === "content") {
227+
return getViewportContentProgress(metrics, options);
228+
}
229+
230+
if (items.length === 0) return fallbackProgress;
231+
232+
return getOutlineProgress(
233+
view,
234+
items,
235+
headings,
236+
activeIndex,
237+
metrics,
238+
win,
239+
options,
240+
fallbackProgress
241+
);
242+
}
243+
192244
function syncActiveItemVisibility(
193245
view: ReturnType<typeof createTocRailView>,
194246
items: readonly TocRailItem[],
@@ -240,9 +292,11 @@ function getOutlineProgress(
240292
const currentCenter = getLinkCenter(items[activeIndex]!.link);
241293
let targetCenter = currentCenter;
242294
const activePoint =
243-
win.scrollY +
244-
(options.topOffset ?? DEFAULT_TOP_OFFSET) +
245-
(options.activeOffset ?? DEFAULT_ACTIVE_OFFSET);
295+
options.activeBoundary === "viewport-end"
296+
? win.scrollY + win.innerHeight - (options.activeOffset ?? DEFAULT_ACTIVE_OFFSET)
297+
: win.scrollY +
298+
(options.topOffset ?? DEFAULT_TOP_OFFSET) +
299+
(options.activeOffset ?? DEFAULT_ACTIVE_OFFSET);
246300

247301
const nextHeading = headings[activeIndex + 1];
248302
const nextItem = items[activeIndex + 1];

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,14 @@ export interface TocRailOptions {
2323
ariaLabel?: string;
2424
minWidth?: number;
2525
topOffset?: number;
26+
activeBoundary?: "viewport-start" | "viewport-end";
2627
activeOffset?: number;
28+
progressMode?: "outline" | "content";
2729
edge?: {
2830
hideBefore?: boolean;
2931
hideAfter?: boolean;
32+
afterBoundary?: "viewport-start" | "viewport-end";
33+
afterOffset?: number;
3034
beforeOffset?: number;
3135
afterFadeDistance?: number;
3236
};

test/browser-smoke.spec.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,119 @@ test("browser fixture covers progress-only, encoded fragments, and hidden state
384384
await expect(hiddenRail.locator(".toc-rail__link")).toHaveAttribute("tabindex", "-1");
385385
});
386386

387+
test("viewport-end content progress stays synced in a real browser layout", async ({ page }) => {
388+
await page.goto(`${baseUrl}/demo/index.html`);
389+
390+
await page.evaluate(async () => {
391+
document.body.innerHTML =
392+
'<main style="width:min(100%,1120px);margin:0 auto;padding:64px 24px 120vh"><article id="viewport-content" style="width:min(100%,680px)"><h1>Viewport content</h1></article></main>';
393+
const article = document.querySelector("#viewport-content");
394+
if (!article) throw new Error("Missing viewport content article.");
395+
396+
for (let index = 1; index <= 8; index += 1) {
397+
const heading = document.createElement("h2");
398+
heading.id = `viewport-section-${index}`;
399+
heading.textContent = `Viewport section ${index}`;
400+
heading.style.marginTop = index === 1 ? "96px" : "420px";
401+
heading.style.scrollMarginTop = "84px";
402+
403+
const paragraph = document.createElement("p");
404+
paragraph.textContent = "Body ".repeat(140);
405+
article.append(heading, paragraph);
406+
}
407+
408+
const { mountTocRail } = (await window.eval('import("/dist/index.js")')) as typeof import("../dist/index.js");
409+
mountTocRail({
410+
content: "#viewport-content",
411+
headings: "#viewport-content h2[id]",
412+
title: false,
413+
minWidth: 800,
414+
topOffset: 56,
415+
activeBoundary: "viewport-end",
416+
activeOffset: 120,
417+
progressMode: "content",
418+
edge: {
419+
hideBefore: false,
420+
afterBoundary: "viewport-end",
421+
afterOffset: 120,
422+
afterFadeDistance: 160
423+
}
424+
});
425+
426+
document.documentElement.style.setProperty("scroll-behavior", "auto", "important");
427+
document.body.style.setProperty("scroll-behavior", "auto", "important");
428+
});
429+
430+
const rail = page.locator("[data-toc-rail='true']");
431+
await expect(rail).toBeVisible();
432+
433+
const samples = await page.evaluate(async () => {
434+
const article = document.querySelector<HTMLElement>("#viewport-content");
435+
if (!article) throw new Error("Missing viewport content article.");
436+
const articleTop = article.getBoundingClientRect().top + window.scrollY;
437+
const articleEnd = articleTop + Math.max(article.scrollHeight, article.getBoundingClientRect().height);
438+
const results: Array<{
439+
expected: number;
440+
progress: number;
441+
activeHref: string | null;
442+
state: string | null;
443+
}> = [];
444+
445+
for (const sectionId of ["viewport-section-1", "viewport-section-4", "viewport-section-8"]) {
446+
const heading = document.getElementById(sectionId);
447+
if (!heading) throw new Error(`Missing heading ${sectionId}`);
448+
window.scrollTo(0, Math.max(0, heading.getBoundingClientRect().top + window.scrollY - window.innerHeight + 120));
449+
window.dispatchEvent(new Event("scroll"));
450+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
451+
452+
const rail = document.querySelector<HTMLElement>("[data-toc-rail='true']");
453+
const active = document.querySelector<HTMLAnchorElement>("[data-toc-rail-active='true'] a");
454+
const progress = Number(rail?.getAttribute("data-toc-rail-progress"));
455+
const progressPoint = window.scrollY + window.innerHeight - 120;
456+
const expected = Math.min(1, Math.max(0, Number(((progressPoint - articleTop) / (articleEnd - articleTop)).toFixed(4))));
457+
results.push({
458+
expected,
459+
progress,
460+
activeHref: active?.getAttribute("href") ?? null,
461+
state: rail?.getAttribute("data-toc-rail-state") ?? null
462+
});
463+
}
464+
465+
return results;
466+
});
467+
468+
for (const sample of samples) {
469+
expect(sample.state).toBe("visible");
470+
expect(sample.progress).toBeCloseTo(sample.expected, 3);
471+
}
472+
expect(samples.map((sample) => sample.activeHref)).toEqual([
473+
"#viewport-section-1",
474+
"#viewport-section-4",
475+
"#viewport-section-8"
476+
]);
477+
478+
const afterContent = await page.evaluate(async () => {
479+
const article = document.querySelector<HTMLElement>("#viewport-content");
480+
if (!article) throw new Error("Missing viewport content article.");
481+
const articleBottom = article.getBoundingClientRect().bottom + window.scrollY;
482+
window.scrollTo(0, articleBottom - window.innerHeight + 240);
483+
window.dispatchEvent(new Event("scroll"));
484+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
485+
486+
const rail = document.querySelector<HTMLElement>("[data-toc-rail='true']");
487+
return {
488+
progress: Number(rail?.getAttribute("data-toc-rail-progress")),
489+
state: rail?.getAttribute("data-toc-rail-state") ?? null,
490+
opacity: Number(rail?.style.getPropertyValue("--toc-rail-edge-opacity"))
491+
};
492+
});
493+
494+
expect(afterContent.progress).toBe(1);
495+
expect(afterContent.state).toBe("fading-after");
496+
expect(afterContent.opacity).toBeGreaterThan(0);
497+
expect(afterContent.opacity).toBeLessThan(1);
498+
});
499+
387500
test("long outlines keep the active item visible and synced", async ({ page }) => {
388501
await page.goto(`${baseUrl}/demo/index.html`);
389502

test/consumer-smoke.mjs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,16 @@ try {
7171
import { mountReadingRail, mountTocRail, type TocRailHeading, type TocRailInstance, type TocRailOptions } from "toc-rail";
7272
import "toc-rail/style.css";
7373
74-
const options: TocRailOptions = { content: document.body, headings: false };
74+
const options: TocRailOptions = {
75+
content: document.body,
76+
headings: false,
77+
activeBoundary: "viewport-end",
78+
progressMode: "content",
79+
edge: {
80+
afterBoundary: "viewport-end",
81+
afterOffset: 120
82+
}
83+
};
7584
const rail: TocRailInstance = mountTocRail(options);
7685
const alias: typeof mountTocRail = mountReadingRail;
7786
const headings: readonly TocRailHeading[] = rail.headings;

0 commit comments

Comments
 (0)