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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: CI

"on":
pull_request:
push:
branches:
- main

jobs:
release-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run release:check
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

- Hardened progress-only mode so the visual rail has a stable track height.
- Added package consumer, TypeScript surface, CSS token, demo version, SSR, and browser layout checks.
- Added a CI workflow that runs the release check on pushes and pull requests.
- Clarified SSR/hydration usage, option docs, and runtime/read-only CSS tokens.

## 0.1.1

- Added the GitHub Pages demo entry and public demo homepage metadata.
Expand Down
49 changes: 27 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,36 @@ rail.unmount(); // call before leaving the page

`mountReadingRail` is an alias for `mountTocRail`.

Use it on the client only.

In SSR or hydrated apps, give headings stable `id` values so hash links do not
change after a re-render.

## Options

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `content` | `string \| Element` | required | Article element or selector. |
| `headings` | `string \| Iterable<Element> \| false` | `h2[id], h3[id]` | Pass `false` for progress-only mode. |
| `container` | `Element` | `document.body` | Where the rail is appended. |
| `title` | `string \| false` | `"On this page"` | Rail heading; `false` hides it. |
| `ariaLabel` | `string` | title text or `"Table of contents"` | Accessible label on the nav element. |
| `minWidth` | `number` | `1140` | Hide below this viewport width (px). |
| `topOffset` | `number` | `52` | Fixed header height for scroll math. |
| `title` | `string \| false` | `"On this page"` | Rail heading; `false` hides it. |
| `activeOffset` | `number` | `32` | Extra offset for deciding the active heading. |
| `edge.hideBefore` | `boolean` | `true` | Hide before the article enters the viewport. |
| `edge.hideAfter` | `boolean` | `true` | Hide after the article leaves the viewport. |
| `edge.beforeOffset` | `number` | `120` | Before-content threshold (px). |
| `edge.afterFadeDistance` | `number` | `160` | Fade distance (px) near the article end. |
| `classes` | `object` | — | Extra class hooks: `root`, `link`, `activeItem`. |
| `getHeadingText` | `(heading) => string` | text content | Customize link text extraction. |
| `idPrefix` | `string` | `toc-rail-section` / `toc-rail` | Prefix for generated IDs: headings use `toc-rail-section`, title uses `toc-rail`. |
| `scrollingClassDuration` | `number` | `1400` | How long `is-scrolling` stays after scroll. |
| `environment.window` | `Window` | global window | Advanced testing/adapter escape hatch. |

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

Full option reference and live examples: [demo](https://hanityx.github.io/toc-rail/)
Live example: [demo](https://hanityx.github.io/toc-rail/)

## Styling

Expand All @@ -49,29 +64,19 @@ Override CSS tokens:
}
```

The package ships a `[data-theme='dark']` token block; `prefers-color-scheme` is intentionally not wired so the site controls when dark tokens apply.
The root also exposes `data-toc-rail-progress` and `--toc-rail-progress` for
debugging or custom UI around the rail.

Add `scroll-margin-top` to headings if you use a fixed header.
Runtime tokens such as `--toc-rail-progress`, `--toc-rail-edge-opacity`,
`--toc-rail-edge-offset`, and `--toc-rail-visibility-delay` are read-only state
hooks, not theme tokens.

## Framework usage
The demo lists the supported customization tokens; other implementation
variables may change before 1.0.

```js
// React
useEffect(() => {
const rail = mountTocRail({ content: "article" });
return () => rail.unmount();
}, []);

// Vue
onMounted(() => (rail = mountTocRail({ content: "article" })));
onUnmounted(() => rail?.unmount());

// Svelte
onMount(() => {
const rail = mountTocRail({ content: "article" });
return () => rail.unmount();
});
```
The package ships a `[data-theme='dark']` token block; `prefers-color-scheme` is intentionally not wired so the site controls when dark tokens apply.

Add `scroll-margin-top` to headings if you use a fixed header.

## License

Expand Down
79 changes: 78 additions & 1 deletion demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,54 @@
font-size: 0.86rem;
}

.token-reference {
margin-top: 1rem;
color: #555;
font-size: 0.9rem;
}

.token-reference summary {
cursor: pointer;
color: #222;
font-weight: 650;
}

.token-reference__label {
margin: 0.85rem 0 0.4rem;
color: #777;
font-size: 0.78rem;
font-weight: 650;
text-transform: uppercase;
}

.token-reference ul {
columns: 3 12rem;
column-gap: 1.5rem;
margin-top: 0;
padding-left: 1rem;
}

.token-reference li {
break-inside: avoid;
margin-bottom: 0.4rem;
}

.token-reference code {
font-size: 0.78rem;
white-space: nowrap;
}

@media (max-width: 720px) {
.token-reference ul {
columns: 1;
}
}

.toc-rail {
/* Layout only. The rail color stays on the package default tokens. */
--toc-rail-right: max(1.5rem, calc((100vw - 1120px) / 2));
--toc-rail-top: max(92px, 17vh);
--toc-rail-list-bottom-gap: 132px;
--toc-rail-base-opacity: 1;
}

@media (max-width: 1139px) {
Expand Down Expand Up @@ -288,6 +330,41 @@ <h2 id="style">Style</h2>
--toc-rail-right: 2rem;
--toc-rail-top: max(96px, 18vh);
}</code></pre>
<details class="token-reference">
<summary>CSS token reference</summary>
<p class="token-reference__label">Customization</p>
<ul data-token-reference="customization">
<li><code>--toc-rail-accent</code></li>
<li><code>--toc-rail-line</code></li>
<li><code>--toc-rail-muted</code></li>
<li><code>--toc-rail-faint</code></li>
<li><code>--toc-rail-text</code></li>
<li><code>--toc-rail-title</code></li>
<li><code>--toc-rail-width</code></li>
<li><code>--toc-rail-top</code></li>
<li><code>--toc-rail-right</code></li>
<li><code>--toc-rail-left</code></li>
<li><code>--toc-rail-z-index</code></li>
<li><code>--toc-rail-font-family</code></li>
<li><code>--toc-rail-title-size</code></li>
<li><code>--toc-rail-link-size</code></li>
<li><code>--toc-rail-link-weight</code></li>
<li><code>--toc-rail-link-line-height</code></li>
<li><code>--toc-rail-link-indent</code></li>
<li><code>--toc-rail-link-nested-indent</code></li>
<li><code>--toc-rail-nav-height</code></li>
<li><code>--toc-rail-panel-bottom-gap</code></li>
<li><code>--toc-rail-list-bottom-gap</code></li>
<li><code>--toc-rail-list-end-space</code></li>
</ul>
<p class="token-reference__label">Runtime / read-only</p>
<ul data-token-reference="runtime">
<li><code>--toc-rail-edge-opacity</code></li>
<li><code>--toc-rail-edge-offset</code></li>
<li><code>--toc-rail-visibility-delay</code></li>
<li><code>--toc-rail-progress</code></li>
</ul>
</details>

<h3 id="style-placement">Placement</h3>
<p>
Expand Down
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,14 @@
"build": "tsc -p tsconfig.json",
"test": "node --test test/*.test.mjs",
"test:browser": "playwright test",
"check": "npm run build && npm test && npm run test:browser",
"test:types": "tsc -p test/tsconfig.json",
"test:consumer": "npm run build && npm run test:consumer:packed",
"test:consumer:packed": "node test/consumer-smoke.mjs",
"check": "npm run build && npm test && npm run test:types && npm run test:consumer:packed && npm run test:browser",
"prepack": "npm run build && npm test",
"pack:dry-run": "npm pack --dry-run"
"prepublishOnly": "npm run release:check",
"pack:dry-run": "npm pack --dry-run",
"release:check": "npm run check && npm run pack:dry-run"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
Expand Down
4 changes: 4 additions & 0 deletions style.css
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@
overflow: hidden;
}

.toc-rail[data-toc-rail-mode="progress"] .toc-rail__wrap {
height: max(160px, calc(100vh - var(--toc-rail-nav-height) - var(--toc-rail-list-bottom-gap)));
}

.toc-rail__progress {
position: absolute;
top: 4px;
Expand Down
31 changes: 31 additions & 0 deletions test/browser-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,11 @@ test("browser fixture covers progress-only, encoded fragments, and hidden state
}) => {
await page.goto(`${baseUrl}/demo/index.html`);

await page.evaluate(() => {
document.documentElement.style.setProperty("scroll-behavior", "auto", "important");
document.body.style.setProperty("scroll-behavior", "auto", "important");
});

await page.evaluate(async () => {
const { mountTocRail } = (await window.eval('import("/dist/index.js")')) as typeof import("../dist/index.js");
const fixtureStyles = document.createElement("style");
Expand Down Expand Up @@ -264,6 +269,32 @@ test("browser fixture covers progress-only, encoded fragments, and hidden state
await expect(progressRail).toHaveAttribute("aria-hidden", "true");
await expect(progressRail.locator("nav")).toHaveCount(0);
await expect(progressRail.locator(".toc-rail__link")).toHaveCount(0);
const progressOnlyBox = await progressRail.locator(".toc-rail__progress").boundingBox();
expect(progressOnlyBox?.height ?? 0).toBeGreaterThan(80);

const progressOnlyState = await page.evaluate(async () => {
const article = document.querySelector<HTMLElement>("#progress-fixture");
if (!article) throw new Error("Missing progress fixture.");
window.scrollTo(0, article.getBoundingClientRect().top + window.scrollY + 520);
window.dispatchEvent(new Event("scroll"));
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));

const rail = document.querySelector<HTMLElement>(".progress-fixture-rail");
const fill = rail?.querySelector<HTMLElement>(".toc-rail__progress-fill");
const track = rail?.querySelector<HTMLElement>(".toc-rail__progress");
if (!rail || !fill || !track) throw new Error("Missing progress-only rail elements.");

return {
data: Number(rail.getAttribute("data-toc-rail-progress")),
root: Number(getComputedStyle(rail).getPropertyValue("--toc-rail-progress")),
fill: Number(getComputedStyle(fill).getPropertyValue("--toc-rail-progress")),
trackHeight: track.getBoundingClientRect().height
};
});
expect(progressOnlyState.trackHeight).toBeGreaterThan(80);
expect(progressOnlyState.data).toBeGreaterThan(0);
expect(progressOnlyState.root).toBe(progressOnlyState.data);
expect(progressOnlyState.fill).toBe(progressOnlyState.data);

const fragmentRail = page.locator(".fragment-fixture-rail");
await fragmentRail.locator(".toc-rail__link").click();
Expand Down
108 changes: 108 additions & 0 deletions test/consumer-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";

const execFile = promisify(execFileCallback);
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const workspace = await mkdtemp(join(tmpdir(), "toc-rail-consumer-"));
const packDir = join(workspace, "pack");
const consumerDir = join(workspace, "consumer");
const childEnv = { ...process.env };
delete childEnv.npm_config_dry_run;

try {
await mkdir(packDir, { recursive: true });
await mkdir(consumerDir, { recursive: true });
const sourcePackage = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));

const { stdout: packStdout } = await execFile(
"npm",
["pack", "--ignore-scripts", "--json", "--pack-destination", packDir],
{ cwd: packageRoot, env: childEnv }
);
const [packResult] = JSON.parse(packStdout);
assert.equal(packResult.name, "toc-rail");
assert.equal(packResult.version, sourcePackage.version);
assert.equal(packResult.files.some((file) => file.path.startsWith("demo/")), false);
assert.equal(packResult.files.some((file) => file.path.startsWith("src/")), false);
assert.equal(packResult.files.some((file) => file.path.startsWith("test/")), false);
assert.equal(packResult.files.some((file) => file.path === "index.html"), false);

const tarballPath = join(packDir, packResult.filename);
await writeFile(join(consumerDir, "package.json"), '{"private":true,"type":"module"}\n');
await execFile(
"npm",
["install", "--ignore-scripts", "--no-audit", "--no-fund", tarballPath],
{ cwd: consumerDir, env: childEnv }
);

const smokeScript = `
import { mountTocRail, mountReadingRail } from "toc-rail";
const cssUrl = import.meta.resolve("toc-rail/style.css");
if (typeof mountTocRail !== "function") throw new Error("mountTocRail missing");
if (mountReadingRail !== mountTocRail) throw new Error("mountReadingRail alias mismatch");
if (!cssUrl.endsWith("/node_modules/toc-rail/style.css")) throw new Error(cssUrl);
console.log(JSON.stringify({ ok: true, cssUrl }));
`;
const { stdout: smokeStdout } = await execFile("node", ["--input-type=module", "-e", smokeScript], {
cwd: consumerDir
});
const smoke = JSON.parse(smokeStdout);
assert.equal(smoke.ok, true);

const installedPackage = JSON.parse(
await readFile(join(consumerDir, "node_modules/toc-rail/package.json"), "utf8")
);
assert.equal(installedPackage.files.includes("dist"), true);
assert.equal(installedPackage.files.includes("style.css"), true);
assert.match(await readFile(join(consumerDir, "node_modules/toc-rail/style.css"), "utf8"), /\.toc-rail/);
assert.match(
await readFile(join(consumerDir, "node_modules/toc-rail/dist/index.d.ts"), "utf8"),
/TocRailOptions/
);

await writeFile(
join(consumerDir, "index.ts"),
`
import { mountReadingRail, mountTocRail, type TocRailHeading, type TocRailInstance, type TocRailOptions } from "toc-rail";
import "toc-rail/style.css";

const options: TocRailOptions = { content: document.body, headings: false };
const rail: TocRailInstance = mountTocRail(options);
const alias: typeof mountTocRail = mountReadingRail;
const headings: readonly TocRailHeading[] = rail.headings;

alias({ content: "article" });
rail.unmount();
headings[0]?.element.getBoundingClientRect();
`
);
await writeFile(
join(consumerDir, "tsconfig.json"),
JSON.stringify(
{
compilerOptions: {
lib: ["DOM", "ES2022"],
module: "ES2022",
moduleResolution: "Bundler",
noEmit: true,
strict: true,
target: "ES2022",
types: []
},
include: ["index.ts"]
},
null,
2
)
);
await execFile(join(packageRoot, "node_modules/.bin/tsc"), ["-p", consumerDir, "--pretty", "false"], {
cwd: consumerDir
});
} finally {
await rm(workspace, { recursive: true, force: true });
}
Loading
Loading