'use client'; import { Root as SlotRoot, Slottable } from '@radix-ui/react-slot'; import type { JSX, MutableRefObject, ReactNode, RefObject } from 'react'; import { createContext, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react'; import { createPortal } from 'react-dom'; import useMeasure from 'react-use-measure'; import { useReducedMotion } from '../../hooks/use-reduced-motion'; import { cn } from '../../lib/cn'; const INACTIVE_TIMEOUT_MS = 250; const ENTER_DELAY_MS = 0; const OPEN_DELAY_MS = 150; const MAX_TRANSITION_DISTANCE_PX = 150; const MAX_OVERLAP_FOR_TRANSITION_PERCENT = 100; const PERSIST_TIMEOUT_MS = 1000; interface Point { x: number; y: number; } interface ContextCardData { id: string; origin: Point; contentSize: { width: number; height: number }; side: Side; arrowOffset: Point; } type NullString = string | null; interface ContextCardContextType { rootOrigin: Point; rootBounds: { width: number; height: number }; portalRef: RefObject; activeId: RefObject; setActiveId: (id: NullString) => void; hoveredId: NullString; setHoveredId: (id: NullString) => void; updateActiveContextCard: (update: ContextCardData | null) => void; skipTransition: boolean; rootVisible: boolean; distanceFromLast: number; lastOrigin: Point | null; /** Whether a real {@link ContextCardProvider} is mounted above this consumer. */ isProvided: boolean; } const ContextCardContext = createContext({ rootOrigin: { x: 0, y: 0 }, rootBounds: { width: 0, height: 0 }, portalRef: null as unknown as RefObject, activeId: null as unknown as MutableRefObject, setActiveId: () => void 0, hoveredId: null, setHoveredId: () => void 0, updateActiveContextCard: () => void 0, skipTransition: false, rootVisible: false, distanceFromLast: 0, lastOrigin: null, isProvided: false, }); ContextCardContext.displayName = 'ContextCardContext'; /** * Returns whether the current subtree is wrapped in a {@link ContextCardProvider}. */ export function useHasContextCardProvider(): boolean { return useContext(ContextCardContext).isProvided; } function usePrevious(value: T): T | null { const ref = useRef(null); useEffect(() => { ref.current = value; }); return ref.current; } function isRefObject( value: HTMLElement | RefObject | null | undefined ): value is RefObject { return value !== null && value !== undefined && 'current' in value; } /** * Provides shared state and a portal container for {@link ContextCardTrigger} * components. Must wrap any tree containing triggers; manages positioning, * visibility transitions, and the animated arrow tip across all cards. */ export function ContextCardProvider({ children, portalTarget, }: { children: ReactNode; /** Custom container for the portal. Accepts either an element or a ref. */ portalTarget?: HTMLElement | RefObject | null; }): ReactNode { const [isMounted, setIsMounted] = useState(false); useEffect(() => setIsMounted(true), []); const resolvedPortalTarget = isRefObject(portalTarget) ? portalTarget.current : portalTarget; const [ref, bounds] = useMeasure(); const portalRef = useRef(null); const [activeContextCard, setActiveContextCard] = useState(null); const [visible, setVisible] = useState(false); const [hoveredId, setHoveredId] = useState(null); const activeId = useRef(null); function setActiveId(id: NullString): void { activeId.current = id; setVisible(id !== null); } const updateActiveContextCard = (update: ContextCardData | null): void => { if (update?.id !== activeId.current && update !== null) return; setActiveContextCard(update); }; const BORDER_WIDTH = 1; const activeBounds = useMemo(() => { if (!activeContextCard) return { width: 0, height: 0, x: 0, y: 0, side: 'top' as Side, arrowOffset: { x: 0, y: 0 }, }; return { width: activeContextCard.contentSize.width + BORDER_WIDTH * 2, height: activeContextCard.contentSize.height + BORDER_WIDTH * 2, x: activeContextCard.origin.x, y: activeContextCard.origin.y, side: activeContextCard.side, arrowOffset: activeContextCard.arrowOffset, }; }, [activeContextCard]); const lastBounds = usePrevious(activeBounds); const lastId = usePrevious(activeId.current); const percentOverlapFromLast = useMemo(() => { if (!lastBounds) return 0; const overlapLeft = Math.max(activeBounds.x, lastBounds.x); const overlapRight = Math.min( activeBounds.x + activeBounds.width, lastBounds.x + lastBounds.width ); const overlapTop = Math.max(activeBounds.y, lastBounds.y); const overlapBottom = Math.min( activeBounds.y + activeBounds.height, lastBounds.y + lastBounds.height ); if (overlapLeft < overlapRight && overlapTop < overlapBottom) { const overlapArea = (overlapRight - overlapLeft) * (overlapBottom - overlapTop); const activeBoundsArea = activeBounds.width * activeBounds.height; if (activeBoundsArea === 0) return 0; return (overlapArea / activeBoundsArea) * 100; } return 0; }, [activeBounds, lastBounds]); const distanceFromLast = useMemo( () => Math.sqrt( ((lastBounds?.x ?? 0) - activeBounds.x) ** 2 + ((lastBounds?.y ?? 0) - activeBounds.y) ** 2 ), [activeBounds.x, activeBounds.y, lastBounds?.x, lastBounds?.y] ); const lastOrigin = useMemo(() => { if (!lastBounds) return null; return { x: lastBounds.x - activeBounds.x, y: lastBounds.y - activeBounds.y, }; }, [activeBounds, lastBounds]); const [isScrolling, setIsScrolling] = useState(false); const skipTransition = useMemo( () => distanceFromLast > MAX_TRANSITION_DISTANCE_PX || percentOverlapFromLast > MAX_OVERLAP_FOR_TRANSITION_PERCENT || lastId === null || isScrolling, [distanceFromLast, isScrolling, lastId, percentOverlapFromLast] ); useEffect(() => { let isScrollingTimeout: ReturnType | null = null; const onScroll = (): void => { if (isScrollingTimeout) window.clearTimeout(isScrollingTimeout); isScrollingTimeout = setTimeout(() => { setIsScrolling(false); }, 66); if (!isScrolling) { setIsScrolling(true); } }; document.addEventListener('scroll', onScroll, true); return () => { document.removeEventListener('scroll', onScroll, true); }; }, [isScrolling]); const contextValue = useMemo( () => ({ updateActiveContextCard, rootOrigin: { ...activeBounds }, rootBounds: bounds, portalRef, setActiveId, activeId, setHoveredId, hoveredId, skipTransition, rootVisible: visible, distanceFromLast, lastOrigin, isProvided: true, }), [ activeBounds, bounds, hoveredId, skipTransition, visible, distanceFromLast, lastOrigin, ] ); return ( {children} {isMounted ? createPortal(
, resolvedPortalTarget ?? document.body ) : null} ); } export type Side = 'top' | 'bottom' | 'left' | 'right'; export type Align = 'start' | 'center' | 'end'; /** Props for the {@link ContextCardTrigger} component. */ export interface ContextCardTriggerProps extends Omit, 'content'> { /** The content rendered inside the context card popup. */ content: ReactNode; /** Which side of the trigger to display the card. Defaults to `"right"`. */ side?: Side; /** Alignment of the card relative to the trigger. Defaults to `"center"`. */ align?: Align; /** Distance in pixels between the trigger and the card. Defaults to `16`. */ sideOffset?: number; /** Offset in pixels along the alignment axis. Defaults to `0`. */ alignOffset?: number; /** Whether to disable pointer events on the card content. */ ignoreCardPointerEvents?: boolean; /** Whether to remove default padding from the card content. */ noPadding?: boolean; /** Whether the trigger uses the Slot pattern to merge props onto its child. */ asChild?: boolean; /** Can be used to hide the card when needed. */ hide?: boolean; /** Time in milliseconds before the card is dismissed after mouse leaves. */ inactiveTimeoutMs?: number; } /** * Displays a floating context card on hover with viewport-aware collision * detection and animated position transitions between triggers in the same * {@link ContextCardProvider}. Persists briefly after mouse leave. */ export function ContextCardTrigger({ content, children, side = 'right', align = 'center', sideOffset = 16, alignOffset = 0, ignoreCardPointerEvents = false, noPadding, asChild, className, hide, inactiveTimeoutMs = INACTIVE_TIMEOUT_MS, }: ContextCardTriggerProps): JSX.Element { const triggerRef = useRef(null); const contentRef = useRef(null); const [contentMeasureRef, contentMeasureBounds] = useMeasure(); const { portalRef, updateActiveContextCard, activeId, setActiveId, hoveredId, setHoveredId, skipTransition, rootVisible, } = useContext(ContextCardContext); const id = useId(); useEffect(() => { return () => { // Only clear the shared active card if this trigger was the active one; // otherwise an unmounting inactive trigger would hide another trigger's card. if (activeId.current === id) { setActiveId(null); } }; }, []); const reduceMotion = useReducedMotion(); const [localHover, setLocalHover] = useState(false); const [persisting, setPersisting] = useState(false); const [visible, setVisible] = useState(false); const activeTimeout = useRef | null>(null); const persistingTimeout = useRef | null>(null); const openTimeout = useRef | null>(null); useEffect(() => { if ( localHover && contentMeasureBounds.width > 0 && contentMeasureBounds.height > 0 ) { if (openTimeout.current) clearTimeout(openTimeout.current); openTimeout.current = setTimeout( () => { setActiveId(id); updateBounds(); }, rootVisible ? 0 : OPEN_DELAY_MS ); } return () => { if (openTimeout.current) clearTimeout(openTimeout.current); }; }, [localHover, contentMeasureBounds]); const enterTimeout = useRef | null>(null); useEffect(() => { if (enterTimeout.current) clearTimeout(enterTimeout.current); if (hoveredId !== id) { // Exits are instant setVisible(false); } else { // Enters can be delayed unless we wish to script the transition enterTimeout.current = setTimeout( () => { setVisible(true); }, reduceMotion || skipTransition ? 0 : ENTER_DELAY_MS ); } }, [hoveredId, id, reduceMotion, skipTransition]); function updateBounds(): void { if (!contentRef.current) return; const bounds = computeBounds(); updateActiveContextCard({ id, ...bounds }); } function computeBounds(sideOverride?: Side): { origin: Point; contentSize: { width: number; height: number }; side: Side; arrowOffset: Point; } { if (!triggerRef.current) throw new Error('Trigger not found'); if (!contentRef.current) throw new Error('Content not found'); const appliedSide = sideOverride ?? side; const triggerRect = triggerRef.current.getBoundingClientRect(); const contentRect = contentRef.current.getBoundingClientRect(); contentRef.current.style.width = 'max-content'; contentRef.current.style.position = 'absolute'; const contentSize = { width: Math.max(contentRect.width, contentRef.current.offsetWidth), height: contentRect.height, }; let origin: Point; const arrowWidth = 14; const halfArrowWidth = arrowWidth / 2; const halfTriggerWidth = triggerRect.width / 2; const halfTriggerHeight = triggerRect.height / 2; const halfContentWidth = contentSize.width / 2; const halfContentHeight = contentSize.height / 2; const top = triggerRect.top; const left = triggerRect.left; let alignX = left + halfTriggerWidth - halfContentWidth + alignOffset; if (align === 'start') alignX = left + alignOffset; if (align === 'end') alignX = left + triggerRect.width - contentRect.width + alignOffset; let alignY = top + halfTriggerHeight - halfContentHeight + alignOffset; if (align === 'start') alignY = top + alignOffset; if (align === 'end') alignY = top + triggerRect.height - contentRect.height + alignOffset; switch (appliedSide) { case 'top': origin = { x: alignX, y: top - contentRect.height - sideOffset, }; break; case 'right': origin = { x: left + triggerRect.width + sideOffset, y: alignY, }; break; case 'bottom': origin = { x: alignX, y: top + triggerRect.height + sideOffset, }; break; case 'left': origin = { x: left - contentRect.width - sideOffset, y: alignY, }; break; default: { const _exhaustive: never = appliedSide; throw new Error(`Unknown side: ${String(_exhaustive)}`); } } const viewportWidth = document.documentElement.clientWidth; const viewportHeight = document.documentElement.clientHeight; const isOffscreenLeft = origin.x < 0; const isOffscreenRight = origin.x + contentRect.width > viewportWidth; const isOffscreenTop = origin.y < 0; const isOffscreenBottom = origin.y + contentRect.height > viewportHeight; if (!sideOverride) { const spaceAbove = top; const spaceBelow = viewportHeight - (top + triggerRect.height); const spaceLeft = left; const spaceRight = viewportWidth - (left + triggerRect.width); if (side === 'top' && isOffscreenTop && spaceBelow > spaceAbove) { return computeBounds('bottom'); } if (side === 'bottom' && isOffscreenBottom && spaceAbove > spaceBelow) { return computeBounds('top'); } if (side === 'right' && isOffscreenRight && spaceLeft > spaceRight) { return computeBounds('left'); } if (side === 'left' && isOffscreenLeft && spaceRight > spaceLeft) { return computeBounds('right'); } if ( (side === 'top' || side === 'bottom') && isOffscreenLeft && spaceRight > contentRect.width ) { return computeBounds('right'); } if ( (side === 'top' || side === 'bottom') && isOffscreenRight && spaceLeft > contentRect.width ) { return computeBounds('left'); } if ( (side === 'left' || side === 'right') && isOffscreenTop && spaceBelow > contentRect.height ) { return computeBounds('bottom'); } if ( (side === 'left' || side === 'right') && isOffscreenBottom && spaceAbove > contentRect.height ) { return computeBounds('top'); } } const viewportPadding = 8; if (appliedSide === 'left' || appliedSide === 'right') { origin.y = Math.max( viewportPadding, Math.min( origin.y, viewportHeight - contentSize.height - viewportPadding ) ); } else { origin.x = Math.max( viewportPadding, Math.min(origin.x, viewportWidth - contentSize.width - viewportPadding) ); } // Always constrain Y position within viewport bounds origin.y = Math.max( viewportPadding, Math.min(origin.y, viewportHeight - contentSize.height - viewportPadding) ); const triggerCenterX = left + halfTriggerWidth; const triggerCenterY = top + halfTriggerHeight; const cardCenterX = origin.x + contentSize.width / 2; const cardCenterY = origin.y + contentSize.height / 2; let arrowOffsetX = triggerCenterX - cardCenterX; let arrowOffsetY = triggerCenterY - cardCenterY; // Clamp arrow offset to ensure it stays within card boundaries. // Arrow should be at least halfArrowWidth (7px) from the card edges. const maxArrowOffsetX = halfContentWidth - halfArrowWidth; const maxArrowOffsetY = halfContentHeight - halfArrowWidth; if (appliedSide === 'top' || appliedSide === 'bottom') { arrowOffsetX = Math.max( -maxArrowOffsetX, Math.min(arrowOffsetX, maxArrowOffsetX) ); } if (appliedSide === 'left' || appliedSide === 'right') { arrowOffsetY = Math.max( -maxArrowOffsetY, Math.min(arrowOffsetY, maxArrowOffsetY) ); } const arrowOffset = { x: arrowOffsetX, y: arrowOffsetY, }; return { origin, contentSize, side: appliedSide, arrowOffset }; } useEffect(() => { document.addEventListener('scroll', updateBounds, true); window.addEventListener('resize', updateBounds); return () => { document.removeEventListener('scroll', updateBounds, true); window.removeEventListener('resize', updateBounds); }; }, []); function resetDestructiveTimeouts(): void { if (activeTimeout.current) clearTimeout(activeTimeout.current); if (persistingTimeout.current) clearTimeout(persistingTimeout.current); if (openTimeout.current) clearTimeout(openTimeout.current); } function attemptDeactivate(): void { if (activeTimeout.current) clearTimeout(activeTimeout.current); activeTimeout.current = setTimeout(() => { if (activeId.current !== id) return; setActiveId(null); }, inactiveTimeoutMs); } function attemptPersist(): void { setPersisting(true); if (persistingTimeout.current) clearTimeout(persistingTimeout.current); persistingTimeout.current = setTimeout(() => { setPersisting(false); }, PERSIST_TIMEOUT_MS); } function onMouseEnter(): void { resetDestructiveTimeouts(); setHoveredId(id); setLocalHover(true); } function onMouseLeave(): void { setLocalHover(false); attemptDeactivate(); attemptPersist(); } useEffect(() => { // close instantly if (hide) { setLocalHover(false); setActiveId(null); } }, [hide, setActiveId]); function handleClick(e: React.MouseEvent): void { // Close if clicking a link. We assume we want the card to disappear if navigating. if (e.target instanceof HTMLElement && e.target.closest('a[href]')) { onMouseLeave(); } } const Comp = asChild ? SlotRoot : 'div'; return ( {children} {portalRef?.current && (localHover || persisting) ? createPortal(
{content}
, portalRef.current ) : null}
); }