arka triymfalnaya

плавающее оглавление на 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);
};

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


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

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

const debouncedUpdateHeadings = debounce(500, updateHeadings);

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


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

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

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

	return rect.top <= DEFAULT_HEADER_OFFSET + 24;
};

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

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

	setActiveHeaderIndex(index);
});

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

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

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

	window.scrollTo({
		top,
		behavior: "smooth",
	});
};

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

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

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);

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

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

onMount(() => {
	setIsDocumentReady(true);

	debouncedUpdateHeadings();

	window.addEventListener("scroll", updateActiveHeader);

	onCleanup(() => {
		window.removeEventListener("scroll", updateActiveHeader);
	});
});

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

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

createEffect(
	on(
		() => props.body,
		() => {
			if (isDocumentReady()) {
				debouncedUpdateHeadings();
			}
		},
	),
);

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

основной 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>

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

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

.TableOfContentsHeadingsItemH3 {
	padding-left: 8px;
}

.TableOfContentsHeadingsItemH4 {
	padding-left: 16px;
}

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

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

.TableOfContentsHeadingsItem.active {
	font-weight: 700 !important;
}

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

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

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

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;
	}
}

toggle видимости

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

const toggleIsVisible = () => {
	setIsVisible((v) => !v);
};

итог

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

это:

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

source code