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 @@