diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 7421e6a84d6..41122678791 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -7,7 +7,7 @@ import type {FocusTrapHookSettings} from '../hooks/useFocusTrap' import {useFocusTrap} from '../hooks/useFocusTrap' import type {FocusZoneHookSettings} from '../hooks/useFocusZone' import {useFocusZone} from '../hooks/useFocusZone' -import {useAnchoredPosition, useProvidedRefOrCreate, useRenderForcingRef} from '../hooks' +import {useAnchoredPosition, useProvidedRefOrCreate, useRenderForcingRef, useAnchorVisibility} from '../hooks' import {useId} from '../hooks/useId' import type {AnchorPosition, PositionSettings} from '@primer/behaviors' import {type ResponsiveValue} from '../hooks/useResponsiveValue' @@ -120,6 +120,13 @@ interface AnchoredOverlayBaseProps extends Pick + /** + * When enabled (and CSS anchor positioning feature flag is on), hides the overlay + * when the anchor element scrolls out of the viewport. This uses IntersectionObserver + * to track anchor visibility. + * @default false + */ + hideOnAnchorHidden?: boolean } export type AnchoredOverlayProps = AnchoredOverlayBaseProps & @@ -162,6 +169,7 @@ export const AnchoredOverlay: React.FC { const cssAnchorPositioningFlag = useFeatureFlag('primer_react_css_anchor_positioning') const supportsNativeCSSAnchorPositioning = useRef(false) @@ -170,6 +178,12 @@ export const AnchoredOverlay: React.FC() const anchorId = useId(externalAnchorId) + // Track anchor visibility to hide overlay when anchor scrolls out of viewport. + // This provides a JS fallback for CSS `position-visibility: anchors-visible` + // which only considers overflow clipping, not viewport visibility. + // Only enabled when both CSS anchor positioning is active AND hideOnAnchorHidden prop is true. + const isAnchorVisible = useAnchorVisibility(anchorRef, cssAnchorPositioning && open && hideOnAnchorHidden) + const onClickOutside = useCallback(() => onClose?.('click-outside'), [onClose]) const onEscape = useCallback(() => onClose?.('escape'), [onClose]) @@ -317,7 +331,7 @@ export const AnchoredOverlay: React.FC, + enabled = true, + threshold = 0, +): boolean { + const [isAnchorVisible, setIsAnchorVisible] = useState(true) + + useEffect(() => { + // When disabled or no anchor, don't set up the observer + // The hook returns true by default, so the overlay stays visible + if (!enabled || !anchorRef.current) { + return + } + + const anchor = anchorRef.current + + const observer = new IntersectionObserver( + entries => { + for (const entry of entries) { + // isIntersecting is true when any part of the element is visible + // (based on threshold). When threshold is 0, this means any pixel. + setIsAnchorVisible(entry.isIntersecting) + } + }, + { + // Use null for root to observe against the viewport + root: null, + // No margin adjustments needed + rootMargin: '0px', + // threshold of 0 means callback fires as soon as even one pixel is visible/hidden + threshold, + }, + ) + + observer.observe(anchor) + + return () => { + observer.disconnect() + } + }, [anchorRef, enabled, threshold]) + + // When disabled, always return true so overlay remains visible + if (!enabled) { + return true + } + + return isAnchorVisible +}