Skip to content

Commit

Permalink
fix erratic highlighting of active heading near page bottom + keep ac…
Browse files Browse the repository at this point in the history
…tive heading in ToC scrolled into view + some new CSS variables (closes #2)
  • Loading branch information
janosh committed Dec 31, 2021
1 parent 3b995cd commit 559b99d
Show file tree
Hide file tree
Showing 8 changed files with 647 additions and 54 deletions.
21 changes: 19 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<aside>
<button>open/close (only visible on mobile)</button>
<nav>
<h2>{title}</h2>
<ul>
<li>{heading1}</li>
<li>{heading2}</li>
...
</ul>
</nav>
</aside>
```

`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.

Expand Down
117 changes: 82 additions & 35 deletions src/lib/Toc.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
</script>

<svelte:window bind:innerWidth={windowWidth} />
<svelte:window
bind:scrollY
bind:innerWidth={windowWidth}
bind:innerHeight={windowHeight}
on:scroll={setActiveHeading}
/>

<aside
use:onClickOutside={() => (open = false)}
Expand All @@ -88,39 +126,45 @@
{#if title}
<h2>{title}</h2>
{/if}
{#each headings as { title, depth }, idx}
<li
tabindex={idx + 1}
style="margin-left: {depth}em; font-size: {2 - 0.2 * depth}ex"
class:active={activeHeading === nodes[idx]}
on:click={clickHandler(idx)}
>
{title}
</li>
{/each}
<ul>
{#each headings as { title, depth, node }, idx}
<li
tabindex={idx + 1}
style="margin-left: {depth}em; font-size: {2 - 0.2 * depth}ex"
class:active={activeHeading?.node === node}
on:click={clickHandler(node)}
>
{title}
</li>
{/each}
</ul>
</nav>
{/if}
</aside>

<style>
:where(aside) {
z-index: var(--toc-z-index, 1);
min-width: var(--toc-desktop-min-width, 14em);
width: var(--toc-width);
}
:where(nav) {
list-style: none;
max-height: var(--toc-max-height, 90vh);
overflow: auto;
overscroll-behavior: contain;
}
:where(nav > li) {
:where(nav > ul) {
list-style: none;
padding: 0;
}
:where(nav > ul > li) {
margin-top: 5pt;
cursor: pointer;
}
:where(nav > li:hover) {
:where(nav > ul > li:hover) {
color: var(--toc-hover-color, cornflowerblue);
}
:where(nav > li.active) {
:where(nav > ul > li.active) {
color: var(--toc-active-color, orange);
}
:where(button) {
Expand Down Expand Up @@ -164,8 +208,11 @@
}
:where(aside.toc.desktop > nav) {
position: sticky;
padding: 5pt 1ex 1ex 1.5ex;
padding: 12pt 14pt 0;
margin: 0 2ex 0 0;
top: var(--toc-desktop-sticky-top, 2em);
background-color: var(--toc-desktop-bg-color);
border-radius: 5pt;
}
:where(aside.toc.desktop > button) {
display: none;
Expand Down
8 changes: 4 additions & 4 deletions src/routes/index.svx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@

<main>

# ![Logo](https://raw.githubusercontent.com/janosh/svelte-toc/main/static/favicon.svg) &nbsp;Svelte-ToC
# ![Logo](https://raw.githubusercontent.com/janosh/svelte-toc/main/static/favicon.svg) &nbsp;Svelte-ToC

<Docs />

## Test Page Navigation
## Test Page Navigation

- [Page with ToC](/other-toc-page)
- [Page without ToC](/no-toc-page)
- [Long Page with ToC](/long-page)
- [Page without ToC](/no-toc-page)

</main>

Expand Down
Loading

0 comments on commit 559b99d

Please sign in to comment.