fix(segmentation): Fixed the manual scrolling of the segments list and implemented automatic scrolling to the active segment. (#5510)

The height of the list container is now calculated based on a parent div instead of itself.
DataRow component now has a forward ref to allow for automatic scrolling.

Added better detection to the useDynamicMaxHeight hook for when to recalculate the max height using intersection observers.

The useEffect for scrolling a segment into view used to be excessively called to the point
where it was auto scrolling after the user manually scrolled. The auto scroll useEffect is
now dependent on the segment index changing which should only happen once per active segment change.
This commit is contained in:
Joe Boccanfuso 2025-10-21 11:58:23 -04:00 committed by GitHub
parent cf0b561a49
commit 1df4f843fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 472 additions and 359 deletions

View File

@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react';
import React, { useState } from 'react';
import { Button } from '../../components/Button/Button';
import {
DropdownMenu,
@ -107,305 +107,305 @@ interface DataRowProps {
children?: React.ReactNode;
}
const DataRowComponent: React.FC<DataRowProps> = ({
number,
title,
colorHex,
details,
onSelect,
isLocked,
onToggleVisibility,
onToggleLocked,
onRename,
onDelete,
onColor,
isSelected = false,
isVisible = true,
disableEditing = false,
className,
children,
}) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const isTitleLong = title?.length > 25;
const rowRef = useRef<HTMLDivElement>(null);
const DataRowComponent = React.forwardRef<HTMLDivElement, DataRowProps>(
(
{
number,
title,
colorHex,
details,
onSelect,
isLocked,
onToggleVisibility,
onToggleLocked,
onRename,
onDelete,
onColor,
isSelected = false,
isVisible = true,
disableEditing = false,
className,
children,
},
ref
) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const isTitleLong = title?.length > 25;
// Extract Status components from children
const statusComponents = React.Children.toArray(children).filter(
child =>
React.isValidElement(child) &&
child.type &&
(child.type as any).displayName?.startsWith('DataRow.Status')
);
// Extract Status components from children
const statusComponents = React.Children.toArray(children).filter(
child =>
React.isValidElement(child) &&
child.type &&
(child.type as React.ComponentType).displayName?.startsWith('DataRow.Status')
);
// useEffect(() => {
// if (isSelected && rowRef.current) {
// setTimeout(() => {
// rowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
// }, 200);
// }
// }, [isSelected]);
const handleAction = (action: string, e: React.MouseEvent) => {
e.stopPropagation();
switch (action) {
case 'Rename':
onRename(e);
break;
case 'Lock':
onToggleLocked(e);
break;
case 'Delete':
onDelete(e);
break;
case 'Color':
onColor(e);
break;
}
};
const handleAction = (action: string, e: React.MouseEvent) => {
e.stopPropagation();
switch (action) {
case 'Rename':
onRename(e);
break;
case 'Lock':
onToggleLocked(e);
break;
case 'Delete':
onDelete(e);
break;
case 'Color':
onColor(e);
break;
}
};
const decodeHTML = (html: string) => {
const txt = document.createElement('textarea');
txt.innerHTML = html;
return txt.value;
};
const decodeHTML = (html: string) => {
const txt = document.createElement('textarea');
txt.innerHTML = html;
return txt.value;
};
const renderDetailText = (text: string, indent: number = 0) => {
const indentation = ' '.repeat(indent);
if (text === '') {
const renderDetailText = (text: string, indent: number = 0) => {
const indentation = ' '.repeat(indent);
if (text === '') {
return (
<div
key={`empty-${indent}`}
className="h-2"
></div>
);
}
const cleanText = decodeHTML(text);
return (
<div
key={`empty-${indent}`}
className="h-2"
></div>
key={cleanText}
className="whitespace-pre-wrap"
>
{indentation}
<span className="font-medium">{cleanText}</span>
</div>
);
}
const cleanText = decodeHTML(text);
return (
<div
key={cleanText}
className="whitespace-pre-wrap"
>
{indentation}
<span className="font-medium">{cleanText}</span>
</div>
);
};
};
const renderDetails = (details: string[]) => {
const visibleLines = details.slice(0, 4);
const hiddenLines = details.slice(4);
const renderDetails = (details: string[]) => {
const visibleLines = details.slice(0, 4);
const hiddenLines = details.slice(4);
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">
<div className="flex flex-col space-y-1">
{visibleLines.map((line, lineIndex) =>
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">
<div className="flex flex-col space-y-1">
{visibleLines.map((line, lineIndex) =>
renderDetailText(line, line.startsWith(' ') ? 1 : 0)
)}
</div>
{hiddenLines.length > 0 && (
<div className="text-muted-foreground mt-1 flex items-center text-sm">
<span>...</span>
<Icons.Info className="mr-1 h-5 w-5" />
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
className="max-w-md"
>
<div className="text-secondary-foreground flex flex-col space-y-1 text-sm leading-normal">
{details.map((line, lineIndex) =>
renderDetailText(line, line.startsWith(' ') ? 1 : 0)
)}
</div>
{hiddenLines.length > 0 && (
<div className="text-muted-foreground mt-1 flex items-center text-sm">
<span>...</span>
<Icons.Info className="mr-1 h-5 w-5" />
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
className="max-w-md"
>
<div className="text-secondary-foreground flex flex-col space-y-1 text-sm leading-normal">
{details.map((line, lineIndex) =>
renderDetailText(line, line.startsWith(' ') ? 1 : 0)
)}
</div>
</TooltipContent>
</Tooltip>
);
};
</TooltipContent>
</Tooltip>
);
};
return (
<div
ref={rowRef}
className={cn('flex flex-col', !isVisible && 'opacity-60', className)}
>
return (
<div
className={`flex items-center ${
isSelected ? 'bg-popover' : 'bg-muted'
} group relative cursor-pointer`}
onClick={onSelect}
data-cy="data-row"
ref={ref}
className={cn('flex flex-col', !isVisible && 'opacity-60', className)}
>
{/* Hover Overlay */}
<div className="bg-primary/20 pointer-events-none absolute inset-0 opacity-0 transition-opacity group-hover:opacity-100"></div>
<div
className={`flex items-center ${
isSelected ? 'bg-popover' : 'bg-muted'
} group relative cursor-pointer`}
onClick={onSelect}
data-cy="data-row"
>
{/* Hover Overlay */}
<div className="bg-primary/20 pointer-events-none absolute inset-0 opacity-0 transition-opacity group-hover:opacity-100"></div>
{/* Number Box */}
{number !== null && (
<div
className={`flex h-7 max-h-7 w-7 flex-shrink-0 items-center justify-center rounded-l border-r border-black text-base ${
isSelected ? 'bg-highlight text-black' : 'bg-muted text-muted-foreground'
} overflow-hidden`}
>
{number}
</div>
)}
{/* Number Box */}
{number !== null && (
<div
className={`flex h-7 max-h-7 w-7 flex-shrink-0 items-center justify-center rounded-l border-r border-black text-base ${
isSelected ? 'bg-highlight text-black' : 'bg-muted text-muted-foreground'
} overflow-hidden`}
>
{number}
</div>
)}
{/* add some space if there is not segment index */}
{number === null && <div className="ml-1 h-7"></div>}
{colorHex && (
<div className="flex h-7 w-5 items-center justify-center">
<span
className="ml-2 h-2 w-2 rounded-full"
style={{ backgroundColor: colorHex }}
></span>
</div>
)}
{/* add some space if there is not segment index */}
{number === null && <div className="ml-1 h-7"></div>}
{colorHex && (
<div className="flex h-7 w-5 items-center justify-center">
<span
className="ml-2 h-2 w-2 rounded-full"
style={{ backgroundColor: colorHex }}
></span>
</div>
)}
{/* Label with Conditional Tooltip */}
<div className="ml-2 flex-1 overflow-hidden">
{isTitleLong ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={`cursor-default text-base ${
isSelected ? 'text-highlight' : 'text-muted-foreground'
} [overflow:hidden] [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]`}
{/* Label with Conditional Tooltip */}
<div className="ml-2 flex-1 overflow-hidden">
{isTitleLong ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={`cursor-default text-base ${
isSelected ? 'text-highlight' : 'text-muted-foreground'
} [overflow:hidden] [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]`}
>
{title}
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
>
{title}
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
</TooltipContent>
</Tooltip>
) : (
<span
className={`text-base ${
isSelected ? 'text-highlight' : 'text-muted-foreground'
} [overflow:hidden] [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]`}
>
{title}
</TooltipContent>
</Tooltip>
) : (
<span
className={`text-base ${
isSelected ? 'text-highlight' : 'text-muted-foreground'
} [overflow:hidden] [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]`}
</span>
)}
</div>
{/* Actions and Visibility Toggle */}
<div className="relative ml-2 flex items-center space-x-1">
{/* Visibility Toggle Icon */}
<Button
size="icon"
variant="ghost"
className={`h-6 w-6 transition-opacity ${
isSelected || !isVisible ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
aria-label={isVisible ? 'Hide' : 'Show'}
onClick={e => {
e.stopPropagation();
onToggleVisibility(e);
}}
>
{title}
</span>
)}
</div>
{isVisible ? <Icons.Hide className="h-6 w-6" /> : <Icons.Show className="h-6 w-6" />}
</Button>
{/* Actions and Visibility Toggle */}
<div className="relative ml-2 flex items-center space-x-1">
{/* Visibility Toggle Icon */}
<Button
size="icon"
variant="ghost"
className={`h-6 w-6 transition-opacity ${
isSelected || !isVisible ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
aria-label={isVisible ? 'Hide' : 'Show'}
onClick={e => {
e.stopPropagation();
onToggleVisibility(e);
}}
>
{isVisible ? <Icons.Hide className="h-6 w-6" /> : <Icons.Show className="h-6 w-6" />}
</Button>
{/* Lock Icon (if needed) */}
{isLocked && !disableEditing && (
<Icons.Lock className="text-muted-foreground h-6 w-6" />
)}
{/* Lock Icon (if needed) */}
{isLocked && !disableEditing && <Icons.Lock className="text-muted-foreground h-6 w-6" />}
{/* Status Components */}
{statusComponents}
{/* Status Components */}
{statusComponents}
{/* Actions Dropdown Menu */}
{disableEditing && <div className="h-6 w-6"></div>}
{!disableEditing && (
<DropdownMenu onOpenChange={open => setIsDropdownOpen(open)}>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className={`h-6 w-6 transition-opacity ${
isSelected || isDropdownOpen
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
}`}
aria-label="Actions"
dataCY="actionsMenuTrigger"
onClick={e => e.stopPropagation()} // Prevent row selection on button click
{/* Actions Dropdown Menu */}
{disableEditing && <div className="h-6 w-6"></div>}
{!disableEditing && (
<DropdownMenu onOpenChange={open => setIsDropdownOpen(open)}>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className={`h-6 w-6 transition-opacity ${
isSelected || isDropdownOpen
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
}`}
aria-label="Actions"
dataCY="actionsMenuTrigger"
onClick={e => e.stopPropagation()} // Prevent row selection on button click
>
<Icons.More className="h-6 w-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
// this was causing issue for auto focus on input dialog
onCloseAutoFocus={e => e.preventDefault()}
>
<Icons.More className="h-6 w-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
// this was causing issue for auto focus on input dialog
onCloseAutoFocus={e => e.preventDefault()}
>
<>
<DropdownMenuItem onClick={e => handleAction('Rename', e)}>
<Icons.Rename className="text-foreground" />
<span
className="pl-2"
data-cy="Rename"
>
Rename
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={e => handleAction('Delete', e)}>
<Icons.Delete className="text-foreground" />
<span
className="pl-2"
data-cy="Delete"
>
Delete
</span>
</DropdownMenuItem>
{onColor && (
<DropdownMenuItem onClick={e => handleAction('Color', e)}>
<Icons.ColorChange className="text-foreground" />
<>
<DropdownMenuItem onClick={e => handleAction('Rename', e)}>
<Icons.Rename className="text-foreground" />
<span
className="pl-2"
data-cy="Change Color"
data-cy="Rename"
>
Change Color
Rename
</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={e => handleAction('Lock', e)}>
<Icons.Lock className="text-foreground" />
<span
className="pl-2"
data-cy="LockToggle"
>
{isLocked ? 'Unlock' : 'Lock'}
</span>
</DropdownMenuItem>
</>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Details Section */}
{details && (details.primary?.length > 0 || details.secondary?.length > 0) && (
<div className="ml-7 px-2 py-2">
<div className="text-secondary-foreground flex items-center gap-1 text-base leading-normal">
{details.primary?.length > 0 && renderDetails(details.primary)}
{details.secondary?.length > 0 && (
<div className="text-muted-foreground ml-auto text-sm">
{renderDetails(details.secondary)}
</div>
<DropdownMenuItem onClick={e => handleAction('Delete', e)}>
<Icons.Delete className="text-foreground" />
<span
className="pl-2"
data-cy="Delete"
>
Delete
</span>
</DropdownMenuItem>
{onColor && (
<DropdownMenuItem onClick={e => handleAction('Color', e)}>
<Icons.ColorChange className="text-foreground" />
<span
className="pl-2"
data-cy="Change Color"
>
Change Color
</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={e => handleAction('Lock', e)}>
<Icons.Lock className="text-foreground" />
<span
className="pl-2"
data-cy="LockToggle"
>
{isLocked ? 'Unlock' : 'Lock'}
</span>
</DropdownMenuItem>
</>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
)}
</div>
);
};
{/* Details Section */}
{details && (details.primary?.length > 0 || details.secondary?.length > 0) && (
<div className="ml-7 px-2 py-2">
<div className="text-secondary-foreground flex items-center gap-1 text-base leading-normal">
{details.primary?.length > 0 && renderDetails(details.primary)}
{details.secondary?.length > 0 && (
<div className="text-muted-foreground ml-auto text-sm">
{renderDetails(details.secondary)}
</div>
)}
</div>
</div>
)}
</div>
);
}
);
DataRowComponent.displayName = 'DataRow';
interface StatusProps {
children: React.ReactNode;
@ -490,7 +490,7 @@ Status.Success = StatusSuccess;
Status.Error = StatusError;
Status.Info = StatusInfo;
const DataRow = DataRowComponent as React.FC<DataRowProps> & {
const DataRow = DataRowComponent as typeof DataRowComponent & {
Status: typeof Status;
};

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { ScrollArea, DataRow } from '../../components';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '../../components/HoverCard';
import { useSegmentationTableContext, useSegmentationExpanded } from './contexts';
@ -23,6 +23,11 @@ export const SegmentationSegments = ({ children = null }: { children?: React.Rea
let segmentation;
let representation;
const activeSegmentRef = React.useRef<{
element: HTMLElement | null;
index: number | null;
}>({ element: null, index: null });
try {
// Try to use the SegmentationExpanded context if available
const segmentationInfo = useSegmentationExpanded('SegmentationSegments');
@ -38,108 +43,163 @@ export const SegmentationSegments = ({ children = null }: { children?: React.Rea
}
const segments = Object.values(representation.segments);
// Find the active segment to scroll to it when it changes
const activeSegment = segments.find(segment => {
if (!segment) {
return false;
}
const segmentFromSegmentation = segmentation.segments[segment.segmentIndex];
return segmentFromSegmentation?.active;
});
const isActiveSegmentation = segmentation.segmentationId === activeSegmentationId;
const { ref: scrollableContainerRef, maxHeight } = useDynamicMaxHeight(segments);
useEffect(() => {
const activeSegmentIndex = activeSegmentRef.current.index;
if (!activeSegmentIndex || activeSegmentIndex !== activeSegment?.segmentIndex) {
return;
}
const activeSegmentElement = activeSegmentRef.current.element;
if (!activeSegmentElement) {
return;
}
// Check if the active segment is already visible.
const activeSegmentElementBounds = activeSegmentElement.getBoundingClientRect();
const scrollableContainerRect = scrollableContainerRef.current.getBoundingClientRect();
if (
activeSegmentElementBounds.top > scrollableContainerRect.top &&
activeSegmentElementBounds.bottom < scrollableContainerRect.bottom
) {
// The active segment is already visible, so we don't need to scroll.
return;
}
activeSegmentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, [activeSegment?.segmentIndex, scrollableContainerRef]);
if (!representation || !segmentation) {
return null;
}
return (
<ScrollArea
className={`bg-bkg-low space-y-px`}
showArrows={true}
>
<div
ref={scrollableContainerRef}
style={{ maxHeight: maxHeight }}
<div ref={scrollableContainerRef}>
<ScrollArea
className={`bg-bkg-low space-y-px`}
showArrows={
scrollableContainerRef?.current
? scrollableContainerRef?.current?.offsetHeight >= parseFloat(maxHeight)
: false
}
>
{segments.map(segment => {
if (!segment) {
return null;
}
const { segmentIndex, color, visible } = segment as {
segmentIndex: number;
color: number[];
visible: boolean;
};
const segmentFromSegmentation = segmentation.segments[segmentIndex];
<div style={{ maxHeight: maxHeight }}>
{segments.map(segment => {
if (!segment) {
return null;
}
const { segmentIndex, color, visible } = segment as {
segmentIndex: number;
color: number[];
visible: boolean;
};
const segmentFromSegmentation = segmentation.segments[segmentIndex];
if (!segmentFromSegmentation) {
return null;
}
if (!segmentFromSegmentation) {
return null;
}
const { locked, active, label, displayText } = segmentFromSegmentation;
const cssColor = `rgb(${color[0]},${color[1]},${color[2]})`;
const { locked, active, label, displayText } = segmentFromSegmentation;
const cssColor = `rgb(${color[0]},${color[1]},${color[2]})`;
const hasStats = segmentFromSegmentation.cachedStats?.namedStats;
const DataRowComponent = (
<DataRow
key={segmentIndex}
number={showSegmentIndex ? segmentIndex : null}
title={label}
// details={displayText}
description={displayText}
colorHex={cssColor}
isSelected={active}
isVisible={visible}
isLocked={locked}
disableEditing={disableEditing}
className={!isActiveSegmentation ? 'opacity-80' : ''}
onColor={() => onSegmentColorClick(segmentation.segmentationId, segmentIndex)}
onToggleVisibility={() =>
onToggleSegmentVisibility(
segmentation.segmentationId,
segmentIndex,
representation.type
)
const hasStats = segmentFromSegmentation.cachedStats?.namedStats;
const segmentRowRef = (element: HTMLElement) => {
if (!active) {
return;
}
onToggleLocked={() => onToggleSegmentLock(segmentation.segmentationId, segmentIndex)}
onSelect={() => onSegmentClick(segmentation.segmentationId, segmentIndex)}
onRename={() => onSegmentEdit(segmentation.segmentationId, segmentIndex)}
onDelete={() => onSegmentDelete(segmentation.segmentationId, segmentIndex)}
/>
);
return hasStats ? (
<HoverCard
key={`hover-${segmentIndex}`}
openDelay={300}
>
<HoverCardTrigger asChild>
<div>{DataRowComponent}</div>
</HoverCardTrigger>
<HoverCardContent
side="left"
align="start"
className="w-72 border"
>
<div className="mb-4 flex items-center space-x-2">
<div
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{ backgroundColor: cssColor }}
></div>
<h3 className="text-muted-foreground break-words font-semibold">{label}</h3>
</div>
if (element) {
activeSegmentRef.current = { element, index: segmentIndex };
} else {
activeSegmentRef.current = { element: null, index: null };
}
};
<SegmentStatistics
segment={{
...segmentFromSegmentation,
const DataRowComponent = (
<DataRow
ref={segmentRowRef}
key={segmentIndex}
number={showSegmentIndex ? segmentIndex : null}
title={label}
// details={displayText}
description={displayText}
colorHex={cssColor}
isSelected={active}
isVisible={visible}
isLocked={locked}
disableEditing={disableEditing}
className={!isActiveSegmentation ? 'opacity-80' : ''}
onColor={() => onSegmentColorClick(segmentation.segmentationId, segmentIndex)}
onToggleVisibility={() =>
onToggleSegmentVisibility(
segmentation.segmentationId,
segmentIndex,
}}
segmentationId={segmentation.segmentationId}
representation.type
)
}
onToggleLocked={() =>
onToggleSegmentLock(segmentation.segmentationId, segmentIndex)
}
onSelect={() => onSegmentClick(segmentation.segmentationId, segmentIndex)}
onRename={() => onSegmentEdit(segmentation.segmentationId, segmentIndex)}
onDelete={() => onSegmentDelete(segmentation.segmentationId, segmentIndex)}
/>
);
return hasStats ? (
<HoverCard
key={`hover-${segmentIndex}`}
openDelay={300}
>
<HoverCardTrigger asChild>
<div>{DataRowComponent}</div>
</HoverCardTrigger>
<HoverCardContent
side="left"
align="start"
className="w-72 border"
>
{children}
</SegmentStatistics>
</HoverCardContent>
</HoverCard>
) : (
DataRowComponent
);
})}
</div>
</ScrollArea>
<div className="mb-4 flex items-center space-x-2">
<div
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{ backgroundColor: cssColor }}
></div>
<h3 className="text-muted-foreground break-words font-semibold">{label}</h3>
</div>
<SegmentStatistics
segment={{
...segmentFromSegmentation,
segmentIndex,
}}
segmentationId={segmentation.segmentationId}
>
{children}
</SegmentStatistics>
</HoverCardContent>
</HoverCard>
) : (
DataRowComponent
);
})}
</div>
</ScrollArea>
</div>
);
};

View File

@ -1,5 +1,29 @@
import { useRef, useState, useEffect, RefObject } from 'react';
const _getMovementIntersectionObserver = ({
callback,
rootMargin,
threshold,
}: {
callback: () => void;
rootMargin: string;
threshold: number[];
}): IntersectionObserver => {
return new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
callback();
}
});
},
{
threshold,
rootMargin,
}
);
};
/**
* Calculates the maximum height for an element based on its position
* relative to the bottom of the viewport.
@ -31,20 +55,49 @@ export function useDynamicMaxHeight(
}
};
// Calculate initially
// Use requestAnimationFrame to ensure layout is stable after initial render
const rafId = requestAnimationFrame(calculateMaxHeight);
// Two intersection observers to trigger a recalculation when the target element
// moves up or down. One for moving up and one for moving down.
// Note that with this approach we don't need to use a resize observer nor
// a window resize listener.
// Recalculate on window resize
window.addEventListener('resize', calculateMaxHeight);
// The trick is to use a margin for the IntersectionObserver to detect movement.
// See more below.
const rootMarginHeight = maxHeight === '100vh' ? `${window.innerHeight}px` : `${maxHeight}`;
// Note that we use a fine grained threshold because we don't know how
// much it will move and we want any movement to trigger the intersection observer.
const threshold = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0];
// The trick here is to use the calculated maxHeight as the root margin height
// so that any movement of the target element down (i.e. "out of the" viewport)
// will trigger the intersection observer.
const moveDownIntersectionObserver = _getMovementIntersectionObserver({
callback: calculateMaxHeight,
rootMargin: `0px 0px ${rootMarginHeight} 0px`,
threshold,
});
// The trick here is to use the calculated maxHeight as the negative
// root margin height so that any movement of the target element up
// (i.e. "into the" viewport) will trigger the intersection observer.
const moveUpIntersectionObserver = _getMovementIntersectionObserver({
callback: calculateMaxHeight,
rootMargin: `0px 0px -${rootMarginHeight} 0px`,
threshold,
});
if (ref.current) {
moveUpIntersectionObserver.observe(ref.current);
moveDownIntersectionObserver.observe(ref.current);
}
// Cleanup listener and requestAnimationFrame on component unmount
return () => {
window.removeEventListener('resize', calculateMaxHeight);
cancelAnimationFrame(rafId);
moveUpIntersectionObserver.disconnect();
moveDownIntersectionObserver.disconnect();
};
// Dependencies: buffer, minHeight, and data.
}, [data, buffer, minHeight]);
}, [data, buffer, minHeight, maxHeight]);
return { ref, maxHeight };
}