mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 15:45:13 +00:00
Make ToC groups collapsible and other styles fixes/improvements (#4177)
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user