zebflow 0.11.14

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { cx, createContext, useContext, useRef, useState, useEffect, createPortal } from "zeb/react";
import { useAnchoredPosition, useEscape } from "zeb/ui/hooks";

/**
 * HoverCard — shadcn's hover-card, on Zebflow's engine. `HoverCard`/
 * `HoverCardTrigger`/`HoverCardContent` compose through `createContext`
 * instead of `child.type` detection, the same shape as `zeb/ui/tooltip`:
 * `HoverCardTrigger` wraps its children in a plain `<span>` that owns the
 * hover timer and anchors the panel, without touching the trigger child's
 * own props. Opens after 700ms of hovering (upstream's default), closes
 * 150ms after the pointer leaves both the trigger and the panel — the delay
 * (and the panel's own `onMouseEnter` cancelling it) is the hover bridge
 * that lets the pointer travel from trigger to panel without the card
 * closing first. Escape also dismisses it. `HoverCardContent` reads its own
 * `side`/`align`/`sideOffset` (defaulting to `bottom`/`center`/`8`) and
 * portals to `document.body` in the browser, rendering inline during SSR.
 */

const HoverCardContext = createContext(null);

function useHoverCardContext(name) {
  const ctx = useContext(HoverCardContext);
  if (!ctx) throw new Error(`${name} must be rendered inside <HoverCard>`);
  return ctx;
}

export function HoverCard({ openDelay = 700, closeDelay = 150, children }) {
  const [isOpen, setIsOpen] = useState(false);
  const anchorRef = useRef(null);
  const panelRef = useRef(null);
  const timerRef = useRef(null);

  useEscape(isOpen, () => setIsOpen(false));

  useEffect(() => () => {
    if (timerRef.current) clearTimeout(timerRef.current);
  }, []);

  function show() {
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => setIsOpen(true), openDelay);
  }
  function hide() {
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => setIsOpen(false), closeDelay);
  }
  function cancelHide() {
    if (timerRef.current) clearTimeout(timerRef.current);
  }

  return (
    <HoverCardContext.Provider value={{ isOpen, show, hide, cancelHide, anchorRef, panelRef }}>{children}</HoverCardContext.Provider>
  );
}

export function HoverCardTrigger({ className, children, ...props }) {
  const { anchorRef, show, hide } = useHoverCardContext("HoverCardTrigger");
  return (
    <span ref={anchorRef} data-slot="hover-card-trigger" className={cx("inline-flex", className)} onMouseEnter={show} onMouseLeave={hide} {...props}>
      {children}
    </span>
  );
}

export function HoverCardContent({ className, children, side = "bottom", align = "center", sideOffset = 8, ...props }) {
  const { isOpen, anchorRef, panelRef, cancelHide, hide } = useHoverCardContext("HoverCardContent");
  const { top, left } = useAnchoredPosition(anchorRef, panelRef, { side, align, offset: sideOffset });

  if (!isOpen) return null;

  const node = (
    <div ref={panelRef} className="fixed z-50" style={{ top: `${top}px`, left: `${left}px` }} onMouseEnter={cancelHide} onMouseLeave={hide}>
      <div
        data-slot="hover-card-content"
        className={cx("w-64 rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none", className)}
        {...props}
      >
        {children}
      </div>
    </div>
  );

  return typeof document === "undefined" ? node : createPortal(node, document.body);
}

export default HoverCard;