mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 23:55:20 +00:00
Update site section tabs with better column calc and animations (#4252)
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
@@ -153,23 +169,18 @@ 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>
|
||||
@@ -177,6 +188,41 @@ export function SiteSectionTabs(props: {
|
||||
) : 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
|
||||
*/
|
||||
@@ -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={{
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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}
|
||||
@@ -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))`,
|
||||
}}
|
||||
@@ -344,6 +407,7 @@ function SectionGroupTile(props: {
|
||||
key={nestedChild.id}
|
||||
child={nestedChild}
|
||||
currentSection={currentSection}
|
||||
invertIcon={true}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user