Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

/* ----------------------------------------------------------------------------
 * Positioning: flip to the opposite side when there is no room, shift along
 * the cross axis to stay inside the viewport, and aim the arrow at the anchor.
 * ------------------------------------------------------------------------- */

export type Side = "top" | "bottom" | "left" | "right";
export type Align = "start" | "center" | "end";

type Placement = { x: number; y: number; side: Side; arrow: number };
const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), Math.max(lo, hi));

export function computePlacement(
  a: DOMRect,
  f: { width: number; height: number },
  side: Side,
  align: Align,
  offset: number,
  pad = 8,
): Placement {
  const vw = document.documentElement.clientWidth;
  const vh = window.innerHeight;
  const room: Record<Side, number> = {
    top: a.top - pad,
    bottom: vh - a.bottom - pad,
    left: a.left - pad,
    right: vw - a.right - pad,
  };
  const need = (s: Side) => (s === "top" || s === "bottom" ? f.height : f.width) + offset;
  let s = side;
  if (room[s] < need(s) && room[OPPOSITE[s]] > room[s]) s = OPPOSITE[s];

  if (s === "top" || s === "bottom") {
    const y = s === "top" ? a.top - offset - f.height : a.bottom + offset;
    let x = align === "start" ? a.left : align === "end" ? a.right - f.width : a.left + a.width / 2 - f.width / 2;
    x = clamp(x, pad, vw - pad - f.width);
    return { x, y, side: s, arrow: clamp(a.left + a.width / 2 - x, 12, f.width - 12) };
  }
  const x = s === "left" ? a.left - offset - f.width : a.right + offset;
  let y = align === "start" ? a.top : align === "end" ? a.bottom - f.height : a.top + a.height / 2 - f.height / 2;
  y = clamp(y, pad, vh - pad - f.height);
  return { x, y, side: s, arrow: clamp(a.top + a.height / 2 - y, 10, f.height - 10) };
}

function useFloating(open: boolean, side: Side, align: Align, offset: number) {
  const anchorRef = React.useRef<HTMLElement | null>(null);
  const floatingRef = React.useRef<HTMLDivElement | null>(null);
  const [place, setPlace] = React.useState<Placement | null>(null);

  React.useLayoutEffect(() => {
    if (!open) return;
    const update = () => {
      const a = anchorRef.current;
      const f = floatingRef.current;
      if (!a || !f) return;
      setPlace(computePlacement(a.getBoundingClientRect(), { width: f.offsetWidth, height: f.offsetHeight }, side, align, offset));
    };
    update();
    const ro = new ResizeObserver(update);
    if (floatingRef.current) ro.observe(floatingRef.current);
    if (anchorRef.current) ro.observe(anchorRef.current);
    window.addEventListener("scroll", update, true);
    window.addEventListener("resize", update);
    return () => {
      ro.disconnect();
      window.removeEventListener("scroll", update, true);
      window.removeEventListener("resize", update);
    };
  }, [open, side, align, offset]);

  return { anchorRef, floatingRef, place: open ? place : null };
}

/* ---------------------------------------------------------------------------- */

type AnyProps = Record<string, unknown> & { ref?: React.Ref<HTMLElement> };
type TriggerElement = React.ReactElement<AnyProps>;

function setRef<T>(ref: React.Ref<T> | undefined, value: T) {
  if (typeof ref === "function") ref(value);
  else if (ref && typeof ref === "object") (ref as React.RefObject<T>).current = value;
}

/** Merge our handlers into the child's own props (theirs run first). */
function withHandlers(child: TriggerElement, ours: Record<string, unknown>, ref: (el: HTMLElement | null) => void) {
  const props: Record<string, unknown> = { ...ours };
  for (const [k, fn] of Object.entries(ours)) {
    const theirs = child.props[k];
    if (typeof fn === "function" && typeof theirs === "function") {
      props[k] = (...args: unknown[]) => {
        (theirs as (...a: unknown[]) => void)(...args);
        (fn as (...a: unknown[]) => void)(...args);
      };
    }
  }
  const childRef = child.props.ref;
  props.ref = (el: HTMLElement | null) => {
    ref(el);
    setRef(childRef, el);
  };
  return React.cloneElement(child, props);
}

function Arrow({ side, pos, className }: { side: Side; pos: number; className?: string }) {
  const style: React.CSSProperties =
    side === "top"
      ? { left: pos, bottom: -5, transform: "translateX(-50%) rotate(45deg)" }
      : side === "bottom"
        ? { left: pos, top: -5, transform: "translateX(-50%) rotate(45deg)" }
        : side === "left"
          ? { top: pos, right: -5, transform: "translateY(-50%) rotate(45deg)" }
          : { top: pos, left: -5, transform: "translateY(-50%) rotate(45deg)" };
  const borders = { top: "border-b border-r", bottom: "border-t border-l", left: "border-t border-r", right: "border-b border-l" }[side];
  return <span aria-hidden className={cn("absolute size-2.5", borders, className)} style={style} />;
}

const originFor = (p: Placement): string =>
  p.side === "top" ? `${p.arrow}px 100%` : p.side === "bottom" ? `${p.arrow}px 0` : p.side === "left" ? `100% ${p.arrow}px` : `0 ${p.arrow}px`;
const nudge = (side: Side, d: number) =>
  side === "top" ? { y: d } : side === "bottom" ? { y: -d } : side === "left" ? { x: d } : { x: -d };

/* ----------------------------------------------------------------------------
 * Tooltip + delay group
 * ------------------------------------------------------------------------- */

type GroupCtx = {
  activeId: string | null;
  setActive: (id: string | null) => void;
  delay: number;
  /** True when another tooltip in the group closed moments ago. */
  isWarm: () => boolean;
  markClosed: () => void;
};
const TooltipGroupContext = React.createContext<GroupCtx | null>(null);

export function TooltipGroup({ children, delay = 550, skipDelay = 350 }: { children: React.ReactNode; delay?: number; skipDelay?: number }) {
  const [activeId, setActiveId] = React.useState<string | null>(null);
  const closedAt = React.useRef(-Infinity);
  const value = React.useMemo<GroupCtx>(
    () => ({
      activeId,
      setActive: setActiveId,
      delay,
      isWarm: () => performance.now() - closedAt.current < skipDelay,
      markClosed: () => {
        closedAt.current = performance.now();
      },
    }),
    [activeId, delay, skipDelay],
  );
  return <TooltipGroupContext.Provider value={value}>{children}</TooltipGroupContext.Provider>;
}

export interface TooltipProps {
  children: TriggerElement;
  content: React.ReactNode;
  side?: Side;
  align?: Align;
  offset?: number;
  /** Hover delay in ms (falls back to the group delay). */
  delay?: number;
  disabled?: boolean;
  className?: string;
}

export function Tooltip({ children, content, side = "top", align = "center", offset = 8, delay, disabled, className }: TooltipProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const group = React.useContext(TooltipGroupContext);
  const [localOpen, setLocalOpen] = React.useState(false);
  const [instant, setInstant] = React.useState(false);
  const open = !disabled && (group ? group.activeId === id : localOpen);
  const timer = React.useRef<number | undefined>(undefined);
  const { anchorRef, floatingRef, place } = useFloating(open, side, align, offset);

  const setOpen = React.useCallback(
    (v: boolean) => {
      if (group) {
        if (v) group.setActive(id);
        else if (group.activeId === id) {
          group.markClosed();
          group.setActive(null);
        }
      } else setLocalOpen(v);
    },
    [group, id],
  );

  const show = (now = false) => {
    window.clearTimeout(timer.current);
    const warm = !!group?.isWarm() || (!!group?.activeId && group.activeId !== id);
    setInstant(warm);
    const d = now || warm ? 0 : (delay ?? group?.delay ?? 550);
    timer.current = window.setTimeout(() => setOpen(true), d);
  };
  const hide = (after = 90) => {
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setOpen(false), after);
  };

  React.useEffect(() => () => window.clearTimeout(timer.current), []);
  React.useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, setOpen]);

  const trigger = withHandlers(
    children,
    {
      "aria-describedby": open ? id : undefined,
      onPointerEnter: (e: React.PointerEvent) => e.pointerType === "mouse" && show(),
      onPointerLeave: () => hide(),
      onPointerDown: () => hide(0),
      onFocus: (e: React.FocusEvent<HTMLElement>) => {
        if (e.currentTarget.matches(":focus-visible")) show(true);
      },
      onBlur: () => hide(0),
    },
    (el) => {
      anchorRef.current = el;
    },
  );

  return (
    <>
      {trigger}
      <AnimatePresence>
        {open && (
          <motion.div
            ref={floatingRef}
            id={id}
            role="tooltip"
            onPointerEnter={() => window.clearTimeout(timer.current)}
            onPointerLeave={() => hide()}
            initial={reduce || instant ? { opacity: 0 } : { opacity: 0, scale: 0.9, ...nudge(place?.side ?? side, 4) }}
            animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
            exit={{ opacity: 0, scale: instant || reduce ? 1 : 0.96, transition: { duration: 0.1 } }}
            transition={instant ? { duration: 0.08 } : { type: "spring", stiffness: 600, damping: 32 }}
            style={{
              position: "fixed",
              left: place?.x ?? 0,
              top: place?.y ?? 0,
              visibility: place ? "visible" : "hidden",
              transformOrigin: place ? originFor(place) : undefined,
            }}
            className={cn(
              "z-50 max-w-[min(18rem,calc(100vw-1rem))] rounded-lg border border-foreground/10 bg-foreground px-2.5 py-1.5 text-xs leading-snug text-background shadow-lg",
              className,
            )}
          >
            {content}
            {place && <Arrow side={place.side} pos={place.arrow} className="border-foreground/10 bg-foreground" />}
          </motion.div>
        )}
      </AnimatePresence>
    </>
  );
}

/* ----------------------------------------------------------------------------
 * Popover (click) / hover card (hover)
 * ------------------------------------------------------------------------- */

export interface PopoverProps {
  children: TriggerElement;
  content: React.ReactNode | ((close: () => void) => React.ReactNode);
  /** "click" = dialog popover, "hover" = hover card that also opens on keyboard focus. */
  trigger?: "click" | "hover";
  side?: Side;
  align?: Align;
  offset?: number;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Accessible name of the popover dialog. */
  label?: string;
  arrow?: boolean;
  className?: string;
}

const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select,textarea,[tabindex]:not([tabindex="-1"])';

export function Popover({
  children,
  content,
  trigger = "click",
  side = "bottom",
  align = "center",
  offset = 10,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  label,
  arrow = true,
  className,
}: PopoverProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const [inner, setInner] = React.useState(defaultOpen);
  const open = openProp ?? inner;
  const { anchorRef, floatingRef, place } = useFloating(open, side, align, offset);
  const timer = React.useRef<number | undefined>(undefined);
  const focusOnOpen = React.useRef(false);

  const setOpen = React.useCallback(
    (v: boolean) => {
      if (openProp === undefined) setInner(v);
      onOpenChange?.(v);
    },
    [openProp, onOpenChange],
  );
  const close = React.useCallback(
    (restore = true) => {
      setOpen(false);
      if (restore) anchorRef.current?.focus();
    },
    [setOpen, anchorRef],
  );

  // dismissal: Escape, outside press
  React.useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        e.preventDefault();
        close(true);
      }
    };
    const onDown = (e: PointerEvent) => {
      const t = e.target as Node;
      if (!floatingRef.current?.contains(t) && !anchorRef.current?.contains(t)) close(false);
    };
    window.addEventListener("keydown", onKey);
    document.addEventListener("pointerdown", onDown);
    return () => {
      window.removeEventListener("keydown", onKey);
      document.removeEventListener("pointerdown", onDown);
    };
  }, [open, close, floatingRef, anchorRef]);

  // move focus inside click popovers once positioned
  React.useEffect(() => {
    if (!open || !place || !focusOnOpen.current || trigger !== "click") return;
    focusOnOpen.current = false;
    const f = floatingRef.current;
    (f?.querySelector<HTMLElement>("[data-autofocus]") ?? f?.querySelector<HTMLElement>(FOCUSABLE) ?? f)?.focus({ preventScroll: true });
  }, [open, place, trigger, floatingRef]);

  React.useEffect(() => () => window.clearTimeout(timer.current), []);

  const hoverOpen = (d = 320) => {
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setOpen(true), d);
  };
  const hoverClose = (d = 220) => {
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setOpen(false), d);
  };

  const handlers: Record<string, unknown> =
    trigger === "click"
      ? {
          "aria-haspopup": "dialog",
          "aria-expanded": open,
          "aria-controls": open ? id : undefined,
          onClick: () => {
            focusOnOpen.current = !open;
            setOpen(!open);
          },
        }
      : {
          "aria-expanded": open,
          "aria-controls": open ? id : undefined,
          onPointerEnter: (e: React.PointerEvent) => e.pointerType === "mouse" && hoverOpen(),
          onPointerLeave: () => hoverClose(),
          onFocus: (e: React.FocusEvent<HTMLElement>) => e.currentTarget.matches(":focus-visible") && hoverOpen(0),
          onBlur: (e: React.FocusEvent) => {
            if (!floatingRef.current?.contains(e.relatedTarget as Node)) hoverClose(80);
          },
        };

  const el = withHandlers(children, handlers, (node) => {
    anchorRef.current = node;
  });

  return (
    <>
      {el}
      <AnimatePresence>
        {open && (
          <motion.div
            ref={floatingRef}
            id={id}
            role="dialog"
            aria-label={label}
            tabIndex={-1}
            onPointerEnter={trigger === "hover" ? () => window.clearTimeout(timer.current) : undefined}
            onPointerLeave={trigger === "hover" ? () => hoverClose() : undefined}
            onBlur={(e) => {
              const to = e.relatedTarget as Node | null;
              if (to && !e.currentTarget.contains(to) && !anchorRef.current?.contains(to)) close(false);
            }}
            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.92, ...nudge(place?.side ?? side, 6) }}
            animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, transition: { duration: 0.12 } }}
            transition={{ type: "spring", stiffness: 480, damping: 30 }}
            style={{
              position: "fixed",
              left: place?.x ?? 0,
              top: place?.y ?? 0,
              visibility: place ? "visible" : "hidden",
              transformOrigin: place ? originFor(place) : undefined,
            }}
            className={cn(
              "z-50 w-max max-w-[min(22rem,calc(100vw-1rem))] rounded-xl border bg-popover p-4 text-sm text-popover-foreground shadow-xl shadow-black/10 outline-none dark:shadow-black/40",
              className,
            )}
          >
            {typeof content === "function" ? content(() => close(true)) : content}
            {arrow && place && <Arrow side={place.side} pos={place.arrow} className="bg-popover" />}
          </motion.div>
        )}
      </AnimatePresence>
    </>
  );
}

More in Overlays & Feedback

View all →