reui
Scrollspy
Scrollspy — кастомный компонент, портированный из ReUI (keenthemes/reui, MIT).
Загрузка превью…
src/reui/scrollspy/Scrollspy.vue
<script setup lang="ts">
/**
* Порт ReUI Scrollspy (registry-reui/bases/radix/reui/scrollspy.tsx, MIT).
*
* Компонент чисто поведенческий: рендерит один <div data-slot="scrollspy">
* и подсвечивает якоря по мере прокрутки, проставляя им data-active.
*
* Отличие API от React-оригинала (осознанное):
* React принимает `targetRef` как RefObject. Во Vue идиоматично передавать
* сам элемент, поэтому проп называется `target` и принимает элемент,
* Document или null. Логика скролла при этом идентична оригиналу.
*/
import type { HTMLAttributes } from "vue"
import { onBeforeUnmount, onMounted, ref } from "vue"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
/** Скроллируемый контейнер. По умолчанию — документ. */
target?: HTMLElement | Document | null
offset?: number
smooth?: boolean
dataAttribute?: string
/** Синхронизировать ли хеш в адресной строке. */
history?: boolean
}>(),
{
class: undefined,
target: null,
offset: 0,
smooth: true,
dataAttribute: "scrollspy",
history: true,
}
)
const emit = defineEmits<{ update: [id: string] }>()
const selfRef = ref<HTMLDivElement | null>(null)
let anchorElements: Element[] | null = null
let prevIdTracker: string | null = null
const clickHandlers = new Map<Element, (event: Event) => void>()
function attr(suffix: string): string {
return `data-${props.dataAttribute}-${suffix}`
}
/** Ставит активный якорь, синхронизирует хеш и уведомляет наружу. */
function setActiveSection(sectionId: string | null, force = false): void {
if (!sectionId) return
anchorElements?.forEach((item) => {
if (item.getAttribute(attr("anchor")) === sectionId) {
item.setAttribute("data-active", "true")
} else {
item.removeAttribute("data-active")
}
})
emit("update", sectionId)
if (props.history && (force || prevIdTracker !== sectionId)) {
window.history.replaceState({}, "", `#${sectionId}`)
}
prevIdTracker = sectionId
}
/**
* Разворачивает цель в реально скроллящийся элемент.
*
* ВАЖНО — совпадение с оригиналом: если цель не передана, компонент
* инертен и не подсвечивает ничего. Это поведение апстрима (там при
* отсутствии targetRef происходит ранний return). Подстановка
* document.documentElement «по умолчанию» выглядит логичнее, но это
* уже другая семантика, и она ломает совместимость. Менять — только
* осознанно и с отражением в документации.
*/
function resolveScrollElement(): HTMLElement | null {
let scrollElement =
props.target === document
? document.documentElement
: (props.target as HTMLElement | null)
if (!scrollElement) return null
// Внутри ScrollArea скроллится вьюпорт, а не сам контейнер.
const viewport = scrollElement.querySelector(
'[data-slot="scroll-area-viewport"]'
)
if (viewport instanceof HTMLElement) scrollElement = viewport
return scrollElement
}
function handleScroll(): void {
if (!anchorElements || anchorElements.length === 0) return
const scrollElement = resolveScrollElement()
if (!scrollElement) return
const scrollTop =
scrollElement === document.documentElement
? window.scrollY || document.documentElement.scrollTop
: scrollElement.scrollTop
// Ищем якорь, чья секция ближе всего сверху, но ещё не ушла за верх.
let activeIdx = 0
let minDelta = Infinity
anchorElements.forEach((anchor, idx) => {
const sectionId = anchor.getAttribute(attr("anchor"))
if (!sectionId) return
const sectionElement = document.getElementById(sectionId)
if (!sectionElement) return
let customOffset = props.offset
const dataOffset = anchor.getAttribute(attr("offset"))
if (dataOffset) customOffset = Number.parseInt(dataOffset, 10)
const delta = Math.abs(sectionElement.offsetTop - customOffset - scrollTop)
if (sectionElement.offsetTop - customOffset <= scrollTop && delta < minDelta) {
minDelta = delta
activeIdx = idx
}
})
// У самого низа принудительно активируем последний якорь —
// иначе последняя короткая секция никогда не станет активной.
if (
scrollTop + scrollElement.clientHeight >=
scrollElement.scrollHeight - 2
) {
activeIdx = anchorElements.length - 1
}
const activeAnchor = anchorElements[activeIdx]
setActiveSection(activeAnchor?.getAttribute(attr("anchor")) ?? null)
}
function scrollTo(anchorElement: HTMLElement): void {
const sectionId = anchorElement
.getAttribute(attr("anchor"))
?.replace("#", "")
if (!sectionId) return
const sectionElement = document.getElementById(sectionId)
if (!sectionElement) return
let scrollToElement: HTMLElement | Window | null =
props.target === document ? window : (props.target as HTMLElement | null)
if (scrollToElement instanceof HTMLElement) {
const viewport = scrollToElement.querySelector(
'[data-slot="scroll-area-viewport"]'
)
if (viewport instanceof HTMLElement) scrollToElement = viewport
}
let customOffset = props.offset
const dataOffset = anchorElement.getAttribute(attr("offset"))
if (dataOffset) customOffset = Number.parseInt(dataOffset, 10)
scrollToElement?.scrollTo({
top: sectionElement.offsetTop - customOffset,
left: 0,
behavior: props.smooth ? "smooth" : "auto",
})
setActiveSection(sectionId, true)
}
/** Если в URL уже есть хеш — переходим к соответствующей секции. */
function scrollToHashSection(): void {
const hash = CSS.escape(window.location.hash.replace("#", ""))
if (!hash) return
const targetElement = document.querySelector(
`[${attr("anchor")}="${hash}"]`
)
if (targetElement instanceof HTMLElement) scrollTo(targetElement)
}
function onScroll(event: Event): void {
const scrollElement =
props.target === document ? window : (props.target as HTMLElement | null)
if (!scrollElement) return
if (
scrollElement === window ||
(scrollElement instanceof HTMLElement &&
scrollElement.contains(event.target as Node))
) {
handleScroll()
}
}
let initialTimeout: ReturnType<typeof setTimeout> | undefined
onMounted(() => {
if (selfRef.value) {
anchorElements = Array.from(
selfRef.value.querySelectorAll(`[${attr("anchor")}]`)
)
}
// Ссылку на обработчик храним, иначе removeEventListener не сработает:
// в оригинале scrollTo(item) каждый раз возвращал новую функцию, и
// отписка была фиктивной. Здесь это исправлено.
anchorElements?.forEach((item) => {
const handler = (event: Event): void => {
event.preventDefault()
scrollTo(item as HTMLElement)
}
clickHandlers.set(item, handler)
item.addEventListener("click", handler)
})
// Capture-фаза: ловим скролл цели, даже если её назначили позже.
window.addEventListener("scroll", onScroll, true)
initialTimeout = setTimeout(() => {
scrollToHashSection()
handleScroll()
}, 100)
})
onBeforeUnmount(() => {
window.removeEventListener("scroll", onScroll, true)
clickHandlers.forEach((handler, item) => {
item.removeEventListener("click", handler)
})
clickHandlers.clear()
if (initialTimeout) clearTimeout(initialTimeout)
})
</script>
<template>
<div ref="selfRef" data-slot="scrollspy" :class="props.class">
<slot />
</div>
</template>
src/reui/scrollspy/index.ts
export { default as Scrollspy } from "./Scrollspy.vue"
Установка
npx shadcn-vue@latest add https://revueui.rootapi.dev/r/scrollspy.jsonИсточник: порт из ReUI (Keenthemes, MIT)