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)