Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 124 additions & 60 deletions packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ import type {
ClientSiteSections,
} from './encodeClientSiteSections';

const DESKTOP_BREAKPOINT = 768;
const SCREEN_OFFSET = 16; // 1rem
const MAX_ITEMS_PER_COLUMN = 10; // number of items per column
const GROUP_MASONRY_THRESHOLD = 6; // if a section group has more than this many child groups, it will be shown in a masonry grid
const GROUP_MASONRY_THRESHOLD = 3; // if a section group has more than this many child groups, it will be shown in a masonry grid
const COLUMN_WIDTH = '18rem';
const COLUMN_GAP = '2rem';
const MAX_MASONRY_COLUMNS = 4;

/**
* A set of navigational links representing site sections for multi-section sites
Expand All @@ -37,24 +41,19 @@ export function SiteSectionTabs(props: {

const containerRef = React.useRef<HTMLDivElement>(null);
const currentTriggerRef = React.useRef<HTMLButtonElement | null>(null);
const [offset, setOffset] = React.useState<number | null>(null);
const [value, setValue] = React.useState<string | undefined>();

const isMobile = useIsMobile(768);

React.useEffect(() => {
const trigger = currentTriggerRef.current;
const container = containerRef.current;
if (!value || !trigger || !container) {
return;
}

const triggerWidth = trigger.getBoundingClientRect().width - SCREEN_OFFSET;
const triggerLeft =
trigger.getBoundingClientRect().left -
(window.innerWidth - container.getBoundingClientRect().width) / 2;
setOffset(triggerLeft + triggerWidth / 2);
}, [value]);
const isMobile = useIsMobile(DESKTOP_BREAKPOINT);
const offset = useNavigationMenuViewportOffset({
value,
isMobile,
triggerRef: currentTriggerRef,
containerRef,
});
const viewportLeft =
!isMobile && offset !== null
? `clamp(0px, calc(${offset - SCREEN_OFFSET}px - var(--radix-navigation-menu-viewport-width, 0px)/2), calc(100% - var(--radix-navigation-menu-viewport-width, 0px)))`
: '0px';

return structure.length > 0 ? (
<NavigationMenu.Root
Expand All @@ -65,6 +64,12 @@ export function SiteSectionTabs(props: {
className
)}
ref={containerRef}
style={
{
'--site-section-column-width': COLUMN_WIDTH,
'--site-section-column-gap': COLUMN_GAP,
} as React.CSSProperties
}
value={value}
onValueChange={setValue}
skipDelayDuration={500}
Expand Down Expand Up @@ -122,11 +127,22 @@ export function SiteSectionTabs(props: {
icon={icon as IconName}
/>
</NavigationMenu.Trigger>
<NavigationMenu.Content>
<SectionGroupTileList
items={structureItem.children}
currentSection={currentSection}
/>
<NavigationMenu.Content
className={tcls([
'absolute top-0 left-0 w-full md:w-auto',
'data-[motion=from-start]:*:animate-[enterFromLeft_300ms_ease_both] data-[motion=to-end]:*:animate-[exitToRight_300ms_ease_both] data-[motion=to-start]:*:animate-[exitToLeft_300ms_ease_both] motion-safe:data-[motion=from-end]:*:animate-[enterFromRight_300ms_ease_both]',
])}
>
<div
className={tcls(
'max-h-[calc(100vh-8rem)] w-full overflow-y-auto overflow-x-hidden'
)}
>
<SectionGroupTileList
items={structureItem.children}
currentSection={currentSection}
/>
</div>
</NavigationMenu.Content>
</>
) : (
Expand All @@ -153,30 +169,60 @@ export function SiteSectionTabs(props: {

<div
className="absolute top-full left-0 z-20 flex w-full"
style={{
padding: `0 ${SCREEN_OFFSET}px 0 ${SCREEN_OFFSET}px`,
}}
style={{ paddingInline: `${SCREEN_OFFSET}px` }}
>
<NavigationMenu.Viewport
className={tcls(
'relative origin-top overflow-auto circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base shadow-lg ease-in-out',
'-mt-0.5 w-full md:w-max',
'relative origin-[center_top] overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base shadow-lg',
'-mt-0.5 h-(--radix-navigation-menu-viewport-height) w-full max-w-full md:w-(--radix-navigation-menu-viewport-width)',
'max-h-[calc(100vh-8rem)] data-[state=closed]:animate-scale-out data-[state=open]:animate-scale-in',
"[&:not([style*='--radix-navigation-menu-viewport-width'])]:hidden" // The viewport width is only calculated once it's triggered, and can take a while. We hide the viewport until it's ready.
'ease has-[&[data-motion]]:transition-[left,width,height] has-[&[data-motion]]:duration-300'
)}
style={{
translate:
!isMobile && offset
? `clamp(0px, calc(${offset}px - var(--radix-navigation-menu-viewport-width, 0px)/2), calc(100vw - var(--radix-navigation-menu-viewport-width, 0px) - ${SCREEN_OFFSET * 3}px)) 0 0`
: '0 0 0', // TranslateZ is needed to force a stacking context, fixing a rendering bug on Safari
display: offset === null ? 'none' : undefined,
left: viewportLeft,
translate: '0 0 0', // TranslateZ is needed to force a stacking context, fixing a rendering bug on Safari
}}
/>
</div>
</NavigationMenu.Root>
) : null;
}

function useNavigationMenuViewportOffset(args: {
value: string | undefined;
isMobile: boolean;
triggerRef: React.RefObject<HTMLButtonElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
}) {
const { value, isMobile, triggerRef, containerRef } = args;
const [offset, setOffset] = React.useState<number | null>(null);

React.useLayoutEffect(() => {
if (isMobile) {
setOffset(null);
return;
}

if (!value) {
return;
}

const trigger = triggerRef.current;
const container = containerRef.current;
if (!trigger || !container) {
return;
}

const containerLeft = container.getBoundingClientRect().left;
const triggerWidth = trigger.getBoundingClientRect().width;
const triggerLeft = trigger.getBoundingClientRect().left - containerLeft;

setOffset(triggerLeft + triggerWidth / 2);
}, [containerRef, isMobile, triggerRef, value]);

return offset;
}

/**
* A tab representing a section or section group
*/
Expand Down Expand Up @@ -222,14 +268,16 @@ function SectionGroupTileList(props: {

const hasSections = sections.length > 0;
const hasGroups = groups.length > 0;
const isMasonryLayout = groups.length > GROUP_MASONRY_THRESHOLD;
const masonryColumnCount = Math.min(Math.ceil(groups.length / 2), MAX_MASONRY_COLUMNS);

return (
<div className="flex flex-col md:flex-row">
<div className="flex w-full flex-col md:flex-row">
{/* Non-grouped sections */}
{hasSections && (
<ul
className={tcls(
'flex min-w-48 shrink-0 grid-flow-row flex-col gap-x-2 gap-y-1 self-start p-3 md:grid',
'flex w-full shrink-0 grid-flow-row flex-col gap-x-2 gap-y-0.5 self-stretch p-3 md:sticky md:top-0 md:grid md:w-max md:self-start',
hasGroups ? 'bg-tint-base' : ''
)}
style={{
Expand All @@ -248,25 +296,38 @@ function SectionGroupTileList(props: {

{/* Grouped sections */}
{hasGroups && (
<ul
<div
className={tcls(
'grow gap-x-8 space-y-8 p-3',
groups.length > GROUP_MASONRY_THRESHOLD
? 'w-screen md:columns-[15rem]'
: 'flex flex-col justify-start md:flex-row md:items-start',
'w-full md:w-max md:min-w-0 md:max-w-full',
hasSections
? 'border-tint-subtle bg-tint-subtle max-md:border-t md:border-l'
: ''
)}
>
{groups.map((group) => (
<SectionGroupTile
key={group.id}
child={group}
currentSection={currentSection}
/>
))}
</ul>
<ul
className={tcls(
'p-3',
isMasonryLayout
? 'w-full max-md:space-y-8 md:w-max md:max-w-full md:gap-x-[var(--site-section-column-gap)] md:[column-count:var(--masonry-columns)] md:[&>li]:mb-4'
: 'flex w-full flex-col justify-start space-y-8 md:w-max md:flex-row md:items-start md:gap-[var(--site-section-column-gap)] md:space-y-0'
)}
style={
isMasonryLayout
? ({
'--masonry-columns': String(masonryColumnCount),
} as React.CSSProperties)
: undefined
}
>
{groups.map((group) => (
<SectionGroupTile
key={group.id}
child={group}
currentSection={currentSection}
/>
))}
</ul>
</div>
)}
</div>
);
Expand All @@ -278,38 +339,40 @@ function SectionGroupTileList(props: {
function SectionGroupTile(props: {
child: ClientSiteSection | ClientSiteSectionGroup;
currentSection: ClientSiteSection;
invertIcon?: boolean;
}) {
const { child, currentSection } = props;
const { child, currentSection, invertIcon } = props;

if (child.object === 'site-section') {
const { url, icon, title, description } = child;
const isActive = child.id === currentSection.id;
return (
<li className="group/section-tile flex shrink-0 grow md:max-w-68">
<li className="group/section-tile flex w-full min-w-0 shrink-0 grow md:max-w-[var(--site-section-column-width)]">
<Link
href={url}
className={tcls(
'grow circular-corners:rounded-2xl rounded-corners:rounded-lg px-3 py-2 transition-colors',
'grow circular-corners:rounded-2xl rounded-corners:rounded-lg px-2.5 py-1.5 transition-colors',
isActive
? 'bg-primary-active text-primary-strong'
: 'text-tint-strong hover:bg-tint-hover'
)}
>
<div className="mb-auto flex grow items-center gap-2">
<div className="mb-auto flex min-w-0 grow items-center gap-2">
{icon && (
<div
className={tcls(
'-ml-1 self-start circular-corners:rounded-2xl rounded-corners:rounded-lg p-2 transition-colors',
isActive || invertIcon ? 'bg-primary-base' : 'bg-tint',
isActive
? 'bg-primary-base text-primary-subtle'
: 'bg-tint text-tint-strong group-hover/section-tile:bg-tint-base'
? 'text-primary-subtle'
: 'text-tint-strong group-hover/section-tile:bg-tint-base'
)}
>
<SectionIcon isActive={isActive} icon={icon as IconName} />
</div>
)}
<div className="flex flex-col gap-1">
{title}
<div className="flex min-w-0 flex-col gap-0.5">
<span className="block min-w-0 whitespace-normal">{title}</span>
{description && (
<p className={isActive ? 'text-primary' : 'text-tint'}>
{description}
Expand All @@ -326,15 +389,15 @@ function SectionGroupTile(props: {
const { title, icon, children } = child;

return (
<li className="flex shrink-0 break-inside-avoid flex-col gap-1">
<div className="mt-3 mb-1 flex gap-2.5 px-3 font-semibold text-tint-subtle text-xs uppercase tracking-wider">
<li className="flex w-full min-w-0 shrink-0 break-inside-avoid flex-col gap-1 md:w-auto">
<div className="mt-2 mb-1 flex min-w-0 gap-2 px-2.5 font-semibold text-tint-subtle text-xs">
{icon && (
<SectionIcon className="mt-0.5" isActive={false} icon={icon as IconName} />
)}
{title}
<span className="min-w-0 flex-1 whitespace-normal">{title}</span>
</div>
<ul
className="flex grid-flow-row flex-col gap-x-2 gap-y-1 md:grid"
className="flex w-full grid-flow-row flex-col gap-x-2 gap-y-0.5 md:grid"
style={{
gridTemplateColumns: `repeat(${Math.ceil(children.length / MAX_ITEMS_PER_COLUMN)}, minmax(0, auto))`,
}}
Expand All @@ -344,6 +407,7 @@ function SectionGroupTile(props: {
key={nestedChild.id}
child={nestedChild}
currentSection={currentSection}
invertIcon={true}
/>
))}
</ul>
Expand Down
Loading