Hover Card

PreviousNext

For sighted users to preview content available behind a link.

Docs
opticscomponent

Preview

Loading preview…
registry/optics/hover-card.jsx
"use client"

import * as React from "react"
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"

import { cn } from "@/lib/utils"

// Context local para cada instancia de HoverCard para manejar estados de interacción
const HoverCardInstanceContext = React.createContext({
  shouldKeepOpenRef: { current: false },
  setShouldKeepOpen: () => { },
  isPointerOverTriggerRef: { current: false },
  isPointerOverContentRef: { current: false },
  openHoverCard: () => { },
  delay: 600,
});

function HoverCard({
  open: controlledOpen,
  onOpenChange,
  delay = 600,
  ...props
} = {}) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);

  const isControlled = controlledOpen !== undefined;
  const isOpen = isControlled ? controlledOpen : uncontrolledOpen;

  // Refs locales para cada instancia de HoverCard
  const shouldKeepOpenRef = React.useRef(false);
  const isPointerOverTriggerRef = React.useRef(false);
  const isPointerOverContentRef = React.useRef(false);

  const setShouldKeepOpen = React.useCallback((value) => {
    shouldKeepOpenRef.current = value;
  }, []);

  const openHoverCard = React.useCallback(() => {
    if (isControlled) {
      onOpenChange?.(true);
    } else {
      setUncontrolledOpen(true);
    }
  }, [isControlled, onOpenChange]);

  function handleOpenChange(nextOpen, eventDetails) {
    // Lógica para mantener abierto si el mouse está sobre el trigger o el contenido
    if (!nextOpen) {
      if (
        isPointerOverTriggerRef.current ||
        isPointerOverContentRef.current ||
        shouldKeepOpenRef.current
      ) {
        if (isControlled) {
          onOpenChange?.(true);
        } else {
          setUncontrolledOpen(true);
        }
        return;
      }
    }

    if (!isControlled) {
      setUncontrolledOpen(nextOpen);
    }

    if (nextOpen) {
      setShouldKeepOpen(true);
    } else {
      setShouldKeepOpen(false);
    }

    onOpenChange?.(nextOpen, eventDetails);
  }

  return (
    <HoverCardInstanceContext.Provider
      value={{
        shouldKeepOpenRef,
        setShouldKeepOpen,
        isPointerOverTriggerRef,
        isPointerOverContentRef,
        openHoverCard,
        delay,
      }}
    >
      <PreviewCardPrimitive.Root
        data-slot="hover-card"
        open={isOpen}
        onOpenChange={handleOpenChange}
        delay={delay}
        {...props}
      />
    </HoverCardInstanceContext.Provider>
  );
}

function HoverCardTrigger({
  onClick,
  onPointerDown,
  onPointerLeave,
  onPointerEnter,
  onTouchStart,
  onTouchEnd,
  onTouchCancel,
  ...props
} = {}) {
  const {
    setShouldKeepOpen,
    isPointerOverTriggerRef,
    isPointerOverContentRef,
    openHoverCard,
    delay,
  } = React.useContext(HoverCardInstanceContext);

  const longPressTimeoutRef = React.useRef(null);
  const isTouchActiveRef = React.useRef(false);

  React.useEffect(() => {
    return () => {
      if (longPressTimeoutRef.current) {
        clearTimeout(longPressTimeoutRef.current);
      }
    };
  }, []);

  const handleClick = React.useCallback(
    (event) => {
      event.stopPropagation();
      onClick?.(event);
    },
    [onClick],
  );

  const handlePointerDown = React.useCallback(
    (event) => {
      setShouldKeepOpen(true);
      isPointerOverTriggerRef.current = true;
      event.stopPropagation();
      onPointerDown?.(event);
    },
    [onPointerDown, setShouldKeepOpen],
  );

  const handlePointerEnter = React.useCallback(
    (event) => {
      setShouldKeepOpen(true);
      isPointerOverTriggerRef.current = true;
      onPointerEnter?.(event);
    },
    [onPointerEnter, setShouldKeepOpen],
  );

  const handlePointerLeave = React.useCallback(
    (event) => {
      isPointerOverTriggerRef.current = false;
      if (!isPointerOverContentRef.current) {
        setShouldKeepOpen(false);
      }
      onPointerLeave?.(event);
    },
    [onPointerLeave, setShouldKeepOpen, isPointerOverContentRef],
  );

  const handleTouchStart = React.useCallback(
    (event) => {
      isTouchActiveRef.current = true;
      if (longPressTimeoutRef.current) {
        clearTimeout(longPressTimeoutRef.current);
      }

      longPressTimeoutRef.current = setTimeout(() => {
        if (isTouchActiveRef.current) {
          setShouldKeepOpen(true);
          isPointerOverTriggerRef.current = true;
          openHoverCard();
          // En mobile, el preventDefault evita el menú contextual al hacer long press
          if (event.cancelable) event.preventDefault();
        }
      }, delay);

      onTouchStart?.(event);
    },
    [onTouchStart, setShouldKeepOpen, openHoverCard, delay],
  );

  const handleTouchEnd = React.useCallback(
    (event) => {
      isTouchActiveRef.current = false;
      if (longPressTimeoutRef.current) {
        clearTimeout(longPressTimeoutRef.current);
        longPressTimeoutRef.current = null;
      }
      onTouchEnd?.(event);
    },
    [onTouchEnd],
  );

  const handleTouchCancel = React.useCallback(
    (event) => {
      isTouchActiveRef.current = false;
      if (longPressTimeoutRef.current) {
        clearTimeout(longPressTimeoutRef.current);
        longPressTimeoutRef.current = null;
      }
      onTouchCancel?.(event);
    },
    [onTouchCancel],
  );

  return (
    <PreviewCardPrimitive.Trigger
      data-slot="hover-card-trigger"
      onClick={handleClick}
      onPointerDown={handlePointerDown}
      onPointerEnter={handlePointerEnter}
      onPointerLeave={handlePointerLeave}
      onTouchStart={handleTouchStart}
      onTouchEnd={handleTouchEnd}
      onTouchCancel={handleTouchCancel}
      {...props}
    />
  );
}

function HoverCardContent({
  className = "",
  side = "bottom",
  sideOffset = 4,
  align = "center",
  alignOffset = 4,
  onPointerEnter,
  onPointerLeave,
  ...props
}) {
  const {
    setShouldKeepOpen,
    isPointerOverTriggerRef,
    isPointerOverContentRef,
  } = React.useContext(HoverCardInstanceContext);

  const handlePointerEnter = React.useCallback(
    (event) => {
      setShouldKeepOpen(true);
      isPointerOverContentRef.current = true;
      onPointerEnter?.(event);
    },
    [onPointerEnter, setShouldKeepOpen],
  );

  const handlePointerLeave = React.useCallback(
    (event) => {
      isPointerOverContentRef.current = false;
      if (!isPointerOverTriggerRef.current) {
        setShouldKeepOpen(false);
      }
      onPointerLeave?.(event);
    },
    [onPointerLeave, setShouldKeepOpen, isPointerOverTriggerRef],
  );

  return (
    <PreviewCardPrimitive.Portal data-slot="hover-card-portal">
      <PreviewCardPrimitive.Positioner
        align={align}
        alignOffset={alignOffset}
        side={side}
        sideOffset={sideOffset}
        className="isolate z-50">
        <PreviewCardPrimitive.Popup
          data-slot="hover-card-content"
          className={cn(
            "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground w-72 rounded-lg p-2.5 text-xs/relaxed shadow-md ring-1 duration-100 z-50 origin-(--transform-origin) outline-hidden",
            className
          )}
          onPointerEnter={handlePointerEnter}
          onPointerLeave={handlePointerLeave}
          {...props} />
      </PreviewCardPrimitive.Positioner>
    </PreviewCardPrimitive.Portal>
  );
}

export { HoverCard, HoverCardTrigger, HoverCardContent }

Installation

npx shadcn@latest add @optics/hover-card

Usage

import { HoverCard } from "@/components/hover-card"
<HoverCard />