aliaset
Version:
twind monorepo
119 lines (98 loc) • 2.85 kB
text/typescript
import type { Action } from './types'
import mutationObserver from './mutation-observer'
export interface ScrollspyOptions {
enabled?: boolean
offset?: number
anchorSelector?: string
linkSelector?: string
/** CSS classes to add for an active link */
className?: string
}
export default function scrollspy(
node: Element,
initialOptions?: ScrollspyOptions,
): ReturnType<Action> {
let options: Required<ScrollspyOptions>
let checkTimer: ReturnType<typeof setTimeout>
let observer: ReturnType<Action> | undefined | void
let lastActive: Element | undefined
let anchors: Map<string, Element>
let links: Map<string, Element>
update(initialOptions)
function findElements() {
anchors = new Map(
Array.from(node.querySelectorAll(options.anchorSelector), (element) => [element.id, element]),
)
links = new Map(
Array.from(node.querySelectorAll(options.linkSelector), (element) => [
new URL((element as HTMLAnchorElement).href).hash.slice(1),
element,
]),
)
scheduleCheck()
}
function check() {
const nextActive = Array.from(anchors.values(), (element) => ({
e: element,
y: element.getBoundingClientRect().y - options.offset,
}))
.filter((a) => a.y <= 0)
.sort((a, b) => b.y - a.y)
.map((a) => links.get(a.e.id))
.filter(Boolean)[0]
if (lastActive !== nextActive) {
const classList = options.className.split(/\s+/g)
lastActive?.classList.remove(...classList)
nextActive?.classList.add(...classList)
lastActive = nextActive
}
}
function scheduleCheck() {
clearTimeout(checkTimer)
checkTimer = setTimeout(check, 35)
}
function update({
enabled = true,
offset = 100,
className = 'active',
anchorSelector = '[id]',
linkSelector = 'nav a[href]',
}: ScrollspyOptions = {}) {
destroy()
options = { enabled, offset, className, anchorSelector, linkSelector }
if (enabled) {
observer = mutationObserver(node, {
enabled,
childList: true,
subtree: true,
callback: findElements,
})
addEventListener('scroll', scheduleCheck, {
capture: true,
passive: true,
})
addEventListener('resize', scheduleCheck, {
capture: true,
passive: true,
})
findElements()
}
}
function destroy() {
observer = observer?.destroy()
if (options && lastActive) {
lastActive.classList.remove(...options.className.split(/\s+/g))
lastActive = undefined
}
removeEventListener('scroll', scheduleCheck, {
capture: true,
// passive: true,
})
removeEventListener('resize', scheduleCheck, {
capture: true,
// passive: true,
})
clearTimeout(checkTimer)
}
return { update, destroy }
}