From cbe379195178d9c57c190640f82a0aabd1b31ccf Mon Sep 17 00:00:00 2001 From: Sam Laycock Date: Sun, 31 Dec 2023 18:15:50 +0000 Subject: [PATCH 1/2] fix: fix scroll highlight logic for TableOfContents component --- .../src/components/table-of-contents.tsx | 128 +++++++++++++----- .../src/stories/table-of-contents.stories.tsx | 22 +-- 2 files changed, 105 insertions(+), 45 deletions(-) diff --git a/packages/react/src/components/table-of-contents.tsx b/packages/react/src/components/table-of-contents.tsx index 1516dd7..25842dc 100644 --- a/packages/react/src/components/table-of-contents.tsx +++ b/packages/react/src/components/table-of-contents.tsx @@ -1,12 +1,13 @@ import { slugifyWithCounter } from "@sindresorhus/slugify"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "../utils"; +import { Prose } from "./prose"; interface TOCNode { id: string; children: TOCNode[]; - type: "h2" | "h3"; + type: `h${number}`; title: string; } @@ -14,7 +15,7 @@ function collectHeadings(nodes: TOCNode[], slugify = slugifyWithCounter()) { const sections: TOCNode[] = []; for (const node of nodes) { - const id = slugify(node.title); + const id = node.id || slugify(node.title); if (node.type === "h3" && sections[sections.length - 1]) { // biome-ignore lint/style/noNonNullAssertion: we know it exists @@ -27,11 +28,18 @@ function collectHeadings(nodes: TOCNode[], slugify = slugifyWithCounter()) { return sections; } +interface TableOfContentsInnerProps { + scrollContainer: HTMLElement | null; + scrollOffset?: number; + tableOfContents: TOCNode[]; +} + function TableOfContentsInner({ + scrollContainer, + scrollOffset = 64, // 4rem (16 * 4) tableOfContents, -}: { tableOfContents: TOCNode[] }) { +}: TableOfContentsInnerProps) { const [currentSection, setCurrentSection] = useState(tableOfContents[0]?.id); - const getHeadings = useCallback((tableOfContents: TOCNode[]) => { return tableOfContents .flatMap((node) => [node.id, ...node.children.map((child) => child.id)]) @@ -55,34 +63,36 @@ function TableOfContentsInner({ }, []); useEffect(() => { - if (tableOfContents.length === 0) { - return; - } + if (scrollContainer) { + if (tableOfContents.length === 0) { + return; + } - const headings = getHeadings(tableOfContents); + const headings = getHeadings(tableOfContents); - function onScroll() { - const top = window.scrollY; - let current = headings[0]?.id; + function onScroll() { + const top = scrollContainer?.scrollTop ?? 0; + let current = headings[0]?.id; - for (const heading of headings) { - if (top >= heading.top) { - current = heading.id; - } else { - break; + for (const heading of headings) { + if (top >= heading.top - scrollOffset) { + current = heading.id; + } else { + break; + } } - } - setCurrentSection(current); - } + setCurrentSection(current); + } - window.addEventListener("scroll", onScroll, { passive: true }); - onScroll(); + scrollContainer.addEventListener("scroll", onScroll, { passive: true }); + onScroll(); - return () => { - window.removeEventListener("scroll", onScroll); - }; - }, [getHeadings, tableOfContents]); + return () => { + scrollContainer.removeEventListener("scroll", onScroll); + }; + } + }, [scrollContainer, getHeadings, tableOfContents, scrollOffset]); function isActive(section: TOCNode) { if (section.id === currentSection) { @@ -97,8 +107,8 @@ function TableOfContentsInner({ } return ( - + ); } export interface TableOfContentsProps { - querySelector?: string; + className?: string; + contentQuerySelector?: string; + maxHeading?: number; + minHeading?: number; + scrollOffset?: number; + scrollQuerySelector?: string; } -function TableOfContents({ querySelector = "article" }: TableOfContentsProps) { +function TableOfContents({ + className, + contentQuerySelector = "article", + maxHeading = 3, + minHeading = 2, + scrollOffset, + scrollQuerySelector = contentQuerySelector, +}: TableOfContentsProps) { + const el = useRef(null); const [sections, setSections] = useState([]); useEffect(() => { if (typeof window !== "undefined") { - const contentNodeEl = document.querySelector(querySelector); - const nodeEls = contentNodeEl?.querySelectorAll("h2, h3"); + const contentNodeEl = document.querySelector(contentQuerySelector); + + el.current = scrollQuerySelector + ? document.querySelector(scrollQuerySelector) + : (contentNodeEl as HTMLElement); + + const nodeEls = contentNodeEl?.querySelectorAll( + new Array(maxHeading - minHeading + 1) + .fill(0) + .map((_, i) => `h${i + minHeading}`) + .join(", "), + ); const nodes: TOCNode[] = []; for (const el of nodeEls ?? []) { - const type = el.tagName.toLowerCase() as "h2" | "h3"; + const type = el.tagName.toLowerCase() as `h${number}`; const title = el.textContent ?? ""; - nodes.push({ type, title, id: "", children: [] }); + nodes.push({ type, title, id: el.id ?? "", children: [] }); } setSections(collectHeadings(nodes)); } - }, [querySelector]); + }, [contentQuerySelector, maxHeading, minHeading, scrollQuerySelector]); - return ; + return ( + + ); } TableOfContents.displayName = "TableOfContents"; diff --git a/www/storybook/src/stories/table-of-contents.stories.tsx b/www/storybook/src/stories/table-of-contents.stories.tsx index 15380b2..47ba230 100644 --- a/www/storybook/src/stories/table-of-contents.stories.tsx +++ b/www/storybook/src/stories/table-of-contents.stories.tsx @@ -23,32 +23,38 @@ type TableOfContentsStory = StoryObj; export const Default: TableOfContentsStory = { render: () => (
-
+
-

Heading 1

+

Heading 1

Some content underneath heading 1


-

Heading 2

+

Heading 2

Some content underneath heading 2

-

Heading 3

+

Heading 3

Some content underneath heading 3


-

Heading 4

+

Heading 4

Some content underneath heading 4

-

Heading 5

+

Heading 5

Some content underneath heading 5

-
- +
+
+ +
), From 064215ef0ac20c8bd77a985db5b045870e1607c1 Mon Sep 17 00:00:00 2001 From: Sam Laycock Date: Sun, 31 Dec 2023 18:16:44 +0000 Subject: [PATCH 2/2] chore: add changeset --- .changeset/weak-birds-wink.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/weak-birds-wink.md diff --git a/.changeset/weak-birds-wink.md b/.changeset/weak-birds-wink.md new file mode 100644 index 0000000..4107352 --- /dev/null +++ b/.changeset/weak-birds-wink.md @@ -0,0 +1,5 @@ +--- +"@cloudmix-dev/react": patch +--- + +Fix scroll highlighting logic for TableOfContents component