diff --git a/readme.md b/readme.md index 18da84b..37787e5 100644 --- a/readme.md +++ b/readme.md @@ -69,17 +69,34 @@ To control how far from the viewport top headings come to rest when scrolled int ## Styling +The HTML structure of this component is + +```html + +``` + `Toc.svelte` offers the following CSS variables listed here with their defaults that can be [passed in directly as props](https://github.com/sveltejs/rfcs/pull/13): - `var(--toc-z-index, 1)`: Controls `z-index` of the top-level ToC `aside` element on both mobile and desktop. -- `var(--toc-mobile-width, 12em)`: -- `var(--toc-desktop-min-width, 14em)`: +- `var(--toc-width)`: Width of the `aside` element. +- `var(--toc-mobile-width, 12em)`: Width of the ToC component's `aside.mobile > nav` element. - `var(--toc-max-height, 90vh)`: Height beyond which ToC will use scrolling instead of growing vertically. - `var(--toc-hover-color, cornflowerblue)`: Text color of hovered headings. - `var(--toc-active-color, orange)`: Text color of the currently active heading. The active heading is the one closest to current scroll position. - `var(--toc-mobile-btn-color, black)`: Color of the menu icon used as ToC opener button on mobile screen sizes. - `var(--toc-mobile-btn-bg-color, rgba(255, 255, 255, 0.2))`: Background color of the padding area around the menu icon button. - `var(--toc-mobile-bg-color, white)`: Background color of the `nav` element hovering in the lower-left screen corner when the ToC was opened on mobile screens. +- `var(--toc-desktop-bg-color)`: Background color of the `nav` element on desktop screens. - `var(--toc-desktop-sticky-top, 2em)`: How far below the screen's top edge the ToC starts being sticky. - `var(--toc-desktop-margin, 0)`: Margin of the outer-most `aside.toc` element. diff --git a/src/lib/Toc.svelte b/src/lib/Toc.svelte index a295533..7ac549c 100644 --- a/src/lib/Toc.svelte +++ b/src/lib/Toc.svelte @@ -10,67 +10,105 @@ export let getHeadingIds = (node: HTMLHeadingElement): string => node.id export let getHeadingLevels = (node: HTMLHeadingElement): number => Number(node.nodeName[1]) - export let activeHeading: HTMLHeadingElement | null = null + export let activeHeading: Heading | null = null export let open = false export let title = `Contents` export let openButtonLabel = `Open table of contents` export let breakpoint = 1000 - export let flashClickedHeadingsFor = 1000 type Heading = { title: string depth: number + node: HTMLHeadingElement + visible?: boolean } let windowWidth: number + let windowHeight: number + let scrollY: number let headings: Heading[] = [] - let nodes: HTMLHeadingElement[] = [] function handleRouteChange() { - nodes = [...document.querySelectorAll(headingSelector)] as HTMLHeadingElement[] + const nodes = [...document.querySelectorAll(headingSelector)] as HTMLHeadingElement[] + const depths = nodes.map(getHeadingLevels) const minDepth = Math.min(...depths) headings = nodes.map((node, idx) => ({ title: getHeadingTitles(node), depth: depths[idx] - minDepth, + node, })) + + // set first heading as active if null on page load + if (activeHeading === null) activeHeading = headings[0] } // (re-)compute list of HTML headings on mount and on route changes if (typeof window !== `undefined`) { page.subscribe(handleRouteChange) } + onMount(() => { + handleRouteChange() const observer = new IntersectionObserver( - // callback receives all observed nodes whose intersection changed, we only need the first - ([entry]) => { - activeHeading = entry.target as HTMLHeadingElement // assign intersecting node to activeHeading + (entries) => { + // callback receives only observed nodes whose intersection changed + for (const { target, isIntersecting } of entries) { + const hdn = headings.find(({ node }) => node === target) + if (hdn) hdn.visible = isIntersecting + } }, - { threshold: [1] } // only consider heading as intersecting once it fully entered viewport + { threshold: 1 } // only consider headings intersecting once they fully entered viewport ) - nodes.map((node) => observer.observe(node)) - handleRouteChange() - return () => nodes.map((node) => observer.unobserve(node)) + headings.map(({ node }) => observer.observe(node)) + return () => observer.disconnect() // clean up function to run when component unmounts }) - const clickHandler = (idx: number) => () => { - open = false - const heading = nodes[idx] - heading.scrollIntoView({ behavior: `smooth`, block: `start` }) - if (getHeadingIds) { - history.replaceState({}, ``, `#${getHeadingIds(heading)}`) + function setActiveHeading() { + const visibleHeadings = headings.filter((hd) => hd.visible) + + if (visibleHeadings.length > 0) { + // if any heading is visible, set the top one as active + activeHeading = visibleHeadings[0] + } else { + // if no headings are visible, set active heading to the last one we scrolled past + const nextHdnIdx = headings.findIndex((hd) => hd.node.offsetTop > scrollY) + activeHeading = headings[nextHdnIdx > 0 ? nextHdnIdx - 1 : 0] + } + const pageHeight = document.body.scrollHeight + const scrollProgress = (scrollY + windowHeight) / pageHeight + if (scrollProgress > 0.99) { + activeHeading = headings[headings.length - 1] } + + const activeTocLi = document.querySelector(`aside > nav > ul > li.active`) + activeTocLi?.scrollIntoViewIfNeeded?.() + } + + const clickHandler = (node: HTMLHeadingElement) => () => { + open = false + // Chrome doesn't yet support multiple simulatneous smooth scrolls (https://stackoverflow.com/q/49318497) + // with node.scrollIntoView({ behavior: `smooth` }) so we use window.scrollTo() instead + window.scrollTo({ top: node.offsetTop, behavior: `smooth` }) + + if (getHeadingIds) history.replaceState({}, ``, `#${getHeadingIds(node)}`) + if (flashClickedHeadingsFor) { - heading.classList.add(`toc-clicked`) - setTimeout(() => heading.classList.remove(`toc-clicked`), flashClickedHeadingsFor) + node.classList.add(`toc-clicked`) + setTimeout(() => node.classList.remove(`toc-clicked`), flashClickedHeadingsFor) } } - + @@ -105,7 +145,7 @@