Rework table of contents to accommodate faded edges

This commit is contained in:
Zeno Kapitein
2025-12-11 17:27:49 +01:00
parent 8a887fd890
commit 4cdab37eac
7 changed files with 99 additions and 72 deletions
@@ -229,7 +229,7 @@ export function AIChatBody(props: {
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
contentClassName="p-4 gutter-stable flex flex-col gap-4"
orientation="vertical"
fadeEdges={['leading']}
trailing={{ fade: false, button: true }}
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
>
{isEmpty ? (
@@ -79,7 +79,7 @@ export async function EmbeddableDocsPage(
orientation="vertical"
className="not-hydrated:animate-blur-in-slow"
contentClassName="p-4"
fadeEdges={context.sections ? [] : ['leading']}
leading={{ fade: !context.sections, button: true }}
>
<TableOfContents context={context} />
<PageBody
@@ -77,7 +77,11 @@ export function SiteSectionTabs(props: {
: 'after:contents[] after:absolute after:inset-y-2 after:right-0 after:border-transparent after:border-r after:transition-colors'
)}
active={`#${currentSection.id}`}
trailingEdgeScrollClassName={children ? 'after:border-tint' : ''}
trailing={{
fade: true,
button: true,
className: children ? 'after:border-tint' : '',
}}
>
<NavigationMenu.List
className={tcls(
@@ -14,10 +14,10 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
<li className="flex flex-col">
<div
className={tcls(
'-top-6 sticky z-1 flex items-center gap-3 px-3 pt-6',
'-top-2 sticky z-1 flex items-center gap-3 px-3',
'font-semibold text-xs uppercase tracking-wide',
'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.
'pt-4 pb-3', // Add extra padding to make the header fade a bit nicer
'-mb-1.5 mt-2', // 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.
'bg-tint-base',
'sidebar-filled:bg-tint-subtle',
@@ -27,7 +27,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'lg:[html.sidebar-default.theme-gradient_&]:bg-gradient-primary',
'lg:[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint',
isFirst ? '-mt-6' : ''
isFirst ? '-mt-2' : ''
)}
>
<TOCPageIcon page={page} />
@@ -74,7 +74,7 @@ export async function TableOfContents(props: {
'lg:page-no-toc:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!',
'lg:page-no-toc:[html[style*="--outline-height"]_&]:top-(--outline-height)!',
'pt-6 pb-4',
'pt-4 pb-4',
'lg:sidebar-filled:pr-6',
'lg:page-no-toc:pr-0',
'max-lg:pl-8',
@@ -88,7 +88,7 @@ export async function TableOfContents(props: {
<div // The actual sidebar, either shown with a filled bg or transparent.
className={tcls(
'-ms-5',
'relative flex grow flex-col overflow-hidden border-tint-subtle',
'relative flex min-h-0 grow flex-col border-tint-subtle',
'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
@@ -108,10 +108,15 @@ export async function TableOfContents(props: {
{innerHeader ? innerHeader : null}
<ScrollContainer
data-testid="toc-scroll-container"
fadeEdges={['trailing']}
orientation="vertical"
contentClassName="flex grow flex-col p-2 gutter-stable"
contentClassName="flex flex-col p-2 gutter-stable"
active="[data-active=true]"
leading={{
fade: true,
button: {
className: '-mt-2',
},
}}
>
<PagesList
pages={pages}
@@ -17,7 +17,7 @@ export function Trademark(props: {
}) {
const { className, ...rest } = props;
return (
<div className={tcls('mt-auto pr-4 pb-2 pl-2', className)}>
<div className={tcls('mt-auto px-2 sidebar-default:pr-4 pb-2', className)}>
<TrademarkLink {...rest} />
</div>
);
@@ -4,7 +4,7 @@ import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import * as React from 'react';
import { useScrollListener } from '../hooks/useScrollListener';
import { Button } from './Button';
import { Button, type ButtonProps } from './Button';
/**
* A container that encapsulates a scrollable area with usability features.
@@ -17,17 +17,26 @@ export type ScrollContainerProps = {
className?: string;
contentClassName?: string;
/** Optional class(es) to apply when there the container can be scrolled on the leading (left or top) edge */
leadingEdgeScrollClassName?: string;
/** Optional class(es) to apply when there the container can be scrolled on the trailing (right or bottom) edge */
trailingEdgeScrollClassName?: string;
/** The direction of the scroll container. */
orientation: 'horizontal' | 'vertical';
/** Whether to fade out the edges of the container. */
fadeEdges?: ('leading' | 'trailing')[];
leading?: {
/** Whether to fade out the leading edge of the container. */
fade: boolean;
/** Whether to show a button to scroll back. */
button: boolean | ButtonProps;
/** Optional class(es) to apply when there the container can be scrolled on the leading (left or top) edge */
className?: string;
};
trailing?: {
/** Whether to fade out the trailing edge of the container. */
fade: boolean;
/** Whether to show a button to scroll forward. */
button: boolean | ButtonProps;
/** Optional class(es) to apply when there the container can be scrolled on the trailing (right or bottom) edge */
className?: string;
};
/** The ID or ref of the active item to scroll to. */
active?: string | React.RefObject<HTMLElement | null>;
@@ -39,10 +48,9 @@ export function ScrollContainer(props: ScrollContainerProps) {
className,
contentClassName,
orientation,
fadeEdges = ['leading', 'trailing'],
active,
leadingEdgeScrollClassName,
trailingEdgeScrollClassName,
leading = { fade: true, button: true },
trailing = { fade: true, button: true },
...rest
} = props;
@@ -140,28 +148,30 @@ export function ScrollContainer(props: ScrollContainerProps) {
return (
<div
className={tcls(
'group/scroll-container relative flex overflow-hidden',
'group/scroll-container relative flex shrink grow',
orientation === 'horizontal' ? 'min-w-0' : 'min-h-0',
className,
scrollPosition > 0 ? leadingEdgeScrollClassName : '',
scrollPosition < scrollSize ? trailingEdgeScrollClassName : ''
scrollPosition > 0 ? leading?.className : '',
scrollPosition < scrollSize ? trailing?.className : ''
)}
{...rest}
>
{/* Scrollable content */}
<div
className={tcls(
'flex shrink grow',
'flex flex-1 overflow-hidden',
orientation === 'horizontal' ? 'min-w-0' : 'min-h-0',
orientation === 'horizontal' ? 'no-scrollbar' : 'hide-scrollbar',
orientation === 'horizontal' ? 'overflow-x-scroll' : 'flex-col overflow-y-auto',
fadeEdges.includes('leading') && scrollPosition > 0
leading.fade && scrollPosition > 0
? orientation === 'horizontal'
? 'mask-l-from-[calc(100%-2rem)]'
: 'mask-t-from-[calc(100%-2rem)]'
? 'mask-l-from-[calc(100%-1.5rem)]'
: 'mask-t-from-[calc(100%-1.5rem)]'
: '',
fadeEdges.includes('trailing') && scrollPosition < scrollSize
trailing.fade && scrollPosition < scrollSize
? orientation === 'horizontal'
? 'mask-r-from-[calc(100%-2rem)]'
: 'mask-b-from-[calc(100%-2rem)]'
? 'mask-r-from-[calc(100%-1.5rem)]'
: 'mask-b-from-[calc(100%-1.5rem)]'
: '',
contentClassName
)}
@@ -171,44 +181,52 @@ export function ScrollContainer(props: ScrollContainerProps) {
</div>
{/* Scroll buttons back & forward */}
<Button
icon={orientation === 'horizontal' ? 'chevron-left' : 'chevron-up'}
iconOnly
size="xsmall"
variant="secondary"
tabIndex={-1}
className={tcls(
'bg-tint-base!',
orientation === 'horizontal'
? '-translate-y-1/2! top-1/2 left-0 ml-2'
: '-translate-x-1/2! top-0 left-1/2 mt-2',
'absolute z-10 not-pointer-none:block hidden scale-0 opacity-0 transition-[scale,opacity]',
scrollPosition > 0
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
: 'pointer-events-none'
)}
onClick={scrollBack}
label={tString(language, 'scroll_back')}
/>
<Button
icon={orientation === 'horizontal' ? 'chevron-right' : 'chevron-down'}
iconOnly
size="xsmall"
variant="secondary"
tabIndex={-1}
className={tcls(
'bg-tint-base!',
orientation === 'horizontal'
? '-translate-y-1/2! top-1/2 right-0 mr-2'
: '-translate-x-1/2! bottom-0 left-1/2 mb-2',
'absolute z-10 not-pointer-none:block hidden scale-0 transition-[scale,opacity]',
scrollPosition < scrollSize
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
: 'pointer-events-none'
)}
onClick={scrollFurther}
label={tString(language, 'scroll_further')}
/>
{leading.button !== false ? (
<Button
icon={orientation === 'horizontal' ? 'chevron-left' : 'chevron-up'}
iconOnly
size="xsmall"
variant="secondary"
tabIndex={-1}
onClick={scrollBack}
label={tString(language, 'scroll_back')}
{...(typeof leading.button === 'object' ? leading.button : {})}
className={tcls(
'bg-tint-base!',
orientation === 'horizontal'
? '-translate-y-1/2! top-1/2 left-0 ml-2'
: '-translate-x-1/2! top-0 left-1/2 mt-2',
'absolute z-10 not-pointer-none:block hidden scale-0 opacity-0 transition-[scale,opacity]',
scrollPosition > 0
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
: 'pointer-events-none',
typeof leading.button === 'object' ? leading.button.className : ''
)}
/>
) : null}
{trailing.button !== false ? (
<Button
icon={orientation === 'horizontal' ? 'chevron-right' : 'chevron-down'}
iconOnly
size="xsmall"
variant="secondary"
tabIndex={-1}
onClick={scrollFurther}
label={tString(language, 'scroll_further')}
{...(typeof trailing.button === 'object' ? trailing.button : {})}
className={tcls(
'bg-tint-base!',
orientation === 'horizontal'
? '-translate-y-1/2! top-1/2 right-0 mr-2'
: '-translate-x-1/2! bottom-0 left-1/2 mb-2',
'absolute z-10 not-pointer-none:block hidden scale-0 transition-[scale,opacity]',
scrollPosition < scrollSize
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
: 'pointer-events-none',
typeof trailing.button === 'object' ? trailing.button.className : ''
)}
/>
) : null}
</div>
);
}