Update site section tabs with better column calc and animations (#4252)

This commit is contained in:
Zeno Kapitein
2026-06-10 17:35:38 +02:00
committed by GitHub
parent 74fc3f74d7
commit 6bd522b322
@@ -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>