diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f4059e6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 99fcef7..bb7bf1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 0ccfa42..242aa60 100644 --- a/README.md +++ b/README.md @@ -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 \| 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 @@ -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 diff --git a/demo/index.html b/demo/index.html index 66529d3..60d25d4 100644 --- a/demo/index.html +++ b/demo/index.html @@ -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) { @@ -288,6 +330,41 @@

Style

--toc-rail-right: 2rem; --toc-rail-top: max(96px, 18vh); } +
+ CSS token reference +

Customization

+ +

Runtime / read-only

+ +

Placement

diff --git a/package.json b/package.json index cdd3454..98bc74c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/style.css b/style.css index 7bc8ee4..8d29f71 100644 --- a/style.css +++ b/style.css @@ -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; diff --git a/test/browser-smoke.spec.ts b/test/browser-smoke.spec.ts index 0032d10..59448f0 100644 --- a/test/browser-smoke.spec.ts +++ b/test/browser-smoke.spec.ts @@ -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"); @@ -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("#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(".progress-fixture-rail"); + const fill = rail?.querySelector(".toc-rail__progress-fill"); + const track = rail?.querySelector(".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(); diff --git a/test/consumer-smoke.mjs b/test/consumer-smoke.mjs new file mode 100644 index 0000000..4a2c743 --- /dev/null +++ b/test/consumer-smoke.mjs @@ -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 }); +} diff --git a/test/consumer-types.ts b/test/consumer-types.ts new file mode 100644 index 0000000..290b8ee --- /dev/null +++ b/test/consumer-types.ts @@ -0,0 +1,38 @@ +// Checks local dist declarations directly; consumer-smoke checks the packed package. +import { + mountReadingRail, + mountTocRail, + type TocRailHeading, + type TocRailInstance, + type TocRailOptions +} from "../dist/index.js"; + +const options: TocRailOptions = { + content: "article", + headings: false, + activeOffset: 40, + ariaLabel: "Article progress", + container: document.body, + edge: { + afterFadeDistance: 120, + beforeOffset: 80, + hideAfter: true, + hideBefore: false + }, + environment: { window }, + getHeadingText: (heading) => heading.textContent ?? "", + idPrefix: "docs", + minWidth: 1024, + scrollingClassDuration: 120, + title: false, + topOffset: 56 +}; + +const rail: TocRailInstance = mountTocRail(options); +const aliasRail: TocRailInstance = mountReadingRail(options); +const alias: typeof mountTocRail = mountReadingRail; +const firstHeading: TocRailHeading | undefined = rail.headings[0]; + +alias({ content: document.body }); +aliasRail.unmount(); +firstHeading?.element.getBoundingClientRect(); diff --git a/test/css-token-docs.test.mjs b/test/css-token-docs.test.mjs new file mode 100644 index 0000000..2307237 --- /dev/null +++ b/test/css-token-docs.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + +test("README documents the current public option rows", async () => { + const readme = await readFile(join(packageRoot, "README.md"), "utf8"); + const optionsSection = readme.match(/## Options[\s\S]*?## Styling/)?.[0] ?? ""; + const documentedOptions = Array.from( + optionsSection.matchAll(/^\| `([^`]+)` \|/gm), + ([, option]) => option + ); + + assert.deepEqual(documentedOptions, [ + "content", + "headings", + "container", + "title", + "ariaLabel", + "minWidth", + "topOffset", + "activeOffset", + "edge.hideBefore", + "edge.hideAfter", + "edge.beforeOffset", + "edge.afterFadeDistance", + "classes", + "getHeadingText", + "idPrefix", + "scrollingClassDuration", + "environment.window" + ]); +}); + +test("demo documents CSS customization tokens separately from runtime tokens", async () => { + const [styleCss, demoHtml] = await Promise.all([ + readFile(join(packageRoot, "style.css"), "utf8"), + readFile(join(packageRoot, "demo/index.html"), "utf8") + ]); + + const declaredTokens = [ + ...new Set( + Array.from( + styleCss.matchAll(/^\s*(--toc-rail-[\w-]+)\s*:/gm), + ([, token]) => token + ) + ) + ]; + const publicCustomizationTokens = [ + "--toc-rail-accent", + "--toc-rail-faint", + "--toc-rail-font-family", + "--toc-rail-left", + "--toc-rail-line", + "--toc-rail-link-indent", + "--toc-rail-link-line-height", + "--toc-rail-link-nested-indent", + "--toc-rail-link-size", + "--toc-rail-link-weight", + "--toc-rail-list-bottom-gap", + "--toc-rail-list-end-space", + "--toc-rail-muted", + "--toc-rail-nav-height", + "--toc-rail-panel-bottom-gap", + "--toc-rail-right", + "--toc-rail-text", + "--toc-rail-title", + "--toc-rail-title-size", + "--toc-rail-top", + "--toc-rail-width", + "--toc-rail-z-index" + ].sort(); + const runtimeTokens = [ + "--toc-rail-edge-opacity", + "--toc-rail-edge-offset", + "--toc-rail-progress", + "--toc-rail-visibility-delay" + ].sort(); + const documentedTokenAllowlist = [...publicCustomizationTokens, ...runtimeTokens].sort(); + + const customizationReference = + demoHtml.match(/