zebflow 0.9.0

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { createPortal, useEffect, useId, useLayoutEffect, useRef, useState } from "zeb/react";

export default function HelpTooltip({ text }) {
  const buttonRef = useRef(null);
  const tooltipRef = useRef(null);
  const [open, setOpen] = useState(false);
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);
  const [rect, setRect] = useState(null);
  const tooltipId = useId();
  const visible = open || hovered || focused;

  useEffect(() => {
    if (!visible) return;
    const onKeyDown = (event) => {
      if (event.key === "Escape") setOpen(false);
    };
    const onPointerDown = (event) => {
      const target = event.target;
      if (
        !(target instanceof Element) ||
        (!target.closest("[data-help-tooltip-root]") && !target.closest("[data-help-tooltip-portal]"))
      ) {
        setOpen(false);
      }
    };
    document.addEventListener("keydown", onKeyDown);
    document.addEventListener("pointerdown", onPointerDown);
    return () => {
      document.removeEventListener("keydown", onKeyDown);
      document.removeEventListener("pointerdown", onPointerDown);
    };
  }, [visible]);

  useLayoutEffect(() => {
    if (!visible || !buttonRef.current || typeof window === "undefined") return;

    const updateRect = () => {
      setRect(buttonRef.current.getBoundingClientRect());
    };

    updateRect();
    window.addEventListener("resize", updateRect);
    window.addEventListener("scroll", updateRect, true);
    return () => {
      window.removeEventListener("resize", updateRect);
      window.removeEventListener("scroll", updateRect, true);
    };
  }, [visible]);

  useEffect(() => {
    const tooltipEl = tooltipRef.current;
    if (!tooltipEl) return;

    const canUsePopover =
      typeof tooltipEl.showPopover === "function" &&
      typeof tooltipEl.hidePopover === "function";
    if (!canUsePopover) return;

    try {
      if (visible && rect && !tooltipEl.matches(":popover-open")) {
        tooltipEl.showPopover();
      } else if ((!visible || !rect) && tooltipEl.matches(":popover-open")) {
        tooltipEl.hidePopover();
      }
    } catch (_) {
      // Popover can throw during rapid open/close races; the next render will resync it.
    }

    return () => {
      try {
        if (tooltipEl.matches(":popover-open")) tooltipEl.hidePopover();
      } catch (_) {}
    };
  }, [visible, rect]);

  const tooltip = visible && rect && typeof document !== "undefined"
    ? createPortal(
        <span
          ref={tooltipRef}
          id={tooltipId}
          popover="manual"
          data-help-tooltip-portal="true"
          className="pointer-events-none fixed z-[1000] min-w-40 max-w-60 rounded-md border border-border bg-foreground px-2.5 py-1.5 text-left text-[11px] font-normal leading-[1.5] tracking-normal text-background shadow-lg"
          style={{
            left: `${Math.min(Math.max(rect.left + rect.width / 2, 132), window.innerWidth - 132)}px`,
            top: `${rect.top - 8}px`,
            margin: "0",
            transform: "translate(-50%, -100%)",
          }}
          role="tooltip"
        >
          {text}
          <span className="absolute left-1/2 top-full h-0 w-0 -translate-x-1/2 border-x-[5px] border-t-[5px] border-x-transparent border-t-foreground" />
        </span>,
        document.body
      )
    : null;

  return (
    <>
      <button
        ref={buttonRef}
        type="button"
        data-help-tooltip-root="true"
        className="group relative inline-flex items-center rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ui-primary/30"
        aria-label={text}
        aria-expanded={visible ? "true" : "false"}
        aria-describedby={visible ? tooltipId : undefined}
        onClick={() => setOpen((value) => !value)}
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
        onFocus={() => setFocused(true)}
        onBlur={(event) => {
          const next = event.relatedTarget;
          if (!(next instanceof Element) || !event.currentTarget.contains(next)) {
            setFocused(false);
            setOpen(false);
          }
        }}
      >
        <span
          className="inline-flex text-muted-foreground transition-colors duration-150 group-hover:text-foreground group-focus-within:text-foreground"
          aria-hidden="true"
        >
          <svg width="13" height="13" viewBox="0 0 24 24" fill="none">
            <circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.8"/>
            <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/>
            <circle cx="12" cy="17" r="0.5" fill="currentColor" stroke="currentColor" strokeWidth="1.5"/>
          </svg>
        </span>
      </button>
      {tooltip}
    </>
  );
}