@svelte-put/toc
Acknowledgement
This package relies on Svelte action and attempts to stay minimal. If you are looking for a declarative, component-oriented solution, checkout janosh/svelte-toc.
Installation
npm install --save-dev @svelte-put/toc@^5.0.0
pnpm add -D @svelte-put/toc@^5.0.0
yarn add -D @svelte-put/toc@^5.0.0
Migration Guide
In version 5, the items
property of TocStore
and TocInitEventDetail
is now a Map instead of plain object as in version 4. This enables better performance and properly preserves the order of collected toc elements.
<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:
search for matching elements (default:
:where(h1, h2, h3, h4, h5, h6)
),generate
id
attribute from elementtextContent
,add anchor tag to element,
attach IntersectionObserver to each matching element to track its visibility on the screen,
expose necessary pieces for building table of contents.
It is recommended to use the complementary @svelte-put/preprocess-auto-slug package for handling 2 and 3 at build time. 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 the two.
The table of contents in this documentation site is generated by toc
itself. Check out its source code here (search for tocStore
).
Quick Start
Given the following Svelte source code, let’s see how toc
does its job.
<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 highlighted lines, specifically:
- the
createTocStore
helper is used to create an idiomatic Svelte store, whoseitems
property will be populated with the extracted toc elements and can trackactiveItem
if theobserve
option is set totrue
- the complementary optional
toclink
action is used on anchor tags within the table of contents to help save some manual effort and keep behavior consistent with the maintoc
action. See Complementary TocLink for more details.
<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 it is attached to. In Quick Start, 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 require a page refresh to work again. This is because currently toc
only runs once on mount.
Supporting dynamic update is quite a task (tracking what’s changed and avoiding duplicate operations) that will increase the bundle size & complexity but is not practically 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 raise an issue.
use:toc
accepts an optional config object with the TocConfig
interface. It is recommended to make use of your code editor and language server for API discovery. But, should it be necessary, you can refer to the full type definition here.
Options to toc
are global and affect all matching elements. Some of them may be overridden per matching element, see Toc Data Attributes.
CustomEvents
In Quick Start, a Svelte store created with the createTocStore
helper is used to keep code minimal. Alternatively, you may listen for tocinit
and tocchange
CustomEvents.
<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
option (See Observing In View Element for more information). Specifically:
- When
observe
isfalse
, expect notocchange
CustomEvent. This makes sense because all necessary information has been extracted at initialization. - When
observe
istrue
, expect atocchange
CustomEvent that follows shortly aftertocinit
. Theobserve
property of each extractedTocItem
is only guaranteed to be populated in thistocchange
event and nottocinit
. This is becauseobserve
initialization operations are run asynchronously to avoid blocking any potential work with the extracted information fromtocinit
(such as rendering the table of content itself).
Toc Anchor
If not handled by @svelte-put/preprocess-auto-slug, toc
will attempt to add an anchor tag to each matching element, similar to how Github adds anchor tags to headings in a rendered README
.
<!-- input -->
<h2>Section Heading Level 2</h2>
<!-- output -->
<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>
To customize how anchor tags are inserted (or disable it), specify the anchor
option in use:toc
parameter. It accepts a boolean or a config object with the TocAnchorConfig
interface. Please refer to the its type definition here.
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 on the scene, 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.8
forIntersectionObserver
), set it to active.
With this design decision, a typical solution is wrapping a heading tag and its associated content within a <section>
or <div>
(as shown in Quick Start).
<section>
<h2>Heading, whether it is h1,h2,...</h2>
<p>...content...</p>
</section>
Grouping content into sections as discussed above will help toc
better track the active state as you scroll. But is is not mandatory; things will work just fine with flat headings and content; it will just be a bit less accurate.
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 Complementary TocLink.
Enabling
In toc
, this feature is turned off by default. To use it, set the observe
option to true
or a config object.
<!-- use default options -->
<main use:toc={{ observe: true }}>...</main>
<!-- customization-->
<main use:toc={{
observe: {
strategy: 'auto',
threshold: 1,
}
}}>...</main>
Customization
It is recommended to use your code editor and language server for API discovery. But, should it be necessary, you can refer to the full type definition here.
Complementary TocLink Action
As seen in Quick Start, at the table of contents section:
<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>
Regarding markup, this is essentially the same as:
<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>
However,toclink
does provide an additional click listener that makes sure the toc item being clicked on will be the active one, which is not guaranteed otherwise. This is because toc
relies on IntersectionObserver, and when a matching toc element is scrolled into view, the next one might already intersects with viewport and become the active one.
In short, unless you need full control over the behavior of the anchor tag, it is recommended to use toclink
for consistency and to save some manual effort.
Toc Data Attributes
On Toc Elements
Options provided to the toc
action parameter, such as threshold
or strategy
, are global and affect all matching toc elements. Attributes listed below can be used to override behavior of toc
per matching element. All of them are undefined
by default.
interface TocElementDataAttributes {
/** whether to ignore this element when searching for matching elements */
'data-toc-ignore'?: boolean;
/**
* 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.
*/
'data-toc-id'?: string;
/**
* override the `strategy` for this element to use in creating
* `IntersectionObserver` This only has effect if the `observe`
* option is enabled in {@link TocParameters}
*/
'data-toc-strategy'?: TocObserveConfig['strategy'];
/**
* override the `threshold` for this element to use in creating
* `IntersectionObserver` This only has effect if the `observe`
* option is enabled in {@link TocParameters}
*/
'data-toc-threshold'?: number;
}
Check code the source type definition here if necessary.
By Observe Operation
The following attributes are utilized by the observe
operation when enabled. Notice some of them are readonly
, which means they are handled internally by observe
and should not be changed manually.
interface TocObserveDataAttributes {
/**
* added to the element where IntersectionObserver is used when observe is
* turned on and references the associated toc element
*/
readonly 'data-toc-observe-for'?: string;
/**
* added to toc root (the element where toc action is placed on) and
* references the id of the active matching element
*
* This attribute is reactive. When changed (either by toc or manually),
* it will trigger events and store update accordingly
*/
'data-toc-observe-active-id'?: string;
/**
* 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
*/
readonly 'data-toc-observe-throttled'?: boolean;
/**
* added to the element where toclink is used and
* set to true when the linked toc element is active
*/
readonly 'data-toc-link-active'?: boolean;
}
Check code the source type definition here if necessary.
Reference Markers
The following attributes act as readonly reference markers added by toc
(or @svelte-put/preprocess-auto-slug).
interface TocReferenceMarkerDataAttributes {
/**
* marking this element that it's been processed by toc
*
* If this is already preprocessed by {@link https://svelte-put.vnphanquang.com/docs/preprocess-auto-slug | @svelte-put/preprocess-auto-slug},
* there will also be a `data-auto-slug` attribute.
*/
readonly 'data-toc'?: '';
/**
* if the anchor option is enabled in toc parameters, this attribute is present on the injected anchor element.
*
* If the element is already added by {@link https://svelte-put.vnphanquang.com/docs/preprocess-auto-slug | @svelte-put/preprocess-auto-slug},
* there `data-auto-slug-anchor` attribute is found instead.
*/
readonly 'data-toc-anchor'?: '';
/**
* added to the element where toc action is used for internal reference
*/
readonly 'data-toc-root'?: '';
/**
* added to the element where toclink action is used and references the linked toc element
*/
readonly 'data-toc-link-for'?: '';
/**
* from {@link https://svelte-put.vnphanquang.com/docs/preprocess-auto-slug | @svelte-put/preprocess-auto-slug}
*/
'data-auto-slug'?: '';
'data-auto-slug-anchor'?: '';
'data-auto-slug-anchor-position'?: '';
}
Check code the source type definition here.
Happy making table of contents! 👨💻