@svelte-put/toc
action and utilities for building table of contents
Acknowledgement
This package relies on svelte action strategy and attempts to stay minimal. If you are looking for a declarative, component-oriented solution, check out Janosh 's svelte-toc .
Installation
terminal
npm install --save-dev @svelte-put/toc Migration Guide
In version 5, the items property of TocStore
and TocInitEventDetail is now a Map
instead of a plain object as in version 4, enabling better performance and preserving order of collected toc elements.
Migration: items from Object to Map
<section>
<h2>Table of Contents</h2>
- {#if Object.values($tocStore.items).length}
+ {#if $tocStore.items.size}
<ul>
- {#each Object.values($tocStore.items) as tocItem}
+ {#each $tocStore.items.values() as tocItem}
<li>
...
</li>
{/each}
</ul>
{/if}
</section>
Introduction
@svelte-put/toc operates at runtime and does the following:
1
search for matching elements (default:
:where(h1, h2, h3, h4, h5, h6)),2
generate
idattribute from elementtextContent,3
add anchor tag to element,
4
attach IntersectionObserver to each matching element to track its visibility on the screen,
5
expose necessary pieces for building table of contents.
It is recommended to use the complementary @svelte-put/preprocess-auto-slug for handling
2
and
3 at build time when possible.
toc will skip those operations if they are already handled by
preprocess-auto-slug.
Notice toc relies on IntersectionObserver and not
on:scroll for better performance and predictability. See this article for a performance comparison between on:scroll vs
IntersectionObserver.
The table of contents in this documentation site is generated by toc itself. Check
out the
source code here (search for tocStore).
Quick Start
Given the following svelte component, let's see how toc does its job.
quick start - input
<script>
import { toc, createTocStore, toclink } from '@svelte-put/toc';
const tocStore = createTocStore();
</script>
<main use:toc={{ store: tocStore, observe: true }}>
<h1>Page Heading</h1>
<section>
<h2>Table of Contents</h2>
{#if $tocStore.items.size}
<ul>
{#each $tocStore.items.values() as tocItem}
<li>
<!-- svelte-ignore a11y-missing-attribute -->
<!-- eslint-disable-next-line svelte/valid-compile -->
<a use:toclink={{ store: tocStore, tocItem, observe: true }} />
</li>
{/each}
</ul>
{/if}
</section>
<section>
<h2>Section Heading Level 2</h2>
<p>...</p>
</section>
<section>
<h3>Section Heading Level 3</h3>
<p>...</p>
</section>
<!-- ... -->
</main>
Notice the usage of createTocStore as a helper for creating an idiomatic
svelte store , which will populate its items property with
the extracted toc elements, and track activeItem if observe is set to
true.
Also note the complementary optional toclink used on anchor tags in the table of
contents. This helps save some manual effort and keep the behavior consistent with the main
toc
action. See Complementary toclink
for more details.
quick start - output
<main
data-toc-observe-for="page-heading"
data-toc-root="ee4f13a3-dfec-401d-b52c-a52550e20ddf"
data-toc-observe-active-id="section-heading-level-3"
>
<h1 id="page-heading" data-toc="">
<a aria-hidden="true" tabindex="-1" href="#page-heading" data-toc-anchor="">#</a>Page Heading
</h1>
<section data-toc-observe-for="table-of-contents">
<h2 id="table-of-contents" data-toc="">
<a aria-hidden="true" tabindex="-1" href="#table-of-contents" data-toc-anchor="">#</a>Table of
Contents
</h2>
<ul>
<li>
<a href="#page-heading" data-toc-link-for="page-heading" data-toc-link-current="false"
>Page Heading</a
>
</li>
<li>
<a
href="#table-of-contents"
data-toc-link-for="table-of-contents"
data-toc-link-current="false">Table of Contents</a
>
</li>
<li>
<a
href="#section-heading-level-2"
data-toc-link-for="section-heading-level-2"
data-toc-link-current="false">Section Heading Level 2</a
>
</li>
<li>
<a
href="#section-heading-level-3"
data-toc-link-for="section-heading-level-3"
data-toc-link-current="true">Section Heading Level 3</a
>
</li>
</ul>
</section>
<section data-toc-observe-for="section-heading-level-2">
<h2 id="section-heading-level-2" data-toc="">
<a aria-hidden="true" tabindex="-1" href="#section-heading-level-2" data-toc-anchor="">#</a
>Section Heading Level 2
</h2>
<p>...</p>
</section>
<section data-toc-observe-for="section-heading-level-3">
<h3 id="section-heading-level-3" data-toc="">
<a aria-hidden="true" tabindex="-1" href="#section-heading-level-3" data-toc-anchor="">#</a
>Section Heading Level 3
</h3>
<p>...</p>
</section>
</main>
Toc Action
use:toc will search for matching elements only from descendants of the element
where it is used. In the Quick Start example, that's
the main element. To search from everything on the page, use it on
svelte:body.
<svelte:body use:toc /> No Dynamic Update
During development, you may notice that toc does not update when you change the
action parameters at runtime and requires a page refresh to work again. This is because
currently
toc only runs once on mount.
Supporting dynamic update is quite a heavy task (tracking what's changed and avoiding duplicated operations) that will increase the bundle size & complexity but is not very useful in most use cases (how often does a table of contents change at runtime?).
If you think otherwise and have a valid use case, please submit an issue .
Events
In Quick Start ,
svelte store is used to keep code minimal. Alternatively, you can listen for
tocinit and tocchange events.
toc events
<script lang="ts">
import { toc } from '@svelte-put/toc';
import type { TocInitEventDetail, TocChangeEventDetail } from '@svelte-put/toc';
function handleTocInit(event: CustomEvent<TocInitEventDetail>) {
const { items } = event.detail;
console.log('Extracted item', items);
}
function handleTocChange(event: CustomEvent<TocChangeEventDetail>) {
const { activeItem } = event.detail;
console.log('Item currently on viewport', activeItem);
}
</script>
<main use:toc={{ observe: true }} on:tocinit={handleTocInit} on:tocchange={handleTocChange}>
...
</main>
Runtime Expectation
tocinit is only fired once. And whether tocchange is fired depends
on the
observe action parameter (discussed in
Observing 'In View' Element
).
- When
observeisfalse, expect notocchangeevent. This makes sense because all necessary information has been extracted at initialization. - When
observeistrue, expect atocchangeevent that follows shortly aftertocinit. Theobserveproperty of each extractedTocItemis only guaranteed to be populated in thistocchangeevent and nottocinit. This is becauseobserveinitialization operations are run asynchronously to avoid blocking any potential work with the extracted information fromtocinit(such as rendering the table of content itself).
Observing 'In View' Element
A common feature of a table of contents on the web is to track which part is "in view".
Traditionally this has been done with on:scroll, but with the relatively new
IntersectionObserver , we can do this in a more performant way.
Caveat
Unfortunately IntersectionObserver comes with its own caveat. For
on:scroll, we can achieve something like:
For an element (typically heading), when it reach 10% offset of screen from the top, set it as active.
This is not trivial with IntersectionObserver without some hacking (to my
knowledge at least), because
IntersectionObserver triggers when element (or part of it) intersects with
viewport. For this reason, toc prefers to "think" in terms of "section" rather than
individual element, something like this:
When 80% of a "section" is visible within the viewport (threshold of
0.8forIntersectionObserver), set it to active.
With this design decision, a typical use case is wrapping heading tags within a section
or div (as shown in Quick Start ).
think in terms of section
<section>
<h2>Heading, whether it is h1,h2,...</h2>
<p>...content...</p>
</section> rather than flat individual elements
<h2>Heading, whether it is h1,h2,...</h2>
<p>...content...</p> You might also find that when an anchor linked to its matching toc element is clicked on (to
scroll to said element), toc might not set that element as the active one. This
is explained and an idiomatic solution is provided in toclink .
Observe Customization
In toc, this feature is turned off by default. To use it, set the
toc
action parameter observe to true or provide an object with customization
options.
turn on observe feature
<!-- use default options -->
<main use:toc={{ observe: true }}>...</main>
<!-- customization-->
<main use:toc={{
observe: {
strategy: 'auto',
threshold: 1,
}
}}>...</main> observe.strategy
Type: 'parent' | 'self' | 'auto'
Default: 'auto'
This option affects which element the IntersectionObserver will observe.
parent: observeparentElementof the matching toc element,self: observe the matching toc element itself,auto: attempt to compare matching toc element & its parentoffsetHeightwithwindow.innerHeightto determine the best strategy.
Alternatively, data-toc-strategy can be set on the matching toc element to override this global settings.
observe.threshold
Type: number | ((element: HTMLElement) => number)
Default: (element) => Math.min((0.8 * window.innerHeight) / element.offsetHeight, 1)
The threshold passed to IntersectionObserver .
Alternatively, data-toc-threshold can be set on the matching toc element to override this global settings.
observe.enabled
Type: boolean
Default: false
Whether to turn on observe feature. When observe
is provided as an object, this is true implicitly.
Complementary toclink Action
As seen in Quick Start :
toclink
<a use:toclink={{ store: tocStore, tocItem, observe: true }}></a> Regarding markup, this is essentially the same as:
toclink - markup equivalence
<script>
import { toc, createTocStore } from '@svelte-put/toc';
const tocStore = createTocStore();
</script>
<main use:toc={{ store: tocStore, observe: true }}>
<!-- ... -->
<section>
<h2>Table of Contents</h2>
{#if $tocStore.items.size}
<ul>
{#each $tocStore.items.values() as { id, text }}
<li>
<a href="#{id}" data-toc-link-active={$tocStore.activeItem?.id === id}>{text}</a>
</li>
{/each}
</ul>
{/if}
</section>
<!-- ... -->
</main>
However, toclink provides an additional click listener that makes sure the toc item
being clicked on will be the active one.
This is not always guaranteed otherwise (when not using toclink), because toc
relies on
IntersectionObserver, and when a matching toc element is scrolled into view, the
next one might already intersects with the viewport and become the active one.
Toc Data Attributes
On Toc Elements
Attributes listed below can be used to override behavior of toc per matching
element. All of them are undefined by default.
Name
Description
data-toc-id
The id to use for this element in toc context. If not provided,
this will be the element id, or generated by toc if element does
not have an id either
type: string
mutability: customizable
data-toc-ignore
Whether to ignore this element when searching for matching elements
type: boolean
mutability: customizable
data-toc-strategy
Override the strategy for this element to use in creating
IntersectionObserver. This only has effect if the observe option
is enabled in toc parameters
type: 'parent' | 'self' | 'auto'
mutability: customizable
data-toc-threshold
Override the threshold for this element to use in creating
IntersectionObserver. This only has effect if the observe option
is enabled in toc parameters
type: number
mutability: customizable
Below instructions show how to add type support for these attributes
app.d.ts
/// <reference types="svelte" />
import type { TocDataAttributes } from '@svelte-put/toc';
declare namespace svelteHTML {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-interface
interface HTMLAttributes<T> extends TocDataAttributes {}
}
By Observe Operation
The following attributes are utilized by the observe operation when enabled.
Name
Description
data-toc-observe-for
Added to the element where IntersectionObserver is used when
observe is turned on and references the associated toc element
type: string
mutability: readonly
data-toc-observe-active-id
Added to toc root (the element where toc action is placed on) and
references the id of the active matching element
type: string
mutability: customizable
This attribute is reactive. When changed (either by toc or manually), it will
trigger events and store updates accordingly
data-toc-observe-throttled
Added to toc root (the element where toc action is placed on) and
indicate whether observe is being throttled, typically seen in conjunction with
usage of the complementary toclink action
type: boolean
mutability: readonly
data-toc-link-active
Added to the element where toclink is used
and set to true when the linked toc element is active
type: boolean
mutability: readonly
Reference Markers
The following attributes act as readonly reference markers.
Name
Description
data-toc
Marking this element that it's been processed by toc
If this is already preprocessed by @svelte-put/preprocess-auto-slug , there will also be a data-auto-slug attribute.
data-toc-anchor
If the anchor option is enabled in toc parameters, this attribute
is present on the injected anchor element.
If the element is added by @svelte-put/preprocess-auto-slug , the
data-auto-slug-anchor can be seen instead.
data-toc-root
Added to the element where toc action is used for internal reference
data-toc-link-for
Added to the element where toclink action is used and references the linked toc element
Happy making table of contents! 👨💻