Fix sidebar group title clipping on scroll

This commit is contained in:
Tomek Gargula
2026-09-18 16:25:12 +02:00
parent f80429e4e8
commit da4bf8207c
4 changed files with 22 additions and 54 deletions
@@ -14,7 +14,6 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
const descendants = page.descendants ?? [];
const hasDescendants = descendants.length > 0;
const [isOpen, setIsOpen] = React.useState(true);
const { sentinelRef, isSticking } = useIsSticking();
const handleToggle = () => {
if (!hasDescendants) {
@@ -26,28 +25,24 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
return (
<li className="page-group-item flex flex-col">
<div ref={sentinelRef} className="h-0" aria-hidden="true" />
<div
className={tcls(
'-top-4 sticky z-1 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-linear-to-b after:from-tint-base after:to-transparent after:transition-opacity',
isSticking ? '' : 'after:opacity-0',
'mt-1 pt-2.5 pb-0',
// Pinned below the sidebar's 16px top fade (ScrollContainer's `mask-t-from-*`),
// so a stuck header is never rendered inside the band that fades it out.
'top-4 sticky z-1',
// Spacing lives in the margin, not padding, to keep the pinned box the size of
// the button: padding would push the title further down the sidebar when stuck.
'mt-3.5',
'bg-tint-base',
'sidebar-filled:after:from-tint-subtle',
'sidebar-filled:bg-tint-subtle',
'theme-muted:after:from-tint-subtle',
'theme-muted:bg-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:after:from-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-muted_&]:after:from-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:after:from-tint-base',
'lg:[html.sidebar-default.theme-gradient_&]:bg-gradient-primary',
'lg:[html.sidebar-default.theme-gradient_&]:after:from-primary-2',
'lg:[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint',
'lg:[html.sidebar-default.theme-gradient.tint_&]:after:from-tint-subtle',
isFirst ? '-mt-2 -top-2 circular-corners:rounded-t-2xl rounded-t-md pt-2' : ''
// The first group rests exactly on its sticky offset, so it never shifts on scroll.
isFirst ? 'mt-0 circular-corners:rounded-t-2xl rounded-t-md' : ''
)}
>
<button
@@ -101,39 +96,3 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
</li>
);
}
/**
* Detect when a sticky element becomes "stuck" using an IntersectionObserver on a sentinel.
* Place the sentinel ref on a 0-height element right before the sticky element.
*/
function useIsSticking() {
const sentinelRef = React.useRef<HTMLDivElement>(null);
const [isSticking, setIsSticking] = React.useState(false);
React.useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
// Find the closest scrollable ancestor to use as IntersectionObserver root
let scrollParent: Element | null = sentinel.parentElement;
while (scrollParent) {
const { overflowY } = getComputedStyle(scrollParent);
if (overflowY === 'auto' || overflowY === 'scroll') break;
scrollParent = scrollParent.parentElement;
}
const observer = new IntersectionObserver(
([entry]) => {
if (!entry) return;
const rootTop = entry.rootBounds?.top ?? 0;
setIsSticking(!entry.isIntersecting && entry.boundingClientRect.top < rootTop);
},
{ root: scrollParent }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, []);
return { sentinelRef, isSticking };
}
@@ -132,7 +132,8 @@ export async function TableOfContents(props: {
<ScrollContainer
data-testid="toc-scroll-container"
orientation="vertical"
contentClassName="flex flex-col p-2 gutter-stable"
// `pt-4` matches the page-group headers' sticky offset, so the first group rests on it.
contentClassName="flex flex-col p-2 pt-4 gutter-stable"
active="[data-active=true]"
leading={{
fade: true,
@@ -88,9 +88,15 @@ export function useScrollOverflow(
}
scheduleMeasure();
});
// Also watch descendants (subtree/characterData) so text/content changes deeper in
// the tree that grow or shrink scrollHeight/scrollWidth still trigger a re-measure.
mo.observe(container, { childList: true, subtree: true, characterData: true });
// Also watch descendants (subtree/characterData/attributes) so text/content changes deeper
// in the tree that grow or shrink scrollHeight/scrollWidth still trigger a re-measure.
// Attributes matter because collapsing a section can be a class-only change.
mo.observe(container, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
});
return () => {
if (frame !== null) {
@@ -133,7 +133,9 @@ export function ScrollContainer(props: ScrollContainerProps) {
{/* Scrollable content */}
<div
className={tcls(
'flex flex-1 overflow-hidden',
// Scroll anchoring silently shifts scrollTop when content above collapses,
// desyncing the measured scroll position from the edge masks.
'flex flex-1 overflow-hidden [overflow-anchor:none]',
orientation === 'horizontal' ? 'min-w-0' : 'min-h-0',
orientation === 'horizontal' ? 'no-scrollbar' : 'hide-scrollbar',
orientation === 'horizontal' ? 'overflow-x-scroll' : 'flex-col overflow-y-auto',