Make ToC groups collapsible and other styles fixes/improvements (#4177)

This commit is contained in:
Claire Chabas
2026-04-14 19:41:51 +02:00
committed by GitHub
parent 2bdade3b57
commit bda9fe2d76
5 changed files with 132 additions and 24 deletions
@@ -1,41 +1,140 @@
'use client';
import React from 'react';
import type { ClientTOCPageGroup } from './encodeClientTableOfContents';
import { tcls } from '@/lib/tailwind';
import { ToggleChevron } from '../primitives';
import { PagesList } from './PagesList';
import { TOCPageIcon } from './TOCPageIcon';
import { ToCButtonItemStyles } from './styles';
export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boolean }) {
const { page, isFirst } = props;
const descendants = page.descendants ?? [];
const hasDescendants = descendants.length > 0;
const [isOpen, setIsOpen] = React.useState(true);
const { sentinelRef, isSticking } = useIsSticking();
const handleToggle = () => {
if (!hasDescendants) {
return;
}
setIsOpen((prev) => !prev);
};
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 flex items-center gap-3 px-3',
'font-semibold text-xs uppercase tracking-wide',
'mt-2 pt-4 pb-3', // Add extra padding to make the header fade a bit nicer
'-mb-1.5', // Then pull the page items a bit closer, effective bottom padding is 1.5 units / 6px.
'mask-[linear-gradient(rgba(0,0,0,1)_70%,rgba(0,0,0,0))]', // Fade out effect of fixed page items. We want the fade to start past the header, this is a good approximation.
'-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',
'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',
isFirst ? '-mt-2 -top-2 rounded-t-2xl pt-2' : ''
'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' : ''
)}
>
<TOCPageIcon page={page} />
{page.title}
<button
type="button"
disabled={!hasDescendants}
aria-expanded={hasDescendants ? isOpen : undefined}
onClick={handleToggle}
className={tcls(
ToCButtonItemStyles,
'toc-group min-h-8 w-full border-0 text-left',
'font-semibold text-xs uppercase tracking-wide',
'appearance-none',
'[&_.toc-group-chevron]:transition-opacity',
'hover:[&_.toc-group-chevron]:opacity-11',
'focus-visible:[&_.toc-group-chevron]:opacity-11',
hasDescendants ? 'cursor-pointer' : ''
)}
>
<TOCPageIcon page={page} />
<span className="min-w-0 flex-1">{page.title}</span>
{hasDescendants ? (
<span
className={tcls(
'toc-group-chevron ml-auto flex shrink-0 transition-opacity duration-150',
isOpen
? 'pointer-events-none opacity-0 delay-75'
: 'opacity-6 delay-0'
)}
>
<ToggleChevron
open={isOpen}
orientation="right-to-down"
className="m-0! size-3!"
/>
</span>
) : null}
</button>
</div>
{page.descendants && page.descendants.length > 0 ? (
<PagesList pages={page.descendants} />
{hasDescendants ? (
<div
className={tcls(
'mt-px grid transition-[grid-template-rows,opacity] duration-200 ease-in-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
)}
>
<div className="overflow-hidden">
<PagesList pages={descendants} />
</div>
</div>
) : null}
</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) {
setIsSticking(!entry.isIntersecting);
}
},
{ root: scrollParent }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, []);
return { sentinelRef, isSticking };
}
@@ -18,7 +18,7 @@ export function PageLinkItem(props: { page: ClientTOCPageLink }) {
<li className="page-link-item flex flex-col [.page-group-item+&]:mt-4">
<Link
href={page.href ?? '#'}
classNames={['ToggleableLinkItemStyles']}
classNames={['ToCLinkItemStyles']}
insights={{
type: 'link_click',
link: {
@@ -113,8 +113,8 @@ function LinkItem(
insights={insights}
aria-current={isActive ? 'page' : undefined}
classNames={[
'ToggleableLinkItemStyles',
...(isActive ? ['ToggleableLinkItemActiveStyles' as const] : []),
'ToCLinkItemStyles',
...(isActive ? ['ToCLinkItemActiveStyles' as const] : []),
]}
onClick={handleClick}
>
@@ -1,14 +1,26 @@
export const ToggleableLinkItemStyles = [
'group/toclink toclink relative transition-colors',
export const ToCItemBaseStyles = [
'flex flex-row justify-start items-center gap-3',
'circular-corners:rounded-2xl rounded-md straight-corners:rounded-none p-1.5 pl-3',
'text-balance font-normal text-sm text-tint-strong/7 hover:bg-tint-hover hover:text-tint-strong contrast-more:text-tint-strong',
'contrast-more:hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-tint-12',
'focus-visible:-outline-offset-2',
'before:contents[] before:-left-px before:absolute before:inset-y-0',
'sidebar-list-line:rounded-l-none! sidebar-list-line:before:w-px [&+div_a]:sidebar-list-default:rounded-l-none [&+div_a]:pl-5 [&+div_a]:sidebar-list-default:before:w-px',
];
export const ToggleableLinkItemActiveStyles = [
export const ToCLinkItemStyles = [
'group/toclink toclink relative transition-colors',
ToCItemBaseStyles,
'text-balance font-normal text-sm text-tint-strong/7 hover:bg-tint-hover hover:text-tint-strong contrast-more:text-tint-strong',
'contrast-more:hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-tint-12',
];
export const ToCButtonItemStyles = [
'relative transition-colors',
ToCItemBaseStyles,
'text-balance font-normal text-sm text-tint-strong hover:bg-tint-hover hover:text-tint-strong contrast-more:text-tint-strong',
'contrast-more:hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-tint-12',
];
export const ToCLinkItemActiveStyles = [
'font-semibold',
'sidebar-list-line:before:w-0.5',
@@ -2,10 +2,7 @@
import type { ClassValue } from '@/lib/tailwind';
import { RecordCardLinkStyles, RecordCardStyles } from '../DocumentView/Table/styles';
import {
ToggleableLinkItemActiveStyles,
ToggleableLinkItemStyles,
} from '../TableOfContents/styles';
import { ToCLinkItemActiveStyles, ToCLinkItemStyles } from '../TableOfContents/styles';
import { ButtonStyles, CardStyles, LinkStyles } from './styles';
const styles = {
@@ -14,8 +11,8 @@ const styles = {
ButtonStyles,
RecordCardStyles,
RecordCardLinkStyles,
ToggleableLinkItemStyles,
ToggleableLinkItemActiveStyles,
ToCLinkItemStyles,
ToCLinkItemActiveStyles,
};
export type DesignTokenName = keyof typeof styles;