Skip to content
Draft
Show file tree
Hide file tree
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
18 changes: 16 additions & 2 deletions packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -120,6 +120,13 @@ interface AnchoredOverlayBaseProps extends Pick<OverlayProps, 'height' | 'width'
* Props to be spread on the close button in the overlay.
*/
closeButtonProps?: Partial<IconButtonProps>
/**
* 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 &
Expand Down Expand Up @@ -162,6 +169,7 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
onPositionChange,
displayCloseButton = true,
closeButtonProps = defaultCloseButtonProps,
hideOnAnchorHidden = true,
}) => {
const cssAnchorPositioningFlag = useFeatureFlag('primer_react_css_anchor_positioning')
const supportsNativeCSSAnchorPositioning = useRef(false)
Expand All @@ -170,6 +178,12 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
const [overlayRef, updateOverlayRef] = useRenderForcingRef<HTMLDivElement>()
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])

Expand Down Expand Up @@ -317,7 +331,7 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
ignoreClickRefs={[anchorRef]}
onEscape={onEscape}
role="none"
visibility={cssAnchorPositioning || position ? 'visible' : 'hidden'}
visibility={cssAnchorPositioning ? (isAnchorVisible ? 'visible' : 'hidden') : position ? 'visible' : 'hidden'}
height={height}
width={width}
top={cssAnchorPositioning ? undefined : position?.top || 0}
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export {useRefObjectAsForwardedRef} from './useRefObjectAsForwardedRef'
export {useId} from './useId'
export {useIsMacOS} from './useIsMacOS'
export {useMergedRefs} from './useMergedRefs'
export {useAnchorVisibility} from './useAnchorVisibility'
62 changes: 62 additions & 0 deletions packages/react/src/hooks/useAnchorVisibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {useState, useEffect} from 'react'

/**
* Hook that uses IntersectionObserver to track whether an anchor element
* is visible in the viewport. This is used to hide positioned overlays
* when their anchor scrolls out of view - working around the limitation
* that CSS `position-visibility: anchors-visible` only considers clipping
* by overflow containers, not viewport visibility.
*
* @param anchorRef - Ref to the anchor element to observe
* @param enabled - Whether to enable the observer (default: true)
* @param threshold - Intersection threshold (default: 0, meaning any part visible)
* @returns Whether the anchor is currently visible in the viewport
*/
export function useAnchorVisibility(
anchorRef: React.RefObject<HTMLElement | null>,
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
}
Loading