DEV Community

собачья будка
собачья будка

Posted on

плавающее оглавление на solidjs

иногда плавающее оглавление кажется довольно простой штукой: берёшь заголовки, рисуешь список, добавляешь якоря — готово.

в этой статье — разбор table of contents, который я собрал на solidjs: от поиска заголовков до sticky/fixed поведения и адаптивного ui.


контекст

задача :

  • собрать список заголовков статьи

  • сделать быстрые переходы по клику

  • подсвечивать текущий раздел при скролле

  • поддерживать мобильный и десктопный режим

  • уметь скрываться и появляться


сбор заголовков из документа

вся система начинается с обхода dom и поиска заголовков внутри контейнера статьи.

const updateHeadings = () => {
  const parent = document.querySelector(props.parentSelector)

  if (!parent) return

  const nodes = Array.from(
    parent.querySelectorAll<HTMLElement>('h1, h2, h3, h4')
  )

  setHeadings(nodes)
  setAreHeadingsLoaded(true)
}
Enter fullscreen mode Exit fullscreen mode

по сути это “снимок структуры документа”.


debounce пересборки оглавления

чтобы не пересобирать список слишком часто:

const debouncedUpdateHeadings = debounce(500, updateHeadings)
Enter fullscreen mode Exit fullscreen mode

это особенно важно, если контент может динамически изменяться.


определение активного заголовка

самая “живая” часть компонента — отслеживание скролла.

const isInViewport = (el: HTMLElement) => {
  const rect = el.getBoundingClientRect()

  return rect.top <= DEFAULT_HEADER_OFFSET + 24
}
Enter fullscreen mode Exit fullscreen mode

и дальше поиск активного элемента:

const updateActiveHeader = throttle(50, () => {
  const index = headings().findIndex((h) => isInViewport(h))

  setActiveHeaderIndex(index)
})
Enter fullscreen mode Exit fullscreen mode

переход к заголовку

при клике происходит ручной scroll с компенсацией fixed header’а:

const scrollToHeader = (element: HTMLElement) => {
  const top =
    element.getBoundingClientRect().top -
    document.body.getBoundingClientRect().top -
    DEFAULT_HEADER_OFFSET

  window.scrollTo({
    top,
    behavior: 'smooth'
  })
}
Enter fullscreen mode Exit fullscreen mode

состояние компонента

оглавление держит сразу несколько слоёв состояния:

const [headings, setHeadings] = createSignal<HTMLElement[]>([])
const [activeHeaderIndex, setActiveHeaderIndex] = createSignal(-1)
const [isVisible, setIsVisible] = createSignal(true)
const [areHeadingsLoaded, setAreHeadingsLoaded] = createSignal(false)
const [isDocumentReady, setIsDocumentReady] = createSignal(false)
Enter fullscreen mode Exit fullscreen mode

подписка на скролл

основная реактивность строится через window scroll:

onMount(() => {
  setIsDocumentReady(true)

  debouncedUpdateHeadings()

  window.addEventListener('scroll', updateActiveHeader)

  onCleanup(() => {
    window.removeEventListener('scroll', updateActiveHeader)
  })
})
Enter fullscreen mode Exit fullscreen mode

реакция на изменение статьи

если меняется тело статьи — пересобираем структуру:

createEffect(
  on(
    () => props.body,
    () => {
      if (isDocumentReady()) {
        debouncedUpdateHeadings()
      }
    }
  )
)
Enter fullscreen mode Exit fullscreen mode

рендер списка

основной ui — это список кнопок, привязанных к заголовкам.

<ul class={styles.TableOfContentsHeadingsList}>
  <For each={headings()}>
    {(h, index) => (
      <li>
        <button
          class={clsx(styles.TableOfContentsHeadingsItem, {
            [styles.TableOfContentsHeadingsItemH3]: h.nodeName === 'H3',
            [styles.TableOfContentsHeadingsItemH4]: h.nodeName === 'H4',
            [styles.active]: index() === activeHeaderIndex()
          })}
          innerHTML={h.textContent || ''}
          onClick={(e) => {
            e.preventDefault()
            scrollToHeader(h)
          }}
        />
      </li>
    )}
  </For>
</ul>
Enter fullscreen mode Exit fullscreen mode

визуальная иерархия заголовков

уровни заголовков просто сдвигаются визуально:

.TableOfContentsHeadingsItemH3 {
  padding-left: 8px;
}

.TableOfContentsHeadingsItemH4 {
  padding-left: 16px;
}
Enter fullscreen mode Exit fullscreen mode

активный пункт

подсветка текущего раздела минимальная, но важная:

.TableOfContentsHeadingsItem.active {
  font-weight: 700 !important;
}
Enter fullscreen mode Exit fullscreen mode

sticky поведение на десктопе

на больших экранах оглавление становится sticky-блоком:

.TableOfContentsContainer {
  @include media-breakpoint-up(xl) {
    position: sticky;
    top: 100px;
    height: calc(100vh - 120px);
    flex-direction: column;
  }
}
Enter fullscreen mode Exit fullscreen mode

mobile режим (fixed bottom panel)

на мобильных это уже не sidebar, а выезжающая панель:

.TableOfContentsFixedWrapper {
  @include media-breakpoint-down(xl) {
    position: fixed;
    bottom: 0;
    left: 0;
    width: 100%;
    max-height: 50vh;
    background: #000;
    color: #fff;
  }
}
Enter fullscreen mode Exit fullscreen mode

toggle видимости

оглавление можно скрывать и показывать:

const toggleIsVisible = () => {
  setIsVisible((v) => !v)
}
Enter fullscreen mode Exit fullscreen mode

итог

в итоге это не просто оглавление.

это:

  • парсинг DOM структуры статьи

  • debounce пересборки контента

  • scroll tracking с throttle

  • вычисление активного раздела

  • sticky + fixed адаптивный layout

  • UI, который живёт вместе со скроллом

и самое интересное — такие компоненты всегда выглядят простыми в начале, но постепенно превращаются в полноценный слой навигации поверх документа, который “понимает” структуру текста и помогает по нему двигаться.

source code

Top comments (0)